steam-backlog-enforcer/steam_backlog_enforcer/_manual_pick_lifecycle.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

159 lines
5.7 KiB
Python

"""Abandonment and the status payload for manual picks.
Split out of :mod:`steam_backlog_enforcer._actions` to keep both files
under the 250-line cap.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer._actions import (
find_manual_pick,
is_manual_pick_locked,
)
from steam_backlog_enforcer._allowed_games import (
active_manual_picks,
allowed_games,
)
from steam_backlog_enforcer._assignment_progress import record_assignment
from steam_backlog_enforcer._total_block import get_total_block_status
from steam_backlog_enforcer.game_install import get_installed_games, is_protected_app
from steam_backlog_enforcer.store_blocker import is_store_blocked
# Marks these two as an intentional re-export (they're imported from
# _allowed_games above, not defined here) -- without this, mypy's
# --no-implicit-reexport flags every downstream `from _actions import
# active_manual_picks` as an error. Everything else in this file is
# accessible normally; this only needs to list names imported-then-reexported.
__all__ = [
"active_manual_picks",
"allowed_games",
]
if TYPE_CHECKING:
from steam_backlog_enforcer.config import State
# How long an abandoned pick stays out of the auto-assignment pool, so that
# ``scan`` does not immediately hand back the game the user just rejected.
ABANDON_COOLDOWN_DAYS = 30
def manual_pick_age_days(state: State, app_id: int) -> float | None:
"""Return how many days ago *app_id* was picked, or ``None``.
Informational only — picks can be abandoned at any age. ``None`` means the
question does not apply: *app_id* is not an active manual pick, or its
timestamp is missing/malformed.
Args:
state: The loaded enforcer state.
app_id: The manually-picked app id to measure.
Returns:
Fractional days elapsed since the pick was made, or ``None`` when the
question does not apply.
"""
pick = find_manual_pick(state, app_id)
if pick is None or not pick.get("started_at"):
return None
try:
started = datetime.fromisoformat(pick["started_at"])
except ValueError:
return None
return (datetime.now(UTC) - started).total_seconds() / 86400
def abandon_manual_pick(state: State, app_id: int) -> bool:
"""Drop one manual pick and persist ``state``.
A pick may be abandoned at any time, however long ago it was made, and
also after it was released or expired (it is still installed then).
Only the named pick is dropped: any other active pick keeps its own lock
and deadline. The abandoned app id goes onto the existing skip cooldown so
``scan`` will not hand the same game straight back. State-only: like
``apply_manual_pick`` this performs no uninstall/hide cascade, so the MCP
server can call it without touching the filesystem.
Args:
state: The enforcer state to mutate and save.
app_id: The manually-picked app id to back out of.
Returns:
``True`` if the pick was abandoned, ``False`` if it is not an active
pick, in which case ``state`` is untouched.
"""
if all(p.get("app_id") != app_id for p in state.manual_picks):
return False
state.skip_for_days(app_id, ABANDON_COOLDOWN_DAYS)
state.manual_picks = [p for p in state.manual_picks if p.get("app_id") != app_id]
# The abandoned pick may also have been the current assignment; hand the
# assignment to a surviving pick so the enforcer keeps guarding it, or
# clear it so 'scan' can reassign.
if state.current_app_id == app_id:
survivors = active_manual_picks(state) or state.manual_picks
survivor = survivors[-1] if survivors else None
if survivor is None:
state.current_app_id = None
state.current_game_name = ""
state.current_assigned_at = ""
else:
# The survivor has been assigned since it was picked.
record_assignment(
state,
survivor["app_id"],
survivor["game_name"],
at=survivor.get("started_at") or None,
)
state.save()
return True
def status_payload(state: State) -> dict[str, Any]:
"""Build the structured status snapshot that ``cmd_status`` renders as text.
Pure data, no stdout — safe for the MCP server. Reads only ``State`` and the
stdout-free leaf helpers; never constructs ``Config`` and never exposes the
Steam API key.
Args:
state: The loaded enforcer state.
Returns:
A JSON-ready dict describing the current enforcement status.
"""
total_block = get_total_block_status()
installed = get_installed_games()
real_games = [(aid, name) for aid, name in installed if not is_protected_app(aid)]
assigned_installed = (
any(aid == state.current_app_id for aid, _ in installed)
if state.current_app_id
else None
)
return {
"current_app_id": state.current_app_id,
"current_game_name": state.current_game_name or None,
"finished_count": len(state.finished_app_ids),
"store_blocked": is_store_blocked(),
"installed_count": len(real_games),
"assigned_game_installed": assigned_installed,
"total_block": {
"active": total_block.active,
"days_remaining": round(total_block.days_remaining, 1),
"until": total_block.until.isoformat() if total_block.until else None,
},
"manual_pick_locked": is_manual_pick_locked(state),
"manual_picks": [
{
**pick,
"age_days": manual_pick_age_days(state, pick["app_id"]),
}
for pick in active_manual_picks(state)
],
}