mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 15:33:50 +01:00
Requiring 100% locked the user onto one game for weeks, which proved ineffective. The assignment and every manual pick now release as soon as one achievement is unlocked after the game was assigned (Steam unlock_time vs the pick's started_at / State.current_assigned_at). - finished_app_ids still means 100% complete; a game released below 100% gets released_at plus a 7-day cooldown and returns to the pool. - Next pick: least recently assigned first, then shortest HLTB time. - Releasing never evicts: released/expired picks stay installed and playable until the user explicitly chooses another game (new pick-manual, or accepting the next assignment); with no replacement the current game stays. - Tampering detection judges a released game by unlocks after its release. - The daemon reloads every State field each tick so its sweep save cannot overwrite cooldowns or assignment times written by the CLI. - State.load backfills assignment times once and persists them; load-time migrations moved to _state_migrations.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhzaTAWtRfJk8gre6NLSka
117 lines
4.0 KiB
Python
117 lines
4.0 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._allowed_games import allowed_games
|
|
from steam_backlog_enforcer._assignment_progress import iso_to_epoch
|
|
from steam_backlog_enforcer._snapshot 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 _legitimately_played(state: State) -> set[int]:
|
|
"""Return every app id the user was allowed to earn achievements on.
|
|
|
|
Wider than ``allowed_app_ids`` on purpose. A manual pick that has been
|
|
completed is no longer *allowed* -- it leaves the active set the moment it
|
|
lands in ``finished_app_ids`` -- but the achievements that finished it were
|
|
earned legitimately, so comparing it against a stale snapshot would report
|
|
the user's own completion as tampering. Every game ever manually picked,
|
|
plus every finished game, is therefore exempt.
|
|
|
|
Args:
|
|
state: Current enforcer state.
|
|
|
|
Returns:
|
|
App ids whose achievement progress must not be treated as suspicious.
|
|
"""
|
|
exempt = {app_id for app_id, _ in allowed_games(state)}
|
|
exempt.update(state.finished_app_ids)
|
|
exempt.update(
|
|
pick["app_id"] for pick in state.manual_picks if pick.get("app_id") is not None
|
|
)
|
|
return exempt
|
|
|
|
|
|
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 in _legitimately_played(state):
|
|
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 is None:
|
|
return None
|
|
released = iso_to_epoch(state.released_at.get(str(app_id), ""))
|
|
if released is not None:
|
|
# Released below 100%: the snapshot predates the unlocks earned while
|
|
# it was assigned, so only unlocks after the release are suspicious.
|
|
diff = sum(
|
|
1 for a in game.achievements if a.achieved and a.unlock_time > released
|
|
)
|
|
else:
|
|
diff = game.unlocked_achievements - entry["unlocked_achievements"]
|
|
if diff > 0:
|
|
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!",
|
|
)
|