owenrusk.dev

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)