steam-backlog-enforcer/steam_backlog_enforcer/main/picks.py
2026-10-10 16:07:42 +02:00

220 lines
7.5 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._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 <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)")
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)