/* 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 = `
Record hand
${esc(s.meta.venue || "")}${s.meta.stakes ? " · " + esc(s.meta.stakes) : ""}
Your seat
${POSITIONS.map((p) => ``).join("")}
Players in the hand
${s.seats.filter((x) => x.pos !== s.heroPos).map((seat) => renderSeat(seat)).join("") || '
none yet
'}
${POSITIONS.filter((p) => p !== s.heroPos && !s.seats.some((x) => x.pos === p)).map((p) => ``).join("")}
Streets
${STREETS.map((st) => ``).join("")}
${renderStreet(ctx)}
Result
`; } function renderSeat(seat) { return `
${seat.pos}
`; } function renderStreet(ctx) { const s = ctx.state; const st = s.street; const players = s.seats.map((x) => x.pos); const boardInput = st === "preflop" ? "" : ``; return ` ${boardInput}
${s.board[st] && s.board[st].length && st !== "preflop" ? `
${st}: ${cardsText(s.board[st])}
` : ""} ${s.actions.filter((a) => a.street === st).map((a, i) => `
${a.pos} ${a.action}${a.amount != null ? " " + a.amount : ""}${a.action === "post" ? "" : ` `}
`).join("")}
`; } 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 "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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); } void SUITS; })();