mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 18:13:49 +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
116 lines
4.0 KiB
Python
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
|