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

149 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<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
```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. ~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.
```