diff --git a/lyra/web/static/recorder.css b/lyra/web/static/recorder.css index 5b0991d..d6b85b3 100644 --- a/lyra/web/static/recorder.css +++ b/lyra/web/static/recorder.css @@ -58,24 +58,17 @@ background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12); color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .85rem; } -.rec-seat-cards { display: flex; gap: 5px; } .rec-rm { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .9rem; padding: 4px; } -/* cards */ -.rec-card { - display: inline-flex; align-items: center; justify-content: center; - min-width: 30px; height: 40px; padding: 0 5px; border-radius: 6px; - background: #f4f4f4; color: #111; font-weight: 700; font-size: 1rem; line-height: 1; +/* typed card entry */ +.rec-field { display: block; margin-top: 10px; } +.rec-cards { + width: 100%; padding: 10px 11px; border-radius: 8px; + background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12); + color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .95rem; + letter-spacing: 1px; box-sizing: border-box; } -.rec-card .rs { font-size: .8rem; margin-left: 1px; } -.rec-card.spade, .rec-card.club { color: #111; } -.rec-card.heart { color: #d11; } -.rec-card.diamond { color: #1463d1; } -.rec-card.empty { background: var(--bg-elev, #0e0e0e); color: var(--text-fade, #8a8a8a); border: 1px dashed var(--border-bright, #4a2f15); } -.rec-card.empty.active { border-color: var(--accent, #ff7a00); color: var(--accent, #ff7a00); } -.rec-card.unknown { background: #2a2a2a; color: #aaa; } - -.rec-board { display: flex; gap: 6px; margin: 8px 0; } +.rec-cards.sm { width: 88px; flex: none; padding: 8px 9px; font-size: .85rem; } .rec-street-tabs { display: flex; gap: 6px; margin-bottom: 8px; } .rec-tab { @@ -105,23 +98,7 @@ .rec-save { width: 100%; padding: 14px; border-radius: 10px; background: var(--accent, #ff7a00); color: #111; border: none; font-weight: 700; font-size: 1rem; } .rec-save:disabled { opacity: .6; } -/* card picker bottom sheet */ -.rec-picker-wrap { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: flex-end; z-index: 1100; } -.rec-picker { width: 100%; background: var(--bg-elev, #0e0e0e); border-top: 1px solid var(--border-bright, #4a2f15); border-radius: 16px 16px 0 0; padding: 14px; } -.rec-picker-head { display: flex; align-items: center; justify-content: space-between; font-size: .85rem; color: var(--text-fade, #8a8a8a); margin-bottom: 12px; } -.rec-pk-x { background: var(--border-bright, #4a2f15); color: #fff; border: none; border-radius: 8px; padding: 6px 12px; font-weight: 600; } -.rec-suits { display: flex; gap: 8px; margin-bottom: 12px; } -.rec-suit { flex: 1; height: 52px; border-radius: 10px; font-size: 1.6rem; background: #f4f4f4; border: 2px solid transparent; } -.rec-suit.spade, .rec-suit.club { color: #111; } -.rec-suit.heart { color: #d11; } -.rec-suit.diamond { color: #1463d1; } -.rec-suit.x { background: #2a2a2a; color: #aaa; font-size: 1.1rem; font-weight: 700; } -.rec-suit.on { border-color: var(--accent, #ff7a00); box-shadow: 0 0 0 2px var(--accent, #ff7a00); } -.rec-ranks { display: grid; grid-template-columns: repeat(7, 1fr); gap: 7px; } -.rec-rank { height: 46px; border-radius: 9px; font-size: 1.05rem; font-weight: 700; background: var(--bg-dark, #070707); color: var(--text-main, #e8e8e8); border: 1px solid var(--border, #2a1d12); } -.rec-rank.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); } -.rec-pk-foot { display: flex; justify-content: space-between; margin-top: 12px; } -.rec-pk-foot button { background: var(--bg-dark, #070707); color: var(--text-fade, #8a8a8a); border: 1px solid var(--border, #2a1d12); border-radius: 8px; padding: 9px 14px; font-size: .85rem; } +.rec-undo { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .75rem; padding: 0 4px; } /* The Record tab swaps in for Mind in the bottom bar, but only in poker (cash) mode. body.cash-mode is toggled on mode change in index.html. */ diff --git a/lyra/web/static/recorder.js b/lyra/web/static/recorder.js index 9be2165..074c196 100644 --- a/lyra/web/static/recorder.js +++ b/lyra/web/static/recorder.js @@ -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 `+`; - 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 || []; + const hero = s.seats.find((x) => x.pos === s.heroPos) || {}; ctx.container.innerHTML = `