steam-backlog-enforcer/steam_backlog_enforcer/_scanning_assign.py
Krzysztof kuhy Rudnicki 1bdf360193 pick keeps already-picked games; fast install reports progress
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
2026-10-23 20:14:12 +02:00

205 lines
7.2 KiB
Python

"""Interactive pick prompt and assignment of the chosen game.
Split out of :mod:`steam_backlog_enforcer.scanning` to keep both files under
the 250-line cap. Leaf helpers: nothing here calls back into ``scanning``.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import allowed_app_ids
from steam_backlog_enforcer._allowed_games import drop_inactive_picks
from steam_backlog_enforcer._assignment_progress import (
last_assigned_epoch,
record_assignment,
)
from steam_backlog_enforcer._own_pick import (
OWN_PICK_OPTION,
OWN_PICK_VALUE,
prompt_own_pick,
)
from steam_backlog_enforcer._pick_keep import keep_outgoing_assignment
from steam_backlog_enforcer._prompter import PromptOption, current_prompter
from steam_backlog_enforcer._scanning_candidates import (
_pick_next_shortest_candidate,
_sort_key,
)
from steam_backlog_enforcer._scanning_confidence import (
_apply_cached_confidence_to_candidates,
_report_poll_confidence,
)
from steam_backlog_enforcer._steam_state import is_game_fully_installed
from steam_backlog_enforcer.game_install import (
_echo,
install_game,
uninstall_other_games,
)
if TYPE_CHECKING:
from collections.abc import Callable
from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.steam_api import GameInfo
logger = logging.getLogger(__name__)
_NO_CONF_MSG = (
"\nNo assignable games found "
"(HLTB confidence thresholds: comp_100 polls>=3, "
"count_comp>=15, sum>=18)."
)
def _prompt_user_pick(
qualified: list[GameInfo], games: list[GameInfo], state: State
) -> GameInfo:
"""Present the ranked list plus "pick my own"; return the chosen game.
Backing out of the own-game search (empty answer) shows the list again.
"""
options: list[PromptOption] = []
for i, g in enumerate(qualified):
hours_str = (
f" (~{g.completionist_hours:.1f}h)" if g.completionist_hours > 0 else ""
)
label = f"{g.name} (AppID={g.app_id}){hours_str}"
options.append(PromptOption(value=str(i), label=label))
options.append(OWN_PICK_OPTION)
while True:
picked = current_prompter().choice("Select game number", options)
if picked != OWN_PICK_VALUE:
return qualified[int(picked)]
own = prompt_own_pick(games, state)
if own is not None:
return own
def _assign_chosen_game(
chosen: GameInfo,
games: list[GameInfo],
state: State,
config: Config,
*,
keep: bool = False,
) -> None:
"""Save assignment, announce it, and handle install/uninstall.
Replacing (the default, and what ``done``/``check`` do) is the explicit
choice that lets enforcement remove released picks, so they are dropped
here. With ``keep`` (plain ``pick``) nothing is dropped and the outgoing
assignment is parked as a released pick, so it stays allowed and the
uninstall below spares it.
"""
if keep:
if chosen.app_id in allowed_app_ids(state):
_echo(f"\n{chosen.name} (AppID={chosen.app_id}) is already allowed.")
return
keep_outgoing_assignment(state)
else:
drop_inactive_picks(state)
record_assignment(state, chosen.app_id, chosen.name)
state.save()
hours_str = (
f" (~{chosen.completionist_hours:.1f}h leisure+dlc)"
if chosen.completionist_hours > 0
else ""
)
_echo(f"\n>>> ASSIGNED: {chosen.name} (AppID={chosen.app_id}){hours_str}")
_echo(
f" Progress: {chosen.unlocked_achievements}/{chosen.total_achievements}"
f" ({chosen.completion_pct:.1f}%)"
)
_report_poll_confidence(chosen, games, state)
if config.uninstall_other_games:
# The whole allowed set, not just the new assignment: passing a bare
# int made the `in` test raise TypeError, and would tear down a
# concurrent manual pick even if it did not. Matches the other four
# call sites (main/install.py, _cmd_done_finalize.py, main/picks.py,
# _enforce_steps.py). state.current_app_id is already `chosen` here.
count = uninstall_other_games(allowed_app_ids(state))
if count:
_echo(f"\n Uninstalled {count} non-assigned games")
if not is_game_fully_installed(chosen.app_id):
_echo(f"\n Auto-installing {chosen.name}...")
install_game(
chosen.app_id, chosen.name, config.steam_id, use_steam_protocol=True
)
def _clear_assignment(state: State, message: str) -> None:
"""Say why nothing could be assigned; keep the current game, if any.
With no replacement chosen, the game already assigned stays installed and
playable: only an explicit choice of another game may remove it.
"""
_echo(message)
state.save()
def _no_pick_message(confidence_skipped: int, linux_skipped: int) -> str:
"""Why the candidate pass produced nothing: confidence, or ProtonDB."""
if confidence_skipped > 0 and linux_skipped == 0:
return _NO_CONF_MSG
return "\nNo playable games left (all have poor ProtonDB ratings)!"
def _open_candidates(games: list[GameInfo], state: State) -> list[GameInfo]:
"""Unfinished, unskipped games with cached confidence, in pick order.
Least-recently-assigned first (never-assigned games lead), then shortest
HLTB time. A game released after one new achievement is still unfinished,
so without the recency key the same short game would come straight back
and the user would be locked onto it again.
"""
skip = set(state.finished_app_ids) | state.active_skipped_ids()
candidates = [g for g in games if not g.is_complete and g.app_id not in skip]
if candidates:
candidates.sort(
key=lambda g: (last_assigned_epoch(state, g.app_id), *_sort_key(g))
)
_apply_cached_confidence_to_candidates(candidates)
return candidates
def _pick_next_game_sequential(
games: list[GameInfo],
state: State,
config: Config,
on_select: Callable[[GameInfo], bool | GameInfo],
) -> None:
"""Pick the next-shortest playable game, asking the user per candidate.
``on_select`` is called with each prospective pick. Returning ``True``
accepts the assignment; returning ``False`` records a 7-day skip on
``state`` for that game and the next candidate is evaluated; returning a
game assigns that one instead (the user picked their own).
"""
while True:
candidates = _open_candidates(games, state)
if not candidates:
_clear_assignment(state, _NO_CONF_MSG)
return
chosen, confidence_skipped, linux_skipped = _pick_next_shortest_candidate(
candidates
)
if chosen is None:
_clear_assignment(
state, _no_pick_message(confidence_skipped, linux_skipped)
)
return
verdict = on_select(chosen)
if verdict is False:
state.skip_for_days(chosen.app_id, 7)
state.save()
_echo(f"\n Skipped {chosen.name} for 7 days; picking next...")
continue
_assign_chosen_game(
chosen if verdict is True else verdict, games, state, config
)
return