owenrusk.dev

casebench

test cases straight onto the board.

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

commit 991e1e7c89d98f4e4ac4028b4ac6770be6ce857b
parent b44d61677233222bf05fc0f12d066d262ca5089b
author Owen Rusk <owen@papermothgames.com>
date   2026-02-03 15:58:21 -0600
list: show locks and note counts

and runtime cases the bench has locked, which it didn't show before.
casebench/cli.py+8-2
casebench/lock.py+8-0
casebench/memstore.py+5-2
casebench/pgstore.py+14-5
casebench/store.py+2-0
diff --git a/casebench/cli.py b/casebench/cli.py
index 6b897cb..30f3201 100644
--- a/casebench/cli.py
+++ b/casebench/cli.py
@@ -159,8 +159,14 @@ def run_unlock(store: Store, args: argparse.Namespace) -> None:
 def run_list(store: Store, args: argparse.Namespace) -> None:
     for who in targets(store, args):
         for placed in store.board(who):
-            if placed.source == BENCH:
-                print(f"{who.handle}\t{placed.case_id}\t{placed.title}")
+            if placed.source != BENCH and placed.lock is None:
+                continue  # the runtime's own, untouched
+            marks = [placed.source]
+            if placed.lock:
+                marks.append(placed.lock)
+            if placed.notes:
+                marks.append(f"{placed.notes} note" + ("" if placed.notes == 1 else "s"))
+            print("\t".join([who.handle, placed.case_id, placed.title, ", ".join(marks)]))
 
 
 def run_pull(store: Store, args: argparse.Namespace) -> None:
diff --git a/casebench/lock.py b/casebench/lock.py
index 475800c..5c78e1b 100644
--- a/casebench/lock.py
+++ b/casebench/lock.py
@@ -61,6 +61,14 @@ def make(answers: list[str], rest: Rest, text: str = "locked.") -> Lock:
     return Lock(text, len(answers), salt, tuple(hashes), rest)
 
 
+def state(lock: Lock, now: datetime) -> str:
+    if lock.opened_at is not None:
+        return "open"
+    if lock.resting_until is not None and now < lock.resting_until:
+        return "resting"
+    return "locked"
+
+
 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.
diff --git a/casebench/memstore.py b/casebench/memstore.py
index a0e197b..7f5e099 100644
--- a/casebench/memstore.py
+++ b/casebench/memstore.py
@@ -3,7 +3,7 @@ from dataclasses import dataclass, field
 from datetime import UTC, datetime
 
 from .casefile import Case, Image
-from .lock import Lock
+from .lock import Lock, state
 from .store import BENCH, Placed, Player
 
 
@@ -50,7 +50,10 @@ class MemStore:
 
     def placed(self, player: Player, case_id: str) -> Placed | None:
         case = self.cases.get((player.id, case_id))
-        return None if case is None else Placed(case_id, case.title, case.source, case.placed_at)
+        if case is None:
+            return None
+        held = None if case.lock is None else state(case.lock, datetime.now(UTC))
+        return Placed(case_id, case.title, case.source, case.placed_at, held, len(case.notes))
 
     def board(self, player: Player) -> list[Placed]:
         found = [self.placed(player, case_id) for pid, case_id in self.cases if pid == player.id]
diff --git a/casebench/pgstore.py b/casebench/pgstore.py
index c8a06a2..b0429eb 100644
--- a/casebench/pgstore.py
+++ b/casebench/pgstore.py
@@ -6,6 +6,15 @@ from .casefile import Case, Image
 from .lock import Lock, Rest
 from .store import Placed, Player
 
+PLACED = (
+    "select c.case_id, c.title, c.source, c.placed_at,"
+    " case when l.opened_at is not null then 'open'"
+    " when l.resting_until > now() then 'resting'"
+    " when l.case_id is not null then 'locked' end,"
+    " (select count(*) from board_notes n where n.player_id = c.player_id and n.case_id = c.case_id)"
+    " from board_cases c left join board_locks l on l.player_id = c.player_id and l.case_id = c.case_id"
+)
+
 
 class PgStore:
     # the hub's postgres, underneath the runtime.
@@ -26,15 +35,13 @@ class PgStore:
 
     def placed(self, player: Player, case_id: str) -> Placed | None:
         row = self.conn.execute(
-            "select case_id, title, source, placed_at from board_cases where player_id = %s and case_id = %s",
-            (player.id, case_id),
+            PLACED + " where c.player_id = %s and c.case_id = %s", (player.id, case_id)
         ).fetchone()
         return Placed(*row) if row else None
 
     def board(self, player: Player) -> list[Placed]:
         rows = self.conn.execute(
-            "select case_id, title, source, placed_at from board_cases where player_id = %s order by placed_at",
-            (player.id,),
+            PLACED + " where c.player_id = %s order by c.placed_at", (player.id,)
         ).fetchall()
         return [Placed(*row) for row in rows]
 
@@ -108,7 +115,9 @@ class PgStore:
         if row is None:
             return None
         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)
+        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/casebench/store.py b/casebench/store.py
index 39f45ba..f2d549e 100644
--- a/casebench/store.py
+++ b/casebench/store.py
@@ -20,6 +20,8 @@ class Placed:
     title: str
     source: str
     placed_at: datetime
+    lock: str | None = None  # locked, resting or open
+    notes: int = 0
 
 
 class Store(Protocol):