steam-backlog-enforcer/steam_backlog_enforcer/_scanning_tampering.py
Krzysztof kuhy Rudnicki dd480f3208 Bring _stats.py and scanning.py under the 250-line cap
Leaf-set extraction only, so every new import is one-way and no cycle is
possible:

  _stats.py    523 -> 233   + _stats_types (the shared _GameTimes dataclass,
                              imported by parent and helpers alike),
                              _stats_gathering, _stats_display
  scanning.py  521 -> 248   + _scanning_candidates, _scanning_assign,
                              _scanning_tampering

_GameTimes and the four moved scanning helpers are re-exported from their
original modules via __all__ (mypy --no-implicit-reexport needs them listed),
so callers and tests keep their existing import paths.

Test patch targets were retargeted per file from the pytest failure list —
the target is the module whose source now contains the function under test.
Note TestPrintScenario and TestDoCheck did NOT move, so they keep the
original target while the rest of their files change.

1344 tests, 100.00% coverage. ./run.sh status and ./run.sh stats verified
against the live install; service at 0 restarts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
2026-08-24 16:00:54 +02:00

81 lines
2.5 KiB
Python

"""Tampering detection for installed games.
Split out of :mod:`steam_backlog_enforcer.scanning` to keep both files under
the 250-line cap. Leaf helpers: nothing here calls back into ``scanning``.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer.config import load_snapshot
from steam_backlog_enforcer.enforcer import send_notification
from steam_backlog_enforcer.game_install import _echo
from steam_backlog_enforcer.steam_api import SteamAPIClient
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config, State
logger = logging.getLogger(__name__)
_TAMPER_CHECK_LIMIT = 3
def _check_game_tampering(
client: SteamAPIClient,
entry: dict[str, Any],
state: State,
) -> tuple[str, int, int] | None:
"""Check if a single game has unexpected achievement progress.
Args:
client: Steam API client.
entry: Snapshot entry for the game.
state: Current enforcer state.
Returns:
Tuple of (name, app_id, diff) if tampering detected, else None.
"""
app_id = entry["app_id"]
if app_id == state.current_app_id:
return None
if entry["unlocked_achievements"] >= entry["total_achievements"]:
return None
if entry.get("playtime_minutes", 0) <= 0:
return None
game = client.refresh_single_game(
app_id, entry["name"], entry.get("playtime_minutes", 0)
)
if game and game.unlocked_achievements > entry["unlocked_achievements"]:
diff = game.unlocked_achievements - entry["unlocked_achievements"]
return (entry["name"], app_id, diff)
return None
def detect_tampering(config: Config, state: State) -> None:
"""Check if achievements were unlocked on non-assigned games."""
old_snapshot = load_snapshot()
if old_snapshot is None:
return
client = SteamAPIClient(config.steam_api_key, config.steam_id)
# Quick check: only re-fetch a few random non-assigned games.
suspicious: list[tuple[str, int, int]] = []
for entry in old_snapshot:
result = _check_game_tampering(client, entry, state)
if result:
suspicious.append(result)
if len(suspicious) >= _TAMPER_CHECK_LIMIT:
break
if suspicious:
_echo("\n TAMPERING DETECTED:")
for name, app_id, diff in suspicious:
_echo(f" {name} (AppID={app_id}): +{diff} new achievements!")
send_notification(
"Tampering Detected!",
f"Achievements unlocked on {len(suspicious)} non-assigned games!",
)