d7f3ba330a
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>
149 lines
8.1 KiB
Markdown
149 lines
8.1 KiB
Markdown
# 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. ~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.
|
||
```
|