steam-backlog-enforcer/steam_backlog_enforcer/_playtime.py
Krzysztof kuhy Rudnicki 8d31e8e9af
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Cut the daily gaming budget to 6h on a day with no workout
8h is now earned rather than given. A gaming day with a counted workout
logged in screen-locker gets daily_gaming_seconds; a day without gets the
new unearned_gaming_seconds floor. Skipping a workout costs two hours the
same day, instead of nothing until the weekly lock fires.

The fact comes from screen-locker's GET /api/status, which reports only
workout_today -- the hour values live here. Neither repo can drift from the
other about what a workout day is: that flag is derived from
count_day_credits, the same rule the locker enforces with.

Every way of not getting an answer applies the floor, logged at warning with
the URL and the concrete error. Failing open would let the coupling be
defeated by stopping one user service, and a silent 8h would be
indistinguishable from an earned one.

resolve_budget_seconds is called from rules_for rather than at each call
site, because the daemon holds one Config for its whole life while
_budget_view reloads it per HTTP request. Resolved at only one of those and
the web UI would report a budget the daemon was not enforcing -- the exact
silent divergence this codebase is built to prevent.

A budget that rises mid-day now releases the block properly.
_release_after_raise clears blocked_at, which otherwise only roll_over reset
and which would leave the UI reporting "blocked" while gaming worked, and
clears warned_seconds, which is keyed on seconds *remaining* and would
otherwise let the extra two hours arrive with no warnings at all.

Known and accepted (2026-08-29): the budget can DROP mid-day if the resolved
answer changes under a day already in progress, re-pricing time already
spent and cutting off on the next tick with no warning. The module docstring
says so plainly rather than claiming a monotonicity the code does not
implement; the fix, if it ever bites, is a persisted granted_budget_seconds.

Tests get an autouse stub for the lookup. Without it every test that builds
rules makes a real loopback request, and the suite would pass in the morning
and fail in the evening depending on whether the machine's owner had trained.
test_budget_view builds its RULES at import time, before fixtures apply, so
it stubs the resolver explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
2026-08-29 23:11:27 +02:00

225 lines
7.8 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 raises it from the unearned floor to the
earned budget, and the mounts come down on the very next tick. 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 two hours 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)