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

217 lines
7.3 KiB
Python

"""Game-selection commands: ``pick``, ``pick-manual`` and ``abandon-pick``."""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import (
active_manual_picks,
allowed_app_ids,
allowed_games,
apply_configured_pick,
manual_pick_slots_left,
)
from steam_backlog_enforcer._allowed_games import (
MANUAL_LOCK_DAYS as _MANUAL_LOCK_DAYS,
)
from steam_backlog_enforcer._hltb_types import load_hltb_cache
from steam_backlog_enforcer._pick_completion import (
report_completion,
warn_stale_assignment,
)
from steam_backlog_enforcer._snapshot import load_snapshot, snapshot_game_name
from steam_backlog_enforcer._steam_state import is_game_fully_installed
from steam_backlog_enforcer.game_install import (
_echo,
install_game,
uninstall_other_games,
)
from steam_backlog_enforcer.library_hider import hide_others_and_report
from steam_backlog_enforcer.main._shared import _MANUAL_LOCK_EXEMPT_COMMANDS
from steam_backlog_enforcer.scanning import pick_next_game
from steam_backlog_enforcer.steam_api import (
GameInfo,
SteamAPIClient,
SteamAPIError,
)
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config, State
def cmd_pick(config: Config, state: State) -> None:
"""Manually pick a new game from the shortest-first candidate list."""
snapshot_data = load_snapshot()
if not snapshot_data:
_echo("No snapshot found. Run 'scan' first.")
return
games = [GameInfo.from_snapshot(d) for d in snapshot_data]
hltb_cache = load_hltb_cache()
for game in games:
if game.app_id in hltb_cache:
game.completionist_hours = hltb_cache[game.app_id]
pick_next_game(games, state, config)
if state.current_app_id is not None:
hide_others_and_report(config, state)
def _resolve_game_name(config: Config, app_id: int) -> str | None:
"""Look up a game name by app_id, checking snapshot then Steam API.
Returns the game name, or None if not found.
"""
# Fast path: snapshot already on disk.
name = snapshot_game_name(app_id)
if name is not None:
return name
# Slower path: owned games API.
try:
client = SteamAPIClient(config.steam_api_key, config.steam_id)
owned = client.get_owned_games()
for g in owned:
if g.get("appid") == app_id:
return str(g.get("name", f"Unknown ({app_id})"))
except SteamAPIError, OSError, RuntimeError, ValueError:
return None
return None
def _report_pick_slots(config: Config, state: State) -> list[dict[str, object]]:
"""Show which manual picks are already locked in; exit if the cap is full.
Args:
config: Enforcer configuration (for ``max_manual_picks``).
state: Current enforcer state.
Returns:
The currently-active manual picks.
"""
existing = active_manual_picks(state)
if existing:
_echo(f"\nAlready locked in ({len(existing)}/{config.max_manual_picks}):")
for pick in existing:
_echo(f" - {pick['game_name']} (AppID={pick['app_id']})")
if manual_pick_slots_left(state, config.max_manual_picks) == 0:
_echo(
f"\nError: you already have {config.max_manual_picks} manual pick(s)."
f"\nFinish one, or undo one with 'abandon-pick <app_id>' — a pick"
f"\ncan be abandoned at any time."
)
sys.exit(1)
return existing
def _apply_allowed_set(config: Config, state: State) -> None:
"""Make the filesystem and library match the allowed set.
Uninstalls everything outside it, installs every allowed game that is
missing, and hides the rest of the library. Operating on the whole set
(rather than one app id) is what lets a second manual pick coexist with
the first instead of tearing it down.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
allowed = allowed_app_ids(state)
if config.uninstall_other_games:
_echo(" Uninstalling non-allowed games...")
count = uninstall_other_games(allowed)
if count:
_echo(f" Uninstalled {count} non-allowed game(s)")
for app_id, name in allowed_games(state):
if is_game_fully_installed(app_id):
_echo(f" {name} is already installed.")
continue
_echo(f" Installing {name}...")
install_game(app_id, name, config.steam_id, use_steam_protocol=True)
hide_others_and_report(config, state, lead=" ")
def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
"""Manually pick a game by Steam app_id, locking the enforcer.
The lock lasts 2 weeks, or until one new achievement is earned in it.
Args:
config: Enforcer configuration.
state: Current enforcer state.
args: Remaining CLI args (first element should be the app_id).
"""
raw_id = args[0] if args else input("Enter Steam app_id: ").strip()
try:
app_id = int(raw_id)
except ValueError:
_echo(f"Error: app_id must be a number, got '{raw_id}'.")
return
_echo(f"Looking up AppID={app_id}...")
game_name = _resolve_game_name(config, app_id)
if game_name is None:
_echo(
f"Error: AppID={app_id} not found in your Steam library or snapshot.\n"
"Run 'scan' first, or verify the app_id is correct."
)
return
_echo(f"\nGame found: {game_name} (AppID={app_id})")
retired = report_completion(config, state)
existing = _report_pick_slots(config, state)
_echo(
f"\nWARNING: Picking this game will:"
f"\n - Add it to your allowed games ({len(existing) + 1} of"
f" {config.max_manual_picks} slot(s) used)"
f"\n - Lock ALL other commands for {_MANUAL_LOCK_DAYS} DAYS or until"
f"\n you earn at least one new achievement on every pick"
f"\n - Leave only these commands usable:"
f"\n {', '.join(sorted(_MANUAL_LOCK_EXEMPT_COMMANDS))}"
f"\n - Stay undoable at any time via 'abandon-pick {app_id}'"
)
active = active_manual_picks(state)
for old_pick in (p for p in state.manual_picks if p not in active):
_echo(
f" - Replace {old_pick['game_name']} (released or expired; it stays"
" playable until you confirm)"
)
_echo()
confirm = input(
f"Type YES to confirm you will play {game_name} until you earn a new"
" achievement: "
).strip()
if confirm != "YES":
_echo("Aborted.")
warn_stale_assignment(state, retired)
return
# State mutation is the shared, stdout-free core (also used by the MCP
# server); the destructive post-assignment cascade below stays CLI-only.
refused = apply_configured_pick(config, state, app_id, game_name)
if refused is not None:
_echo(f"\nError: {refused}")
sys.exit(1)
_echo(f"\nManual pick confirmed: {game_name} (AppID={app_id})")
_echo(
f"Lock active from now until one new achievement or {_MANUAL_LOCK_DAYS} days."
)
_echo("Run 'done' or 'check' once you earn one to release the lock.\n")
# Post-assignment: mirror what _assign_chosen_game + cmd_pick do, but for
# the whole allowed set so an earlier pick is not torn down by a later one.
_apply_allowed_set(config, state)