earned_time 0.15.0: leisure switch file /etc/leisure-watch/leisure_from (C6)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run

Contract C6 of the leisure rollout (leisure-watch TODO-leisure-rollout.md).
leisure_from(path=LEISURE_FROM_PATH) -> date reads the switch file afresh on
every call (O_RDONLY|O_NONBLOCK|O_NOFOLLOW, fstat S_ISREG, <= 64 bytes,
strict YYYY-MM-DD with one optional newline); any failure is the parked
LEISURE_FROM. Never raises. leisure_active(day, path=...) = day >=
leisure_from() is the one way consumers ask "is Phase 2 on?".

gaming_base_minutes_for / gaming_ceiling_minutes_for / base_for / resolve
use it; leisure=True still forces (now a boolean, so a file date later than
2027-01-01 cannot drop the shadow budget back to 5 h). resolve reads the file
once, so base and cap never disagree mid-call. Tests redirect the path via
an autouse fixture and never touch the real /etc file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
This commit is contained in:
Krzysztof Rudnicki 2026-10-19 13:57:21 +02:00
parent 00085a7de6
commit 3afa269e2e
7 changed files with 303 additions and 52 deletions

View File

@ -18,14 +18,19 @@ rows in its ledger — and is registered here once.
`gaming_minutes` from its `penalty_from` date; each earner done adds its
`gaming_minutes` back. Capped at `gaming_ceiling_minutes_for(day)`.
**Leisure budget (0.14.0, `_leisure.py`).** From `LEISURE_FROM` the budget
covers all leisure (games, browsing, video, messaging): base 5 h -> 7 h, cap
8 h -> 10 h; pay, penalties and shutdown untouched (fully done day: 180 + 300
= 480 before, 300 + 300 = 600 from it). `LEISURE_FROM` is parked at 2027-01-01
until Phase 2 sets it to the day after kuhy confirms the shadow week, so every
real day still resolves byte-identically. `resolve(..., leisure=True)` (and
`base_for(..., leisure=True)`) prices any day with the leisure base and cap:
the shadow budget the enforcer shows during that week.
**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.
**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.
**Shutdown** has two regimes, switched by date (`LADDER_FROM`, 2026-10-10 —
never a same-day cut):
@ -110,18 +115,12 @@ per-matcher reader `_match.ROW_UNITS`):
every 5 minutes) are allowed under the same rules; each write fires
screen-locker's `earner-bonus.path`, so per-minute rows rewrite the
shutdown schedule once a minute.
- Rollout order: no minute row may be written until every consumer process
runs 0.8.0. A 0.7.0 reader pays each row as a 15-minute block.
Days before `TUTOR_FROM` resolve on `EARNERS` exactly as before. Consumers must
iterate `earners_for(day)`, never `EARNERS`; `resolve`/`base_for` default to
it, and ignore answers for an earner of another day's registry.
**The gap waiver (`ANKI_WAIVED_FROM`, 2026-10-09 only).** Until `TUTOR_FROM`
(2026-10-10) the registry is `EARNERS` without `anki` and `automation`: neither
penalised nor paid, so the base rises by what they could pay back (nothing
done 4 h / 19:00; everything 8 h / 23:00). The tutor was confirmed in 0.7.0
(`confirmed_on` 2026-10-09), so its penalty bites from `TUTOR_FROM`.
**Gap waiver** (`ANKI_WAIVED_FROM`, 2026-10-09): no anki/automation earner.
## Gate maturity (0.6.0)
@ -154,8 +153,8 @@ How far to trust a gate is computed from its ledger, never listed by name:
An unwired consumer (no `first_credits`) cuts the tutor from `penalty_from`:
19:00 / 3 h.
- **CLI:** `python -m earned_time maturity [--json] [--day YYYY-MM-DD]`
prints one row per ledger-backed earner of every registry. It is the only
code that resolves real paths (`~/<Earner.ledger>`,
prints one row per ledger-backed earner of every registry. It and
`leisure_from()` alone resolve real paths (`~/<Earner.ledger>`,
`/etc/workout-locker/hmac.key`), and it only reads.
## API
@ -165,6 +164,7 @@ How far to trust a gate is computed from its ledger, never listed by name:
day) -> int`. `base_for(day=None, earners=None, *, first_credits=None,
leisure=False) -> 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`
- `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time
of the earliest verified credit for `day`, on its matcher's stamp
@ -230,8 +230,8 @@ on the day itself is logged and does not count.
## Rules the consumers rely on
- **Minutes everywhere.** Shutdown is minutes after local midnight.
- **Paths are passed in.** No reader resolves a real path itself; consumers'
test suites redirect their own constants.
- **Paths are passed in.** No reader resolves a real path itself, except the
switch file: consumer tests patch `earned_time._leisure.LEISURE_FROM_PATH`.
- **`None` is "could not check", never "no".** It earns nothing (fail closed),
but consumers log it. A missing answer in `resolve()` is logged as well; an
unknown earner name raises.
@ -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.14.0#subdirectory=earned_time"
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.15.0#subdirectory=earned_time"
```
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports

View File

@ -24,7 +24,8 @@ A consumer's whole integration::
``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
(:data:`LEISURE_FROM`) whatever the day: the shadow budget.
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`.
How far to trust a gate is computed, not listed: :func:`maturity` (and
``python -m earned_time maturity``).
@ -63,8 +64,11 @@ from earned_time._leisure import (
LEISURE_BASE_MINUTES,
LEISURE_CEILING_MINUTES,
LEISURE_FROM,
LEISURE_FROM_PATH,
gaming_base_minutes_for,
gaming_ceiling_minutes_for,
leisure_active,
leisure_from,
)
from earned_time._maturity import (
MATURE_MIN_AGE_DAYS,
@ -108,6 +112,7 @@ __all__ = [
"LEISURE_BASE_MINUTES",
"LEISURE_CEILING_MINUTES",
"LEISURE_FROM",
"LEISURE_FROM_PATH",
"MATURE_MIN_AGE_DAYS",
"MATURE_MIN_CREDIT_DAYS",
"READING",
@ -145,6 +150,8 @@ __all__ = [
"gaming_base_minutes_for",
"gaming_ceiling_minutes_for",
"ladder_for",
"leisure_active",
"leisure_from",
"maturity",
"on_ladder",
"penalty_start",

View File

@ -1,5 +1,5 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The leisure budget: from ``LEISURE_FROM`` gaming time covers all leisure.
"""The leisure budget: from ``leisure_from()`` gaming time covers all leisure.
The daily "gaming" budget becomes a budget for every non-productive activity
on the PC (games, browsing, video, messaging), so a fully earned day grows
@ -7,41 +7,102 @@ from 8 h to 10 h: the base rises 5 h -> 7 h and the cap 8 h -> 10 h. Earner
pay, penalties and the shutdown ladder are untouched, so a day still earns
exactly what it did; only the floor and the cap move, by the same 2 h.
Days before ``LEISURE_FROM`` keep ``GAMING_BASE_MINUTES`` and
``GAMING_CEILING_MINUTES``, byte-identical. ``resolve(..., leisure=True)``
prices any day as if ``LEISURE_FROM`` had already passed: the shadow budget
the enforcer reports during the shadow week, while it still enforces the old.
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.
"""
from __future__ import annotations
from datetime import date
import os
from pathlib import Path
import re
import stat
from typing import Final
from earned_time._policy import GAMING_BASE_MINUTES, GAMING_CEILING_MINUTES
# Parked far ahead: Phase 2 of the leisure rollout sets it to the day after
# kuhy confirms the shadow week, never a same-day cut (2026-09-26).
# Parked far ahead: the switch file moves it, never a same-day cut (2026-09-26).
LEISURE_FROM: Final = date(2027, 1, 1)
# From ``LEISURE_FROM``: 7 h base, 10 h cap (+2 h each).
# One line ``YYYY-MM-DD``, root:root 0644 (contract C6 of the leisure rollout).
LEISURE_FROM_PATH: Final = Path("/etc/leisure-watch/leisure_from")
# From the first leisure day: 7 h base, 10 h cap (+2 h each).
LEISURE_BASE_MINUTES: Final = 7 * 60
LEISURE_CEILING_MINUTES: Final = 10 * 60
_MAX_BYTES: Final = 64
# Checked before fromisoformat, which also takes "20261020" and "2026-W43-2".
_ISO_DAY: Final = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\n?")
# NONBLOCK: a FIFO planted there must not hang the root daemon on open().
_OPEN_FLAGS: Final = os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW | os.O_CLOEXEC
def leisure_day(day: date, *, leisure: bool = False) -> date:
"""The day the gaming base and cap are priced on.
``day`` itself, unless ``leisure``: then no earlier than ``LEISURE_FROM``,
so the shadow budget of an old day uses the leisure base and cap.
def _read_day(path: Path) -> date | None:
"""The date in ``path`` if it is a small regular file holding one; else None."""
fd = os.open(path, _OPEN_FLAGS)
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
return None
raw = os.read(fd, _MAX_BYTES + 1)
finally:
os.close(fd)
text = raw.decode("ascii")
if len(raw) > _MAX_BYTES or not _ISO_DAY.fullmatch(text):
return None
return date.fromisoformat(text.rstrip("\n"))
def leisure_from(path: Path = LEISURE_FROM_PATH) -> date:
"""The first leisure day: the switch file's date, else ``LEISURE_FROM``.
Read afresh on every call (go-live can write it mid-run), never following
a symlink or blocking on a FIFO. Anything but a regular file of at most
64 bytes holding exactly ``YYYY-MM-DD`` (one optional newline) -- missing,
unreadable, garbage, an impossible date -- is the parked ``LEISURE_FROM``.
Never raises.
"""
return max(day, LEISURE_FROM) if leisure else day
try:
day = _read_day(path)
except OSError, ValueError: # UnicodeDecodeError is a ValueError
return LEISURE_FROM
return LEISURE_FROM if day is None else day
def leisure_active(day: date, path: Path = LEISURE_FROM_PATH) -> bool:
"""Is ``day`` (a gaming day) on the leisure budget? ``day >= leisure_from()``.
The only way a consumer may ask "is Phase 2 on?"; none reads the file.
"""
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.
Looks ``LEISURE_FROM_PATH`` up at call time, so tests can redirect it.
"""
return leisure or leisure_active(day, LEISURE_FROM_PATH)
def base_minutes(*, leisure: bool) -> int:
"""The gaming base before penalties: 7 h on the leisure budget, else 5 h."""
return LEISURE_BASE_MINUTES if leisure else GAMING_BASE_MINUTES
def ceiling_minutes(*, leisure: bool) -> int:
"""The gaming cap: 10 h on the leisure budget, else 8 h."""
return LEISURE_CEILING_MINUTES if leisure else GAMING_CEILING_MINUTES
def gaming_base_minutes_for(day: date) -> int:
"""The gaming base before penalties: 7 h from ``LEISURE_FROM``, else 5 h."""
return LEISURE_BASE_MINUTES if day >= LEISURE_FROM else GAMING_BASE_MINUTES
"""The gaming base before penalties: 7 h from ``leisure_from()``, else 5 h."""
return base_minutes(leisure=is_leisure(day))
def gaming_ceiling_minutes_for(day: date) -> int:
"""The gaming cap: 10 h from ``LEISURE_FROM``, else 8 h."""
return LEISURE_CEILING_MINUTES if day >= LEISURE_FROM else GAMING_CEILING_MINUTES
"""The gaming cap: 10 h from ``leisure_from()``, else 8 h."""
return ceiling_minutes(leisure=is_leisure(day))

View File

@ -26,11 +26,7 @@ import logging
from typing import TYPE_CHECKING, Final
from earned_time._ladder import on_ladder, shutdown_ceiling_for
from earned_time._leisure import (
gaming_base_minutes_for,
gaming_ceiling_minutes_for,
leisure_day,
)
from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure
from earned_time._maturity import confirmed_start, penalty_start
from earned_time._policy import SHUTDOWN_BASE_MINUTES, Earner
from earned_time._registry import all_earners, earners_for
@ -102,13 +98,24 @@ def base_for(
``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:`leisure_day`).
``leisure`` the leisure base whatever the date (:func:`is_leisure`).
"""
target = day or _today()
registry = earners_for(target) if earners is None else earners
on = is_leisure(target, leisure=leisure)
return _base(target, registry, first_credits, on=on)
def _base(
target: date,
registry: tuple[Earner, ...],
first_credits: Mapping[str, date | None] | None,
*,
on: bool,
) -> Base:
penalised = [e for e in registry if _penalised(e, target, first_credits)]
return Base(
gaming_minutes=gaming_base_minutes_for(leisure_day(target, leisure=leisure))
gaming_minutes=base_minutes(leisure=on)
- sum(e.max_gaming_minutes for e in penalised),
shutdown_minutes=_shutdown_floor(target, registry, penalised),
)
@ -179,8 +186,8 @@ 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 if ``LEISURE_FROM`` had passed (0.14.0): the
leisure base and cap whatever the day -- the shadow budget.
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.
Returns:
@ -193,7 +200,9 @@ def resolve(
target = day or _today()
registry = earners_for(target) if earners is None else earners
_check_registered(answers, registry)
base = base_for(target, registry, first_credits=first_credits, leisure=leisure)
# One switch-file read per resolve, so base and cap can never disagree.
on = is_leisure(target, leisure=leisure)
base = _base(target, registry, first_credits, on=on)
terms = [_term(item, answers, target) for item in registry]
gaming = base.gaming_minutes + sum(t.gaming_minutes for t in terms)
earned = min(
@ -205,9 +214,7 @@ def resolve(
day=target,
base=base,
terms=tuple(terms),
gaming_minutes=min(
gaming_ceiling_minutes_for(leisure_day(target, leisure=leisure)), gaming
),
gaming_minutes=min(ceiling_minutes(leisure=on), gaming),
shutdown_minutes=shutdown,
sick=sick,
sick_lift_minutes=shutdown - earned,

View File

@ -13,6 +13,14 @@ if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture(autouse=True)
def _no_real_switch_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Point the leisure switch file at a missing path, never the real /etc one."""
monkeypatch.setattr(
"earned_time._leisure.LEISURE_FROM_PATH", tmp_path / "no_leisure_from"
)
@pytest.fixture
def key_file(tmp_path: Path) -> Path:
"""The test HMAC key, written where a test can point ``maturity`` at it."""

View File

@ -0,0 +1,168 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The leisure switch file (0.15.0): ``/etc/leisure-watch/leisure_from``."""
from __future__ import annotations
from datetime import date, timedelta
import inspect
import os
from pathlib import Path
import pytest
from earned_time import (
LEISURE_FROM,
LEISURE_FROM_PATH,
TUTOR_EARNERS,
UNIFIED_FROM,
base_for,
gaming_base_minutes_for,
gaming_ceiling_minutes_for,
leisure_active,
leisure_from,
resolve,
)
DAY = date(2026, 10, 20)
FULL = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60}
NONE = dict.fromkeys(FULL, 0)
def _switch(tmp_path: Path, content: bytes) -> Path:
path = tmp_path / "leisure_from"
path.write_bytes(content)
return path
@pytest.fixture
def live(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""The module's switch path, holding ``DAY`` (go-live wrote it)."""
path = _switch(tmp_path, b"2026-10-20\n")
monkeypatch.setattr("earned_time._leisure.LEISURE_FROM_PATH", path)
return path
def test_default_path_is_the_etc_switch_file() -> None:
assert Path("/etc/leisure-watch/leisure_from") == LEISURE_FROM_PATH
for func in (leisure_from, leisure_active):
assert inspect.signature(func).parameters["path"].default == LEISURE_FROM_PATH
def test_missing_file_is_parked(tmp_path: Path) -> None:
assert leisure_from(tmp_path / "absent") == LEISURE_FROM
assert not leisure_active(DAY, tmp_path / "absent")
@pytest.mark.parametrize("content", [b"2026-10-20", b"2026-10-20\n"])
def test_valid_date_is_read(tmp_path: Path, content: bytes) -> None:
assert leisure_from(_switch(tmp_path, content)) == DAY
def test_file_date_is_the_first_leisure_day(tmp_path: Path) -> None:
path = _switch(tmp_path, b"2026-10-21\n") # go-live on the 20th: tomorrow
assert not leisure_active(DAY, path)
assert leisure_active(DAY + timedelta(days=1), path)
assert leisure_active(DAY + timedelta(days=2), path)
assert leisure_active(DAY, _switch(tmp_path, b"2026-10-20\n")) # today
@pytest.mark.parametrize(
"content",
[
b"",
b"garbage\n",
b"2026-13-01\n",
b"2026-02-30\n",
b"20261020\n",
b"2026-W43-2\n",
b" 2026-10-20\n",
b"2026-10-20\n\n",
b"2026-10-20 \n",
b"2026-10-20\r\n",
b"2026-10-\xef\xbc\x920\n", # fullwidth digit two
b"\xff\xfe2026-10-20",
],
)
def test_garbage_is_parked(tmp_path: Path, content: bytes) -> None:
assert leisure_from(_switch(tmp_path, content)) == LEISURE_FROM
def test_oversize_file_is_parked(tmp_path: Path) -> None:
assert leisure_from(_switch(tmp_path, b"2026-10-20\n" + b"\n" * 54)) == LEISURE_FROM
assert leisure_from(_switch(tmp_path, b"2026-10-20" + b" " * 54)) == LEISURE_FROM
def test_fifo_is_parked_without_blocking(tmp_path: Path) -> None:
fifo = tmp_path / "fifo"
os.mkfifo(fifo)
assert leisure_from(fifo) == LEISURE_FROM
def test_directory_is_parked(tmp_path: Path) -> None:
assert leisure_from(tmp_path) == LEISURE_FROM
def test_symlink_is_parked_even_to_a_valid_file(tmp_path: Path) -> None:
target = _switch(tmp_path, b"2026-10-20\n")
link = tmp_path / "link"
link.symlink_to(target)
assert leisure_from(target) == DAY
assert leisure_from(link) == LEISURE_FROM
def test_unreadable_file_is_parked(tmp_path: Path) -> None:
path = _switch(tmp_path, b"2026-10-20\n")
path.chmod(0)
try:
if os.access(path, os.R_OK): # pragma: no cover - running as root
pytest.skip("root reads a 0000 file")
assert leisure_from(path) == LEISURE_FROM
finally:
path.chmod(0o644)
def test_file_appearing_mid_run_is_seen(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "leisure_from"
monkeypatch.setattr("earned_time._leisure.LEISURE_FROM_PATH", path)
assert gaming_base_minutes_for(DAY) == 300
path.write_bytes(b"2026-10-20\n")
assert gaming_base_minutes_for(DAY) == 420
path.unlink()
assert gaming_base_minutes_for(DAY) == 300
@pytest.mark.usefixtures("live")
def test_base_and_ceiling_follow_the_file() -> None:
eve = DAY - timedelta(days=1)
assert gaming_base_minutes_for(eve) == 300
assert gaming_ceiling_minutes_for(eve) == 480
assert gaming_base_minutes_for(DAY) == 420
assert gaming_ceiling_minutes_for(DAY) == 600
@pytest.mark.usefixtures("live")
def test_resolve_and_base_for_follow_the_file() -> None:
eve = DAY - timedelta(days=1)
assert base_for(eve).gaming_minutes == 180
assert base_for(DAY).gaming_minutes == 300
assert resolve(NONE, eve, TUTOR_EARNERS).gaming_minutes == 180
assert resolve(FULL, eve, TUTOR_EARNERS).gaming_minutes == 480
assert resolve(NONE, DAY, TUTOR_EARNERS).gaming_minutes == 300
assert resolve(FULL, DAY, TUTOR_EARNERS).gaming_minutes == 600
def test_leisure_flag_still_forces_past_a_later_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = _switch(tmp_path, b"2027-06-01\n")
monkeypatch.setattr("earned_time._leisure.LEISURE_FROM_PATH", path)
assert base_for(UNIFIED_FROM, leisure=True).gaming_minutes == 300
assert (
resolve(NONE, UNIFIED_FROM, TUTOR_EARNERS, leisure=True).gaming_minutes == 300
)
assert (
resolve(FULL, UNIFIED_FROM, TUTOR_EARNERS, leisure=True).gaming_minutes == 600
)
assert resolve(FULL, date(2027, 1, 2), TUTOR_EARNERS).gaming_minutes == 480

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "earned-time"
version = "0.14.0"
version = "0.15.0"
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
requires-python = ">=3.14"
dependencies = []