/* 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 = `