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) <noreply@anthropic.com>
8.1 KiB
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 — so it drops straight into the DB and the existing replay viewer, and flows to RTO unchanged.
Principles
- 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. - 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.
- 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<div>insideindex.html(chat/session page stays mounted underneath — a flip-over, not a route change). If that proves janky, the same module mounts intorecorder.htmlwith 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/writesstateand callsbuildStructuredon 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_livestack.current→ hero's starting stack for the handvillains[]: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 thepostactions. - Hero stack from
stack.current.
Two gaps to close as part of the build (flagged, not yet done):
- Seats aren't in the HUD bundle.
player_readshas aseatcolumn, but_session_villains()doesn't select it — so we can name the villains but not place them. Fix: addseat(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. - Hero position isn't tracked live (button/seat moves every hand) — so
hero_posis a per-hand tap, seeded to last-used. That's correct, not a gap to "fix", just noting it.
In-memory state model
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+lockedSuitand 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; flipscompleteness.cardsfalse 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
POST /handsendpoint + addseatto the villains payload (server, small).recorder.jsskeleton:mount(),state,buildStructured()(pure).- Overlay shell in
index.html(open button in session/cash mode) +recorder.css. - V1 capture flow → save → replay. Validate a real hand round-trips identically.
- V2 smart keypad on top.