steam-backlog-enforcer/steam_backlog_enforcer/_gaming_days.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

79 lines
2.7 KiB
Python

"""Gaming-day arithmetic: which day *now* is, and what it already granted.
A gaming "day" runs 06:00-05:59 local, so late-night sessions count against
the day they started in.
There is no carry-over: unspent time is dropped at 06:00, every day. The
Friday-Monday carry ledger was removed on 2026-10-18, after a Sunday ran
past the 6.0h the i3 bar showed because 2h47m had been carried into it.
``load_state`` takes a legacy ledger's share back out of the stored
high-water mark.
Everything here is pure: it works on primitives handed to it, so it imports
nothing from the playtime modules and cannot close an import cycle.
"""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from datetime import datetime
from steam_backlog_enforcer._playtime_state import PlaytimeState
# The gaming day starts here, local time. 05:59 belongs to the previous day.
_DAY_BOUNDARY_HOURS: Final = 6
def gaming_day_key(now: datetime) -> str:
"""Return the ``YYYY-MM-DD`` gaming day containing *now*.
The boundary is 06:00 local, so 02:00 on the 5th belongs to the 4th.
Args:
now: Timezone-aware local timestamp.
Returns:
The gaming day key.
"""
return (now - timedelta(hours=_DAY_BOUNDARY_HOURS)).date().isoformat()
def held_today(stored: PlaytimeState | None, now: datetime) -> float:
"""Return the budget already granted earlier in *now*'s gaming day.
Earners reset at calendar midnight but the gaming day runs to 05:59, and a
cold restart starts with empty answer caches, so a live re-resolution can
come in lower than what was granted hours ago. ``rules_for`` holds the day
at this high-water mark so neither can cut a session off unwarned.
Args:
stored: The persisted production state, or ``None`` if unreadable.
now: Local timestamp for this tick.
Returns:
The recorded high-water budget; 0 when the state is another day's.
"""
if stored is None or stored.day_key != gaming_day_key(now):
return 0.0
return stored.budget_seconds
def held_shadow_today(stored: PlaytimeState | None, now: datetime) -> float:
"""Return the shadow leisure budget already granted in *now*'s gaming day.
The same high-water rule as :func:`held_today`, for ``shadow_budget_seconds``.
Args:
stored: The persisted production state, or ``None`` if unreadable.
now: Local timestamp for this tick.
Returns:
The recorded shadow high-water budget; 0 when the state is another day's.
"""
if stored is None or stored.day_key != gaming_day_key(now):
return 0.0
return stored.shadow_budget_seconds