From ef244889fd021258ea92ed88787de7872ecf2c17 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 17 Oct 2026 16:47:02 +0200 Subject: [PATCH] earned_time 0.12.0: shutdown rungs equal gaming pay from 2026-10-18 (workout 120, LeetCode 60, reading 60, tutor 60; floor 18:00) Co-Authored-By: Claude Opus 5.5 (1M context) --- earned_time/README.md | 19 +++--- earned_time/earned_time/__init__.py | 4 ++ earned_time/earned_time/_ladder.py | 23 ++++++- earned_time/earned_time/tests/test_unified.py | 63 +++++++++++++++++++ earned_time/pyproject.toml | 2 +- 5 files changed, 98 insertions(+), 13 deletions(-) create mode 100644 earned_time/earned_time/tests/test_unified.py diff --git a/earned_time/README.md b/earned_time/README.md index 98f9c77..6a69b32 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -29,16 +29,8 @@ never a same-day cut): earner name; all of a counted earner's capped units), so doing everything lands exactly on the ceiling. Since 0.6.1 `TUTOR_FROM == LADDER_FROM`, so every real ladder day uses `TUTOR_LADDER` (below); `LADDER`, the pre-tutor - split, is never in force on a real day: - - | done | shutdown | - |---|---| - | nothing | 19:00 | - | workout (110) | 20:50 | - | + LeetCode (50) | 21:40 | - | + reading (30) | 22:10 | - | + Anki (25) | 22:35 | - | + Automation (25) | 23:00 | + split (workout 110, LeetCode 50, reading 30, Anki 25, Automation 25; floor + 19:00), is never in force on a real day. A second workout earns nothing more (`Rung.extra` defaults to 0). Move the alarm (`WAKE_MINUTES = 6 * 60`) and the whole ladder shifts an hour @@ -48,6 +40,11 @@ never a same-day cut): The two floors are derived separately (`_resolve._shutdown_floor`), so the ladder cannot move a gaming minute. +**Unified ladder (0.12.0, `UNIFIED_FROM` 2026-10-18):** each rung pays what +its earner pays in gaming (workout 120, LeetCode 60, reading 60, tutor 1/unit +to 60), so the floor drops 18:50 -> 18:00 under the same 23:00 ceiling: +nothing 3 h / 18:00, workout 5 h / 20:00, +LeetCode 21:00, +reading 22:00. + **Sick days (0.11.0, `_sick.py`).** `resolve(..., sick=True)` floors the *final* shutdown at 20:00 (`SICK_SHUTDOWN_FLOOR_MINUTES`), capped at the ceiling: 18:50 becomes 20:00, 21:30 stays 21:30 (nothing stacks on the floor). @@ -243,7 +240,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.11.0#subdirectory=earned_time" +pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.12.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 45fb4ac..69dccc3 100644 --- a/earned_time/earned_time/__init__.py +++ b/earned_time/earned_time/__init__.py @@ -46,6 +46,8 @@ from earned_time._ladder import ( SHUTDOWN_CEILING_MINUTES, TUTOR_FROM, TUTOR_LADDER, + UNIFIED_FROM, + UNIFIED_LADDER, WAKE_MINUTES, Rung, extra_shutdown_minutes_for, @@ -106,6 +108,8 @@ __all__ = [ "TUTOR_LADDER", "TUTOR_QUOTA_FROM", "TUTOR_QUOTA_MINUTES", + "UNIFIED_FROM", + "UNIFIED_LADDER", "WAKE_MINUTES", "WORKOUT", "Base", diff --git a/earned_time/earned_time/_ladder.py b/earned_time/earned_time/_ladder.py index 3fdfd65..c2d0386 100644 --- a/earned_time/earned_time/_ladder.py +++ b/earned_time/earned_time/_ladder.py @@ -9,7 +9,9 @@ frozen ceiling, so the switch never rewrites how an old day resolved. Gaming is not on the ladder. From ``TUTOR_FROM`` the rungs are :data:`TUTOR_LADDER`: Anki is retired and -its 25 minutes fold into the tutor's, paid per active minute. +its 25 minutes fold into the tutor's, paid per active minute. From +``UNIFIED_FROM`` they are :data:`UNIFIED_LADDER`: each rung equals its +earner's gaming pay. """ from __future__ import annotations @@ -47,6 +49,10 @@ TUTOR_FROM: Final = date(2026, 10, 10) # floor rises by exactly what the two could have paid back), so it may land # on its deploy day: 0.5.0 landed 2026-10-09. ANKI_WAIVED_FROM: Final = date(2026, 10, 9) +# From this day every rung pays exactly the gaming time its earner pays +# (:data:`UNIFIED_LADDER`, 0.12.0). It lowers the floor 18:50 -> 18:00, so it +# lands the day after the deploy (2026-10-17), never on it. +UNIFIED_FROM: Final = date(2026, 10, 18) _SLEEP_MINUTES: Final = 8 * 60 _DAY_MINUTES: Final = 24 * 60 @@ -109,6 +115,19 @@ TUTOR_LADDER: Final[Mapping[str, Rung]] = MappingProxyType( ) +# From ``UNIFIED_FROM``: shutdown and gaming pay the same -- workout 120, +# LeetCode 60, reading 60, tutor 1 per unit up to 60. The rungs sum to 300: +# the ceiling stays 23:00 and the floor drops to 18:00. +UNIFIED_LADDER: Final[Mapping[str, Rung]] = MappingProxyType( + { + "workout": Rung(120), + "leetcode": Rung(60), + "reading": Rung(60), + "tutor": Rung(1, extra=1), + } +) + + def on_ladder(day: date) -> bool: """Whether ``day`` resolves shutdown on the sleep ladder.""" return day >= LADDER_FROM @@ -118,6 +137,8 @@ def ladder_for(day: date) -> Mapping[str, Rung] | None: """The rungs in force on ``day``; ``None`` before the ladder.""" if not on_ladder(day): return None + if day >= UNIFIED_FROM: + return UNIFIED_LADDER return TUTOR_LADDER if day >= TUTOR_FROM else LADDER diff --git a/earned_time/earned_time/tests/test_unified.py b/earned_time/earned_time/tests/test_unified.py new file mode 100644 index 0000000..267695e --- /dev/null +++ b/earned_time/earned_time/tests/test_unified.py @@ -0,0 +1,63 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The unified ladder (0.12.0): every shutdown rung equals its gaming pay.""" + +from __future__ import annotations + +from datetime import date, timedelta + +from earned_time import ( + TUTOR_EARNERS, + TUTOR_LADDER, + UNIFIED_FROM, + UNIFIED_LADDER, + ladder_for, + resolve, + shutdown_ceiling_for, +) + +EVE = UNIFIED_FROM - timedelta(days=1) + + +def _hhmm(minutes: int) -> str: + return f"{minutes // 60:02d}:{minutes % 60:02d}" + + +def _day(day: date, **done: int) -> tuple[int, str]: + answers = {e.name: done.get(e.name, 0) for e in TUTOR_EARNERS} + out = resolve(answers, day, TUTOR_EARNERS) + return out.gaming_minutes, _hhmm(out.shutdown_minutes) + + +def test_switch_is_the_day_after_the_deploy() -> None: + assert date(2026, 10, 18) == UNIFIED_FROM + assert ladder_for(EVE) is TUTOR_LADDER # deploy day keeps the old rungs + assert ladder_for(UNIFIED_FROM) is UNIFIED_LADDER + + +def test_each_rung_pays_what_gaming_pays() -> None: + for e in TUTOR_EARNERS: + units = e.max_units or 1 + assert UNIFIED_LADDER[e.name].minutes(units) == e.gaming_for(units), e.name + assert {n: r.minutes(60) for n, r in UNIFIED_LADDER.items()} == { + "workout": 120, + "leetcode": 60, + "reading": 60, + "tutor": 60, + } + + +def test_ceiling_stays_and_floor_drops_to_18() -> None: + assert shutdown_ceiling_for(UNIFIED_FROM) == 23 * 60 + assert _day(UNIFIED_FROM) == (180, "18:00") + assert _day(UNIFIED_FROM, workout=1) == (300, "20:00") + assert _day(UNIFIED_FROM, workout=2) == (300, "20:00") # 2nd unit pays 0 + assert _day(UNIFIED_FROM, workout=1, leetcode=1) == (360, "21:00") + assert _day(UNIFIED_FROM, workout=1, leetcode=1, reading=1) == (420, "22:00") + full = _day(UNIFIED_FROM, workout=1, leetcode=1, reading=1, tutor=60) + assert full == (480, "23:00") + assert _day(UNIFIED_FROM, tutor=30) == (210, "18:30") # per unit, 1:1 + + +def test_old_days_keep_the_tutor_ladder() -> None: + assert _day(EVE) == (180, "18:50") + assert _day(EVE, workout=1) == (300, "20:40") diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index 1e1619b..cd87d0c 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.11.0" +version = "0.12.0" description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time" requires-python = ">=3.14" dependencies = []