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"):