diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3576551..beaf3ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -110,8 +110,8 @@ repos: - --disable-error-code=list-item additional_dependencies: - types-requests - - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time - - leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.2 + - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.18.0#subdirectory=earned_time + - leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.3 # =========================================================================== # PYLINT @@ -131,8 +131,8 @@ repos: # (steam_backlog_enforcer._mcp_server). additional_dependencies: - aiohttp==3.14.5 - - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time - - leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.2 + - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.18.0#subdirectory=earned_time + - leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.3 - howlongtobeatpy==1.0.24 - mcp==2.3.0 - pytest diff --git a/pyproject.toml b/pyproject.toml index e9d7beb..5769a9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,11 +16,11 @@ dependencies = [ "websockets==17.3", # The earner registry (gaming budget terms); stdlib-only, so the root # daemon imports it from the desktop user's site-packages. - "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time", + "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.18.0#subdirectory=earned_time", # The shared /proc game scan and the signed leisure facts (shadow budget, # contract C4); stdlib-only, imported by root from the user site like # earned-time. - "leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.2", + "leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.3", ] # Optional extras. The `mcp` group is ONLY needed by the MCP server diff --git a/requirements.txt b/requirements.txt index bd9dd2e..cd8b763 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,9 +5,9 @@ aiohttp==3.14.5 bandit==1.9.4 codespell==2.4.3 coverage==7.17.0 -earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time +earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.18.0#subdirectory=earned_time howlongtobeatpy==1.0.24 -leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.2 +leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.2.3 mcp==2.3.0 mypy==2.4.0 pre-commit==4.7.0 diff --git a/steam_backlog_enforcer/_leetcode_bonus.py b/steam_backlog_enforcer/_leetcode_bonus.py index af5c8e6..c6fa062 100644 --- a/steam_backlog_enforcer/_leetcode_bonus.py +++ b/steam_backlog_enforcer/_leetcode_bonus.py @@ -21,8 +21,13 @@ strictly more access than the endpoint does. Both carry the same staleness (see below); the fallback is not a fresher source. **Staleness.** leetcode-guard's one-minute harvester credits a solve within -about a minute, even with no lock window up. A day paid ahead from banked -credits counts like a solve (``earned_time`` >= 0.17.0, kuhy 2026-10-23). +about a minute, even with no lock window up. + +**What earns (``earned_time`` >= 0.18.0, kuhy 2026-10-24).** Only today's +signed ``leetcode`` charge -- the day paid in full -- earns the hour; one solve +of a multi-credit day does not. The exception is a day the gate stands down +(free or sick): there one verified solve earns. ``gate_off`` is read live from +leetcode-guard's endpoint, and an unreadable answer means the gate is on. Reading and verifying the ledger itself lives in :mod:`steam_backlog_enforcer._leetcode_ledger`; this module owns the two @@ -59,8 +64,8 @@ def _fetch_leetcode_today(url: str) -> bool: url: The status endpoint, e.g. ``http://127.0.0.1:8771/api/status``. Returns: - Whether an accepted submission is recorded for today, or today was - paid from banked credits. + Whether today was paid in full from verified credits, or -- on a day + the gate stands down -- whether a submission was accepted. Raises: OSError: The endpoint could not be reached. @@ -73,9 +78,38 @@ def _fetch_leetcode_today(url: str) -> bool: # The server answered, but said it could not look. That is not a "no". msg = f"leetcode-guard could not check: {block.get('reason', 'no reason')}" raise ValueError(msg) - # A day paid ahead from banked credits earns the hour like a solve - # (leetcode-guard >= 2026-10-23); an older server has no such field. - return bool(block["solved_today"]) or bool(block.get("settled_by_credit")) + # One solve of a five-credit day is not a done day (kuhy, 2026-10-24): + # only the charge that pays the price earns, unless the gate stands down + # today. An older server has neither field, which reads as "no hour". + if block.get("gate_off") is True: + return bool(block["solved_today"]) + return bool(block.get("settled_by_credit")) + + +def gate_off_now(url: str) -> bool: + """Whether leetcode-guard says its gate stands down today (free or sick). + + This root daemon has neither the free-day nor the sick-day pool, so it + asks the endpoint, live, every time the memo misses. Anything but an + explicit ``true`` -- an unreachable server, an older one without the + field -- means the gate is on: one solve then earns nothing. + + Args: + url: The status endpoint. + + Returns: + The published ``leetcode.gate_off``, or False when it cannot be read. + """ + try: + return get_status(url)["leetcode"].get("gate_off") is True + except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc: + logger.warning( + "Could not ask %s whether the LeetCode gate is off (%s); " + "treating it as on, so only a fully paid day earns", + url, + exc, + ) + return False def leetcode_solved_today(config: Config) -> bool | None: @@ -98,7 +132,7 @@ def leetcode_solved_today(config: Config) -> bool | None: if cached is not None: return cached - answer = read_ledger_solved_today(path) + answer = read_ledger_solved_today(path, gate_off=gate_off_now(url)) if answer is None: answer = _fallback_to_endpoint(url, path) if answer is None: diff --git a/steam_backlog_enforcer/_leetcode_ledger.py b/steam_backlog_enforcer/_leetcode_ledger.py index 49659c1..57b1b84 100644 --- a/steam_backlog_enforcer/_leetcode_ledger.py +++ b/steam_backlog_enforcer/_leetcode_ledger.py @@ -5,10 +5,9 @@ the same one screen-locker uses; ``test_leetcode_ledger.py`` pins its HMAC canonicalisation to a fixed vector. gatelock itself is not imported: it pulls in tkinter, and this daemon runs headless as root. -Only ``credit`` entries count. A ``seen`` entry is first-run seeding worth -zero, and a ``charge`` proves only that the day was *settled* -- which also -happens from banked credit, from the escape hatch and from a classified -outage, so it can never stand in for a solve. +Since earned_time 0.18.0 only today's signed ``leetcode`` ``charge`` (the day +paid in full) earns. A bare ``credit`` earns only when ``gate_off`` is true (a +free or sick day), and a ``seen`` entry is first-run seeding worth zero. The solve time is ``detail["submitted_at"]``, LeetCode's own timestamp, not the entry's ``day``: ``day`` is stamped by the *harvesting* run, so a problem @@ -32,14 +31,19 @@ logger = logging.getLogger(__name__) HMAC_KEY_FILE: Final = Path("/etc/workout-locker/hmac.key") -def read_ledger_solved_today(path: Path) -> bool | None: - """Read leetcode-guard's ledger and count today's verified credits. +def read_ledger_solved_today(path: Path, *, gate_off: bool = False) -> bool | None: + """Read leetcode-guard's ledger: was today paid in full? Args: path: The ledger file. + gate_off: Whether the gate stands down today (asked live). Only then + does one verified solve earn; otherwise only the signed charge + that covers the day's whole price does (earned_time 0.18.0). Returns: True or False when the ledger could be read and verified, ``None`` when it could not -- which is never "nothing was solved". """ - return earned_time.done_today(earned_time.LEETCODE, path, HMAC_KEY_FILE) + return earned_time.done_today( + earned_time.LEETCODE, path, HMAC_KEY_FILE, gate_off=gate_off + ) diff --git a/steam_backlog_enforcer/tests/test_leetcode_bonus.py b/steam_backlog_enforcer/tests/test_leetcode_bonus.py index c618dac..f81b323 100644 --- a/steam_backlog_enforcer/tests/test_leetcode_bonus.py +++ b/steam_backlog_enforcer/tests/test_leetcode_bonus.py @@ -128,7 +128,14 @@ class TestFetchLeetcodeToday: def test_a_good_answer_is_read(self) -> None: """The happy path returns the flag.""" payload = json.dumps( - {"leetcode": {"checked": True, "solved_today": True, "reason": "1"}} + { + "leetcode": { + "checked": True, + "solved_today": True, + "settled_by_credit": True, + "reason": "1", + } + } ).encode() with patch(f"{_API}.http.client.HTTPConnection") as conn: resp = conn.return_value.getresponse.return_value diff --git a/steam_backlog_enforcer/tests/test_leetcode_ledger.py b/steam_backlog_enforcer/tests/test_leetcode_ledger.py index fa15b41..036cef9 100644 --- a/steam_backlog_enforcer/tests/test_leetcode_ledger.py +++ b/steam_backlog_enforcer/tests/test_leetcode_ledger.py @@ -6,6 +6,10 @@ in tkinter, and the enforcer runs headless as root). ``TestSignatureCanonicalisation`` pins it to a fixed vector so the duplication cannot drift silently -- if leetcode-guard ever changes how it signs, that test fails instead of every credit quietly ceasing to count. + +Every read here passes ``gate_off=True``: these pin how a credit row is +parsed, which only decides on a day the gate stands down. On any other day +only the paid-in-full charge earns (``test_leetcode_paid_day.py``). """ from __future__ import annotations @@ -64,7 +68,10 @@ class TestSignatureCanonicalisation: assert key_file.exists() entry = credit(when=datetime.now().astimezone()) entry["amount"] = 99 - assert read_ledger_solved_today(write_ledger(tmp_path, [entry])) is False + assert ( + read_ledger_solved_today(write_ledger(tmp_path, [entry]), gate_off=True) + is False + ) def test_a_missing_signature_fails(self, tmp_path: Path, key_file: Path) -> None: """An entry with no hmac field is not a signed entry. @@ -76,7 +83,10 @@ class TestSignatureCanonicalisation: assert key_file.exists() entry = credit(when=datetime.now().astimezone()) del entry["hmac"] - assert read_ledger_solved_today(write_ledger(tmp_path, [entry])) is False + assert ( + read_ledger_solved_today(write_ledger(tmp_path, [entry]), gate_off=True) + is False + ) class TestReadingTheLedger: @@ -91,7 +101,7 @@ class TestReadingTheLedger: """ assert key_file.exists() ledger = write_ledger(tmp_path, [credit(when=datetime.now().astimezone())]) - assert read_ledger_solved_today(ledger) is True + assert read_ledger_solved_today(ledger, gate_off=True) is True def test_a_credit_yesterday_does_not(self, tmp_path: Path, key_file: Path) -> None: """Yesterday's solve bought yesterday's hour, not today's. @@ -103,7 +113,7 @@ class TestReadingTheLedger: assert key_file.exists() yesterday = datetime.now().astimezone() - timedelta(days=1) ledger = write_ledger(tmp_path, [credit(when=yesterday)]) - assert read_ledger_solved_today(ledger) is False + assert read_ledger_solved_today(ledger, gate_off=True) is False def test_charges_and_seen_entries_do_not_count( self, tmp_path: Path, key_file: Path @@ -141,7 +151,10 @@ class TestReadingTheLedger: } ), ] - assert read_ledger_solved_today(write_ledger(tmp_path, rows)) is False + assert ( + read_ledger_solved_today(write_ledger(tmp_path, rows), gate_off=True) + is False + ) def test_a_forged_credit_does_not_count( self, tmp_path: Path, key_file: Path @@ -155,7 +168,10 @@ class TestReadingTheLedger: assert key_file.exists() forged = credit(when=datetime.now().astimezone()) forged["hmac"] = "00" * 32 - assert read_ledger_solved_today(write_ledger(tmp_path, [forged])) is False + assert ( + read_ledger_solved_today(write_ledger(tmp_path, [forged]), gate_off=True) + is False + ) def test_a_missing_submitted_at_falls_back_to_the_day_key( self, tmp_path: Path, key_file: Path @@ -179,7 +195,10 @@ class TestReadingTheLedger: "detail": {"source": "leetcode"}, } ) - assert read_ledger_solved_today(write_ledger(tmp_path, [entry])) is True + assert ( + read_ledger_solved_today(write_ledger(tmp_path, [entry]), gate_off=True) + is True + ) def test_an_unparsable_submitted_at_is_reported( self, tmp_path: Path, key_file: Path, caplog: pytest.LogCaptureFixture @@ -196,5 +215,8 @@ class TestReadingTheLedger: entry = credit(when=now, entry_id="ac:3") entry["detail"]["submitted_at"] = "not-a-number" entry = sign({k: v for k, v in entry.items() if k != "hmac"}) - assert read_ledger_solved_today(write_ledger(tmp_path, [entry])) is True + assert ( + read_ledger_solved_today(write_ledger(tmp_path, [entry]), gate_off=True) + is True + ) assert "unparsable submitted_at" in caplog.text diff --git a/steam_backlog_enforcer/tests/test_leetcode_paid_day.py b/steam_backlog_enforcer/tests/test_leetcode_paid_day.py new file mode 100644 index 0000000..7a2408a --- /dev/null +++ b/steam_backlog_enforcer/tests/test_leetcode_paid_day.py @@ -0,0 +1,108 @@ +"""The LeetCode gaming hour needs the day paid in full (earned_time 0.18.0). + +kuhy, 2026-10-24: Saturday cost 5 credits, one problem was solved, and the ++1 h gaming paid anyway. Only the signed ``leetcode`` charge that covers the +day earns while the gate is on; on a day the gate stands down (free, or sick +without ``leetcode``) one solve earns. This daemon runs as root without the +free-day and sick-day pools, so it asks leetcode-guard's endpoint for the +live ``gate_off`` fact -- and an unreachable endpoint means "on" (no hour). +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _leetcode_bonus, _leetcode_ledger +from steam_backlog_enforcer._leetcode_bonus import leetcode_solved_today, reset_cache +from steam_backlog_enforcer.config import Config + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +_PKG = "steam_backlog_enforcer._leetcode_bonus" +_API = "steam_backlog_enforcer._status_api" +_URL = "http://127.0.0.1:8771/api/status" + + +@pytest.fixture(autouse=True) +def _clear_cache() -> Iterator[None]: + reset_cache() + yield + reset_cache() + + +def _serve(block: dict[str, bool]) -> object: + """Patch the endpoint to answer ``block`` (checked) with a 200.""" + payload = json.dumps({"leetcode": {"checked": True, **block}}).encode() + conn = patch(f"{_API}.http.client.HTTPConnection") + mock = conn.start() + resp = mock.return_value.getresponse.return_value + resp.status = 200 + resp.read.return_value = payload + return conn + + +@pytest.mark.parametrize( + ("block", "expected"), + [ + # One solve of five, gate on: no hour -- the 2026-10-24 bug. + ({"solved_today": True, "settled_by_credit": False, "gate_off": False}, False), + ({"solved_today": True, "settled_by_credit": False}, False), + ({"solved_today": True, "settled_by_credit": True, "gate_off": False}, True), + # A stood-down day has no price: one solve earns, no solve does not. + ({"solved_today": True, "settled_by_credit": False, "gate_off": True}, True), + ({"solved_today": False, "settled_by_credit": False, "gate_off": True}, False), + ], +) +def test_the_fallback_pays_only_a_paid_day( + block: dict[str, bool], *, expected: bool +) -> None: + conn = _serve(block) + try: + assert _leetcode_bonus._fetch_leetcode_today(_URL) is expected + finally: + conn.stop() + + +@pytest.mark.parametrize("off", [True, False]) +def test_gate_off_is_read_from_the_endpoint(*, off: bool) -> None: + conn = _serve({"solved_today": False, "gate_off": off}) + try: + assert _leetcode_bonus.gate_off_now(_URL) is off + finally: + conn.stop() + + +def test_an_unreachable_endpoint_means_the_gate_is_on() -> None: + with patch(f"{_PKG}.get_status", side_effect=OSError("refused")): + assert _leetcode_bonus.gate_off_now(_URL) is False + + +def test_an_old_endpoint_without_the_field_means_on() -> None: + conn = _serve({"solved_today": True}) + try: + assert _leetcode_bonus.gate_off_now(_URL) is False + finally: + conn.stop() + + +def test_the_ledger_read_is_told_the_live_answer() -> None: + with ( + patch(f"{_PKG}.gate_off_now", return_value=True), + patch(f"{_PKG}.read_ledger_solved_today", return_value=True) as read, + ): + assert leetcode_solved_today(Config()) is True + assert read.call_args.kwargs["gate_off"] is True + + +def test_the_ledger_reader_passes_it_on(tmp_path: Path) -> None: + with patch.object( + _leetcode_ledger.earned_time, "done_today", return_value=True + ) as done: + _leetcode_ledger.read_ledger_solved_today(tmp_path / "l.json", gate_off=True) + assert done.call_args.kwargs["gate_off"] is True