diff --git a/earned_time/README.md b/earned_time/README.md index f74e317..0b65dd6 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -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 (`~/`, + prints one row per ledger-backed earner of every registry. It and + `leisure_from()` alone resolve real paths (`~/`, `/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 diff --git a/earned_time/earned_time/__init__.py b/earned_time/earned_time/__init__.py index 23f168c..6b042d5 100644 --- a/earned_time/earned_time/__init__.py +++ b/earned_time/earned_time/__init__.py @@ -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", diff --git a/earned_time/earned_time/_leisure.py b/earned_time/earned_time/_leisure.py index 4b799bd..6c390ba 100644 --- a/earned_time/earned_time/_leisure.py +++ b/earned_time/earned_time/_leisure.py @@ -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)) diff --git a/earned_time/earned_time/_resolve.py b/earned_time/earned_time/_resolve.py index 4a23351..b770a5d 100644 --- a/earned_time/earned_time/_resolve.py +++ b/earned_time/earned_time/_resolve.py @@ -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, diff --git a/earned_time/earned_time/tests/conftest.py b/earned_time/earned_time/tests/conftest.py index 0ec1587..29bfa4b 100644 --- a/earned_time/earned_time/tests/conftest.py +++ b/earned_time/earned_time/tests/conftest.py @@ -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.""" diff --git a/earned_time/earned_time/tests/test_leisure_switch.py b/earned_time/earned_time/tests/test_leisure_switch.py new file mode 100644 index 0000000..03dcf5e --- /dev/null +++ b/earned_time/earned_time/tests/test_leisure_switch.py @@ -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 diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index a1d918a..7a60c7a 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.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 = []