mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
pick and pick-manual no longer uninstall games that are already allowed: the outgoing assignment is kept as a released pick (installed, no lock) until 'pick --replace', done/check, or abandon-pick. The allowed set is capped at max_manual_picks + 1; a pick over it is refused before the candidate list / confirmation, naming the games. status and the web Picks card list kept games with an Abandon button. Fast install now tracks the installer log from the spawn offset: web jobs drive the progress bar (per-depot %, phase) and echo a line every 30 s; daemon-started runs are exposed via ctl status / /api/daemon and shown on the Dashboard. Also: web deps bumped for dependency freshness (react-query 5.104.5, typescript-eslint 8.72.0, vite 8.3.5, node 24.22.1 in CI). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUaX4zAa6H4sMu3jd6RFdb
249 lines
8.6 KiB
Python
249 lines
8.6 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._pick_keep import (
|
|
allowed_cap_refusal,
|
|
keep_refusal,
|
|
kept_alongside,
|
|
)
|
|
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, args: list[str] | None = None) -> None:
|
|
"""Pick another game from the shortest-first candidate list.
|
|
|
|
The games already allowed stay installed and playable next to the new
|
|
one, up to the cap (refused before the list is shown). ``--replace``
|
|
swaps them all for the new game and uninstalls them instead.
|
|
|
|
Args:
|
|
config: Enforcer configuration.
|
|
state: Current enforcer state.
|
|
args: Remaining CLI args: nothing, or ``--replace``.
|
|
"""
|
|
unknown = [a for a in args or [] if a != "--replace"]
|
|
if unknown:
|
|
_echo(f"Unknown argument(s): {' '.join(unknown)}\nUsage: pick [--replace]")
|
|
sys.exit(1)
|
|
keep = "--replace" not in (args or [])
|
|
refusal = keep_refusal(config, state) if keep else None
|
|
if refusal is not None:
|
|
_echo(f"Error: {refusal}")
|
|
sys.exit(1)
|
|
|
|
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, keep=keep)
|
|
|
|
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, app_id: int
|
|
) -> list[dict[str, object]]:
|
|
"""Show which manual picks are already locked in; exit if a cap is full.
|
|
|
|
Args:
|
|
config: Enforcer configuration (for ``max_manual_picks``).
|
|
state: Current enforcer state.
|
|
app_id: The game about to be picked (an allowed one adds nothing).
|
|
|
|
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)
|
|
refusal = allowed_cap_refusal(state, config.max_manual_picks, adding=app_id)
|
|
if refusal is not None:
|
|
_echo(f"\nError: {refusal}")
|
|
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.
|
|
Already-allowed games stay installed, up to the cap (checked pre-prompt).
|
|
|
|
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, app_id)
|
|
|
|
_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}'"
|
|
)
|
|
kept = kept_alongside(state, app_id)
|
|
if kept:
|
|
_echo(f" - Keep these allowed, they stay installed: {', '.join(kept)}")
|
|
_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)
|