testsAndMisc/python_pkg/steam_backlog_enforcer/_cmd_done.py

407 lines
13 KiB
Python

"""Done-flow helpers and cmd_done command for Steam Backlog Enforcer."""
from __future__ import annotations
from python_pkg.steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
from python_pkg.steam_backlog_enforcer.config import Config, State, load_snapshot
from python_pkg.steam_backlog_enforcer.enforcer import (
enforce_allowed_game,
send_notification,
)
from python_pkg.steam_backlog_enforcer.game_install import (
_echo,
install_game,
is_game_installed,
uninstall_other_games,
)
from python_pkg.steam_backlog_enforcer.hltb import (
fetch_hltb_confidence_cached,
fetch_hltb_times_cached,
load_hltb_cache,
load_hltb_count_comp_cache,
load_hltb_polls_cache,
save_hltb_cache,
)
from python_pkg.steam_backlog_enforcer.library_hider import hide_other_games
from python_pkg.steam_backlog_enforcer.scanning import (
_confidence_fail_reasons,
_pick_next_shortest_candidate,
_refresh_candidate_confidence,
pick_next_game,
)
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient
_REASSIGN_REFRESH_LIMIT = 50
def _backfill_polls_for_finished(
state: State,
extra_app_id: int | None = None,
) -> dict[int, int]:
"""Lazily fetch poll counts for already-finished games missing them.
If ``extra_app_id`` is provided and its poll count is missing, it is
refreshed alongside finished games (used to populate polls for the
currently-assigned game on first run after the schema upgrade).
"""
polls_cache = load_hltb_polls_cache()
snapshot_data = load_snapshot() or []
name_by_id = {d["app_id"]: d["name"] for d in snapshot_data}
candidate_ids = list(state.finished_app_ids)
if extra_app_id is not None and polls_cache.get(extra_app_id, 0) == 0:
candidate_ids.append(extra_app_id)
missing = [
(aid, name_by_id[aid])
for aid in candidate_ids
if aid in name_by_id and polls_cache.get(aid, 0) == 0
]
if not missing:
return polls_cache
_echo(f" Backfilling HLTB poll counts for {len(missing)} game(s)...")
cache = load_hltb_cache()
preserved_hours = {aid: cache[aid] for aid, _ in missing if aid in cache}
for aid, _name in missing:
cache.pop(aid, None)
save_hltb_cache(cache, polls_cache)
fetch_hltb_confidence_cached(missing)
refreshed_hours = load_hltb_cache()
refreshed_polls = load_hltb_polls_cache()
for aid, prior_hours in preserved_hours.items():
if prior_hours > 0 and refreshed_hours.get(aid, -1) <= 0:
refreshed_hours[aid] = prior_hours
save_hltb_cache(refreshed_hours, refreshed_polls)
return refreshed_polls
def _report_assigned_confidence(
app_id: int,
state: State,
) -> None:
"""Print HLTB poll-count confidence for the currently-assigned game."""
polls_cache = _backfill_polls_for_finished(state, extra_app_id=app_id)
chosen_polls = polls_cache.get(app_id, 0)
finished_polls = [
(polls_cache[aid], aid)
for aid in state.finished_app_ids
if polls_cache.get(aid, 0) > 0 and aid != app_id
]
snapshot_data = load_snapshot() or []
name_by_id = {d["app_id"]: d["name"] for d in snapshot_data}
warning = ""
if finished_polls:
min_polls = min(p for p, _ in finished_polls)
if 0 < chosen_polls < min_polls:
warning = " ⚠ NEW LOW — estimate may be unreliable"
elif chosen_polls == 0:
warning = " ⚠ no polls recorded — estimate may be unreliable"
elif chosen_polls == 0:
warning = " ⚠ no polls recorded — estimate may be unreliable"
_echo(f" HLTB confidence: {chosen_polls} polled completionist times{warning}")
if finished_polls:
min_polls, min_aid = min(finished_polls)
min_name = name_by_id.get(min_aid, f"AppID={min_aid}")
_echo(f" Historical min among finished: {min_polls} ({min_name})")
def _apply_cached_hours_to_games(
games: list[GameInfo],
hltb_cache: dict[int, float],
) -> None:
"""Overlay cached HLTB hours onto games (including cached misses)."""
for game in games:
if game.app_id in hltb_cache:
game.completionist_hours = hltb_cache[game.app_id]
def _apply_cached_confidence_to_games(games: list[GameInfo]) -> None:
"""Overlay cached confidence counters onto snapshot-backed game objects."""
polls_cache = load_hltb_polls_cache()
count_comp_cache = load_hltb_count_comp_cache()
for game in games:
if game.app_id in polls_cache:
game.comp_100_count = polls_cache[game.app_id]
if game.app_id in count_comp_cache:
game.count_comp = count_comp_cache[game.app_id]
def _refresh_uncached_shortlist_hours(
games: list[GameInfo],
hltb_cache: dict[int, float],
skip: set[int],
*,
upper_bound_hours: float | None = None,
) -> None:
"""Refresh likely-short uncached games to avoid stale snapshot decisions."""
shorter_uncached = [
(g.app_id, g.name)
for g in sorted(
(
game
for game in games
if not game.is_complete
and game.app_id not in skip
and game.completionist_hours > 0
and game.app_id not in hltb_cache
and (
upper_bound_hours is None
or game.completionist_hours < upper_bound_hours
)
),
key=lambda game: game.completionist_hours,
)[:_REASSIGN_REFRESH_LIMIT]
]
if shorter_uncached:
refreshed = fetch_hltb_times_cached(shorter_uncached)
hltb_cache.update(refreshed)
def _should_reassign_candidate(
playable: GameInfo,
current_hours: float,
*,
force_reassign: bool,
) -> bool:
"""Return whether a playable candidate should trigger reassignment."""
if force_reassign:
return True
if current_hours > 0:
return playable.completionist_hours < current_hours
return True
def _echo_reassign_decision(
playable: GameInfo,
current_hours: float,
current_fail_reasons: list[str],
*,
force_reassign: bool,
) -> None:
"""Emit a human-readable reassignment reason."""
if force_reassign:
_echo(
f"\n Reassigning: current game confidence too low "
f"({'; '.join(current_fail_reasons)})"
)
return
if current_hours > 0:
_echo(
f"\n Reassigning: {playable.name} is shorter"
f" (~{playable.completionist_hours:.1f}h vs ~{current_hours:.1f}h)"
)
return
_echo(
f"\n Reassigning: current game has no usable HLTB time; "
f"picked {playable.name} (~{playable.completionist_hours:.1f}h)"
)
def _try_reassign_shorter_game(
hltb_cache: dict[int, float],
app_id: int,
hours: float,
state: State,
config: Config,
) -> bool:
"""Check if a shorter game is available and reassign if so."""
snapshot_data = load_snapshot()
if not snapshot_data:
return False
all_games = [GameInfo.from_snapshot(d) for d in snapshot_data]
skip = set(config.skip_app_ids) | set(state.finished_app_ids)
_refresh_uncached_shortlist_hours(
all_games,
hltb_cache,
skip,
upper_bound_hours=hours,
)
_apply_cached_hours_to_games(all_games, hltb_cache)
_apply_cached_confidence_to_games(all_games)
current_game = next((g for g in all_games if g.app_id == app_id), None)
if current_game is not None and _confidence_fail_reasons(current_game):
_refresh_candidate_confidence(current_game)
current_fail_reasons = (
_confidence_fail_reasons(current_game) if current_game is not None else []
)
force_reassign = bool(current_fail_reasons)
candidates = [
g
for g in all_games
if not g.is_complete and g.app_id not in skip and g.completionist_hours > 0
]
if not force_reassign and hours > 0:
candidates = [g for g in candidates if g.completionist_hours < hours]
candidates.sort(key=lambda g: g.completionist_hours)
candidates = [c for c in candidates if c.app_id != app_id]
if not candidates:
return False
playable, _confidence_skipped, _linux_skipped = _pick_next_shortest_candidate(
candidates,
)
if playable is None:
return False
if not _should_reassign_candidate(
playable,
hours,
force_reassign=force_reassign,
):
return False
_echo_reassign_decision(
playable,
hours,
current_fail_reasons,
force_reassign=force_reassign,
)
pick_next_game(all_games, state, config)
if state.current_app_id is not None:
owned_ids = get_all_owned_app_ids(config)
if owned_ids:
hidden = hide_other_games(owned_ids, state.current_app_id)
if hidden > 0:
_echo(f"\n Library: hid {hidden} games")
return True
def _finalize_completion(
config: Config,
state: State,
game_name: str,
app_id: int,
) -> None:
"""Mark game complete, pick next, hide non-assigned games, notify."""
_echo(f"\n COMPLETED: {game_name}!")
state.finished_app_ids.append(app_id)
snapshot_data = load_snapshot()
_echo("\nPicking next game...")
if not snapshot_data:
_echo(" No snapshot found. Run 'scan' first.")
state.current_app_id = None
state.current_game_name = ""
state.save()
return
games = [GameInfo.from_snapshot(d) for d in snapshot_data]
hltb_cache = load_hltb_cache()
skip = set(config.skip_app_ids) | set(state.finished_app_ids)
_refresh_uncached_shortlist_hours(games, hltb_cache, skip)
_apply_cached_hours_to_games(games, hltb_cache)
pick_next_game(games, state, config)
if state.current_app_id is None:
_echo(" No more games to assign!")
return
owned_ids = get_all_owned_app_ids(config)
if owned_ids:
hidden = hide_other_games(owned_ids, state.current_app_id)
if hidden > 0:
_echo(f"\n Library: hid {hidden} games")
send_notification(
"Game Complete!",
f"Finished {game_name}! Now playing: {state.current_game_name}",
)
_echo(f"\nAll done! Go play {state.current_game_name}!")
def _enforce_on_done(config: Config, state: State) -> None:
"""Run a single enforcement pass during the 'done' command.
Kills unauthorized game processes, uninstalls unauthorized games,
and ensures the assigned game is installed.
"""
if state.current_app_id is None:
return
if config.kill_unauthorized_games:
violations = enforce_allowed_game(
state.current_app_id,
kill_unauthorized=True,
)
for pid, app_id in violations:
_echo(f" Killed unauthorized game: AppID={app_id} (PID={pid})")
if config.uninstall_other_games:
count = uninstall_other_games(state.current_app_id)
if count:
_echo(f" Uninstalled {count} unauthorized game(s)")
if not is_game_installed(state.current_app_id):
_echo(f" Re-installing {state.current_game_name}...")
install_game(
state.current_app_id,
state.current_game_name,
config.steam_id,
use_steam_protocol=True,
)
# Reconcile library: hide non-assigned games and unhide the assigned one.
# Without this, an interrupted earlier completion can leave the new
# assigned game hidden and stale games visible.
owned_ids = get_all_owned_app_ids(config)
if owned_ids:
hidden = hide_other_games(owned_ids, state.current_app_id)
if hidden > 0:
_echo(f" Library: hid {hidden} games")
def cmd_done(config: Config, state: State) -> None:
"""Check completion, pick next game, uninstall & hide.
All-in-one command for after finishing a game:
1. Verify 100% achievements on Steam.
2. Pick the next game (shortest HLTB leisure+dlc time).
3. Uninstall all non-assigned games.
4. Hide all non-assigned games in the Steam library.
5. Install the newly assigned game.
"""
if state.current_app_id is None:
_echo("No game currently assigned. Run 'scan' first.")
return
client = SteamAPIClient(config.steam_api_key, config.steam_id)
game_name = state.current_game_name
app_id = state.current_app_id
_echo(f"Checking {game_name} (AppID={app_id})...")
game = client.refresh_single_game(app_id, game_name)
if game is None:
_echo(" Could not fetch achievement data from Steam.")
return
_echo(
f" Progress: {game.unlocked_achievements}/{game.total_achievements}"
f" ({game.completion_pct:.1f}%)"
)
hltb_cache = load_hltb_cache()
hours = hltb_cache.get(app_id, -1.0)
if hours < 0:
hltb_cache = fetch_hltb_times_cached([(app_id, game_name)])
hours = hltb_cache.get(app_id, -1.0)
if hours > 0:
_echo(f" HLTB leisure+dlc estimate: {hours:.1f} hours")
_report_assigned_confidence(app_id, state)
if _try_reassign_shorter_game(hltb_cache, app_id, hours, state, config):
return
if not game.is_complete:
remaining = game.total_achievements - game.unlocked_achievements
_echo(f"\n NOT COMPLETE: {remaining} achievements remaining. Keep going!")
_enforce_on_done(config, state)
return
_finalize_completion(config, state, game_name, app_id)