steam-backlog-enforcer/steam_backlog_enforcer/_cmd_done_finalize.py
Krzysztof kuhy Rudnicki a191c8e459 Release a game after one new achievement instead of 100% completion
Requiring 100% locked the user onto one game for weeks, which proved
ineffective. The assignment and every manual pick now release as soon as one
achievement is unlocked after the game was assigned (Steam unlock_time vs the
pick's started_at / State.current_assigned_at).

- finished_app_ids still means 100% complete; a game released below 100% gets
  released_at plus a 7-day cooldown and returns to the pool.
- Next pick: least recently assigned first, then shortest HLTB time.
- Releasing never evicts: released/expired picks stay installed and playable
  until the user explicitly chooses another game (new pick-manual, or
  accepting the next assignment); with no replacement the current game stays.
- Tampering detection judges a released game by unlocks after its release.
- The daemon reloads every State field each tick so its sweep save cannot
  overwrite cooldowns or assignment times written by the CLI.
- State.load backfills assignment times once and persists them; load-time
  migrations moved to _state_migrations.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhzaTAWtRfJk8gre6NLSka
2026-09-26 16:46:03 +02:00

189 lines
6.2 KiB
Python

"""Moving on from a game that earned a new achievement, or re-enforcing.
Split out of :mod:`steam_backlog_enforcer._cmd_done` to keep both files
under the 250-line cap.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import allowed_app_ids
from steam_backlog_enforcer._assignment_progress import (
release_game,
release_verdict,
)
from steam_backlog_enforcer._cmd_done import (
_apply_cached_hours_to_games,
_prompt_keep_or_skip,
_refresh_uncached_shortlist_hours,
_report_assigned_confidence,
)
from steam_backlog_enforcer._hltb_cached import fetch_hltb_times_cached
from steam_backlog_enforcer._hltb_types import load_hltb_cache
from steam_backlog_enforcer._snapshot import load_snapshot
from steam_backlog_enforcer._steam_state import is_game_fully_installed
from steam_backlog_enforcer.enforcer import (
enforce_allowed_game,
send_notification,
)
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.scanning import pick_next_game
from steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config, State
_REASSIGN_REFRESH_LIMIT = 50
_SKIP_DAYS = 7
logger = logging.getLogger(__name__)
def _finalize_completion(
config: Config,
state: State,
game: GameInfo,
) -> None:
"""Release the game, pick next, hide non-assigned games, notify.
The release (and its cooldown) is recorded before the next pick so the
game just played cannot be handed straight back.
"""
game_name = game.name
release_game(state, game)
snapshot_data = load_snapshot()
_echo("\nPicking next game...")
if not snapshot_data:
_echo(" No snapshot found. Run 'scan' first.")
state.save()
return
games = [GameInfo.from_snapshot(d) for d in snapshot_data]
hltb_cache = load_hltb_cache()
skip = set(state.finished_app_ids) | state.active_skipped_ids()
_refresh_uncached_shortlist_hours(games, hltb_cache, skip)
_apply_cached_hours_to_games(games, hltb_cache)
pick_next_game(games, state, config, on_select=_prompt_keep_or_skip)
if state.current_app_id in {None, game.app_id}:
# Nothing new was accepted, so the released game stays assigned.
_echo(f" No new game assigned; {game_name} stays playable.")
return
hide_others_and_report(config, state)
if not is_game_fully_installed(state.current_app_id):
logger.info(
"Assigned game still missing after library reconciliation; "
"re-triggering install"
)
_echo(
"\n Assigned game still missing after library reconciliation; "
"re-triggering install..."
)
_echo(f"\n Auto-installing {state.current_game_name}...")
install_game(
state.current_app_id,
state.current_game_name,
config.steam_id,
use_steam_protocol=True,
)
send_notification(
"Moving on!",
f"New achievement in {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(
allowed_app_ids(state),
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(allowed_app_ids(state))
if count:
_echo(f" Uninstalled {count} unauthorized game(s)")
if not is_game_fully_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.
hide_others_and_report(config, state, lead=" ")
def cmd_done(config: Config, state: State) -> None:
"""Check for a new achievement, pick next game, uninstall & hide.
All-in-one command for after making progress in a game:
1. Verify on Steam that at least one achievement was unlocked since the
game was assigned (or that it is at 100%).
2. Pick the next game (least recently assigned, then shortest HLTB 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)
released, verdict = release_verdict(state, game)
_echo(f"\n {verdict}")
if not released:
_enforce_on_done(config, state)
return
_finalize_completion(config, state, game)