fix: shelve recorder card picker for plain typed card entry
The tap picker wasn't working in practice. Replace it with simple text inputs for hero/board/shown cards: case-insensitive, accepts 'ah kh' / 'AhKh' / '7d2c5h' / '10h'. parseCards() tokenizes; the server's normalize_structured() canonicalizes (case, 10->T, completeness). Also adds per-action remove (✕) in the street log. Picker UI + bottom-sheet CSS removed; tap picker deferred to V2 (docs/RECORDER.md). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+74
-192
@@ -1,30 +1,38 @@
|
||||
/* 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
|
||||
* 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, so this stays best-effort.
|
||||
* 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";
|
||||
|
||||
// --- 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 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 }; // cards revealed per street
|
||||
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 }; // actions that carry an amount
|
||||
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) {
|
||||
@@ -38,7 +46,6 @@
|
||||
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];
|
||||
@@ -73,7 +80,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
// --- 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 };
|
||||
@@ -84,7 +90,6 @@
|
||||
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)) {
|
||||
@@ -111,75 +116,9 @@
|
||||
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) {
|
||||
@@ -189,16 +128,16 @@
|
||||
return hero || {};
|
||||
}
|
||||
|
||||
// expose pure + helpers for testing / V2 reuse
|
||||
window.Recorder = {
|
||||
buildStructured,
|
||||
parseCards,
|
||||
parseBlinds,
|
||||
initialState,
|
||||
_internals: { onSuitTap, onRankTap, onUnknownSuit, onUnknownCard, pushCard, POSITIONS, STREETS },
|
||||
_internals: { POSITIONS, STREETS },
|
||||
mount,
|
||||
};
|
||||
|
||||
// --- mount / render (DOM shell) ------------------------------------------
|
||||
// --- mount / render -------------------------------------------------------
|
||||
async function mount(container, opts) {
|
||||
opts = opts || {};
|
||||
let hud = opts.hud;
|
||||
@@ -212,7 +151,6 @@
|
||||
}
|
||||
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));
|
||||
@@ -220,24 +158,9 @@
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function h(html) {
|
||||
return html;
|
||||
}
|
||||
|
||||
function cardChip(token, active) {
|
||||
if (!token) return `<span class="rec-card empty${active ? " active" : ""}">+</span>`;
|
||||
if (token === "x") return `<span class="rec-card unknown">?</span>`;
|
||||
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 `<span class="rec-card ${cls}">${rank}<span class="rs">${sym}</span></span>`;
|
||||
}
|
||||
|
||||
function render(ctx) {
|
||||
const s = ctx.state;
|
||||
const heroCards = (s.seats.find((x) => x.pos === s.heroPos) || {}).cards || [];
|
||||
const hero = s.seats.find((x) => x.pos === s.heroPos) || {};
|
||||
ctx.container.innerHTML = `
|
||||
<div class="rec-head">
|
||||
<div class="rec-title">Record hand</div>
|
||||
@@ -251,11 +174,11 @@
|
||||
<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>
|
||||
<div class="rec-hero-cards" data-act="pick" data-type="hero">
|
||||
<span class="rec-label">cards</span>
|
||||
${cardChip(heroCards[0], s.pick && s.pick.type === "hero" && heroCards.length === 0)}
|
||||
${cardChip(heroCards[1], s.pick && s.pick.type === "hero" && heroCards.length === 1)}
|
||||
</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">
|
||||
@@ -288,20 +211,17 @@
|
||||
<div class="rec-foot">
|
||||
<button class="rec-save" data-act="save">Save & replay</button>
|
||||
</div>
|
||||
|
||||
${s.pick ? renderPicker(s) : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSeat(seat) {
|
||||
const cards = seat.cards || [];
|
||||
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}" placeholder="name" value="${esc(seat.name || "")}">
|
||||
<span class="rec-seat-cards" data-act="pick" data-type="seat" data-pos="${seat.pos}">
|
||||
${cardChip(cards[0])} ${cardChip(cards[1])}
|
||||
</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>`;
|
||||
}
|
||||
@@ -309,18 +229,17 @@
|
||||
function renderStreet(ctx) {
|
||||
const s = ctx.state;
|
||||
const st = s.street;
|
||||
const board = s.board[st] || [];
|
||||
const need = STREET_BOARD[st] || 0;
|
||||
const boardSlots =
|
||||
const players = s.seats.map((x) => x.pos);
|
||||
const boardInput =
|
||||
st === "preflop"
|
||||
? ""
|
||||
: `<div class="rec-board" data-act="pick" data-type="board" data-street="${st}">
|
||||
${Array.from({ length: need }).map((_, i) => cardChip(board[i], s.pick && s.pick.type === "board" && s.pick.street === st && board.length === i)).join("")}
|
||||
</div>`;
|
||||
const players = s.seats.map((x) => x.pos);
|
||||
const acts = s.actions.filter((a) => a.street === st && a.action !== "post");
|
||||
: `<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>`;
|
||||
return `
|
||||
${boardSlots}
|
||||
${boardInput}
|
||||
<div class="rec-act-add">
|
||||
<select class="rec-sel" data-act="na-pos">
|
||||
<option value="">who</option>
|
||||
@@ -334,32 +253,8 @@
|
||||
<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}: ${s.board[st].map((c) => cardChip(c)).join("")}</div>` : ""}
|
||||
${s.actions.filter((a) => a.street === st).map((a) => `<div class="rec-ln">${a.pos} <b>${a.action}</b>${a.amount != null ? " " + a.amount : ""}</div>`).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPicker(s) {
|
||||
const pend = s.pendingRank;
|
||||
return `
|
||||
<div class="rec-picker-wrap" data-act="picker-bg">
|
||||
<div class="rec-picker" data-stop="1">
|
||||
<div class="rec-picker-head">
|
||||
${pend ? `pick a suit for <b>${pend}</b>` : "tap a suit (locks) or a rank"}
|
||||
<button class="rec-pk-x" data-act="pick-close">done</button>
|
||||
</div>
|
||||
<div class="rec-suits">
|
||||
${SUITS.map((su) => `<button class="rec-suit ${su.cls}${s.lockedSuit === su.s ? " on" : ""}" data-act="suit" data-s="${su.s}">${su.sym}</button>`).join("")}
|
||||
<button class="rec-suit x" data-act="xsuit">x</button>
|
||||
</div>
|
||||
<div class="rec-ranks">
|
||||
${RANKS.map((r) => `<button class="rec-rank${pend === r ? " on" : ""}" data-act="rank" data-r="${r}">${r}</button>`).join("")}
|
||||
</div>
|
||||
<div class="rec-pk-foot">
|
||||
<button data-act="xcard">unknown card</button>
|
||||
<button data-act="backspace">⌫</button>
|
||||
</div>
|
||||
</div>
|
||||
${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) => `<div class="rec-ln">${a.pos} <b>${a.action}</b>${a.amount != null ? " " + a.amount : ""}${a.action === "post" ? "" : ` <button class="rec-undo" data-act="rm-action" data-street="${st}" data-i="${i}">✕</button>`}</div>`).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -381,7 +276,6 @@
|
||||
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);
|
||||
@@ -399,38 +293,12 @@
|
||||
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 "rm-action":
|
||||
removeAction(s, t.getAttribute("data-street"), parseInt(t.getAttribute("data-i"), 10));
|
||||
break;
|
||||
case "save":
|
||||
return doSave(ctx);
|
||||
default:
|
||||
@@ -444,36 +312,48 @@
|
||||
const t = e.target.closest("[data-act]");
|
||||
if (!t) return;
|
||||
const act = t.getAttribute("data-act");
|
||||
if (act === "seat-name") {
|
||||
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);
|
||||
}
|
||||
// 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" };
|
||||
// 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 amtEl = root.querySelector('[data-act="na-amount"]');
|
||||
const amt = amtEl.value;
|
||||
const amt = root.querySelector('[data-act="na-amount"]').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;
|
||||
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");
|
||||
@@ -504,4 +384,6 @@
|
||||
function esc(x) {
|
||||
return String(x == null ? "" : x).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
}
|
||||
|
||||
void SUITS;
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user