/* Hand recorder — tap-to-build poker hands. See docs/RECORDER.md. * * Correctness by construction: every tap writes a known value into a known slot, * so there's no 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, 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. */ (function () { "use strict"; // --- constants ------------------------------------------------------------ const SUITS = [ { s: "s", sym: "♠", cls: "spade" }, { s: "h", sym: "♥", cls: "heart" }, { s: "d", sym: "♦", cls: "diamond" }, { s: "c", sym: "♣", cls: "club" }, ]; const RANKS = ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"]; 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 }; // cards revealed per street const ACTIONS = ["fold", "check", "call", "bet", "raise", "allin"]; const SIZED = { bet: true, raise: true, allin: true }; // actions that carry an amount // --- 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; }); // One flat chronological action list; each street opens with its board reveal. 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 || "", }, }; } // --- state construction --------------------------------------------------- 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); // Seat known villains where Lyra has a read; hero added on position pick. 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: "" }, // transient editor: pick: null, // active card target: {type:'hero'|'board'|'seat', pos?} lockedSuit: null, pendingRank: null, }; } // --- card picker logic ---------------------------------------------------- function pickTargetCards(state) { const p = state.pick; if (!p) return null; if (p.type === "hero") { const hero = ensureHero(state); return { arr: (hero.cards = hero.cards || []), max: 2 }; } if (p.type === "seat") { const seat = state.seats.find((s) => s.pos === p.pos); if (!seat) return null; return { arr: (seat.cards = seat.cards || []), max: 2 }; } if (p.type === "board") { return { arr: state.board[p.street], max: STREET_BOARD[p.street] }; } return null; } function pushCard(state, token) { const t = pickTargetCards(state); if (!t || t.arr.length >= t.max) return; t.arr.push(token); if (t.arr.length >= t.max) state.pick = null; // slot full -> close picker } function onSuitTap(state, suit) { state.lockedSuit = suit; if (state.pendingRank) { pushCard(state, state.pendingRank + suit); state.pendingRank = null; } } function onRankTap(state, rank) { if (state.lockedSuit) { pushCard(state, rank + state.lockedSuit); } else { state.pendingRank = rank; // await a suit (or x) } } function onUnknownSuit(state) { // 'x' suit: complete a pending rank as unknown-suit (e.g. "Ax"); flips // completeness on the server. No-op without a pending rank. if (state.pendingRank) { pushCard(state, state.pendingRank + "x"); state.pendingRank = null; } } function onUnknownCard(state) { state.pendingRank = null; pushCard(state, "x"); // a card never shown } function backspaceCard(state) { const t = pickTargetCards(state); if (t && t.arr.length) t.arr.pop(); state.pendingRank = null; } 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 || {}; } // expose pure + helpers for testing / V2 reuse window.Recorder = { buildStructured, parseBlinds, initialState, _internals: { onSuitTap, onRankTap, onUnknownSuit, onUnknownCard, pushCard, POSITIONS, STREETS }, mount, }; // --- mount / render (DOM shell) ------------------------------------------ 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 h(html) { return html; } function cardChip(token, active) { if (!token) return `+`; if (token === "x") return `?`; const rank = token[0]; const suit = token[1]; const meta = SUITS.find((x) => x.s === suit); const cls = meta ? meta.cls : "unknown"; const sym = meta ? meta.sym : "?"; return `${rank}${sym}`; } function render(ctx) { const s = ctx.state; const heroCards = (s.seats.find((x) => x.pos === s.heroPos) || {}).cards || []; ctx.container.innerHTML = `
Record hand
${esc(s.meta.venue || "")}${s.meta.stakes ? " · " + esc(s.meta.stakes) : ""}
Your seat
${POSITIONS.map((p) => ``).join("")}
cards ${cardChip(heroCards[0], s.pick && s.pick.type === "hero" && heroCards.length === 0)} ${cardChip(heroCards[1], s.pick && s.pick.type === "hero" && heroCards.length === 1)}
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
${s.pick ? renderPicker(s) : ""} `; } function renderSeat(seat) { const cards = seat.cards || []; return `
${seat.pos} ${cardChip(cards[0])} ${cardChip(cards[1])}
`; } function renderStreet(ctx) { const s = ctx.state; const st = s.street; const board = s.board[st] || []; const need = STREET_BOARD[st] || 0; const boardSlots = st === "preflop" ? "" : `
${Array.from({ length: need }).map((_, i) => cardChip(board[i], s.pick && s.pick.type === "board" && s.pick.street === st && board.length === i)).join("")}
`; const players = s.seats.map((x) => x.pos); const acts = s.actions.filter((a) => a.street === st && a.action !== "post"); return ` ${boardSlots}
${(s.board[st] && s.board[st].length && st !== "preflop") ? `
${st}: ${s.board[st].map((c) => cardChip(c)).join("")}
` : ""} ${s.actions.filter((a) => a.street === st).map((a) => `
${a.pos} ${a.action}${a.amount != null ? " " + a.amount : ""}
`).join("")}
`; } function renderPicker(s) { const pend = s.pendingRank; return `
${pend ? `pick a suit for ${pend}` : "tap a suit (locks) or a rank"}
${SUITS.map((su) => ``).join("")}
${RANKS.map((r) => ``).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"); // moving hero seat: drop any old hero-only seat, ensure new 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 "pick": // A new slot starts neutral: first suit tapped locks for the rest of THIS slot // (flush flops stay fast), first rank tapped waits for its suit. The lock never // bleeds from one slot into the next. s.pick = pickFromEl(t); s.pendingRank = null; s.lockedSuit = null; break; case "pick-close": case "picker-bg": if (act === "picker-bg" && e.target.closest("[data-stop]")) return; s.pick = null; s.pendingRank = null; break; case "suit": onSuitTap(s, t.getAttribute("data-s")); break; case "xsuit": onUnknownSuit(s); break; case "rank": onRankTap(s, t.getAttribute("data-r")); break; case "xcard": onUnknownCard(s); break; case "backspace": backspaceCard(s); break; case "add-action": addActionFromControls(ctx); 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 === "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); } // na-* and these inputs don't need a re-render mid-typing } function pickFromEl(t) { const type = t.getAttribute("data-type"); if (type === "board") return { type, street: t.getAttribute("data-street") }; if (type === "seat") return { type, pos: t.getAttribute("data-pos") }; return { type: "hero" }; } 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 amtEl = root.querySelector('[data-act="na-amount"]'); const amt = amtEl.value; if (!pos || !action) return; const entry = { street: ctx.state.street, pos, action }; if (SIZED[action] && amt !== "") entry.amount = parseFloat(amt); else entry.amount = null; ctx.state.actions.push(entry); } 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])); } })();