"""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._progress import tracked from steam_backlog_enforcer._prompter import confirm_phrase, current_prompter 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 ' — 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)") games = allowed_games(state) for app_id, name in tracked(games, "Installing allowed games", lambda g: g[1]): 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 current_prompter().text("Enter Steam app_id") 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() if not confirm_phrase( "pick-manual", f"Confirm you will play {game_name} until you earn a new achievement.", game_name=game_name, ): _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)