mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
Dormant until earned_time.leisure_active(gaming day) -- the switch file written by `leisure-watch go-live`. From that day: - enforced `seconds` are billed by the shadow rule (lock pause, fail-closed, once per wall clock); budget from plain resolve(); warnings and the cutoff say "Leisure". - at 0, beside the unchanged Steam path, _leisure_kill SIGTERMs (SIGKILL 30 s later, per process) every focus/media pid in now.json except browsers, every failclosed browser pid, and -- with now.json untrusted -- every browser, media player and wsg-grabber reviewer. Root/system, lockers and the enforcer's own chain are never signalled. Kills go to budget.jsonl. - tests: an autouse fixture redirects both switch readers, so no test reads /etc/leisure-watch/leisure_from; existing tests unchanged. Re-pins earned-time to v0.15.0 (leisure_active). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
148 lines
4.5 KiB
Python
148 lines
4.5 KiB
Python
"""Pure state transitions for the daily playtime budget.
|
|
|
|
Separated from persistence: nothing here touches the disk or the clock
|
|
beyond what it is handed, which is what makes the budget arithmetic
|
|
straightforward to test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from steam_backlog_enforcer._playtime_state import (
|
|
_DELTA_CLAMP_FACTOR,
|
|
PlaytimeRules,
|
|
PlaytimeState,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from datetime import datetime
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def accumulate(
|
|
state: PlaytimeState,
|
|
*,
|
|
now: datetime,
|
|
qualifying: set[int],
|
|
interval: float,
|
|
credited_key: str = "",
|
|
) -> PlaytimeState:
|
|
"""Add this tick's elapsed gaming time to *state*.
|
|
|
|
Pure. Every argument is mandatory, including the clock — a defaulted ``now``
|
|
would add a branch that exists only to be covered.
|
|
|
|
The delta is clamped to ``_DELTA_CLAMP_FACTOR`` intervals so that a suspend,
|
|
a skipped tick or a forward NTP step cannot inflate the count, and floored
|
|
at zero so a backward step cannot refund time.
|
|
|
|
Args:
|
|
state: Current accounting state.
|
|
now: Timezone-aware local timestamp for this tick.
|
|
qualifying: PIDs currently counting against the budget.
|
|
interval: Nominal seconds between ticks.
|
|
credited_key: Attribution key to charge, or ``""`` when the tick cannot
|
|
be pinned to one game. An unattributable tick still bills
|
|
``seconds``; the shortfall is rendered as Unattributed rather than
|
|
charged to a guess, which is why ``per_game`` only ever sums to at
|
|
most ``seconds``.
|
|
|
|
Returns:
|
|
The updated state.
|
|
"""
|
|
return bill(
|
|
state,
|
|
now=now,
|
|
billed=bool(qualifying),
|
|
interval=interval,
|
|
credited_key=credited_key,
|
|
)
|
|
|
|
|
|
def bill(
|
|
state: PlaytimeState,
|
|
*,
|
|
now: datetime,
|
|
billed: bool,
|
|
interval: float,
|
|
credited_key: str = "",
|
|
) -> PlaytimeState:
|
|
""":func:`accumulate` with the "anything qualifies?" answer given directly.
|
|
|
|
The leisure budget (Phase 2) decides billing from the shadow verdict, not
|
|
from game PIDs; everything else -- the clamp, the first tick, a backward
|
|
step, the attribution -- is the same arithmetic.
|
|
|
|
Args:
|
|
state: Current accounting state.
|
|
now: Timezone-aware local timestamp for this tick.
|
|
billed: Whether this tick counts against the budget.
|
|
interval: Nominal seconds between ticks.
|
|
credited_key: Attribution key to charge, or ``""``.
|
|
|
|
Returns:
|
|
The updated state.
|
|
"""
|
|
stamp = now.timestamp()
|
|
if state.last_tick_at <= 0.0:
|
|
return replace(state, last_tick_at=stamp)
|
|
|
|
delta = stamp - state.last_tick_at
|
|
if delta <= 0.0 or not billed:
|
|
return replace(state, last_tick_at=stamp)
|
|
|
|
credited = min(delta, interval * _DELTA_CLAMP_FACTOR)
|
|
per_game = dict(state.per_game)
|
|
if credited_key:
|
|
per_game[credited_key] = per_game.get(credited_key, 0.0) + credited
|
|
return replace(
|
|
state,
|
|
last_tick_at=stamp,
|
|
seconds=state.seconds + credited,
|
|
per_game=per_game,
|
|
last_credited_key=credited_key or state.last_credited_key,
|
|
)
|
|
|
|
|
|
def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState:
|
|
"""Reset *state* if the gaming day has changed.
|
|
|
|
Pure. ``last_tick_at`` carries across the boundary so the first tick of the
|
|
new day measures a sane delta rather than restarting from zero. Nothing else
|
|
does: the outgoing day's unspent time is dropped.
|
|
|
|
Args:
|
|
state: Current accounting state.
|
|
day_key: The gaming day *now* falls in.
|
|
|
|
Returns:
|
|
Either *state* unchanged, or a fresh record for *day_key*.
|
|
"""
|
|
if state.day_key == day_key:
|
|
return state
|
|
return PlaytimeState(day_key=day_key, last_tick_at=state.last_tick_at)
|
|
|
|
|
|
def pending_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None:
|
|
"""Return the largest un-fired warning threshold *state* has crossed.
|
|
|
|
Pure. Returning only the largest matters when a tick is skipped: crossing
|
|
two thresholds at once should warn once, not twice.
|
|
|
|
Args:
|
|
state: Current accounting state.
|
|
rules: Policy for this tick.
|
|
|
|
Returns:
|
|
The threshold in seconds-remaining, or ``None`` if none is due.
|
|
"""
|
|
remaining = rules.budget_seconds - state.seconds
|
|
for threshold in rules.warn_at:
|
|
if remaining <= threshold and threshold not in state.warned_seconds:
|
|
return threshold
|
|
return None
|