/* 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) : ""}
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)}
`;
}
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"
? ""
: ``;
// 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
? `
`
: "";
return `
${boardInput}
${straddle}
${s.board[st] && s.board[st].length && st !== "preflop" ? `
${st}: ${cardsText(s.board[st])}
` : ""}
${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 ? "" : `
`;
return `
${a.pos} ${label}${amt}${rm}
`;
})
.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 "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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
}
void SUITS;
})();