Files
project-lyra/lyra/web/static/recorder.js
serversdown b6cdf799dc feat: straddle support in recorder
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>
2026-06-27 04:49:50 +00:00

426 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 &amp; 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
void SUITS;
})();