casebench
test cases straight onto the board.
git clone https://owenrusk.dev/casebench.git
commit 1536529604919a60aad9006a6dec34ef5cada45a parent 2800d87e6a72a7f65bb8bf00342dd62d719e4bb9 author Owen Rusk <owen@papermothgames.com> date 2025-05-28 14:26:05 -0500
lock: rest after wrong tries in a row like a real lock. after n wrong tries in a row it takes nothing for m minutes. --rest 3/60 is the default.
| casebench/cli.py | +2 | -1 |
| casebench/db.py | +6 | -4 |
| casebench/lock.py | +36 | -5 |
diff --git a/casebench/cli.py b/casebench/cli.py index b9bc20c..2f82a78 100644 --- a/casebench/cli.py +++ b/casebench/cli.py @@ -35,6 +35,7 @@ def main(argv: list[str] | None = None) -> int: locker.add_argument("--player", required=True) locker.add_argument("--answer", action="append", required=True, help="one per line of the lock") locker.add_argument("--text", default="locked.", help="what the lock says (default: locked.)") + locker.add_argument("--rest", default="3/60", help="after this many wrong tries in a row, take nothing for this many minutes (default: 3/60)") locker.set_defaults(run=run_lock) unlocker = sub.add_parser("unlock", help="take the lock off a case") @@ -88,7 +89,7 @@ def run_lock(conn: psycopg.Connection, args: argparse.Namespace) -> None: if db.source(conn, who, args.case) is None: raise Fail(f"{args.player} doesn't have {args.case}") try:- new = lock.make(args.answer, args.text)+ new = lock.make(args.answer, lock.parse_rest(args.rest), args.text) except ValueError as e: raise Fail(str(e)) from None db.lock(conn, who, args.case, new) diff --git a/casebench/db.py b/casebench/db.py index 6ba3fef..5c5a6e7 100644 --- a/casebench/db.py +++ b/casebench/db.py @@ -68,12 +68,14 @@ def note(conn: psycopg.Connection, player: str, case_id: str, item_id: str | Non def lock(conn: psycopg.Connection, player: str, case_id: str, new: Lock) -> None: + # locking again starts the lock over: new answers, no wrong tries, not resting. conn.execute(- "insert into board_locks (player_id, case_id, text, lines, salt, answer_hash)"- " values (%s, %s, %s, %s, %s, %s)"+ "insert into board_locks (player_id, case_id, text, lines, salt, answer_hash, rest_after, rest_minutes)" + " values (%s, %s, %s, %s, %s, %s, %s, %s)" " on conflict (player_id, case_id) do update set text = excluded.text, lines = excluded.lines,"- " salt = excluded.salt, answer_hash = excluded.answer_hash, opened_at = null",- (player, case_id, new.text, new.lines, new.salt, new.hash),+ " salt = excluded.salt, answer_hash = excluded.answer_hash, rest_after = excluded.rest_after," + " rest_minutes = excluded.rest_minutes, wrong = 0, resting_until = null, opened_at = null", + (player, case_id, new.text, new.lines, new.salt, new.hash, new.rest.after, new.rest.minutes), ) conn.commit() diff --git a/casebench/lock.py b/casebench/lock.py index 68be9d4..2bed125 100644 --- a/casebench/lock.py +++ b/casebench/lock.py @@ -1,7 +1,14 @@ import hashlib import hmac import os-from dataclasses import dataclass+from dataclasses import dataclass, replace +from datetime import datetime, timedelta + + +@dataclass(frozen=True) +class Rest: + after: int + minutes: int @dataclass(frozen=True) @@ -10,6 +17,20 @@ class Lock: lines: int salt: bytes hash: str + rest: Rest + wrong: int = 0 + resting_until: datetime | None = None + opened_at: datetime | None = None + + +def parse_rest(spec: str) -> Rest: + try: + after, minutes = (int(part) for part in spec.split("/")) + except ValueError: + raise ValueError(f"rest is tries/minutes, like 3/60, not {spec!r}") from None + if after < 1 or minutes < 0: + raise ValueError(f"rest is tries/minutes, like 3/60, not {spec!r}") + return Rest(after, minutes) def normalize(answer: str) -> str: @@ -21,14 +42,24 @@ def digest(lines: list[str], salt: bytes) -> str: return hashlib.scrypt(joined.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32).hex()-def make(answers: list[str], text: str = "locked.") -> Lock:+def make(answers: list[str], rest: Rest, text: str = "locked.") -> Lock: if not answers or not all(normalize(answer) for answer in answers): raise ValueError("a lock needs an answer on every line") salt = os.urandom(16)- return Lock(text, len(answers), salt, digest(answers, salt))+ return Lock(text, len(answers), salt, digest(answers, salt), rest)-def opens(lock: Lock, typed: list[str]) -> bool:+def attempt(lock: Lock, typed: list[str], now: datetime) -> tuple[str, Lock]: # the board calls this when someone types into a lock. the lines are # checked as one, so a wrong try never says which line was right.- return len(typed) == lock.lines and hmac.compare_digest(digest(typed, lock.salt), lock.hash)+ if lock.opened_at is not None: + return "open", lock + if lock.resting_until is not None and now < lock.resting_until: + # like a real lock: while it rests it takes nothing, and nothing counts. + return "resting", lock + if len(typed) == lock.lines and hmac.compare_digest(digest(typed, lock.salt), lock.hash): + return "open", replace(lock, wrong=0, resting_until=None, opened_at=now) + wrong = lock.wrong + 1 + if wrong >= lock.rest.after: + return "wrong", replace(lock, wrong=wrong, resting_until=now + timedelta(minutes=lock.rest.minutes)) + return "wrong", replace(lock, wrong=wrong)