From d7f3ba330a9ed69f583dabb82b236ae8a08e999c Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 27 Jun 2026 04:08:20 +0000 Subject: [PATCH] docs: hand recorder design note (v1 core loop + card-entry UX) Tap-to-build recorder: correctness by construction, reusable mount-agnostic module (pure buildStructured core), pre-fill from live session state, emits the locked structured-hand contract. V1 = core capture loop; card entry = contextual picker with sticky-suit-or-rank-first flow + x/unknown. V2 = smart legal-action keypad. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/RECORDER.md | 148 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/RECORDER.md diff --git a/docs/RECORDER.md b/docs/RECORDER.md new file mode 100644 index 0000000..5f24f36 --- /dev/null +++ b/docs/RECORDER.md @@ -0,0 +1,148 @@ +# Hand recorder — design note + +A tap-to-build hand recorder. The point isn't "nicer input" — it's **correctness by +construction**: every tap writes a known action into a known slot, so there's no parse +step that can be wrong. It sidesteps the whole class of LLM-parse replay bugs. The text +parser stays for importing the backlog (old notes, Trilium, ChatGPT history); the recorder +is clean capture going forward. + +Output is the canonical structured shape in [HAND_HISTORY.md](HAND_HISTORY.md) — so it +drops straight into the DB and the existing replay viewer, and flows to RTO unchanged. + +## Principles + +1. **Correctness by construction** — the UI only lets you build valid hands; the emitter + produces the contract shape; the server `normalize_structured()` is the final guarantee. +2. **Reusable module, mount-agnostic.** Decision: overlay first, swap to standalone if the + overlay fights the chat page — *reusing the code either way*. So the recorder is a + self-contained module mounted into a container element, with the **emit logic kept pure + (no DOM)**. Moving overlay → standalone is a re-mount, not a rewrite. +3. **Don't reinvent what Lyra knows.** Pre-fill from live session state. + +## Architecture + +``` +lyra/web/static/recorder.js # the module: state machine + DOM shell + buildStructured() +lyra/web/static/recorder.css # scoped styles (full-screen table + keypad) +``` + +- `Recorder.mount(containerEl, { sessionId, onSave, onClose })` — instantiates into any + container. In V1 the container is a full-screen overlay `
` inside `index.html` + (chat/session page stays mounted underneath — a flip-over, not a route change). If that + proves janky, the *same module* mounts into `recorder.html` with zero logic changes. +- **Pure core, separable from DOM:** `buildStructured(state) -> structuredDict`. No element + access — takes the in-memory state, returns the contract object. This is the testable, + reusable heart; the DOM shell only reads/writes `state` and calls `buildStructured` on save. + +## Pre-fill from live state (the "it already knows" feel) + +Source: `GET /session/data` (→ `poker.hud`). Today it gives us: +- `session`: `venue`, `stakes`, `game`, `format`, `is_live` +- `stack.current` → hero's starting stack for the hand +- `villains[]`: `name`, `category`, `tendencies`, `last_note` (the players read this session) + +Derived in the recorder: +- **Blinds** parsed from `stakes` ("1/3" → SB 1, BB 3) → auto-seed the `post` actions. +- **Hero stack** from `stack.current`. + +**Two gaps to close as part of the build** (flagged, not yet done): +1. **Seats aren't in the HUD bundle.** `player_reads` *has* a `seat` column, but + `_session_villains()` doesn't select it — so we can name the villains but not place them. + Fix: add `seat` (latest read per player) to the villains payload, then auto-seat them. + Until then, V1 seats known villains in read-order and you assign positions by tapping. +2. **Hero position isn't tracked live** (button/seat moves every hand) — so `hero_pos` is a + per-hand tap, seeded to last-used. That's correct, not a gap to "fix", just noting it. + +## In-memory state model + +```js +state = { + meta: { game, stakes, venue, sessionId }, // from /session/data + blinds: { sb, bb }, // parsed from stakes + heroPos: "BTN", // tapped per hand + seats: [ { pos, name, stack, cards: null, in: true } ], // incl. hero seat + street: "preflop", // street currently being entered + board: { flop: [], turn: [], river: [] }, + actions:[ { street, pos, action, amount } ], // appended as you tap + result: { pot: null, heroNet: null, summary: "" } +} +``` + +`buildStructured(state)` → + +| contract field | from | +|---|---| +| `hero_pos` | `state.heroPos` | +| `hero_cards` | the hero seat's `cards` | +| `players[]` | `state.seats` (`{pos,stack,name,cards}`; emitter doesn't set `hero`/version — server normalize does) | +| `actions[]` | `state.actions`, with a `{street, board}` reveal entry spliced in at each street boundary from `state.board` | +| `board` | `flop + turn + river` concatenated | +| `result` | `state.result` | + +Client builds best-effort; **`store_hand_history()` → `normalize_structured()` is the +authority** (canonical cards, hero sync, `schema_version`, `completeness`). Keeps the +client dumb and the contract enforced in one place. + +## Persistence + +New endpoint (small, part of the build): +``` +POST /hands body: { structured, session_id?, tag?, lesson? } + -> store_hand_history(structured, ...) -> { id } +``` +On save: POST, then hand off to the existing viewer `/hand/{id}` to replay — which doubles +as the correctness check (what you tapped is exactly what replays). + +## Scope + +**V1 — core loop (chosen).** Seats + cards + per-street actions emitting valid structured +JSON. Manual street advance (a "next street" button + board entry), free bet-size entry +(type the number). Proves capture → store → replay end to end on the locked schema. + +### Card entry (V1) + +Contextual: tap a card slot (hero card, board square, "they showed") → a compact picker +pops at that slot. One picker holds **4 color-coded suits + 13 ranks + `x` + unknown-card**. +Whichever you tap first sets the flow for that card — no mode switch: + +- **Suit first → it locks** (stays lit). Each subsequent rank tap places `rank+lockedSuit` + and auto-advances. Flush flop = `♥ T 8 5` (4 taps); suited hole = `♥ A K` (3 taps). +- **Rank first → card is pending a suit**; the next tap must be a suit (or `x`). Best for + rainbow/mixed. Locked suit stays in effect until a different suit is tapped. +- **`x`** = unknown suit → stores e.g. `Ax`; flips `completeness.cards` false so RTO skips + suit-dependent math. **Unknown-card** button = a villain card never shown (`x`). + +No typing — lowercase tokens are the internal/contract format only; the player only ever +taps symbols. State: `lockedSuit` (nullable) + the active slot; auto-advance on complete. + +**V2 — the smart keypad.** The contextual state machine layered on top: tracks whose turn +it is and the current bet, offers only legal actions (check vs call; bet/raise reveal size +presets ½/¾/pot/+1bb), auto-advances the street when action closes, tap-a-seat to set the +actor, one-tap "they showed [cards]" at showdown. ~6–10 taps, no typing. Built on the same +`state` + `buildStructured`, so V1's emitter doesn't change — V2 just drives `state` smarter. + +## Layout sketch (full-screen overlay) + +``` +┌───────────────────────────── Record hand ─────────────── ✕ ┐ +│ (CO) (BTN) │ +│ (HJ) ◯ oval table ◯ (SB) │ +│ (MP) (UTG) (BB·hero) │ +│ board: [ 7d ][ 2c ][ 5h ] pot: 40 │ +├──────────────────────────────────────────────────────────────┤ +│ acting: BB [ fold ][ check ][ call ][ bet ][ raise ] │ +│ amount: [ 15 ] [ ½ ][ ¾ ][ pot ][ +1bb ] (V2) │ +│ [ ◀ prev street ] [ next street ▶ ] [ they showed… ] │ +├──────────────────────────────────────────────────────────────┤ +│ preflop: BTN raise 15 · BB call [ save & replay ] │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Build order + +1. `POST /hands` endpoint + add `seat` to the villains payload (server, small). +2. `recorder.js` skeleton: `mount()`, `state`, `buildStructured()` (pure). +3. Overlay shell in `index.html` (open button in session/cash mode) + `recorder.css`. +4. V1 capture flow → save → replay. Validate a real hand round-trips identically. +5. V2 smart keypad on top. +```