owenrusk.dev

casebench

test cases straight onto the board.

git clone https://owenrusk.dev/casebench.git

commit 8ee9741288e34bbe0785a872acf8d982fec33b90
parent 2ed08da9f0fdef0bfd189c05e9176fc18c36b7e3
author Owen Rusk <owen@papermothgames.com>
date   2025-10-21 16:12:08 -0500
lock: more than one answer per line, split with |

every combination is hashed, so the lines are still checked as one. board_locks.answer_hash becomes answer_hashes, text[].
casebench/lock.py+16-4
casebench/pgstore.py+6-6
tests/test_lock.py+11-0
diff --git a/casebench/lock.py b/casebench/lock.py
index 006e7dd..c4d2ad8 100644
--- a/casebench/lock.py
+++ b/casebench/lock.py
@@ -1,5 +1,6 @@
 import hashlib
 import hmac
+import itertools
 import os
 from dataclasses import dataclass, replace
 from datetime import datetime, timedelta
@@ -16,7 +17,7 @@ class Lock:
     text: str
     lines: int
     salt: bytes
-    hash: str
+    hashes: tuple[str, ...]
     rest: Rest
     wrong: int = 0
     resting_until: datetime | None = None
@@ -43,10 +44,14 @@ def digest(lines: list[str], salt: bytes) -> str:
 
 
 def make(answers: list[str], rest: Rest, text: str = "locked.") -> Lock:
-    if not answers or not all(normalize(answer) for answer in answers):
+    # a line can take more than one answer: "grey|gray". every combination
+    # of the lines is hashed, so the lines are still checked as one.
+    choices = [[a for a in (normalize(part) for part in line.split("|")) if a] for line in answers]
+    if not choices or not all(choices):
         raise ValueError("a lock needs an answer on every line")
     salt = os.urandom(16)
-    return Lock(text, len(answers), salt, digest(answers, salt), rest)
+    hashes = sorted(digest(list(combination), salt) for combination in itertools.product(*choices))
+    return Lock(text, len(answers), salt, tuple(hashes), rest)
 
 
 def attempt(lock: Lock, typed: list[str], now: datetime) -> tuple[str, Lock]:
@@ -57,10 +62,17 @@ def attempt(lock: Lock, typed: list[str], now: datetime) -> tuple[str, 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):
+    if len(typed) == lock.lines and _matches(digest(typed, lock.salt), lock.hashes):
         return "open", replace(lock, wrong=0, resting_until=None, opened_at=now)
     wrong = lock.wrong + 1
     if wrong >= lock.rest.after:
         # the count starts over once it has rested.
         return "wrong", replace(lock, wrong=0, resting_until=now + timedelta(minutes=lock.rest.minutes))
     return "wrong", replace(lock, wrong=wrong)
+
+
+def _matches(typed: str, hashes: tuple[str, ...]) -> bool:
+    found = False
+    for stored in hashes:
+        found |= hmac.compare_digest(typed, stored)
+    return found
diff --git a/casebench/pgstore.py b/casebench/pgstore.py
index 960cab9..c8a06a2 100644
--- a/casebench/pgstore.py
+++ b/casebench/pgstore.py
@@ -91,24 +91,24 @@ class PgStore:
         # locking again starts the lock over: new answers, no wrong tries, not resting.
         self.conn.execute(
             "insert into board_locks"
-            " (player_id, case_id, text, lines, salt, answer_hash, rest_after, rest_minutes)"
+            " (player_id, case_id, text, lines, salt, answer_hashes, 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, rest_after = excluded.rest_after,"
+            " salt = excluded.salt, answer_hashes = excluded.answer_hashes, rest_after = excluded.rest_after,"
             " rest_minutes = excluded.rest_minutes, wrong = 0, resting_until = null, opened_at = null",
-            (player.id, case_id, new.text, new.lines, new.salt, new.hash, new.rest.after, new.rest.minutes),
+            (player.id, case_id, new.text, new.lines, new.salt, new.hashes, new.rest.after, new.rest.minutes),
         )
 
     def get_lock(self, player: Player, case_id: str) -> Lock | None:
         row = self.conn.execute(
-            "select text, lines, salt, answer_hash, rest_after, rest_minutes, wrong, resting_until, opened_at"
+            "select text, lines, salt, answer_hashes, rest_after, rest_minutes, wrong, resting_until, opened_at"
             " from board_locks where player_id = %s and case_id = %s",
             (player.id, case_id),
         ).fetchone()
         if row is None:
             return None
-        text, lines, salt, answer_hash, after, minutes, wrong, resting_until, opened_at = row
-        return Lock(text, lines, bytes(salt), answer_hash, Rest(after, minutes), wrong, resting_until, opened_at)
+        text, lines, salt, answer_hashes, after, minutes, wrong, resting_until, opened_at = row
+        return Lock(text, lines, bytes(salt), tuple(answer_hashes), Rest(after, minutes), wrong, resting_until, opened_at)
 
     def unlock(self, player: Player, case_id: str) -> bool:
         gone = self.conn.execute(
diff --git a/tests/test_lock.py b/tests/test_lock.py
index f316d13..2eb2a4d 100644
--- a/tests/test_lock.py
+++ b/tests/test_lock.py
@@ -60,6 +60,17 @@ class AttemptTest(unittest.TestCase):
     def test_answers_are_not_stored(self):
         self.assertNotIn("tuesday", repr(self.lock).casefold())
 
+    def test_alternates(self):
+        either = lock.make(["grey|gray", "glove|mitten"], lock.Rest(3, 60))
+        self.assertEqual(len(either.hashes), 4)
+        for typed in (["Gray", "MITTEN"], ["grey", "glove"]):
+            self.assertEqual(lock.attempt(either, typed, T0)[0], "open")
+        self.assertEqual(lock.attempt(either, ["grey", "hat"], T0)[0], "wrong")
+
+    def test_empty_line(self):
+        with self.assertRaises(ValueError):
+            lock.make(["a", " | "], lock.Rest(3, 60))
+
     def test_parse_rest(self):
         self.assertEqual(lock.parse_rest("3/60"), lock.Rest(3, 60))
         for bad in ("3", "0/60", "a/b", "3/-1"):