mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 18:53:48 +01:00
The budget was binary: 8h with a counted workout, 6h without. There was no base/bonus structure anywhere -- resolve_budget_seconds returned one of two absolute config values, and the "2h bonus" was emergent arithmetic (8 - 6) that appeared in no line of code. A second earner could not be bolted onto that shape without coupling the two. It is now a sum: 5h floor + 2h workout + 1h LeetCode, so a day is worth 5, 6, 7 or 8 hours. Note the deliberate tightening -- a workout-only day drops 8h to 7h, and a day that earns nothing drops 6h to 5h. The two earners are read independently and added. That is what "must not interfere with the workout" means in code: an unreadable LeetCode ledger leaves a workout day at 7h, not 5h, and there is a test asserting exactly that. LeetCode is read from leetcode-guard's ledger directly -- it answers even when leetcode-guard is not running, which is most of the day -- and falls back to its loopback endpoint. Credits are signature-checked; _leetcode_ledger reproduces gatelock's canonicalisation in eight lines because gatelock lives in the user's site-packages and this daemon runs as root, and a pinned vector fails loudly if that ever drifts. Failing to get an answer grants nothing AND raises an incident: desktop notification, warning log, and a line in leetcode_bonus_incidents.log (gitignored, so it never becomes an uncommitted change), rate-limited to once per reason per day. A silently missing hour looks exactly like an unearned one. resolve_budget_seconds is *renamed*, not wrapped: two functions would recreate the second resolution seam this module's docstring warns about. rules_for calls it once and carries the breakdown on PlaytimeRules, so /api/budget reports why today is what it is without resolving again. Known limitation, accepted: leetcode-guard only writes credits from its lock window's poll loop, so a voluntary extra solve after the day is settled never reaches the ledger. Verified live against real services -- all four cases (5/6/7/8h), the endpoint fallback, the fail-closed path with its notification, and the rate limit. 1782 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fp6zWH9zW1AkGdDyKjrbi8
227 lines
8.0 KiB
Python
227 lines
8.0 KiB
Python
"""Daily gaming-time budget: accounting, day rollover and cutoff policy.
|
|
|
|
Tracks how long the user has spent gaming during the current *gaming day* and,
|
|
once the budget is exhausted, drives the cutoff sequence implemented in
|
|
:mod:`steam_backlog_enforcer._playtime_block`.
|
|
|
|
Three design points that are load-bearing:
|
|
|
|
**The gaming day starts at 06:00 local, not midnight.** A session that runs
|
|
past midnight belongs to the day it started in; a midnight boundary would hand
|
|
out a fresh 8-hour budget in the middle of a late session. Rollover is checked
|
|
lazily on every tick against a stored day key rather than scheduled, so it
|
|
survives suspend, reboot and daemon restarts with no timer of its own.
|
|
|
|
**Elapsed time is measured from wall-clock deltas, clamped.** Counting "one
|
|
tick = one interval" silently under-counts whenever the loop is slow and
|
|
over-counts nothing; measuring the raw delta over-counts wildly across a
|
|
suspend. Clamping each delta to a small multiple of the tick interval makes
|
|
suspends, missed ticks, daemon restarts and NTP steps all behave.
|
|
|
|
**Launchers are matched by cmdline, not by ``comm``.** ``/usr/bin/lutris`` is a
|
|
``#!/usr/bin/env python3`` script, so the kernel records its ``comm`` as
|
|
``python3`` and the ``comm``-based scan in :mod:`enforcer` never sees it. That
|
|
is why :func:`get_pids_by_cmdline_names` exists alongside
|
|
``enforcer.get_pids_by_process_names`` rather than replacing it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
from datetime import UTC, datetime
|
|
import logging
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from steam_backlog_enforcer._playtime_block import (
|
|
mounted_targets,
|
|
reconcile,
|
|
)
|
|
from steam_backlog_enforcer._playtime_budget import (
|
|
accumulate,
|
|
roll_over,
|
|
)
|
|
from steam_backlog_enforcer._playtime_cutoff import (
|
|
_begin_cutoff,
|
|
_sustain_block,
|
|
_warn,
|
|
)
|
|
from steam_backlog_enforcer._playtime_procs import attributed_key, qualifying_pids
|
|
from steam_backlog_enforcer._playtime_state import (
|
|
PlaytimeRules,
|
|
PlaytimeState,
|
|
gaming_day_key,
|
|
load_state,
|
|
rules_for,
|
|
save_state,
|
|
)
|
|
from steam_backlog_enforcer._total_block import is_total_block_active
|
|
|
|
if TYPE_CHECKING:
|
|
from steam_backlog_enforcer._playtime_session import PlaytimeSession
|
|
from steam_backlog_enforcer.config import Config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_SECONDS_PER_MINUTE = 60
|
|
_MINUTES_PER_HOUR = 60
|
|
|
|
|
|
def playtime_tick(
|
|
config: Config,
|
|
*,
|
|
interval: float,
|
|
session: PlaytimeSession,
|
|
demo: bool = False,
|
|
) -> None:
|
|
"""Account for this tick's gaming time and enforce the daily budget.
|
|
|
|
Called first in every enforce-loop iteration, before any other guard, so
|
|
that time is still counted (and a 06:00 release still happens) in the
|
|
situations where the rest of the loop early-returns.
|
|
|
|
Time only accrues while the user is actually engaged: a resident game
|
|
process behind a locked screen, an unfocused window or five minutes of
|
|
silence is not play. See :mod:`_playtime_engagement`.
|
|
|
|
Args:
|
|
config: Loaded user configuration.
|
|
interval: Nominal seconds between enforce-loop ticks.
|
|
session: Cross-tick engagement and logging state.
|
|
demo: Whether this is a short-budget demo run.
|
|
"""
|
|
now = datetime.now(UTC).astimezone()
|
|
rules = rules_for(config, demo=demo)
|
|
state = roll_over(_state_or_recover(rules, now=now), day_key=gaming_day_key(now))
|
|
|
|
if is_total_block_active():
|
|
# The total block runs `pacman -R steam` every tick; our bind mounts
|
|
# would make that fail EBUSY. It is strictly stronger — get out of its
|
|
# way, and stop accruing against a budget nobody can spend.
|
|
reconcile(should_block=False)
|
|
save_state(state, demo=demo)
|
|
session.history.observe(state, demo=demo)
|
|
return
|
|
|
|
owners = qualifying_pids(rules)
|
|
pids = set(owners)
|
|
monotonic = time.monotonic()
|
|
verdict = session.tracker.assess(rules, qualifying=pids, now_monotonic=monotonic)
|
|
|
|
state = accumulate(
|
|
state,
|
|
now=now,
|
|
qualifying=pids if verdict.engaged else set(),
|
|
interval=interval,
|
|
credited_key=(
|
|
attributed_key(owners, verdict.focus_pid) if verdict.engaged else ""
|
|
),
|
|
)
|
|
state = session.tracker.backdate(state, verdict, rules=rules)
|
|
state = _policy(state, rules, now=now)
|
|
session.journal.observe(verdict, state, rules=rules, now_monotonic=monotonic)
|
|
save_state(state, demo=demo)
|
|
session.history.observe(state, demo=demo)
|
|
|
|
|
|
def _state_or_recover(rules: PlaytimeRules, *, now: datetime) -> PlaytimeState:
|
|
"""Load state, synthesising a fail-closed record if it is unusable.
|
|
|
|
If the state file is gone but the binaries are still masked, the only safe
|
|
reading is that the file was deleted to lift the block. Synthesising an
|
|
exhausted budget keeps it in force; the normal 06:00 rollover then lifts it
|
|
with no special case.
|
|
|
|
Args:
|
|
rules: Policy for this tick.
|
|
now: Timezone-aware local timestamp.
|
|
|
|
Returns:
|
|
Loaded or synthesised state.
|
|
"""
|
|
loaded = load_state(demo=rules.demo)
|
|
if loaded is not None:
|
|
return loaded
|
|
|
|
stamp = now.timestamp()
|
|
fresh = PlaytimeState(day_key=gaming_day_key(now), last_tick_at=stamp)
|
|
if not mounted_targets():
|
|
return fresh
|
|
|
|
logger.warning("Playtime state missing while blocked; failing closed.")
|
|
return replace(fresh, seconds=rules.budget_seconds, blocked_at=stamp)
|
|
|
|
|
|
def _release_after_raise(
|
|
state: PlaytimeState,
|
|
rules: PlaytimeRules,
|
|
) -> PlaytimeState:
|
|
"""Clear block bookkeeping after the day's budget rose past what was spent.
|
|
|
|
Only ``roll_over`` at the 06:00 boundary ever reset ``blocked_at`` before
|
|
the workout coupling existed, because the budget could not move within a
|
|
day. It can now: logging a workout adds 2h and solving a LeetCode problem
|
|
adds 1h, and the mounts come down on the very next tick. The trigger is
|
|
``state.seconds < rules.budget_seconds`` -- the number alone, never which
|
|
earner moved it -- so both raises release identically. Two things have to
|
|
come down with them.
|
|
|
|
``blocked_at`` — otherwise ``is_blocked()`` stays true and ``build_today``
|
|
keeps reporting ``"blocked": true``, so the UI would claim you were blocked
|
|
while gaming worked fine. That is the exact "state disagrees with reality"
|
|
failure the mount-visibility check exists to catch.
|
|
|
|
``warned_seconds`` — the thresholds are keyed on seconds *remaining*, so a
|
|
raise makes remaining climb back through values already recorded as fired.
|
|
Left alone, the extra hour or two would arrive with no warnings at all.
|
|
|
|
Args:
|
|
state: Accounting state for the current gaming day.
|
|
rules: Policy for this tick, holding the newly raised budget.
|
|
|
|
Returns:
|
|
The state with block bookkeeping cleared.
|
|
"""
|
|
logger.warning(
|
|
"Gaming budget rose to %.1fh past the %.0fs already spent — releasing "
|
|
"the block and re-arming warnings for the remaining time.",
|
|
rules.budget_seconds / 3600,
|
|
state.seconds,
|
|
)
|
|
return replace(state, blocked_at=0.0, warned_seconds=[])
|
|
|
|
|
|
def _policy(
|
|
state: PlaytimeState,
|
|
rules: PlaytimeRules,
|
|
*,
|
|
now: datetime,
|
|
) -> PlaytimeState:
|
|
"""Apply warnings, the cutoff, and block upkeep to *state*.
|
|
|
|
Args:
|
|
state: Accounting state after this tick's accumulation.
|
|
rules: Policy for this tick.
|
|
now: Timezone-aware local timestamp.
|
|
|
|
Returns:
|
|
The updated state.
|
|
"""
|
|
if not rules.enforcement:
|
|
# A kill switch must still release: leaving live mounts behind would
|
|
# make "disabled" mean "permanently blocked".
|
|
reconcile(should_block=False)
|
|
return state
|
|
|
|
if state.seconds < rules.budget_seconds:
|
|
reconcile(should_block=False)
|
|
if state.is_blocked():
|
|
state = _release_after_raise(state, rules)
|
|
return _warn(state, rules)
|
|
|
|
if not state.is_blocked():
|
|
return _begin_cutoff(state, rules, now=now)
|
|
|
|
return _sustain_block(state, rules, now=now)
|