b6cdf799dc
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>
426 lines
16 KiB
JavaScript
426 lines
16 KiB
JavaScript
/* 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 & 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||
}
|
||
|
||
void SUITS;
|
||
})();
|