mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
220 lines
7.5 KiB
Python
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)
|