steam-backlog-enforcer/steam_backlog_enforcer/_playtime.py
Krzysztof kuhy Rudnicki e82ddd9a4b playtime: shadow leisure billing (C4); /proc scan from leisure_watch
The enforcer now imports the Steam/launcher/counted-process scan from
leisure_watch.procscan (v0.1.0, shared with the leisure watcher) and drops
its own copies (_playtime_procs, _counted_procs, _total_block_launchers and
enforcer's two scanners). Every counted-process read passes
config.CONFIG_FILE explicitly, as before.

Each tick also bills a shadow leisure budget beside the enforced one, from
the same incoming stamp and delta clamp, before accumulate: paused (held
gatelock grab or i3lock) -> shadow_paused_seconds; else a qualifying game,
a "leisure" now.json, or an untrusted/missing now.json (fail closed) bills
shadow_seconds once, split over shadow_per_source. shadow_budget_seconds is
earned_time.resolve(leisure=True) from the same answers, held as a same-day
high-water mark. Not billed under a total block. Shadow verdict changes go
to budget.jsonl as shadow_change (the live game view skips them); /api/budget
and MCP get_gaming_time carry a "shadow" block. Enforced fields, cutoff,
warnings and existing log records are unchanged.

Also fixes two tests that assumed earned_time < 0.14 internals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 12:37:01 +02:00

247 lines
9.1 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``. The scan (``leisure_watch.procscan``, shared with the leisure
watcher) therefore reads argv as well as ``comm``.
**Shadow leisure time rides the same tick** (:mod:`_shadow`, contract C4): it
is measured from the same stamp and clamp, before ``accumulate``, and never
touches an enforced field. Under a total block it is not billed.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime
import logging
import time
from typing import TYPE_CHECKING
from leisure_watch.procscan import attributed_key, qualifying_pids
from steam_backlog_enforcer import config as config_mod
from steam_backlog_enforcer._gaming_days import gaming_day_key
from steam_backlog_enforcer._playtime_block import mounted_targets
from steam_backlog_enforcer._playtime_budget import (
accumulate,
roll_over,
)
from steam_backlog_enforcer._playtime_cutoff import (
_begin_cutoff,
_sustain_block,
_warn,
reconcile_for,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeRules,
PlaytimeState,
load_state,
rules_for,
save_state,
)
from steam_backlog_enforcer._shadow import observe_shadow, shadow_accumulate
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.
A qualifying process running is sufficient on its own — there is no
engagement gate on top of it.
Args:
config: Loaded user configuration.
interval: Nominal seconds between enforce-loop ticks.
session: Cross-tick 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))
# Recorded every tick, total-block branch included: it is what the next
# 06:00 roll-over measures this day's leftover against. A high-water mark,
# because earners can reset at midnight -- before the gaming day ends --
# and a 05:59 dip must not erase the evening's earned-but-unspent time.
state = replace(
state,
budget_seconds=max(state.budget_seconds, rules.budget_seconds),
shadow_budget_seconds=max(
state.shadow_budget_seconds, rules.shadow_budget_seconds
),
)
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_for(rules, should_block=False)
save_state(state, demo=demo)
session.history.observe(state, demo=demo)
return
owners = qualifying_pids(rules, config_mod.CONFIG_FILE)
pids = set(owners)
shadow = observe_shadow(owners, stamp=now.timestamp())
state = shadow_accumulate(
state, stamp=now.timestamp(), interval=interval, verdict=shadow
)
state = accumulate(
state,
now=now,
qualifying=pids,
interval=interval,
credited_key=attributed_key(owners),
)
state = _policy(state, rules, now=now)
session.journal.observe(pids, state, rules=rules, now_monotonic=time.monotonic())
session.shadow.observe(shadow, state)
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_for(rules, should_block=False)
return state
if state.seconds < rules.budget_seconds:
reconcile_for(rules, should_block=False)
if state.is_blocked():
state = _release_after_raise(state, rules)
# A raise that never reached the cutoff lifts remaining back above
# thresholds already fired; re-arm them or the new end comes unwarned.
remaining = rules.budget_seconds - state.seconds
rearmed = [t for t in state.warned_seconds if t >= remaining]
if rearmed != state.warned_seconds:
state = replace(state, warned_seconds=rearmed)
return _warn(state, rules)
if not state.is_blocked():
return _begin_cutoff(state, rules, now=now)
return _sustain_block(state, rules, now=now)