LeetCode hour needs the day paid in full (earned_time 0.18.0); gate_off read live from leetcode-guard
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-24 13:26:28 +02:00
parent d2a684ebf9
commit 59a110e5c3
8 changed files with 207 additions and 32 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -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