mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
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
251 lines
9.4 KiB
Python
251 lines
9.4 KiB
Python
"""One read-only view of the gaming budget, shared by MCP and the web API.
|
|
|
|
Both transports answer the same question — "how much have I played today, and
|
|
is the cutoff on?" — so both compose it here. The MCP tool used to build its
|
|
reply inline; a second copy in the HTTP handler would have been a second thing
|
|
to keep in step with ``PlaytimeRules``.
|
|
|
|
Everything here is a read. Changing the budget, resetting it and blocking stay
|
|
on the privileged paths, which is why the web server can serve this without
|
|
gaining a single mutating verb.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING, Any, Final
|
|
|
|
from steam_backlog_enforcer._budget_games import (
|
|
billing_label,
|
|
history_view,
|
|
today_games,
|
|
)
|
|
from steam_backlog_enforcer._budget_log_tail import last_verdict
|
|
from steam_backlog_enforcer._ledger_earners import registry_for
|
|
from steam_backlog_enforcer._playtime_block import mounted_targets
|
|
from steam_backlog_enforcer._playtime_history import load_history
|
|
from steam_backlog_enforcer._playtime_state import (
|
|
load_state,
|
|
rules_for,
|
|
state_path,
|
|
)
|
|
from steam_backlog_enforcer._shadow_view import shadow_view
|
|
from steam_backlog_enforcer.config import Config, State
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
import earned_time
|
|
|
|
from steam_backlog_enforcer._playtime_state import PlaytimeRules, PlaytimeState
|
|
|
|
READABLE: Final = "ok"
|
|
MISSING: Final = "missing"
|
|
DENIED: Final = "denied"
|
|
CORRUPT: Final = "corrupt"
|
|
|
|
_STATUS_MESSAGES: Final[dict[str, str]] = {
|
|
DENIED: (
|
|
"Cannot read the budget state file. It is owned by root, and only "
|
|
"becomes world-readable once the enforcer has rewritten it — restart "
|
|
"steam-backlog-enforcer.service."
|
|
),
|
|
MISSING: "No gaming time has been recorded yet today.",
|
|
CORRUPT: (
|
|
"The budget state file could not be parsed. The enforcer treats that "
|
|
"as tampering and will start the day fail-closed."
|
|
),
|
|
}
|
|
|
|
_HISTORY_DAYS: Final = 14
|
|
|
|
|
|
def state_access(path: Path) -> str:
|
|
"""Classify why the state file did or did not open.
|
|
|
|
``load_state`` deliberately collapses "missing", "corrupt" and "unreadable"
|
|
into ``None`` — its caller is the enforcer, whose recovery is the same for
|
|
all three. A display needs to tell them apart, because "you have not gamed
|
|
today" and "I am not allowed to look" are opposite messages.
|
|
|
|
The read is attempted rather than pre-checked with ``os.access``: the
|
|
kernel's answer to the actual open is the only one that cannot disagree
|
|
with what ``load_state`` just experienced. The file is a couple of hundred
|
|
bytes, so reading it twice costs nothing.
|
|
|
|
Args:
|
|
path: The state file.
|
|
|
|
Returns:
|
|
``READABLE``, ``MISSING`` or ``DENIED``.
|
|
"""
|
|
try:
|
|
path.read_bytes()
|
|
except FileNotFoundError:
|
|
return MISSING
|
|
except OSError:
|
|
return DENIED
|
|
return READABLE
|
|
|
|
|
|
def next_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None:
|
|
"""Return the next warning threshold that has yet to be reached.
|
|
|
|
Not ``_playtime_budget.pending_warning``: that returns a threshold already
|
|
*crossed* and awaiting its notification, which the daemon fires within one
|
|
tick — so an external reader sees ``None`` virtually always. This is the
|
|
forward-looking one, the threshold the countdown is heading towards.
|
|
|
|
Args:
|
|
state: Current accounting state.
|
|
rules: Policy in force.
|
|
|
|
Returns:
|
|
Seconds-remaining at which the next warning fires, or ``None`` when
|
|
every threshold has been passed.
|
|
"""
|
|
remaining = rules.budget_seconds - state.seconds
|
|
upcoming = [
|
|
threshold
|
|
for threshold in rules.warn_at
|
|
if threshold < remaining and threshold not in state.warned_seconds
|
|
]
|
|
return max(upcoming) if upcoming else None
|
|
|
|
|
|
def build_today(state: PlaytimeState, rules: PlaytimeRules) -> dict[str, Any]:
|
|
"""Compose the "today" block from loaded state.
|
|
|
|
Args:
|
|
state: Loaded accounting state.
|
|
rules: Policy in force.
|
|
|
|
Returns:
|
|
Serialisable today-block.
|
|
"""
|
|
remaining = max(0.0, rules.budget_seconds - state.seconds)
|
|
fraction = state.seconds / rules.budget_seconds if rules.budget_seconds else 1.0
|
|
return {
|
|
"gaming_day": state.day_key,
|
|
"day_starts_at": "06:00 local",
|
|
"seconds_used": round(state.seconds, 1),
|
|
"budget_seconds": rules.budget_seconds,
|
|
"seconds_remaining": round(remaining, 1),
|
|
"fraction_used": round(min(1.0, max(0.0, fraction)), 4),
|
|
"blocked": state.is_blocked(),
|
|
"blocked_at": state.blocked_at,
|
|
"next_warning_seconds": next_warning(state, rules),
|
|
"warned_seconds": list(state.warned_seconds),
|
|
"games": today_games(state),
|
|
"shadow": shadow_view(state),
|
|
}
|
|
|
|
|
|
def _most(earner: earned_time.Earner) -> int:
|
|
"""Gaming minutes a fully done day pays (earned_time >= 0.5 knows units)."""
|
|
return int(getattr(earner, "max_gaming_minutes", earner.gaming_minutes))
|
|
|
|
|
|
def build_rules(rules: PlaytimeRules) -> dict[str, Any]:
|
|
"""Compose the rules block.
|
|
|
|
An explicit allowlist, never ``Config.__dict__``: that carries
|
|
``steam_api_key``, and this payload is served over HTTP.
|
|
|
|
Args:
|
|
rules: Policy in force.
|
|
|
|
Returns:
|
|
Serialisable rules block.
|
|
"""
|
|
return {
|
|
"budget_seconds": rules.budget_seconds,
|
|
"enforcement": rules.enforcement,
|
|
"counts_launchers": rules.count_launchers,
|
|
"warn_at": list(rules.warn_at),
|
|
"demo": rules.demo,
|
|
"masked_launchers": sorted(str(path) for path in mounted_targets()),
|
|
# Read off the rules, never re-resolved: resolving a second time here
|
|
# is what would let this view report a budget the daemon is not
|
|
# enforcing. See _budget_resolve's "one seam".
|
|
"budget_reason": rules.budget_reason,
|
|
"bonuses": {
|
|
"base": rules.base_seconds,
|
|
"workout": rules.workout_seconds,
|
|
"leetcode": rules.leetcode_seconds,
|
|
"reading": rules.reading_seconds,
|
|
},
|
|
# The registry, in its order: what each earner adds today and what it
|
|
# would add once done. The bar renders "+<icon><size>" for every
|
|
# entry with nothing earned yet, so a new earner needs no bar change
|
|
# beyond an icon -- and no consumer hard-codes a size.
|
|
"earners": [
|
|
{
|
|
"name": earner.name,
|
|
"label": earner.label,
|
|
"earned_seconds": rules.earned_seconds.get(earner.name, 0.0),
|
|
# A full day pays up to the tutor's 60 credited units.
|
|
"bonus_seconds": _most(earner) * 60,
|
|
}
|
|
for earner in registry_for(datetime.now().astimezone().date())
|
|
],
|
|
}
|
|
|
|
|
|
def build_budget_snapshot(*, demo: bool = False) -> dict[str, Any]:
|
|
"""Compose the whole budget view: today, live session, history, rules.
|
|
|
|
Args:
|
|
demo: Whether to read the demo run's state and log.
|
|
|
|
Returns:
|
|
The serialisable payload behind ``GET /api/budget``.
|
|
"""
|
|
config = Config.load()
|
|
rules = rules_for(config, demo=demo)
|
|
stored = load_state(demo=demo)
|
|
access = state_access(state_path(demo=demo))
|
|
# A file that opened but did not parse is the remaining case: the enforcer
|
|
# treats that as tampering, and the display should say so rather than
|
|
# implying an empty day.
|
|
status = access if access != READABLE else (READABLE if stored else CORRUPT)
|
|
session = last_verdict(demo=demo)
|
|
# Demo runs have no history by design — HistoryWriter skips them, for the
|
|
# same reason they get their own state file and log. Serving the production
|
|
# days here would plot real 8-hour days against the demo's 60-second budget
|
|
# line, painting every one of them over-budget. `today.games` is still
|
|
# emitted: the demo's own breakdown is real.
|
|
history, legend = ([], []) if demo else history_view(load_history(_HISTORY_DAYS))
|
|
|
|
return {
|
|
"ok": True,
|
|
"readable": status == READABLE,
|
|
"state_status": status,
|
|
"error": _STATUS_MESSAGES.get(status),
|
|
"today": build_today(stored, rules) if stored is not None else None,
|
|
"session": {
|
|
# What the budget is actually charging — a different question from
|
|
# the backlog assignment reported as "game_name" below.
|
|
"billing_label": billing_label(stored.last_credited_key if stored else ""),
|
|
"available": session.available,
|
|
"observed_at": session.observed_at,
|
|
"state": session.state,
|
|
# A single Proton title qualifies a dozen processes — reaper,
|
|
# srt-bwrap, wineserver, plugplay.exe. Listing those as "games
|
|
# running" would be nonsense, so the headline is the game the
|
|
# enforcer actually assigned and the processes are the detail.
|
|
"game_name": State.load().current_game_name,
|
|
"qualifying_count": len(session.games),
|
|
"processes": [
|
|
{"pid": game.pid, "name": game.name} for game in session.games
|
|
],
|
|
},
|
|
# Demo runs have no history by design — HistoryWriter skips them, for
|
|
# the same reason they get their own state file and log. Serving the
|
|
# production days here would plot real 8-hour days against the demo's
|
|
# 60-second budget line, painting every one of them over-budget.
|
|
"history": history,
|
|
"legend": legend,
|
|
"rules": build_rules(rules),
|
|
}
|