steam-backlog-enforcer/steam_backlog_enforcer/_playtime_budget.py
Krzysztof kuhy Rudnicki 9586128163 Carry unspent Fri-Sun gaming time to the rest of the Fri-Mon block
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 21:07:49 +02:00

127 lines
3.9 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._gaming_days import pass_on
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.
"""
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 qualifying:
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, and the carry
ledger crosses it with the outgoing day's leftover added (see
``_gaming_days``).
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,
carry=pass_on(
state.carry,
outgoing_day=state.day_key,
budget=state.budget_seconds,
spent=state.seconds,
new_day=day_key,
),
)
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