mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 12:07:32 +01:00
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
189 lines
6.2 KiB
Python
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)
|