diff --git a/DOCS-ctl-socket.md b/DOCS-ctl-socket.md index 84331f1..fca02ac 100644 --- a/DOCS-ctl-socket.md +++ b/DOCS-ctl-socket.md @@ -36,8 +36,8 @@ server: `_ctl_client.py` (results in `_ctl_types.py`, errors in `_ctl_protocol.p ## Gaming reset `_gaming_reset.reset_today`, shared with the CLI: zeroes billed time and per-game -attribution, re-arms warnings, releases the mounts, keeps earned time (`carry`, -`budget_seconds`; the old CLI path dropped both), and writes a `manual_adjustment` +attribution, re-arms warnings, releases the mounts, keeps earned time +(`budget_seconds`; the old CLI path dropped it), and writes a `manual_adjustment` record to `/var/log/steam-backlog-enforcer/budget.jsonl` (billed seconds and per-game breakdown before). That record is the auditable trail; the per-day history mirrors current billed time, so it shows the day as quiet. @@ -55,7 +55,7 @@ whole downtime. Everything else already lives on disk after every tick: | Billed seconds, per-game attribution | yes | `playtime_state.json` saved every tick; the final tick bills up to the exit, the first tick of the new daemon bills the gap | | Cutoff / kill-grace timing | yes | `blocked_at` is persisted; `_sustain_block` measures from it | | Playtime mounts (the block itself) | yes | kernel mount table; `reconcile` is mountinfo-driven. `RuntimeDirectoryPreserve=yes` keeps the stub file | -| Fired warnings, carry ledger | yes | in `PlaytimeState` | +| Fired warnings, budget high-water | yes | in `PlaytimeState` | | Open store window | yes (blip) | deadline in `state.json`; startup re-blocks the store once, the first tick re-opens it | | Total block | yes | lock file, guard-lib | | Armed gaming reset | no | memory only: arm again (fails closed) | diff --git a/TODO-log-carry-in-budget-line.md b/TODO-log-carry-in-budget-line.md deleted file mode 100644 index 47fc91c..0000000 --- a/TODO-log-carry-in-budget-line.md +++ /dev/null @@ -1,22 +0,0 @@ -# Log the Fri->Sat/Sun/Mon carry in the budget resolver line - -Carried over from the 2026-10-10 tutor-cutover handoff (item 4b, optional). - -## what -Saturday's budget showed 15144 s = 14400 + 744 s carried over from Friday -(`_gaming_days.py` `pass_on`, commit 9586128). That is intended, but the -"Gaming budget ..." log line (`_budget_resolve.py` ~:176) does not show the -carry, so the number looks wrong. Include the carry seconds in that line. - -## where -`steam_backlog_enforcer/_budget_resolve.py`, carry from -`_playtime_budget.py` (`PlaytimeState.carry`). - -## must -- must not: change the budget arithmetic — log only. - -## done -On a carry day the journal line reads like -`Gaming budget ... (+744 s carried from Fri)`; a unit test asserts it. - -REMOVE ME AFTER FINISH diff --git a/steam_backlog_enforcer/_budget_resolve.py b/steam_backlog_enforcer/_budget_resolve.py index 03e59a7..5a5950f 100644 --- a/steam_backlog_enforcer/_budget_resolve.py +++ b/steam_backlog_enforcer/_budget_resolve.py @@ -28,7 +28,7 @@ while the gaming day runs to 05:59, and a cold restart resolves with empty answer caches, so a live answer can come in lower than one given hours ago. That bit twice (2026-10-03 and 2026-10-04 00:00, 8h -> 4h, unwarned cutoff). ``PlaytimeState.budget_seconds`` is the day's high-water mark, and -``rules_for`` applies ``max(resolved + carry, held)`` via +``rules_for`` applies ``max(resolved, held)`` via ``_gaming_days.held_today`` -- so the daemon, ``/api/budget`` and MCP all see the held figure. Only the 06:00 roll-over lowers it. @@ -64,6 +64,8 @@ logger = logging.getLogger(__name__) _SECONDS_PER_MINUTE: Final = 60.0 _SECONDS_PER_HOUR: Final = 3600.0 +# The last reason logged, so a long-lived daemon logs a budget only on change. +_last_logged: Final[dict[str, str]] = {} # How each earner reads in the reason string: (earned, missed, unknown). # An earner registered later without an entry here gets a generic phrase. @@ -173,7 +175,11 @@ def resolve_budget(config: Config) -> BudgetResolution: _describe(t.earner.name, t.earner.label, t.answer) for t in day.terms ) reason = f"{total / _SECONDS_PER_HOUR:.1f}h: {parts}" - logger.info("Gaming budget %s", reason) + # The daemon resolves every tick (3 s): an unchanged answer logged each + # time was 25k identical journal lines a day that buried everything else. + if reason != _last_logged.get("reason"): + _last_logged["reason"] = reason + logger.info("Gaming budget %s", reason) return BudgetResolution( seconds=total, base_seconds=day.base.gaming_minutes * _SECONDS_PER_MINUTE, diff --git a/steam_backlog_enforcer/_budget_view.py b/steam_backlog_enforcer/_budget_view.py index 4708f52..00094a8 100644 --- a/steam_backlog_enforcer/_budget_view.py +++ b/steam_backlog_enforcer/_budget_view.py @@ -167,8 +167,6 @@ def build_rules(rules: PlaytimeRules) -> dict[str, Any]: # 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, - # Unspent Fri-Sun time inherited by today; already inside budget_seconds. - "carry_seconds": rules.carry_seconds, "bonuses": { "base": rules.base_seconds, "workout": rules.workout_seconds, diff --git a/steam_backlog_enforcer/_gaming_days.py b/steam_backlog_enforcer/_gaming_days.py index 3e9270f..2cf4358 100644 --- a/steam_backlog_enforcer/_gaming_days.py +++ b/steam_backlog_enforcer/_gaming_days.py @@ -1,24 +1,13 @@ -"""Gaming-day arithmetic: which day *now* is, and what it inherits. +"""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. -**Free-day carry-over.** Friday to Monday is one block. Whatever a Friday, -Saturday or Sunday leaves unspent is split evenly, with no strings attached, -across the days still left in that block: - - Fri leftover -> Sat, Sun, Mon (a third each) - Sat leftover -> Sun, Mon (half each) - Sun leftover -> Mon - -Monday's leftover, and any Tuesday-Thursday leftover, is dropped. A day's -leftover is measured against its whole budget *including* what it inherited, -so time carried into Saturday and not played moves on to Sunday and Monday -rather than being lost. - -The ledger lives on ``PlaytimeState`` -- root-owned and immutable -- and never -in ``playtime_history.json``: that file is user-writable, and a stored budget -read from it would let anyone grant themselves unlimited time. +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. @@ -26,7 +15,7 @@ nothing from the playtime modules and cannot close an import cycle. from __future__ import annotations -from datetime import date, timedelta +from datetime import timedelta from typing import TYPE_CHECKING, Final if TYPE_CHECKING: @@ -37,10 +26,6 @@ if TYPE_CHECKING: # The gaming day starts here, local time. 05:59 belongs to the previous day. _DAY_BOUNDARY_HOURS: Final = 6 -# Weekday (Monday == 0) whose leftover is passed on -> how many later days of -# the Friday-Monday block share it. -_SHARES: Final = {4: 3, 5: 2, 6: 1} - def gaming_day_key(now: datetime) -> str: """Return the ``YYYY-MM-DD`` gaming day containing *now*. @@ -56,97 +41,6 @@ def gaming_day_key(now: datetime) -> str: return (now - timedelta(hours=_DAY_BOUNDARY_HOURS)).date().isoformat() -def pass_on( - carry: dict[str, float], - *, - outgoing_day: str, - budget: float, - spent: float, - new_day: str, -) -> dict[str, float]: - """Return the carry ledger as it stands once *new_day* begins. - - Shares owed to days before *new_day* are dropped, which both bounds the - ledger and means a day the daemon never saw simply forfeits its share. - - Args: - carry: Day key to seconds owed, as held by the outgoing day. - outgoing_day: The gaming day that just ended; ``""`` if none. - budget: The outgoing day's budget, carry included. - spent: Seconds billed during the outgoing day. - new_day: The gaming day now starting. - - Returns: - The ledger for *new_day*, the outgoing day's leftover added. - """ - owed = {day: seconds for day, seconds in carry.items() if day >= new_day} - if not outgoing_day: - return owed - ended = date.fromisoformat(outgoing_day) - shares = _SHARES.get(ended.weekday(), 0) - leftover = max(0.0, budget - spent) - for offset in range(1, shares + 1): - target = (ended + timedelta(days=offset)).isoformat() - if target >= new_day: - owed[target] = owed.get(target, 0.0) + leftover / shares - return owed - - -def carry_due( - carry: dict[str, float], - *, - stored_day: str, - budget: float, - spent: float, - today: str, -) -> float: - """Return the seconds carried into *today*. - - The stored record may still describe yesterday -- the first tick after - 06:00 resolves the budget before rolling the state over -- so the ledger is - projected forward exactly as ``roll_over`` will do it. - - Args: - carry: The stored ledger. - stored_day: The gaming day the stored record covers. - budget: That day's budget, carry included. - spent: Seconds billed during that day. - today: The current gaming day. - - Returns: - Seconds inherited by *today*, or 0. - """ - owed = ( - carry - if stored_day == today - else pass_on( - carry, outgoing_day=stored_day, budget=budget, spent=spent, new_day=today - ) - ) - return owed.get(today, 0.0) - - -def carry_into(stored: PlaytimeState | None, now: datetime) -> float: - """Return the seconds carried into the gaming day containing *now*. - - Args: - stored: The persisted production state, or ``None`` if unreadable. - now: Local timestamp for this tick. - - Returns: - The carry owed to today; 0 when no state is recorded. - """ - if stored is None: - return 0.0 - return carry_due( - stored.carry, - stored_day=stored.day_key, - budget=stored.budget_seconds, - spent=stored.seconds, - today=gaming_day_key(now), - ) - - def held_today(stored: PlaytimeState | None, now: datetime) -> float: """Return the budget already granted earlier in *now*'s gaming day. diff --git a/steam_backlog_enforcer/_gaming_reset.py b/steam_backlog_enforcer/_gaming_reset.py index f41b9d8..cf345d6 100644 --- a/steam_backlog_enforcer/_gaming_reset.py +++ b/steam_backlog_enforcer/_gaming_reset.py @@ -56,10 +56,9 @@ class ResetOutcome: def reset_today(*, source: str) -> ResetOutcome: """Zero today's billed time, drop the block and record the adjustment. - The earned-time bookkeeping (``carry`` from earlier free days, the day's - ``budget_seconds`` high-water mark) is kept: resetting spent time must not - also revoke time already earned. Warnings re-arm because ``seconds`` is - back at zero. + The day's ``budget_seconds`` high-water mark is kept: resetting spent + time must not also revoke time already earned. Warnings re-arm because + ``seconds`` is back at zero. The reset is written to the budget audit log as a ``manual_adjustment`` (with what was billed before, per game); that record is the auditable diff --git a/steam_backlog_enforcer/_mcp_gaming_time.py b/steam_backlog_enforcer/_mcp_gaming_time.py index e2b83d8..04ac19d 100644 --- a/steam_backlog_enforcer/_mcp_gaming_time.py +++ b/steam_backlog_enforcer/_mcp_gaming_time.py @@ -56,9 +56,8 @@ def get_gaming_time() -> dict[str, Any]: "seconds_used": today["seconds_used"], "budget_seconds": today["budget_seconds"], "seconds_remaining": today["seconds_remaining"], - # What earned budget_seconds, Friday-Monday carry-over included. + # What earned budget_seconds. "budget_reason": rules["budget_reason"], - "carry_seconds": rules["carry_seconds"], "next_warning_seconds": today["next_warning_seconds"], "blocked": today["blocked"], "enforcement": rules["enforcement"], diff --git a/steam_backlog_enforcer/_playtime_budget.py b/steam_backlog_enforcer/_playtime_budget.py index 581fce6..c7c9312 100644 --- a/steam_backlog_enforcer/_playtime_budget.py +++ b/steam_backlog_enforcer/_playtime_budget.py @@ -11,7 +11,6 @@ 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, @@ -80,9 +79,8 @@ 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``). + new day measures a sane delta rather than restarting from zero. Nothing else + does: the outgoing day's unspent time is dropped. Args: state: Current accounting state. @@ -93,17 +91,7 @@ def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState: """ 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, - ), - ) + return PlaytimeState(day_key=day_key, last_tick_at=state.last_tick_at) def pending_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None: diff --git a/steam_backlog_enforcer/_playtime_cutoff.py b/steam_backlog_enforcer/_playtime_cutoff.py index 61a9cc1..d86fbb0 100644 --- a/steam_backlog_enforcer/_playtime_cutoff.py +++ b/steam_backlog_enforcer/_playtime_cutoff.py @@ -65,10 +65,14 @@ def _warn(state: PlaytimeState, rules: PlaytimeRules) -> PlaytimeState: if threshold is None: return state - notify_desktop_user( - "Gaming time running out", - f"{_humanise(threshold)} of today's gaming budget left.", + left = f"{_humanise(threshold)} of today's gaming budget left." + logger.info( + "Gaming warning: %s (billed %.0fs of %.0fs)", + left, + state.seconds, + rules.budget_seconds, ) + notify_desktop_user("Gaming time running out", left) return replace(state, warned_seconds=[*state.warned_seconds, threshold]) diff --git a/steam_backlog_enforcer/_playtime_legacy.py b/steam_backlog_enforcer/_playtime_legacy.py new file mode 100644 index 0000000..3f544fd --- /dev/null +++ b/steam_backlog_enforcer/_playtime_legacy.py @@ -0,0 +1,44 @@ +"""Read-side compatibility for the removed Friday-Monday carry-over. + +Exists only to read ``playtime_state.json`` records written before carry-over +was removed on 2026-10-18. Such a record lives at most one gaming day (the +next ``save_state`` drops the ``carry`` key), so this module can be deleted +once no such record can remain. +""" + +from __future__ import annotations + +from dataclasses import replace +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # Type-only: _playtime_state imports this module at runtime. + from steam_backlog_enforcer._playtime_state import PlaytimeState + +logger = logging.getLogger(__name__) + + +def without_legacy_carry(state: PlaytimeState, carry: object) -> PlaytimeState: + """Take a removed carry ledger's share for the day out of its high-water. + + Records written before 2026-10-18 folded the Friday-Monday carry into + ``budget_seconds``, and ``held_today`` would keep paying it out for the + rest of that day. The key itself is dropped by the next ``save_state``. + + Args: + state: The record as loaded. + carry: The raw ``carry`` value from the file, if any. + + Returns: + *state*, its ``budget_seconds`` lowered by the day's carried share. + """ + if not isinstance(carry, dict): + return state + share = carry.get(state.day_key, 0.0) + if not isinstance(share, int | float) or share <= 0: + return state + logger.warning( + "Dropping %.0fs of legacy carry-over from %s's budget.", share, state.day_key + ) + return replace(state, budget_seconds=max(0.0, state.budget_seconds - share)) diff --git a/steam_backlog_enforcer/_playtime_log.py b/steam_backlog_enforcer/_playtime_log.py index 289d89a..fcb6018 100644 --- a/steam_backlog_enforcer/_playtime_log.py +++ b/steam_backlog_enforcer/_playtime_log.py @@ -223,5 +223,6 @@ def _snapshot( "qualifying": sorted(qualifying), "day_key": state.day_key, "billed_seconds": round(state.seconds, 3), + "budget_seconds": round(rules.budget_seconds, 3), "remaining_seconds": round(rules.budget_seconds - state.seconds, 3), } diff --git a/steam_backlog_enforcer/_playtime_state.py b/steam_backlog_enforcer/_playtime_state.py index 445023f..a3a6c00 100644 --- a/steam_backlog_enforcer/_playtime_state.py +++ b/steam_backlog_enforcer/_playtime_state.py @@ -1,6 +1,6 @@ """The daily playtime budget: its state, its rules, and its persistence. -Gaming days and the Friday-Monday carry-over live in ``_gaming_days``. State +Gaming days and the held budget live in ``_gaming_days``. State is written atomically and re-read every tick, which is what lets the enforcer survive a restart mid-session. """ @@ -14,7 +14,8 @@ import logging from typing import TYPE_CHECKING from steam_backlog_enforcer._budget_resolve import resolve_budget -from steam_backlog_enforcer._gaming_days import carry_into, held_today +from steam_backlog_enforcer._gaming_days import held_today +from steam_backlog_enforcer._playtime_legacy import without_legacy_carry from steam_backlog_enforcer._whitelist_locking import ( _try_set_immutable, unlock_for_write, @@ -95,12 +96,10 @@ class PlaytimeState: warned_seconds: list[int] = field(default_factory=list) """Warning thresholds already fired during ``day_key``.""" budget_seconds: float = 0.0 - """Highest budget in force during ``day_key``, carry included; ``0.0`` if unset. + """Highest budget in force during ``day_key``; ``0.0`` if unset. - What ``roll_over`` measures the outgoing day's leftover against. + What ``held_today`` holds the day at when a live re-resolution dips. """ - carry: dict[str, float] = field(default_factory=dict) - """Day key to seconds carried into it from earlier days of the block.""" def is_blocked(self) -> bool: """Whether the cutoff has engaged for this gaming day. @@ -133,7 +132,6 @@ class PlaytimeRules: reading_seconds: float = 0.0 earned_seconds: dict[str, float] = field(default_factory=dict) budget_reason: str = "" - carry_seconds: float = 0.0 def rules_for(config: Config, *, demo: bool) -> PlaytimeRules: @@ -153,12 +151,10 @@ def rules_for(config: Config, *, demo: bool) -> PlaytimeRules: # Resolved *here*, not per caller, so the daemon (one Config for life) and # _budget_view (Config per request) can never report different budgets. resolved = None if demo else resolve_budget(config) - # Carry sits on top of the 8h cap (earned, not spent: no strings). The - # day never re-prices below what it already granted -- see held_today. + # The day never re-prices below what it already granted -- see held_today. now = datetime.now().astimezone() stored = None if demo else load_state(demo=False) - carry = 0.0 if demo else carry_into(stored, now) - live = 0.0 if resolved is None else resolved.seconds + carry + live = 0.0 if resolved is None else resolved.seconds held = held_today(stored, now) held_note = f"; held at {held / 3600:.1f}h (earned earlier today)" return PlaytimeRules( @@ -178,11 +174,8 @@ def rules_for(config: Config, *, demo: bool) -> PlaytimeRules: budget_reason=( "demo run" if resolved is None - else resolved.reason - + (f", +{carry / 60:.0f}m carried over" if carry > 0 else "") - + (held_note if held > live else "") + else resolved.reason + (held_note if held > live else "") ), - carry_seconds=carry, ) @@ -227,7 +220,8 @@ def load_state(*, demo: bool) -> PlaytimeState | None: logger.warning("Playtime state at %s has an unknown schema.", path) return None known = {f.name for f in fields(PlaytimeState)} - return PlaytimeState(**{k: v for k, v in data.items() if k in known}) + state = PlaytimeState(**{k: v for k, v in data.items() if k in known}) + return without_legacy_carry(state, data.get("carry")) def save_state(state: PlaytimeState, *, demo: bool) -> None: diff --git a/steam_backlog_enforcer/tests/test_budget_resolve.py b/steam_backlog_enforcer/tests/test_budget_resolve.py index 8fa0d92..dbbac7c 100644 --- a/steam_backlog_enforcer/tests/test_budget_resolve.py +++ b/steam_backlog_enforcer/tests/test_budget_resolve.py @@ -15,7 +15,7 @@ from unittest.mock import patch import earned_time import pytest -from steam_backlog_enforcer import _leetcode_bonus, _workout_budget +from steam_backlog_enforcer import _budget_resolve, _leetcode_bonus, _workout_budget from steam_backlog_enforcer._budget_resolve import resolve_budget from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.tests._budget_dates import AFTER_CUT, pin_today @@ -41,9 +41,11 @@ def _clear_caches() -> Iterator[None]: """ _workout_budget.reset_cache() _leetcode_bonus.reset_cache() + _budget_resolve._last_logged.clear() yield _workout_budget.reset_cache() _leetcode_bonus.reset_cache() + _budget_resolve._last_logged.clear() @pytest.fixture(autouse=True) @@ -202,3 +204,20 @@ class TestTheExplanation: assert "no counted workout" in caplog.text assert "no LeetCode solve recorded" in caplog.text assert "no reading credited" in caplog.text + + def test_an_unchanged_budget_is_logged_once( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """The daemon resolves every 3 s; only a change is worth a journal line. + + Args: + caplog: pytest's log capture. + """ + with ( + caplog.at_level(logging.INFO), + patch(_WORKOUT, return_value=False), + patch(_LEETCODE, return_value=False), + ): + resolve_budget(Config()) + resolve_budget(Config()) + assert caplog.text.count("Gaming budget") == 1 diff --git a/steam_backlog_enforcer/tests/test_budget_view.py b/steam_backlog_enforcer/tests/test_budget_view.py index f2bc34c..75487ae 100644 --- a/steam_backlog_enforcer/tests/test_budget_view.py +++ b/steam_backlog_enforcer/tests/test_budget_view.py @@ -28,11 +28,10 @@ from steam_backlog_enforcer.tests._no_workout_http import earned_budget, fixed_b # effect yet -- without this patch, collecting this module makes a real HTTP # call to the screen locker and pins RULES to whatever budget today happens to # have earned. Stub it explicitly so the constant is the earned budget always. -# The carry is pinned for the same reason: conftest's redirects are not active -# at import either, so rules_for would read the LIVE playtime_state.json and -# fold whatever Fri-Mon carry it holds today into RULES. +# The held budget is pinned for the same reason: conftest's redirects are not +# active at import either, so rules_for would read the LIVE playtime_state.json +# and hold RULES at whatever high-water mark it records today. with ( - patch("steam_backlog_enforcer._playtime_state.carry_into", return_value=0.0), patch("steam_backlog_enforcer._playtime_state.held_today", return_value=0.0), patch( "steam_backlog_enforcer._playtime_state.resolve_budget", @@ -224,7 +223,6 @@ class TestBuildBudgetSnapshot: "demo", "masked_launchers", "budget_reason", - "carry_seconds", "bonuses", "earners", } diff --git a/steam_backlog_enforcer/tests/test_gaming_days.py b/steam_backlog_enforcer/tests/test_gaming_days.py index c32d909..f9d9eb9 100644 --- a/steam_backlog_enforcer/tests/test_gaming_days.py +++ b/steam_backlog_enforcer/tests/test_gaming_days.py @@ -1,19 +1,23 @@ -"""Tests for the Friday-Monday carry-over in _gaming_days.""" +"""Tests for _gaming_days: the day boundary, the held budget, no carry-over.""" from __future__ import annotations -from dataclasses import replace +from dataclasses import fields from datetime import datetime, timedelta, timezone +import json from unittest.mock import patch -from steam_backlog_enforcer._gaming_days import ( - carry_due, - carry_into, - held_today, - pass_on, -) +import pytest + +from steam_backlog_enforcer._gaming_days import held_today from steam_backlog_enforcer._playtime_budget import roll_over -from steam_backlog_enforcer._playtime_state import PlaytimeState, rules_for +from steam_backlog_enforcer._playtime_state import ( + PlaytimeRules, + PlaytimeState, + load_state, + rules_for, + state_path, +) from steam_backlog_enforcer.config import Config HOUR = 3600.0 @@ -22,105 +26,52 @@ LOCAL = timezone(timedelta(hours=2)) THU, FRI, SAT, SUN, MON, TUE = (f"2026-10-{d:02d}" for d in range(1, 7)) -def _leftover(day: str, *, budget: float, spent: float, new_day: str) -> dict: - return pass_on({}, outgoing_day=day, budget=budget, spent=spent, new_day=new_day) +class TestNoCarryOver: + """Unspent time never moves to a later day (carry-over dropped 2026-10-18).""" - -class TestPassOn: - """Who inherits a day's unspent budget.""" - - def test_friday_splits_three_ways(self) -> None: - owed = _leftover(FRI, budget=8 * HOUR, spent=7 * HOUR, new_day=SAT) - assert owed == {SAT: 1200.0, SUN: 1200.0, MON: 1200.0} - - def test_saturday_splits_two_ways(self) -> None: - owed = _leftover(SAT, budget=8 * HOUR, spent=7 * HOUR, new_day=SUN) - assert owed == {SUN: 1800.0, MON: 1800.0} - - def test_sunday_goes_to_monday(self) -> None: - assert _leftover(SUN, budget=HOUR, spent=0.0, new_day=MON) == {MON: HOUR} - - def test_monday_and_weekdays_drop_their_leftover(self) -> None: - assert _leftover(MON, budget=HOUR, spent=0.0, new_day=TUE) == {} - assert _leftover(THU, budget=HOUR, spent=0.0, new_day=FRI) == {} - - def test_overspent_day_carries_nothing(self) -> None: - owed = _leftover(SUN, budget=HOUR, spent=2 * HOUR, new_day=MON) - assert owed == {MON: 0.0} - - def test_no_outgoing_day_keeps_only_future_shares(self) -> None: - owed = pass_on( - {FRI: 5.0, SUN: 7.0}, outgoing_day="", budget=0.0, spent=0.0, new_day=SAT + def test_a_saturday_leftover_is_not_passed_on(self) -> None: + sun = roll_over( + PlaytimeState(day_key=SAT, seconds=HOUR, budget_seconds=8 * HOUR), + day_key=SUN, ) - assert owed == {SUN: 7.0} + assert sun == PlaytimeState(day_key=SUN) + assert "carry" not in vars(sun) - def test_a_skipped_day_forfeits_its_share(self) -> None: - owed = _leftover(FRI, budget=3 * HOUR, spent=0.0, new_day=SUN) - assert owed == {SUN: HOUR, MON: HOUR} + def test_rules_have_no_carry(self) -> None: + assert "carry_seconds" not in {f.name for f in fields(PlaytimeRules)} - def test_shares_accumulate_with_earlier_ones(self) -> None: - owed = pass_on( - {SUN: 600.0, MON: 600.0}, - outgoing_day=SAT, - budget=HOUR, - spent=0.0, - new_day=SUN, - ) - assert owed == {SUN: 2400.0, MON: 2400.0} + def test_a_legacy_carry_is_taken_out_of_todays_high_water(self) -> None: + # The live file on 2026-10-18 held 6.0h earned + 9994s carried into the + # day's high-water mark; held_today would otherwise keep paying it out. + legacy = { + "schema_version": 1, + "day_key": SUN, + "seconds": 6 * HOUR, + "budget_seconds": 6 * HOUR + 9994.0, + "carry": {SUN: 9994.0, MON: 9994.0}, + } + state_path(demo=False).write_text(json.dumps(legacy), encoding="utf-8") + stored = load_state(demo=False) + assert stored is not None + assert stored.budget_seconds == 6 * HOUR + assert "carry" not in vars(stored) - -class TestCarryDue: - """Today's share, whether or not the stored record has rolled over.""" - - def test_same_day_reads_the_ledger(self) -> None: - due = carry_due({SAT: 60.0}, stored_day=SAT, budget=0.0, spent=0.0, today=SAT) - assert due == 60.0 - - def test_stale_record_is_projected_forward(self) -> None: - due = carry_due({}, stored_day=FRI, budget=4 * HOUR, spent=HOUR, today=SAT) - assert due == HOUR - - -class TestCarryInto: - """Reading the carry off a stored state.""" - - def test_no_state_means_no_carry(self) -> None: - assert carry_into(None, datetime(2026, 10, 3, 10, tzinfo=LOCAL)) == 0.0 - - def test_reads_the_projected_share(self) -> None: - stored = PlaytimeState(day_key=FRI, seconds=HOUR, budget_seconds=4 * HOUR) - assert carry_into(stored, datetime(2026, 10, 3, 10, tzinfo=LOCAL)) == HOUR - - -class TestRollOverCarry: - """roll_over threads the ledger across the 06:00 boundary.""" - - def test_unspent_carry_moves_on(self) -> None: - sat = roll_over( - PlaytimeState(day_key=FRI, seconds=7 * HOUR, budget_seconds=8 * HOUR), - day_key=SAT, - ) - sat = replace(sat, seconds=8 * HOUR, budget_seconds=8 * HOUR + 1200.0) - sun = roll_over(sat, day_key=SUN) - assert sun.carry == {SUN: 1800.0, MON: 1800.0} - - -class TestRulesCarry: - """rules_for adds the carry above the cap and says so.""" - - def test_carry_lands_in_budget_and_reason(self) -> None: - with patch( - "steam_backlog_enforcer._playtime_state.carry_into", return_value=1200.0 - ): - rules = rules_for(Config(), demo=False) - assert rules.carry_seconds == 1200.0 - assert rules.budget_reason.endswith(", +20m carried over") - - def test_demo_never_carries(self) -> None: - with patch( - "steam_backlog_enforcer._playtime_state.carry_into", return_value=1200.0 - ): - assert rules_for(Config(), demo=True).carry_seconds == 0.0 + @pytest.mark.parametrize( + "carry", [{MON: 9994.0}, {SUN: "9994"}, {SUN: 0.0}, [9994.0]] + ) + def test_a_ledger_owing_today_nothing_leaves_the_budget( + self, carry: object + ) -> None: + legacy = { + "schema_version": 1, + "day_key": SUN, + "budget_seconds": 6 * HOUR, + "carry": carry, + } + state_path(demo=False).write_text(json.dumps(legacy), encoding="utf-8") + stored = load_state(demo=False) + assert stored is not None + assert stored.budget_seconds == 6 * HOUR class TestHeldToday: diff --git a/steam_backlog_enforcer/tests/test_gaming_reset.py b/steam_backlog_enforcer/tests/test_gaming_reset.py index 8d75892..351a8f6 100644 --- a/steam_backlog_enforcer/tests/test_gaming_reset.py +++ b/steam_backlog_enforcer/tests/test_gaming_reset.py @@ -37,7 +37,6 @@ def _spent_state(day_key: str) -> PlaytimeState: last_credited_key="1", warned_seconds=[600], budget_seconds=1800.0, - carry={"2999-01-01": 120.0}, ) @@ -58,7 +57,7 @@ class TestResetToday: assert stored is not None assert (stored.seconds, stored.blocked_at, stored.per_game) == (0.0, 0.0, {}) assert (stored.last_credited_key, stored.warned_seconds) == ("", []) - assert (stored.budget_seconds, stored.carry) == (1800.0, {"2999-01-01": 120.0}) + assert stored.budget_seconds == 1800.0 assert stored.last_tick_at > 0 def test_audit_record_has_the_before_picture(self) -> None: diff --git a/steam_backlog_enforcer/tests/test_playtime_cutoff.py b/steam_backlog_enforcer/tests/test_playtime_cutoff.py index a3fbcd4..34e7bd6 100644 --- a/steam_backlog_enforcer/tests/test_playtime_cutoff.py +++ b/steam_backlog_enforcer/tests/test_playtime_cutoff.py @@ -2,6 +2,7 @@ from contextlib import ExitStack from datetime import datetime, timedelta, timezone +import logging from unittest.mock import MagicMock, patch import pytest @@ -196,6 +197,15 @@ class TestWarn: assert out.warned_seconds == [3600, 1800, 600, 300] assert "5 minutes" in mock_notify.call_args.args[1] + def test_a_fired_warning_is_logged(self, caplog: pytest.LogCaptureFixture) -> None: + state = PlaytimeState(seconds=8 * 3600 - 300, warned_seconds=[3600, 1800, 600]) + with ( + caplog.at_level(logging.INFO), + patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"), + ): + _warn(state, _rules()) + assert "5 minutes" in caplog.text + def test_no_op_when_nothing_is_due(self) -> None: state = PlaytimeState(seconds=0.0) with patch( diff --git a/steam_backlog_enforcer/tests/test_playtime_log.py b/steam_backlog_enforcer/tests/test_playtime_log.py index 7700741..a11c405 100644 --- a/steam_backlog_enforcer/tests/test_playtime_log.py +++ b/steam_backlog_enforcer/tests/test_playtime_log.py @@ -176,3 +176,4 @@ class TestTickJournal: assert record["qualifying"] == [11, 22] assert record["state"] == "engaged" assert record["remaining_seconds"] == 28700.0 + assert record["budget_seconds"] == 28800.0 diff --git a/web/mock/budget.ts b/web/mock/budget.ts index 1647343..4456715 100644 --- a/web/mock/budget.ts +++ b/web/mock/budget.ts @@ -79,7 +79,6 @@ export function budget(demo: boolean): BudgetSnapshot { warn_at: [3600, 1800, 600, 300], demo, masked_launchers: ['heroic', 'lutris'], - carry_seconds: demo ? 0 : 1200, }, } } diff --git a/web/src/components/BudgetRulesCard.test.tsx b/web/src/components/BudgetRulesCard.test.tsx index d0143cf..28528e7 100644 --- a/web/src/components/BudgetRulesCard.test.tsx +++ b/web/src/components/BudgetRulesCard.test.tsx @@ -12,13 +12,6 @@ describe('BudgetRulesCard', () => { expect(screen.getByText(/1h 0m, 30m, 10m, 5m remaining/)).toBeInTheDocument() expect(screen.queryByText(/demo/)).toBeNull() expect(screen.queryByText('Masked now')).toBeNull() - expect(screen.queryByText('Carried over')).toBeNull() - }) - - it('shows time carried over from earlier free days', () => { - render() - expect(screen.getByText('Carried over')).toBeInTheDocument() - expect(screen.getByText(/\+20m unspent from earlier free days/)).toBeInTheDocument() }) it('shows the demo mode and masked launchers when they apply', () => { diff --git a/web/src/components/BudgetRulesCard.tsx b/web/src/components/BudgetRulesCard.tsx index 5cd850c..a4787fe 100644 --- a/web/src/components/BudgetRulesCard.tsx +++ b/web/src/components/BudgetRulesCard.tsx @@ -16,12 +16,6 @@ export function BudgetRulesCard({ rules }: Props) {
Daily budget
{fmtDuration(rules.budget_seconds)}
- {rules.carry_seconds > 0 && ( - <> -
Carried over
-
+{fmtDuration(rules.carry_seconds)} unspent from earlier free days
- - )}
Enforcement
{onOff(rules.enforcement)}
Counts launchers
diff --git a/web/src/test/factories.ts b/web/src/test/factories.ts index 14b24b9..c84a725 100644 --- a/web/src/test/factories.ts +++ b/web/src/test/factories.ts @@ -142,7 +142,6 @@ export function makeBudgetRules(over: Partial = {}): BudgetRules { warn_at: [3600, 1800, 600, 300], demo: false, masked_launchers: [], - carry_seconds: 0, ...over, } } diff --git a/web/src/types.ts b/web/src/types.ts index 60e9d62..23ed307 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -173,8 +173,6 @@ export interface BudgetRules { warn_at: number[] demo: boolean masked_launchers: string[] - /** Unspent Fri-Sun time inherited by today; already inside budget_seconds. */ - carry_seconds: number } export interface BudgetSnapshot {