# 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. ```