steam-backlog-enforcer/steam_backlog_enforcer/_gaming_reset.py
Krzysztof kuhy Rudnicki a09f08701a playtime: drop the Fri-Mon carry-over; log budget on change only
A Sunday ran past the 6.0h the i3 bar showed: 2h47m of Saturday's
leftover was carried into the budget the cutoff used, and the 3-second
"Gaming budget" journal line omitted it. Unspent time is now dropped at
06:00 every day; load_state takes a legacy ledger's share back out of
today's high-water mark. The budget line logs only on change (was 25k
lines/day), fired warnings are logged, and budget.jsonl records carry
budget_seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PrwCnQouKTF4EUMENMxnG
2026-10-18 21:37:29 +02:00

123 lines
3.8 KiB
Python

"""The gaming-reset core: zero today's counter, lift the block, leave a record.
Shared by the CLI (``sudo ./run.sh gaming-reset``, the direct recovery path)
and the daemon's two-phase control-socket op, so both do exactly the same
thing and both leave the same trail. The caller owns the confirmation
(``YES`` on the CLI, a phrase plus a heartbeat countdown on the socket).
Needs root: it releases bind mounts and writes the immutable state file.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import UTC, datetime
import logging
from typing import TYPE_CHECKING
from steam_backlog_enforcer._gaming_days import gaming_day_key
from steam_backlog_enforcer._playtime_block import release_block
from steam_backlog_enforcer._playtime_budget import roll_over
from steam_backlog_enforcer._playtime_history import record_day
from steam_backlog_enforcer._playtime_log import (
EVENT_MANUAL_ADJUSTMENT,
BudgetLog,
budget_log_path,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
load_state,
save_state,
)
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ResetOutcome:
"""What a gaming reset changed.
Attributes:
day_key: The gaming day that was reset.
seconds_before: Seconds billed before the reset.
was_blocked: Whether the cutoff had engaged.
released: Launcher mounts that were released.
"""
day_key: str
seconds_before: float
was_blocked: bool
released: list[Path]
def reset_today(*, source: str) -> ResetOutcome:
"""Zero today's billed time, drop the block and record the adjustment.
The day's ``budget_seconds`` high-water mark is kept: resetting spent
time must not also revoke time already earned. Warnings re-arm because
``seconds`` is back at zero.
The reset is written to the budget audit log as a ``manual_adjustment``
(with what was billed before, per game); that record is the auditable
trail. The per-day history is also set to 0 -- it mirrors *current* billed
time, so it shows the day as quiet and the audit log is what says why.
Args:
source: Who asked (``"cli"`` or ``"ctl"``), stored in the audit record.
Returns:
What changed.
"""
now = datetime.now(UTC).astimezone()
day_key = gaming_day_key(now)
stored = load_state(demo=False)
before = roll_over(stored, day_key=day_key) if stored else PlaytimeState()
released = release_block()
save_state(
replace(
before,
day_key=day_key,
seconds=0.0,
blocked_at=0.0,
last_tick_at=now.timestamp(),
per_game={},
last_credited_key="",
warned_seconds=[],
),
demo=False,
)
_record(source, day_key, before, released)
return ResetOutcome(
day_key=day_key,
seconds_before=before.seconds,
was_blocked=before.is_blocked(),
released=released,
)
def _record(
source: str, day_key: str, before: PlaytimeState, released: list[Path]
) -> None:
"""Write the audit record and the history point; never raises."""
log = BudgetLog(path=budget_log_path(demo=False))
log.record(
EVENT_MANUAL_ADJUSTMENT,
kind="gaming_reset",
source=source,
day_key=day_key,
billed_seconds_before=round(before.seconds, 3),
billed_seconds_after=0.0,
per_game_before=before.per_game,
was_blocked=before.is_blocked(),
released=[str(path) for path in released],
)
log.close()
try:
record_day(day_key, 0.0, {})
except OSError as exc:
# A history point is cosmetic; the reset itself already happened.
logger.warning("Could not record the reset in the history: %s", exc)