steam-backlog-enforcer/steam_backlog_enforcer/_allowed_games.py
Krzysztof kuhy Rudnicki a191c8e459 Release a game after one new achievement instead of 100% completion
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
2026-09-26 16:46:03 +02:00

116 lines
4.0 KiB
Python

"""Which games the enforcer currently must leave installed and visible.
Pure functions over ``State`` with no dependency on ``game_install.py``.
Kept as its own leaf module (rather than living in ``_actions.py``, which
already imports from ``game_install.py`` for unrelated functions) so that
``game_install.py`` can import ``allowed_games`` at the top level for its
deletion safety net without creating a circular import.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from steam_backlog_enforcer.config import State
# Days before the manual-pick lock automatically expires. Single source of
# truth: both ``main.py`` and the MCP server import it via ``_actions``.
MANUAL_LOCK_DAYS = 14
def _pick_is_active(state: State, pick: dict[str, Any]) -> bool:
"""Return ``True`` if *pick* still holds the lock and a pick slot.
An inactive pick is *not* evicted: it stays in ``state.manual_picks``, and
so installed and playable, until the user explicitly chooses another game
(:func:`drop_inactive_picks`).
A pick stops being active once it earned a new achievement since it was
picked (``released_at``), once its game is 100% finished, or once
``MANUAL_LOCK_DAYS`` have elapsed. A missing or malformed timestamp
keeps it active: with no deadline to evaluate, the safe answer for an
enforcement tool is "still locked".
Args:
state: The loaded enforcer state (for ``finished_app_ids``).
pick: One ``state.manual_picks`` entry.
Returns:
Whether this pick still counts toward the lock.
"""
app_id = pick.get("app_id")
if app_id is None or app_id in state.finished_app_ids:
return False
if pick.get("released_at"):
return False
started_at = pick.get("started_at") or ""
if started_at:
try:
started = datetime.fromisoformat(started_at)
except ValueError:
return True
if datetime.now(UTC) >= started + timedelta(days=MANUAL_LOCK_DAYS):
return False
return True
def active_manual_picks(state: State) -> list[dict[str, Any]]:
"""Return the manual picks that still hold the lock, oldest first.
Args:
state: The loaded enforcer state.
Returns:
The subset of ``state.manual_picks`` that is neither released,
finished nor past its own deadline.
"""
return [p for p in state.manual_picks if _pick_is_active(state, p)]
def drop_inactive_picks(state: State) -> list[dict[str, Any]]:
"""Forget every pick that no longer holds the lock; return the dropped ones.
Released, finished and expired picks stay installed and playable for as
long as the user wants. Only an explicit choice of another game (a new
manual pick, or accepting the next assignment) calls this and so lets
enforcement remove them. Does not save.
Args:
state: The enforcer state to mutate.
Returns:
The dropped pick entries, oldest first.
"""
active = active_manual_picks(state)
dropped = [p for p in state.manual_picks if p not in active]
state.manual_picks = active
return dropped
def allowed_games(state: State) -> list[tuple[int, str]]:
"""Return ``(app_id, name)`` for every game the enforcer must keep.
That is the current assignment plus every stored manual pick, including
released and expired ones: a game is only removed once the user has
explicitly chosen something else.
Args:
state: The loaded enforcer state.
Returns:
Allowed games as ``(app_id, name)`` pairs, without duplicates. The
current assignment comes first.
"""
games: list[tuple[int, str]] = []
if state.current_app_id is not None:
games.append((state.current_app_id, state.current_game_name))
for pick in state.manual_picks:
app_id = pick.get("app_id")
if app_id is not None and all(app_id != aid for aid, _ in games):
games.append((app_id, pick.get("game_name", "")))
return games