steam-backlog-enforcer/steam_backlog_enforcer/_pick_completion.py
Krzysztof kuhy Rudnicki d0e8ed580c Auto-detect completed manual picks before enforcing the pick cap
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 14:53:04 +02:00

250 lines
8.4 KiB
Python

"""Achievement-based retirement of finished manual picks.
A manual pick stops holding its slot once its app id is in
``State.finished_app_ids`` -- that is what ``_allowed_games._pick_is_active``
reads. Historically only ``State.current_app_id`` was ever checked for
completion (``cmd_done``, ``do_check``), so a manual pick that was *not* the
current assignment had no achievement-based release path at all: it sat on a
slot until the 14-day lock expired or the user abandoned it by hand.
This module closes that gap by checking every active pick and retiring the ones
at 100%. It deliberately stops there -- freeing a slot is not a request for a
replacement game, so nothing here calls ``pick_next_game``.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import time
from typing import TYPE_CHECKING
from steam_backlog_enforcer._allowed_games import active_manual_picks
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
# How long the enforce daemon waits between achievement re-checks. That loop
# ticks every 3s; re-checking every tick would be ~1200 Steam API calls an hour
# for a fact that changes a few times a year. CLI commands do not throttle.
MANUAL_PICK_RECHECK_TTL_SECONDS = 900
logger = logging.getLogger(__name__)
class _DaemonSweep:
"""The enforce daemon's re-check gate, plus what it has retired.
An object rather than module-level values because rebinding those would
need a ``global`` statement; attribute writes do not.
``retired`` exists so the daemon can *record* a completion without
*evicting* the game: retiring drops a pick out of ``allowed_app_ids``, and
the daemon would then kill and uninstall it mid-session, seconds after the
last achievement popped. Unioning these ids back into the allowed set
defers that to a user-invoked command; the freed slot is visible at once.
"""
def __init__(self) -> None:
self.last: float | None = None
self.retired: set[int] = set()
def due(self, now: float, ttl: float) -> bool:
"""Return whether a check is due, recording *now* when it is.
Args:
now: Current ``time.monotonic()`` reading.
ttl: Minimum seconds between checks.
Returns:
Whether the caller should run the check.
"""
if self.last is not None and now - self.last < ttl:
return False
self.last = now
return True
# Process-local by design: the daemon is long-lived, and a restart re-checking
# once immediately is harmless.
daemon_sweep = _DaemonSweep()
@dataclass(frozen=True)
class PickProgress:
"""Achievement progress for one active manual pick.
``determinable`` is ``False`` when Steam returned nothing for this app (no
achievements, or the call failed); ``unlocked``/``total`` are 0 then, and
callers must read it as "unknown", never as "complete".
"""
app_id: int
game_name: str
unlocked: int
total: int
retired: bool
determinable: bool
def describe(self) -> str:
"""Return a one-line human summary for CLI output."""
if not self.determinable:
return f" {self.game_name}: progress unavailable (no achievements)"
if self.retired:
return (
f" {self.game_name}: {self.unlocked}/{self.total} (100%)"
" - COMPLETE, freeing its slot"
)
pct = (self.unlocked / self.total) * 100.0 if self.total else 0.0
return (
f" {self.game_name}: {self.unlocked}/{self.total}"
f" ({pct:.0f}%) - still in progress"
)
def mark_finished(state: State, app_id: int) -> bool:
"""Record *app_id* as finished, without duplicating an existing record.
The single place a completion is written, so the manual-pick sweep and the
``done``/``check`` paths cannot both append the same id.
Args:
state: The enforcer state to mutate (not saved here).
app_id: The completed app id.
Returns:
Whether this call actually added the id.
"""
if app_id in state.finished_app_ids:
return False
state.finished_app_ids.append(app_id)
return True
def retire_completed_manual_picks(
config: Config,
state: State,
*,
client: SteamAPIClient | None = None,
) -> list[PickProgress]:
"""Mark every 100%-complete manual pick finished, freeing its slot.
Mutates and saves ``state`` only when something was actually retired. Does
**not** assign a replacement game, uninstall anything, or touch
``current_app_id``: the caller decides what a freed slot means.
A pick whose achievements cannot be read stays locked: for an enforcement
tool, Steam being unreachable must never look like completion.
Args:
config: Enforcer configuration (for the Steam credentials).
state: The enforcer state to inspect, mutate and save.
client: Pre-built API client, for reuse and for tests.
Returns:
One entry per *active* pick, in pick order -- including the ones left
alone, so callers can report progress rather than only retirements.
"""
picks = active_manual_picks(state)
if not picks:
return []
if client is None:
client = SteamAPIClient(config.steam_api_key, config.steam_id)
results: list[PickProgress] = []
retired_any = False
for pick in picks:
# active_manual_picks has already dropped entries without an app_id.
app_id = pick["app_id"]
name = pick.get("game_name", "") or f"AppID={app_id}"
game = client.refresh_single_game(app_id, name)
if game is None:
results.append(
PickProgress(app_id, name, 0, 0, retired=False, determinable=False)
)
continue
if game.is_complete and mark_finished(state, app_id):
retired_any = True
logger.info("Manual pick retired at 100%%: %s (AppID=%s)", name, app_id)
results.append(
PickProgress(
app_id,
name,
game.unlocked_achievements,
game.total_achievements,
retired=game.is_complete,
determinable=True,
)
)
if retired_any:
state.save()
return results
def report_completion(config: Config, state: State) -> list[PickProgress]:
"""Run the sweep and print a per-pick progress block.
Args:
config: Enforcer configuration.
state: The enforcer state to inspect, mutate and save.
Returns:
Only the picks whose slots this call freed.
"""
results = retire_completed_manual_picks(config, state)
if not results:
return []
_echo("\nChecking your manual picks for completion...")
for result in results:
_echo(result.describe())
return [r for r in results if r.retired]
def warn_stale_assignment(state: State, retired: list[PickProgress]) -> None:
"""Warn when a just-retired pick is still the current assignment.
Only reachable from the ``pick-manual`` abort path: on the success path
``apply_manual_pick`` overwrites ``current_app_id`` with the new pick.
Args:
state: The enforcer state (for ``current_app_id``).
retired: Picks whose slots were freed by this run.
"""
for pick in retired:
if pick.app_id == state.current_app_id:
_echo(
f"\nNote: {pick.game_name} is recorded as complete but is still"
"\n your current assignment. Run './run.sh done' to get"
"\n your next game."
)
return
def retire_completed_manual_picks_throttled(
config: Config,
state: State,
) -> list[PickProgress]:
"""Rate-limited :func:`retire_completed_manual_picks` for the enforce loop.
Records what it retired in :data:`daemon_sweep` so the caller can keep
those games in the allowed set rather than evicting them unattended.
Args:
config: Enforcer configuration.
state: The enforcer state to inspect, mutate and save.
Returns:
The per-pick progress list, or ``[]`` when throttled.
"""
if not daemon_sweep.due(time.monotonic(), MANUAL_PICK_RECHECK_TTL_SECONDS):
return []
results = retire_completed_manual_picks(config, state)
daemon_sweep.retired.update(r.app_id for r in results if r.retired)
return results