mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 18:13:49 +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
183 lines
6.8 KiB
Python
183 lines
6.8 KiB
Python
"""Constants and lock helpers shared by the CLI command modules.
|
|
|
|
Leaf module: it imports from the wider package but never from a sibling in
|
|
``main``, so the command modules can all depend on it without creating an
|
|
import cycle.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
import sys
|
|
from typing import TYPE_CHECKING
|
|
|
|
from steam_backlog_enforcer._actions import active_manual_picks
|
|
from steam_backlog_enforcer._actions import (
|
|
is_manual_pick_locked as _is_manual_pick_locked,
|
|
)
|
|
from steam_backlog_enforcer._allowed_games import (
|
|
MANUAL_LOCK_DAYS as _MANUAL_LOCK_DAYS,
|
|
)
|
|
from steam_backlog_enforcer._manual_pick_lifecycle import manual_pick_age_days
|
|
from steam_backlog_enforcer._total_block import (
|
|
TotalBlockStatus,
|
|
get_total_block_status,
|
|
is_total_block_active,
|
|
)
|
|
from steam_backlog_enforcer.game_install import _echo
|
|
|
|
if TYPE_CHECKING:
|
|
from steam_backlog_enforcer.config import State
|
|
|
|
_LIST_DISPLAY_LIMIT = 50
|
|
_MIN_CLI_ARGS = 2
|
|
|
|
# Commands that remain usable while the manual pick lock is active.
|
|
# Principle: only what is needed to release the lock (done/check) or
|
|
# that cannot change the game assignment (status, enforce, setup, serve).
|
|
_MANUAL_LOCK_EXEMPT_COMMANDS = frozenset(
|
|
{
|
|
"done",
|
|
"check",
|
|
"status",
|
|
"enforce",
|
|
"setup",
|
|
"serve",
|
|
"abandon-pick",
|
|
# Allowed so a second game can be locked in alongside the first; the
|
|
# cap inside cmd_pick_manual is what stops this being a way out.
|
|
"pick-manual",
|
|
# The daily gaming budget is orthogonal to which game is assigned, and
|
|
# gaming-unblock is a recovery hatch for a stuck bind mount - locking
|
|
# it behind a manual pick would leave Steam masked with no way back.
|
|
"gaming-status",
|
|
"gaming-unblock",
|
|
"gaming-reset",
|
|
}
|
|
)
|
|
|
|
# Commands that remain usable while a total gaming block is active. Far
|
|
# stricter than _MANUAL_LOCK_EXEMPT_COMMANDS: no done/pick/reset/
|
|
# add-exception - there is no in-app way to shorten a total block.
|
|
#
|
|
# gaming-unblock is included because a playtime bind mount makes the total
|
|
# block's own `pacman -R steam` fail EBUSY - it must stay reachable exactly
|
|
# when the two collide. gaming-reset is NOT included: it shortens enforcement.
|
|
_TOTAL_BLOCK_EXEMPT_COMMANDS = frozenset(
|
|
{"status", "enforce", "gaming-status", "gaming-unblock"}
|
|
)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Total gaming block lock helpers
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _show_total_block_lock_message(status: TotalBlockStatus) -> None:
|
|
"""Print the total-gaming-block-active message to stdout."""
|
|
_echo("\n" + "=" * 60)
|
|
_echo(" *** TOTAL GAMING BLOCK ACTIVE ***")
|
|
_echo("=" * 60)
|
|
|
|
if status.until is not None:
|
|
_echo(f"\nBlocked until: {status.until.strftime('%Y-%m-%d %H:%M UTC')}")
|
|
_echo(f"Days remaining: {status.days_remaining:.1f}")
|
|
|
|
_echo(
|
|
"\nSteam has been uninstalled, all known game/launcher processes are"
|
|
"\nbeing killed on sight, and Steam + game-website domains are blocked."
|
|
"\nThere is NO in-app command to lift this early."
|
|
f"\n\nAllowed commands: {', '.join(sorted(_TOTAL_BLOCK_EXEMPT_COMMANDS))}"
|
|
)
|
|
_echo("=" * 60 + "\n")
|
|
|
|
|
|
def _enforce_total_block_lock(command: str) -> None:
|
|
"""Exit with a lock message if command is blocked by an active total block."""
|
|
if not is_total_block_active():
|
|
return
|
|
if command in _TOTAL_BLOCK_EXEMPT_COMMANDS:
|
|
return
|
|
_show_total_block_lock_message(get_total_block_status())
|
|
sys.exit(1)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Manual pick lock helpers
|
|
# (the predicate itself lives in _actions so the MCP server can reuse it)
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _describe_pick(state: State, pick: dict[str, object]) -> bool:
|
|
"""Print one manual pick's deadline and grace status; return if abandonable.
|
|
|
|
Args:
|
|
state: Current enforcer state.
|
|
pick: One active entry from ``state.manual_picks``.
|
|
|
|
Returns:
|
|
Whether this pick is still inside its grace window.
|
|
"""
|
|
app_id = pick["app_id"]
|
|
_echo(f"\n {pick['game_name']} (AppID={app_id})")
|
|
|
|
started_at = str(pick.get("started_at") or "")
|
|
if started_at:
|
|
try:
|
|
started = datetime.fromisoformat(started_at)
|
|
deadline = started + timedelta(days=_MANUAL_LOCK_DAYS)
|
|
days_left = (deadline - datetime.now(UTC)).days
|
|
_echo(f" Locked since: {started.strftime('%Y-%m-%d')}")
|
|
_echo(
|
|
f" Deadline: {deadline.strftime('%Y-%m-%d')}"
|
|
f" ({max(0, days_left)} day(s) remaining)"
|
|
)
|
|
except ValueError:
|
|
pass
|
|
|
|
age_days = manual_pick_age_days(state, int(str(app_id)))
|
|
if age_days is not None:
|
|
_echo(
|
|
f" Undo: abandon-pick {app_id}"
|
|
f" (picked {age_days:.1f} day(s) ago)"
|
|
)
|
|
return True
|
|
return False
|
|
|
|
|
|
def _show_manual_pick_lock_message(state: State) -> None:
|
|
"""Print the aggressive lock-active message to stdout."""
|
|
picks = active_manual_picks(state)
|
|
_echo("\n" + "=" * 60)
|
|
_echo(" *** MANUAL PICK LOCK ACTIVE ***")
|
|
_echo("=" * 60)
|
|
_echo(f"\nYou manually picked {len(picks)} game(s):")
|
|
|
|
any_in_grace = False
|
|
for pick in picks:
|
|
any_in_grace |= _describe_pick(state, pick)
|
|
|
|
_echo(
|
|
"\nYou CANNOT use any other feature until you earn at least one new"
|
|
"\nachievement in each of these games or their 2-week deadlines pass."
|
|
"\n\nTo release the lock: earn one, then run 'done' or 'check'."
|
|
)
|
|
|
|
# 'abandon-pick' is dropped from the allowed list once no pick is still
|
|
# inside its window, rather than offered as a command that would refuse.
|
|
usable = set(_MANUAL_LOCK_EXEMPT_COMMANDS)
|
|
if not any_in_grace:
|
|
usable.discard("abandon-pick")
|
|
_echo(f"\nAllowed commands: {', '.join(sorted(usable))}")
|
|
_echo("=" * 60 + "\n")
|
|
|
|
|
|
def _enforce_manual_pick_lock(command: str, state: State) -> None:
|
|
"""Exit with a lock message if command is blocked by the manual pick."""
|
|
if not _is_manual_pick_locked(state):
|
|
return
|
|
if command in _MANUAL_LOCK_EXEMPT_COMMANDS:
|
|
return
|
|
_show_manual_pick_lock_message(state)
|
|
sys.exit(1)
|