From 9733a29a650f1fb3e5e8ec9604cee8044759af83 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 19 Oct 2026 16:58:27 +0200 Subject: [PATCH] earned_time 0.16.0: leisure=None|True|False, so the gaming day decides (C6) resolve()/base_for(leisure=) is now tri-state: None (default) decides by the switch file on `day`, True forces leisure pricing (the shadow budget), False forces the old base and cap. A consumer billing on the 06:00 gaming day passes leisure_active(gaming_day), so 00:00-05:59 on the go-live date no longer prices the eve with the +2 h leisure base/cap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N --- earned_time/README.md | 22 +++++++++--------- earned_time/earned_time/__init__.py | 7 +++--- earned_time/earned_time/_leisure.py | 12 ++++++---- earned_time/earned_time/_resolve.py | 18 +++++++++------ earned_time/earned_time/tests/test_leisure.py | 2 +- .../earned_time/tests/test_leisure_switch.py | 23 +++++++++++++++++++ earned_time/pyproject.toml | 2 +- 7 files changed, 58 insertions(+), 28 deletions(-) diff --git a/earned_time/README.md b/earned_time/README.md index 0b65dd6..2c1dc36 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -21,16 +21,16 @@ rows in its ledger — and is registered here once. **Leisure budget (0.14.0, `_leisure.py`).** From the first leisure day the budget covers all leisure (games, browsing, video, messaging): base 5 h -> 7 h, cap 8 h -> 10 h; pay, penalties and shutdown untouched (full day 480 -> 600). -`resolve`/`base_for(..., leisure=True)` price any day so: the shadow budget. +`resolve`/`base_for(..., leisure=)` (0.16.0): `None` = by the switch file on +`day`, `True` = leisure pricing (shadow budget), `False` = old pricing; a +consumer billing on the 06:00 gaming day passes `leisure_active(gaming_day)`. **The switch file (0.15.0).** The first leisure day is `leisure_from()`: the -date in `/etc/leisure-watch/leisure_from` (`LEISURE_FROM_PATH`, one line -`YYYY-MM-DD`, root:root 0644), else the parked `LEISURE_FROM` (2027-01-01). -Read afresh each call (`O_NOFOLLOW|O_NONBLOCK`, regular file <= 64 bytes, -strict format; anything else is parked; never raises), once per `resolve`. -`leisure_active(day)` (`day >= leisure_from()`, gaming day) is the ONLY way a -consumer asks "is Phase 2 on?"; none reads the file. `leisure-watch go-live` -writes it (always tomorrow's date, after its preflight); `--undo` removes it. +`YYYY-MM-DD` line in `/etc/leisure-watch/leisure_from` (`LEISURE_FROM_PATH`, +root:root 0644; read afresh, once per `resolve`, `O_NOFOLLOW|O_NONBLOCK`, +regular, <= 64 bytes, strict), else the parked `LEISURE_FROM` (2027-01-01); +never raises. Consumers ask only `leisure_active(day)` (gaming day); none +reads the file. `leisure-watch go-live` writes tomorrow's date; `--undo` removes it. **Shutdown** has two regimes, switched by date (`LADDER_FROM`, 2026-10-10 — never a same-day cut): @@ -160,9 +160,9 @@ How far to trust a gate is computed from its ledger, never listed by name: ## API - `resolve(answers, day=None, earners=None, *, first_credits=None, sick=False, - leisure=False) -> Resolution` — the one sum. `sick_shutdown_from(paid_minutes, + leisure=None) -> Resolution` — the one sum. `sick_shutdown_from(paid_minutes, day) -> int`. `base_for(day=None, earners=None, *, first_credits=None, - leisure=False) -> Base` — the day's floors. `gaming_base_minutes_for(day)`, + leisure=None) -> Base` — the day's floors. `gaming_base_minutes_for(day)`, `gaming_ceiling_minutes_for(day) -> int` — the gaming base and cap. `leisure_from(path=LEISURE_FROM_PATH) -> date`, `leisure_active(day, path=...)`. - `done_today(earner, ledger, key_file, *, now=None) -> bool | None` @@ -243,7 +243,7 @@ on the day itself is logged and does not count. ## Install ```console -pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.15.0#subdirectory=earned_time" +pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.16.0#subdirectory=earned_time" ``` The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports diff --git a/earned_time/earned_time/__init__.py b/earned_time/earned_time/__init__.py index 6b042d5..5434d4a 100644 --- a/earned_time/earned_time/__init__.py +++ b/earned_time/earned_time/__init__.py @@ -23,9 +23,10 @@ A consumer's whole integration:: apply(day.gaming_minutes) ``sick`` (0.11.0) only raises shutdown to at least 20:00; gaming is unchanged. -``leisure=True`` (0.14.0) prices gaming with the leisure base and cap -whatever the day: the shadow budget. The first real leisure day is -:func:`leisure_from` (0.15.0, the switch file); ask :func:`leisure_active`. +``leisure`` (0.16.0): ``None`` follows the switch file on ``day``, ``True`` +prices gaming with the leisure base and cap (the shadow budget), ``False`` +with the old ones. The first real leisure day is :func:`leisure_from` +(0.15.0, the switch file); ask :func:`leisure_active` for the gaming day. How far to trust a gate is computed, not listed: :func:`maturity` (and ``python -m earned_time maturity``). diff --git a/earned_time/earned_time/_leisure.py b/earned_time/earned_time/_leisure.py index 6c390ba..a8d66aa 100644 --- a/earned_time/earned_time/_leisure.py +++ b/earned_time/earned_time/_leisure.py @@ -11,8 +11,10 @@ The first leisure day is the switch file ``LEISURE_FROM_PATH`` (0.15.0), written by ``leisure-watch go-live`` (always tomorrow's date); without a readable one it is the parked ``LEISURE_FROM``. Earlier days keep ``GAMING_BASE_MINUTES`` and ``GAMING_CEILING_MINUTES``, byte-identical. -``resolve(..., leisure=True)`` prices any day with the leisure base and cap: -the shadow budget the enforcer reports while it still enforces the old one. +``resolve(..., leisure=...)`` (0.16.0): ``None`` decides by the switch file +on ``day``, ``True`` forces the leisure base and cap (the shadow budget), +``False`` forces the old ones -- so a consumer billing on the 06:00 gaming day +passes ``leisure_active(gaming_day)`` instead of trusting the calendar day. """ from __future__ import annotations @@ -80,12 +82,12 @@ def leisure_active(day: date, path: Path = LEISURE_FROM_PATH) -> bool: return day >= leisure_from(path) -def is_leisure(day: date, *, leisure: bool = False) -> bool: - """``leisure`` (forced: the shadow budget) or ``day`` is a leisure day. +def is_leisure(day: date, *, leisure: bool | None = None) -> bool: + """``leisure`` if forced (``True``/``False``), else is ``day`` a leisure day. Looks ``LEISURE_FROM_PATH`` up at call time, so tests can redirect it. """ - return leisure or leisure_active(day, LEISURE_FROM_PATH) + return leisure_active(day, LEISURE_FROM_PATH) if leisure is None else leisure def base_minutes(*, leisure: bool) -> int: diff --git a/earned_time/earned_time/_resolve.py b/earned_time/earned_time/_resolve.py index b770a5d..7c257ad 100644 --- a/earned_time/earned_time/_resolve.py +++ b/earned_time/earned_time/_resolve.py @@ -88,7 +88,7 @@ def base_for( earners: tuple[Earner, ...] | None = None, *, first_credits: Mapping[str, date | None] | None = None, - leisure: bool = False, + leisure: bool | None = None, ) -> Base: """The floor for ``day`` (default today) under ``earners`` (default its own). @@ -97,8 +97,9 @@ def base_for( derived separately so the ladder can never move a gaming minute. ``first_credits`` (per earner name) delays each penalty, and the ladder rung, to :func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies. - The gaming base is :func:`gaming_base_minutes_for` the day, or with - ``leisure`` the leisure base whatever the date (:func:`is_leisure`). + The gaming base: ``leisure`` ``None`` -- by the switch file on the day, + ``True``/``False`` -- the leisure/old base whatever the date + (:func:`is_leisure`). """ target = day or _today() registry = earners_for(target) if earners is None else earners @@ -166,7 +167,7 @@ def resolve( *, first_credits: Mapping[str, date | None] | None = None, sick: bool = False, - leisure: bool = False, + leisure: bool | None = None, ) -> Resolution: """Sum the base and every earned term, each capped at its ceiling. @@ -186,9 +187,12 @@ def resolve( is 20:00 plus the terms' gaming pay (:func:`~earned_time._sick.sick_shutdown_from`, 0.13.0). Gaming and every term are unchanged. - leisure: Price gaming as a leisure day (0.14.0): the leisure base and - cap whatever the day and the switch file say -- the shadow budget. - Terms, penalties and shutdown are unchanged. + leisure: ``None`` (0.16.0 default): the switch file decides on + ``day``. ``True`` (0.14.0): the leisure base and cap whatever the + day says -- the shadow budget. ``False``: the old base and cap + whatever the file says -- a gaming day (06:00) still before the + switch while the calendar day is past it. Terms, penalties and + shutdown are unchanged. Returns: The resolution, with a term for every registered earner. diff --git a/earned_time/earned_time/tests/test_leisure.py b/earned_time/earned_time/tests/test_leisure.py index ab9bb3d..d4243bd 100644 --- a/earned_time/earned_time/tests/test_leisure.py +++ b/earned_time/earned_time/tests/test_leisure.py @@ -22,7 +22,7 @@ FULL = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60} NONE = dict.fromkeys(FULL, 0) -def _gaming(day: date, answers: dict[str, int], *, leisure: bool = False) -> int: +def _gaming(day: date, answers: dict[str, int], *, leisure: bool | None = None) -> int: return resolve(answers, day, TUTOR_EARNERS, leisure=leisure).gaming_minutes diff --git a/earned_time/earned_time/tests/test_leisure_switch.py b/earned_time/earned_time/tests/test_leisure_switch.py index 03dcf5e..4b272b5 100644 --- a/earned_time/earned_time/tests/test_leisure_switch.py +++ b/earned_time/earned_time/tests/test_leisure_switch.py @@ -166,3 +166,26 @@ def test_leisure_flag_still_forces_past_a_later_file( resolve(FULL, UNIFIED_FROM, TUTOR_EARNERS, leisure=True).gaming_minutes == 600 ) assert resolve(FULL, date(2027, 1, 2), TUTOR_EARNERS).gaming_minutes == 480 + + +@pytest.mark.usefixtures("live") +def test_leisure_false_forces_old_pricing_past_the_file() -> None: + # 0.16.0: the consumer's 06:00 gaming day may still be the eve while the + # calendar day is already the switch day (00:00-05:59 on go-live). + assert base_for(DAY, leisure=False).gaming_minutes == 180 + assert resolve(NONE, DAY, TUTOR_EARNERS, leisure=False).gaming_minutes == 180 + assert resolve(FULL, DAY, TUTOR_EARNERS, leisure=False).gaming_minutes == 480 + + +@pytest.mark.usefixtures("live") +def test_leisure_none_decides_by_the_file() -> None: + eve = DAY - timedelta(days=1) + assert base_for(eve, leisure=None).gaming_minutes == 180 + assert base_for(DAY, leisure=None).gaming_minutes == 300 + assert resolve(FULL, eve, TUTOR_EARNERS, leisure=None).gaming_minutes == 480 + assert resolve(FULL, DAY, TUTOR_EARNERS, leisure=None).gaming_minutes == 600 + + +def test_leisure_defaults_to_none() -> None: + for func in (base_for, resolve): + assert inspect.signature(func).parameters["leisure"].default is None diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index 7a60c7a..bc1b584 100644 --- a/earned_time/pyproject.toml +++ b/earned_time/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "earned-time" -version = "0.15.0" +version = "0.16.0" description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time" requires-python = ">=3.14" dependencies = []