8 Commits

Author SHA1 Message Date
serversdown 924dc297d5 fix: recorder cancel left an empty black screen
mount() adds .rec-root (display:flex) to the overlay element, and that rule sat
after .rec-overlay's display:none — so removing .open didn't hide it. Use
.rec-overlay:not(.open){display:none} to outrank .rec-root by specificity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:55:03 +00:00
serversdown b6cdf799dc feat: straddle support in recorder
Add a straddle from any non-blind seat (default 2×BB) via a preflop control.
Recorded as a preflop post (fits the contract — order carries 'acts last'), tagged
straddle for the log label + removability; the UI-only flag is dropped from the
emitted structured hand. Blinds stay non-removable. Documented in HAND_HISTORY.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:49:50 +00:00
serversdown c52404fbb9 fix: add Cancel to recorder + safe-area insets (notch ate the ✕)
The header ✕ sat under the iPhone status bar/notch (overlay had no safe-area
padding), so there was no reachable way out without saving. Add a labeled Cancel
button next to Save, and pad the header/footer for the notch + home indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:40:32 +00:00
serversdown 4fd7eff7e9 fix: shelve recorder card picker for plain typed card entry
The tap picker wasn't working in practice. Replace it with simple text inputs for
hero/board/shown cards: case-insensitive, accepts 'ah kh' / 'AhKh' / '7d2c5h' /
'10h'. parseCards() tokenizes; the server's normalize_structured() canonicalizes
(case, 10->T, completeness). Also adds per-action remove (✕) in the street log.
Picker UI + bottom-sheet CSS removed; tap picker deferred to V2 (docs/RECORDER.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:35:13 +00:00
serversdown 2bd5b7fd26 fix: move recorder launch into bottom bar (Mind->Record in poker mode)
The floating +Hand button overlapped the chat send button. Replace it with a
Record tab that swaps in for Mind only in poker (cash) mode, via body.cash-mode
CSS toggle. Other modes keep Mind; Record stays hidden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:23:28 +00:00
serversdown 50bcb5533f feat: hand recorder v1 (tap-to-build, overlay) — recorder steps 2-4
Tap-to-build hand recorder per docs/RECORDER.md. Correctness by construction: every
tap writes a known value into a known slot, emitting the canonical structured contract
(server normalize_structured() is the shape authority, so the client is best-effort).

- recorder.js: mount-agnostic module (mounts into any container -> overlay now,
  standalone later, zero logic change). Pure buildStructured(state)->contract dict.
  Card picker: sticky-suit-or-rank-first + x (unknown suit) + unknown-card; lock resets
  per slot so it never bleeds between cards. Pre-fills from /session/data (stakes->blinds,
  stack->hero, villains->seated). Per-street action entry, manual street advance.
- recorder.css: full-screen overlay using app theme tokens; floating launcher.
- index.html: overlay div + script + launcher (chat/session stays mounted underneath).

Validated end-to-end: JS core builds a hand (sticky/rainbow/unknown cards all correct)
-> POST /hands -> normalized -> /hand/{id} replay round-trips identically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:18:38 +00:00
serversdown f745ef43a1 feat: POST /hands endpoint + seat in HUD villains (recorder step 1)
- POST /hands: store a structured hand from the recorder; normalize_structured()
  (via store_hand_history) is the shape authority, so the client stays best-effort.
  Enriches villain dossiers from the recorded players, same as the parser path.
  Verified live: best-effort body -> normalized (version, completeness, hero sync).
- _session_villains: surface each player's latest seat so the recorder can
  auto-place known players on the table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:11:48 +00:00
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 changed files with 744 additions and 3 deletions
+3
View File
@@ -53,6 +53,9 @@ DB, no shared UI components. If RTO is down, Lyra skips analysis and nothing bre
- **Positions:** `UTG UTG1 UTG2 MP LJ HJ CO BTN SB BB`. - **Positions:** `UTG UTG1 UTG2 MP LJ HJ CO BTN SB BB`.
- **Actions:** `post fold check call bet raise allin`. `amount` is a plain number (no `$`), - **Actions:** `post fold check call bet raise allin`. `amount` is a plain number (no `$`),
null for non-sized actions (fold/check). Street boards appear as `{street, board}` entries. null for non-sized actions (fold/check). Street boards appear as `{street, board}` entries.
A **straddle** is recorded as a preflop `post` at a non-blind position (typically 2×BB,
voluntary); preflop action starts left of it and it acts last, but that's reflected by
action *order*, not a distinct verb.
- **Streets:** `preflop flop turn river`. - **Streets:** `preflop flop turn river`.
`lyra/poker.py:normalize_structured()` is the single function that guarantees this shape. `lyra/poker.py:normalize_structured()` is the single function that guarantees this shape.
+148
View File
@@ -0,0 +1,148 @@
# 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.
```
+4 -2
View File
@@ -1185,12 +1185,14 @@ def _session_villains(sid: int) -> list[dict]:
"SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, " "SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, "
"p.adjustment AS adjustment, " "p.adjustment AS adjustment, "
"(SELECT note FROM player_reads r2 WHERE r2.player_id = p.id " "(SELECT note FROM player_reads r2 WHERE r2.player_id = p.id "
" AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note " " AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note, "
"(SELECT seat FROM player_reads r3 WHERE r3.player_id = p.id "
" AND r3.session_id = ? ORDER BY r3.id DESC LIMIT 1) AS seat "
"FROM poker_players p " "FROM poker_players p "
"WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads " "WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads "
" WHERE session_id = ? AND player_id IS NOT NULL) " " WHERE session_id = ? AND player_id IS NOT NULL) "
"ORDER BY p.updated_at DESC", "ORDER BY p.updated_at DESC",
(sid, sid), (sid, sid, sid),
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
+18
View File
@@ -339,6 +339,24 @@ def create_app() -> FastAPI:
async def hands_data(limit: int = 60) -> dict: async def hands_data(limit: int = 60) -> dict:
return {"hands": poker.list_recent_hands(limit=limit)} return {"hands": poker.list_recent_hands(limit=limit)}
@app.post("/hands")
async def hands_create(request: Request) -> dict:
"""Store a structured hand built by the recorder. Body:
{structured, session_id?, tag?, lesson?}. normalize_structured() (in
store_hand_history) is the authority on shape, so the client can be best-effort."""
body = await request.json()
structured = body.get("structured")
if not isinstance(structured, dict):
return {"ok": False, "error": "missing structured hand body"}
hid = await asyncio.to_thread(
poker.store_hand_history, structured,
session_id=body.get("session_id"), tag=body.get("tag"), lesson=body.get("lesson"),
)
# Enrich villain dossiers from the recorded players, same as the parser path.
await asyncio.to_thread(poker.link_hand_players, hid, structured, body.get("session_id"))
logbus.log("info", "hand recorded", id=hid, session=body.get("session_id"))
return {"ok": True, "id": hid}
@app.get("/recap/{session_id}") @app.get("/recap/{session_id}")
async def recap_page() -> FileResponse: async def recap_page() -> FileResponse:
return FileResponse(str(_STATIC / "recap.html")) return FileResponse(str(_STATIC / "recap.html"))
+21 -1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Lyra Core Chat</title> <title>Lyra Core Chat</title>
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="/recorder.css" />
<!-- PWA --> <!-- PWA -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
@@ -129,7 +130,8 @@
<a class="tab active" href="/" aria-current="page"><span class="ti">💬</span><span class="tl">Chat</span></a> <a class="tab active" href="/" aria-current="page"><span class="ti">💬</span><span class="tl">Chat</span></a>
<a class="tab" href="/session"><span class="ti">🎬</span><span class="tl">Session</span></a> <a class="tab" href="/session"><span class="ti">🎬</span><span class="tl">Session</span></a>
<a class="tab" href="/hands"><span class="ti">🃏</span><span class="tl">Hands</span></a> <a class="tab" href="/hands"><span class="ti">🃏</span><span class="tl">Hands</span></a>
<a class="tab" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a> <a class="tab tab-mind" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
<button class="tab tab-rec" id="recordTab" type="button"><span class="ti"></span><span class="tl">Record</span></button>
<button class="tab" id="moreTab" type="button"><span class="ti"></span><span class="tl">More</span></button> <button class="tab" id="moreTab" type="button"><span class="ti"></span><span class="tl">More</span></button>
</nav> </nav>
</div> </div>
@@ -1245,5 +1247,23 @@
}); });
</script> </script>
<script src="/nav.js"></script> <script src="/nav.js"></script>
<!-- Hand recorder (overlay; chat/session stays mounted underneath) -->
<div id="recorderOverlay" class="rec-overlay"></div>
<script src="/recorder.js"></script>
<script>
(function () {
var overlay = document.getElementById("recorderOverlay");
var recordTab = document.getElementById("recordTab");
function close() { overlay.classList.remove("open"); overlay.innerHTML = ""; }
if (recordTab) recordTab.addEventListener("click", function () {
overlay.innerHTML = "";
overlay.classList.add("open");
window.Recorder.mount(overlay, {
onClose: close,
onSave: function (id) { close(); window.open("/hand/" + id, "_blank"); }
});
});
})();
</script>
</body> </body>
</html> </html>
+117
View File
@@ -0,0 +1,117 @@
/* Hand recorder overlay. Uses the app theme tokens (--accent, --bg-* etc.) from
style.css when mounted in index.html. For a standalone recorder.html, import those
tokens too (see :root in style.css). */
.rec-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--bg-dark, #070707);
flex-direction: column;
}
/* :not(.open) outranks .rec-root's display:flex (added on mount), so closing the
overlay actually hides it instead of leaving an empty black screen. */
.rec-overlay:not(.open) { display: none; }
.rec-overlay.open { display: flex; }
.rec-root {
display: flex;
flex-direction: column;
height: 100%;
color: var(--text-main, #e8e8e8);
font-family: var(--font-console, ui-monospace, monospace);
}
.rec-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
padding-top: calc(12px + env(safe-area-inset-top)); /* clear the notch/status bar */
border-bottom: 1px solid var(--border, #2a1d12);
}
.rec-title { font-weight: 700; color: var(--accent, #ff7a00); }
.rec-meta { color: var(--text-fade, #8a8a8a); font-size: .82rem; flex: 1; }
.rec-x {
background: none; border: 1px solid var(--border, #2a1d12); color: var(--text-fade, #8a8a8a);
border-radius: 8px; width: 34px; height: 34px; font-size: 1rem;
}
.rec-body { flex: 1; overflow-y: auto; padding: 12px 14px 20px; -webkit-overflow-scrolling: touch; }
.rec-sec { margin-bottom: 18px; }
.rec-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .6px; color: var(--text-fade, #8a8a8a); margin-bottom: 6px; }
.rec-dim { color: var(--text-fade, #8a8a8a); font-size: .85rem; }
.rec-pos-row { display: flex; flex-wrap: wrap; gap: 6px; }
.rec-pos {
min-width: 44px; padding: 9px 10px; border-radius: 9px;
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8);
border: 1px solid var(--border, #2a1d12); font-size: .82rem; font-weight: 600;
}
.rec-pos.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
.rec-pos.add { color: var(--text-fade, #8a8a8a); border-style: dashed; }
.rec-hero-cards { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.rec-seats { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
.rec-seat { display: flex; align-items: center; gap: 8px; }
.rec-seat-pos { min-width: 42px; font-weight: 700; color: var(--gold, #ffb347); }
.rec-name {
flex: 1; min-width: 0; padding: 8px 9px; border-radius: 8px;
background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12);
color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .85rem;
}
.rec-rm { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .9rem; padding: 4px; }
/* typed card entry */
.rec-field { display: block; margin-top: 10px; }
.rec-cards {
width: 100%; padding: 10px 11px; border-radius: 8px;
background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12);
color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .95rem;
letter-spacing: 1px; box-sizing: border-box;
}
.rec-cards.sm { width: 88px; flex: none; padding: 8px 9px; font-size: .85rem; }
.rec-street-tabs { display: flex; gap: 6px; margin-bottom: 8px; }
.rec-tab {
flex: 1; padding: 9px 6px; border-radius: 9px; font-size: .78rem; font-weight: 600;
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8); border: 1px solid var(--border, #2a1d12);
}
.rec-tab.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
.rec-act-add { display: flex; gap: 6px; margin: 8px 0; }
.rec-sel, .rec-num {
padding: 9px 8px; border-radius: 8px; background: var(--bg-elev, #0e0e0e);
border: 1px solid var(--border, #2a1d12); color: var(--text-main, #e8e8e8);
font-family: inherit; font-size: .85rem; min-width: 0;
}
.rec-sel { flex: 1; }
.rec-num { width: 70px; }
.rec-add-act { padding: 9px 12px; border-radius: 8px; background: var(--border-bright, #4a2f15); color: #fff; border: none; font-weight: 600; }
.rec-result { display: flex; gap: 12px; }
.rec-result label { display: flex; flex-direction: column; gap: 4px; font-size: .72rem; color: var(--text-fade, #8a8a8a); }
.rec-log { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
.rec-ln { font-size: .82rem; color: var(--text-main, #e8e8e8); }
.rec-ln.brd { display: flex; gap: 4px; align-items: center; color: var(--text-fade, #8a8a8a); }
.rec-ln b { color: var(--accent, #ff7a00); font-weight: 700; }
.rec-foot {
display: flex; gap: 10px;
padding: 12px 14px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border, #2a1d12);
}
.rec-save { flex: 1; padding: 14px; border-radius: 10px; background: var(--accent, #ff7a00); color: #111; border: none; font-weight: 700; font-size: 1rem; }
.rec-save:disabled { opacity: .6; }
.rec-cancel { padding: 14px 18px; border-radius: 10px; background: var(--bg-elev, #0e0e0e); color: var(--text-fade, #8a8a8a); border: 1px solid var(--border, #2a1d12); font-weight: 600; font-size: 1rem; }
.rec-undo { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .75rem; padding: 0 4px; }
/* The Record tab swaps in for Mind in the bottom bar, but only in poker (cash) mode.
body.cash-mode is toggled on mode change in index.html. */
#tabbar .tab-rec { display: none; }
body.cash-mode #tabbar .tab-rec { display: flex; }
body.cash-mode #tabbar .tab-mind { display: none; }
#tabbar .tab-rec .ti { color: var(--accent, #ff7a00); filter: none; }
+425
View File
@@ -0,0 +1,425 @@
/* Hand recorder — tap-to-build poker hands. See docs/RECORDER.md.
*
* Correctness by construction: each field writes a known value into a known slot,
* so there's no LLM parse step that can be wrong. Output is the canonical structured
* contract (docs/HAND_HISTORY.md); the server's normalize_structured() is the final
* authority on shape (case, suits, 10->T, completeness), so this stays best-effort.
*
* Mount-agnostic: Recorder.mount(container, opts) renders into ANY element — a
* full-screen overlay in index.html today, a standalone recorder.html later, with
* zero logic changes. buildStructured(state) is pure (no DOM) — the reusable core.
*
* Card entry: plain typed text for now ("ah kh", "AhKh", "7d 2c 5h"). The tap picker
* is shelved (docs/RECORDER.md V2) — parseCards() + server normalize handle the rest.
*/
(function () {
"use strict";
const SUITS = { s: "♠", h: "♥", d: "♦", c: "♣" };
const POSITIONS = ["UTG", "UTG1", "UTG2", "MP", "LJ", "HJ", "CO", "BTN", "SB", "BB"];
const STREETS = ["preflop", "flop", "turn", "river"];
const STREET_BOARD = { flop: 3, turn: 1, river: 1 };
const ACTIONS = ["fold", "check", "call", "bet", "raise", "allin"];
const SIZED = { bet: true, raise: true, allin: true };
// --- card text -> tokens (server normalizes case/suit/10) ------------------
function parseCards(str) {
if (!str) return [];
const s = String(str).trim().replace(/10/g, "T");
if (!s) return [];
const parts = /\s/.test(s) ? s.split(/\s+/) : s.match(/.{1,2}/g) || [];
return parts.map((p) => p.trim()).filter(Boolean);
}
function cardsText(arr) {
return arr && arr.length ? arr.join(" ") : "";
}
// --- pure core: state -> contract dict (testable, no DOM) ------------------
function buildStructured(state) {
const players = state.seats
.filter((s) => s.pos)
.map((s) => {
const p = { pos: s.pos };
if (s.stack != null) p.stack = s.stack;
if (s.name) p.name = s.name;
p.cards = s.cards && s.cards.length ? s.cards.slice() : null;
return p;
});
const actions = [];
for (const st of STREETS) {
const reveal = state.board[st];
if (st !== "preflop" && reveal && reveal.length) {
actions.push({ street: st, board: reveal.slice() });
}
for (const a of state.actions.filter((x) => x.street === st)) {
actions.push({
street: st,
pos: a.pos,
action: a.action,
amount: a.amount != null ? a.amount : null,
});
}
}
const hero = state.seats.find((s) => s.pos === state.heroPos);
const board = [].concat(state.board.flop, state.board.turn, state.board.river);
return {
game: state.meta.game || "NLH",
stakes: state.meta.stakes || null,
hero_pos: state.heroPos || null,
hero_cards: hero && hero.cards ? hero.cards.slice() : [],
players,
actions,
board,
result: {
pot: state.result.pot,
hero_net: state.result.heroNet,
summary: state.result.summary || "",
},
};
}
function parseBlinds(stakes) {
const m = (stakes || "").match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
return m ? { sb: parseFloat(m[1]), bb: parseFloat(m[2]) } : { sb: null, bb: null };
}
function initialState(hud) {
const sess = (hud && hud.session) || {};
const stack = (hud && hud.stack) || {};
const blinds = parseBlinds(sess.stakes);
const seats = [];
for (const v of (hud && hud.villains) || []) {
if (v.seat && POSITIONS.includes(v.seat)) {
seats.push({ pos: v.seat, name: v.name || null, stack: null, cards: null });
}
}
const actions = [];
if (blinds.sb != null) actions.push({ street: "preflop", pos: "SB", action: "post", amount: blinds.sb });
if (blinds.bb != null) actions.push({ street: "preflop", pos: "BB", action: "post", amount: blinds.bb });
return {
meta: {
game: sess.game || "NLH",
stakes: sess.stakes || null,
venue: sess.venue || null,
sessionId: sess.id != null ? sess.id : null,
},
blinds,
heroStack: stack.current != null ? stack.current : null,
heroPos: null,
seats,
street: "preflop",
board: { flop: [], turn: [], river: [] },
actions,
result: { pot: null, heroNet: null, summary: "" },
};
}
function ensureHero(state) {
let hero = state.seats.find((s) => s.pos === state.heroPos);
if (!hero && state.heroPos) {
hero = { pos: state.heroPos, name: "Hero", stack: state.heroStack, cards: [] };
state.seats.push(hero);
}
return hero || {};
}
window.Recorder = {
buildStructured,
parseCards,
parseBlinds,
initialState,
_internals: { POSITIONS, STREETS },
mount,
};
// --- mount / render -------------------------------------------------------
async function mount(container, opts) {
opts = opts || {};
let hud = opts.hud;
if (!hud) {
try {
const url = opts.sessionId != null ? `/session/data?id=${opts.sessionId}` : "/session/data";
hud = await fetch(url).then((r) => r.json());
} catch (e) {
hud = { session: null };
}
}
const state = initialState(hud);
const ctx = { container, state, opts };
container.classList.add("rec-root");
container.addEventListener("click", (e) => handleClick(ctx, e));
container.addEventListener("input", (e) => handleInput(ctx, e));
render(ctx);
return ctx;
}
function render(ctx) {
const s = ctx.state;
const hero = s.seats.find((x) => x.pos === s.heroPos) || {};
ctx.container.innerHTML = `
<div class="rec-head">
<div class="rec-title">Record hand</div>
<div class="rec-meta">${esc(s.meta.venue || "")}${s.meta.stakes ? " · " + esc(s.meta.stakes) : ""}</div>
<button class="rec-x" data-act="close">✕</button>
</div>
<div class="rec-body">
<section class="rec-sec">
<div class="rec-label">Your seat</div>
<div class="rec-pos-row">
${POSITIONS.map((p) => `<button class="rec-pos${s.heroPos === p ? " on" : ""}" data-act="hero-pos" data-pos="${p}">${p}</button>`).join("")}
</div>
<label class="rec-field">
<span class="rec-label">your cards</span>
<input class="rec-cards" data-act="hero-cards" autocapitalize="off" autocomplete="off" spellcheck="false"
placeholder="e.g. ah kh" value="${esc(cardsText(hero.cards))}">
</label>
</section>
<section class="rec-sec">
<div class="rec-label">Players in the hand</div>
<div class="rec-seats">
${s.seats.filter((x) => x.pos !== s.heroPos).map((seat) => renderSeat(seat)).join("") || '<div class="rec-dim">none yet</div>'}
</div>
<div class="rec-pos-row">
${POSITIONS.filter((p) => p !== s.heroPos && !s.seats.some((x) => x.pos === p)).map((p) => `<button class="rec-pos add" data-act="add-seat" data-pos="${p}">+ ${p}</button>`).join("")}
</div>
</section>
<section class="rec-sec">
<div class="rec-label">Streets</div>
<div class="rec-street-tabs">
${STREETS.map((st) => `<button class="rec-tab${s.street === st ? " on" : ""}" data-act="street" data-street="${st}">${st}${boardCount(s, st)}</button>`).join("")}
</div>
${renderStreet(ctx)}
</section>
<section class="rec-sec">
<div class="rec-label">Result</div>
<div class="rec-result">
<label>pot <input class="rec-num" data-act="result" data-k="pot" inputmode="decimal" value="${s.result.pot != null ? s.result.pot : ""}"></label>
<label>your net <input class="rec-num" data-act="result" data-k="heroNet" inputmode="decimal" value="${s.result.heroNet != null ? s.result.heroNet : ""}"></label>
</div>
</section>
</div>
<div class="rec-foot">
<button class="rec-cancel" data-act="close">Cancel</button>
<button class="rec-save" data-act="save">Save &amp; replay</button>
</div>
`;
}
function renderSeat(seat) {
return `
<div class="rec-seat">
<span class="rec-seat-pos">${seat.pos}</span>
<input class="rec-name" data-act="seat-name" data-pos="${seat.pos}" autocapitalize="off" autocomplete="off"
placeholder="name" value="${esc(seat.name || "")}">
<input class="rec-cards sm" data-act="seat-cards" data-pos="${seat.pos}" autocapitalize="off" autocomplete="off" spellcheck="false"
placeholder="shown?" value="${esc(cardsText(seat.cards))}">
<button class="rec-rm" data-act="rm-seat" data-pos="${seat.pos}">✕</button>
</div>`;
}
function renderStreet(ctx) {
const s = ctx.state;
const st = s.street;
const players = s.seats.map((x) => x.pos);
const boardInput =
st === "preflop"
? ""
: `<label class="rec-field">
<span class="rec-label">${st} board (${STREET_BOARD[st]})</span>
<input class="rec-cards" data-act="board-cards" data-street="${st}" autocapitalize="off" autocomplete="off" spellcheck="false"
placeholder="${st === "flop" ? "e.g. 7d 2c 5h" : "e.g. 5h"}" value="${esc(cardsText(s.board[st]))}">
</label>`;
// Straddle: a voluntary preflop blind from any non-blind seat, default 2×BB.
// Action starts left of it and it acts last preflop — order is whatever you enter.
const stradAmt = s.blinds.bb != null ? 2 * s.blinds.bb : null;
const stradElig = players.filter((p) => p !== "SB" && p !== "BB" && !s.actions.some((a) => a.straddle && a.pos === p));
const straddle =
st === "preflop" && stradElig.length
? `<div class="rec-act-add">
<select class="rec-sel" data-act="str-pos">
<option value="">+ straddle${stradAmt != null ? " (" + stradAmt + ")" : ""}…</option>
${stradElig.map((p) => `<option value="${p}">${p}</option>`).join("")}
</select>
<button class="rec-add-act" data-act="add-straddle">add</button>
</div>`
: "";
return `
${boardInput}
${straddle}
<div class="rec-act-add">
<select class="rec-sel" data-act="na-pos">
<option value="">who</option>
${players.map((p) => `<option value="${p}">${p}${p === s.heroPos ? " (you)" : ""}</option>`).join("")}
</select>
<select class="rec-sel" data-act="na-action">
<option value="">action</option>
${ACTIONS.map((a) => `<option value="${a}">${a}</option>`).join("")}
</select>
<input class="rec-num" data-act="na-amount" inputmode="decimal" placeholder="$">
<button class="rec-add-act" data-act="add-action">add</button>
</div>
<div class="rec-log">
${s.board[st] && s.board[st].length && st !== "preflop" ? `<div class="rec-ln brd">${st}: ${cardsText(s.board[st])}</div>` : ""}
${s.actions
.filter((a) => a.street === st)
.map((a, i) => {
const label = a.straddle ? "straddle" : a.action;
const amt = a.amount != null ? " " + a.amount : "";
const fixed = a.action === "post" && !a.straddle; // blinds aren't removable
const rm = fixed ? "" : ` <button class="rec-undo" data-act="rm-action" data-street="${st}" data-i="${i}">✕</button>`;
return `<div class="rec-ln">${a.pos} <b>${label}</b>${amt}${rm}</div>`;
})
.join("")}
</div>`;
}
function boardCount(s, st) {
const n = (s.board[st] || []).length;
return n ? ` ${n}` : "";
}
// --- events ---------------------------------------------------------------
function handleClick(ctx, e) {
const s = ctx.state;
const t = e.target.closest("[data-act]");
if (!t) return;
const act = t.getAttribute("data-act");
switch (act) {
case "close":
if (ctx.opts.onClose) ctx.opts.onClose();
return;
case "hero-pos": {
const pos = t.getAttribute("data-pos");
const old = s.seats.find((x) => x.pos === s.heroPos);
if (old && old.name === "Hero" && !(old.cards || []).length) {
s.seats = s.seats.filter((x) => x !== old);
}
s.heroPos = s.heroPos === pos ? null : pos;
if (s.heroPos) ensureHero(s);
break;
}
case "add-seat":
s.seats.push({ pos: t.getAttribute("data-pos"), name: null, stack: null, cards: null });
break;
case "rm-seat":
s.seats = s.seats.filter((x) => x.pos !== t.getAttribute("data-pos"));
break;
case "street":
s.street = t.getAttribute("data-street");
break;
case "add-action":
addActionFromControls(ctx);
break;
case "add-straddle": {
const sel = ctx.container.querySelector('[data-act="str-pos"]');
const pos = sel && sel.value;
if (pos) {
const amt = s.blinds.bb != null ? 2 * s.blinds.bb : null;
s.actions.push({ street: "preflop", pos, action: "post", amount: amt, straddle: true });
}
break;
}
case "rm-action":
removeAction(s, t.getAttribute("data-street"), parseInt(t.getAttribute("data-i"), 10));
break;
case "save":
return doSave(ctx);
default:
return; // inputs handled in handleInput
}
render(ctx);
}
function handleInput(ctx, e) {
const s = ctx.state;
const t = e.target.closest("[data-act]");
if (!t) return;
const act = t.getAttribute("data-act");
if (act === "hero-cards") {
const hero = ensureHero(s);
hero.cards = parseCards(t.value);
} else if (act === "seat-cards") {
const seat = s.seats.find((x) => x.pos === t.getAttribute("data-pos"));
if (seat) seat.cards = parseCards(t.value);
} else if (act === "board-cards") {
s.board[t.getAttribute("data-street")] = parseCards(t.value);
} else if (act === "seat-name") {
const seat = s.seats.find((x) => x.pos === t.getAttribute("data-pos"));
if (seat) seat.name = t.value.trim() || null;
} else if (act === "result") {
const k = t.getAttribute("data-k");
s.result[k] = t.value === "" ? null : parseFloat(t.value);
}
// no re-render mid-typing (keeps input focus)
}
function addActionFromControls(ctx) {
const root = ctx.container;
const pos = root.querySelector('[data-act="na-pos"]').value;
const action = root.querySelector('[data-act="na-action"]').value;
const amt = root.querySelector('[data-act="na-amount"]').value;
if (!pos || !action) return;
const entry = { street: ctx.state.street, pos, action };
entry.amount = SIZED[action] && amt !== "" ? parseFloat(amt) : null;
ctx.state.actions.push(entry);
}
function removeAction(state, street, idxWithinStreet) {
let seen = -1;
for (let i = 0; i < state.actions.length; i++) {
if (state.actions[i].street === street) {
seen++;
if (seen === idxWithinStreet) {
state.actions.splice(i, 1);
return;
}
}
}
}
async function doSave(ctx) {
const structured = buildStructured(ctx.state);
const btn = ctx.container.querySelector(".rec-save");
if (btn) {
btn.disabled = true;
btn.textContent = "Saving…";
}
try {
const res = await fetch("/hands", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ structured, session_id: ctx.state.meta.sessionId }),
}).then((r) => r.json());
if (res && res.ok) {
if (ctx.opts.onSave) ctx.opts.onSave(res.id);
else window.location.href = `/hand/${res.id}`;
} else {
throw new Error((res && res.error) || "save failed");
}
} catch (err) {
if (btn) {
btn.disabled = false;
btn.textContent = "Save failed — retry";
}
}
}
function esc(x) {
return String(x == null ? "" : x).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
void SUITS;
})();
+8
View File
@@ -101,3 +101,11 @@ def test_list_recent_hands_flags_structured(poker):
rows = {r["id"]: r for r in poker.list_recent_hands()} rows = {r["id"]: r for r in poker.list_recent_hands()}
assert rows[structured_id]["has_structured"] is True assert rows[structured_id]["has_structured"] is True
assert rows[flat_id]["has_structured"] is False assert rows[flat_id]["has_structured"] is False
def test_hud_villains_carry_seat(poker):
"""The recorder auto-places known players, so the HUD bundle must expose their seat."""
poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
poker.add_read("3-bets light", seat="BTN", name="Sal", category="risky")
villains = {v["name"]: v for v in poker.hud()["villains"]}
assert villains["Sal"]["seat"] == "BTN"