Files
project-lyra/docs/RECORDER.md
T
serversdown d7f3ba330a 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) <noreply@anthropic.com>
2026-06-27 04:08:20 +00:00

8.1 KiB
Raw Blame History

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

  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 <div> 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

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. ~610 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.