feat: hand recorder v1 (tap-to-build, overlay) — recorder steps 2-4
Tap-to-build hand recorder per docs/RECORDER.md. Correctness by construction: every
tap writes a known value into a known slot, emitting the canonical structured contract
(server normalize_structured() is the shape authority, so the client is best-effort).
- recorder.js: mount-agnostic module (mounts into any container -> overlay now,
standalone later, zero logic change). Pure buildStructured(state)->contract dict.
Card picker: sticky-suit-or-rank-first + x (unknown suit) + unknown-card; lock resets
per slot so it never bleeds between cards. Pre-fills from /session/data (stakes->blinds,
stack->hero, villains->seated). Per-street action entry, manual street advance.
- recorder.css: full-screen overlay using app theme tokens; floating launcher.
- index.html: overlay div + script + launcher (chat/session stays mounted underneath).
Validated end-to-end: JS core builds a hand (sticky/rainbow/unknown cards all correct)
-> POST /hands -> normalized -> /hand/{id} replay round-trips identically.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Lyra Core Chat</title>
|
<title>Lyra Core Chat</title>
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<link rel="stylesheet" href="/recorder.css" />
|
||||||
<!-- PWA -->
|
<!-- PWA -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
@@ -1245,5 +1246,28 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="/nav.js"></script>
|
<script src="/nav.js"></script>
|
||||||
|
<!-- Hand recorder (overlay; chat/session stays mounted underneath) -->
|
||||||
|
<div id="recorderOverlay" class="rec-overlay"></div>
|
||||||
|
<script src="/recorder.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var btn = document.createElement("button");
|
||||||
|
btn.id = "recLaunch";
|
||||||
|
btn.textContent = "+ Hand";
|
||||||
|
btn.title = "Record a hand";
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
var overlay = document.getElementById("recorderOverlay");
|
||||||
|
function close() { overlay.classList.remove("open"); overlay.innerHTML = ""; }
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
overlay.innerHTML = "";
|
||||||
|
overlay.classList.add("open");
|
||||||
|
var sid = window.currentSession;
|
||||||
|
window.Recorder.mount(overlay, {
|
||||||
|
onClose: close,
|
||||||
|
onSave: function (id) { close(); window.open("/hand/" + id, "_blank"); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/* Hand recorder overlay. Uses the app theme tokens (--accent, --bg-* etc.) from
|
||||||
|
style.css when mounted in index.html. For a standalone recorder.html, import those
|
||||||
|
tokens too (see :root in style.css). */
|
||||||
|
|
||||||
|
.rec-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--bg-dark, #070707);
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.rec-overlay.open { display: flex; }
|
||||||
|
|
||||||
|
.rec-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-main, #e8e8e8);
|
||||||
|
font-family: var(--font-console, ui-monospace, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border, #2a1d12);
|
||||||
|
}
|
||||||
|
.rec-title { font-weight: 700; color: var(--accent, #ff7a00); }
|
||||||
|
.rec-meta { color: var(--text-fade, #8a8a8a); font-size: .82rem; flex: 1; }
|
||||||
|
.rec-x {
|
||||||
|
background: none; border: 1px solid var(--border, #2a1d12); color: var(--text-fade, #8a8a8a);
|
||||||
|
border-radius: 8px; width: 34px; height: 34px; font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-body { flex: 1; overflow-y: auto; padding: 12px 14px 20px; -webkit-overflow-scrolling: touch; }
|
||||||
|
.rec-sec { margin-bottom: 18px; }
|
||||||
|
.rec-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .6px; color: var(--text-fade, #8a8a8a); margin-bottom: 6px; }
|
||||||
|
.rec-dim { color: var(--text-fade, #8a8a8a); font-size: .85rem; }
|
||||||
|
|
||||||
|
.rec-pos-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.rec-pos {
|
||||||
|
min-width: 44px; padding: 9px 10px; border-radius: 9px;
|
||||||
|
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8);
|
||||||
|
border: 1px solid var(--border, #2a1d12); font-size: .82rem; font-weight: 600;
|
||||||
|
}
|
||||||
|
.rec-pos.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
|
||||||
|
.rec-pos.add { color: var(--text-fade, #8a8a8a); border-style: dashed; }
|
||||||
|
|
||||||
|
.rec-hero-cards { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
||||||
|
|
||||||
|
.rec-seats { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.rec-seat { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.rec-seat-pos { min-width: 42px; font-weight: 700; color: var(--gold, #ffb347); }
|
||||||
|
.rec-name {
|
||||||
|
flex: 1; min-width: 0; padding: 8px 9px; border-radius: 8px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.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-street-tabs { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.rec-tab {
|
||||||
|
flex: 1; padding: 9px 6px; border-radius: 9px; font-size: .78rem; font-weight: 600;
|
||||||
|
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8); border: 1px solid var(--border, #2a1d12);
|
||||||
|
}
|
||||||
|
.rec-tab.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
|
||||||
|
|
||||||
|
.rec-act-add { display: flex; gap: 6px; margin: 8px 0; }
|
||||||
|
.rec-sel, .rec-num {
|
||||||
|
padding: 9px 8px; border-radius: 8px; background: var(--bg-elev, #0e0e0e);
|
||||||
|
border: 1px solid var(--border, #2a1d12); color: var(--text-main, #e8e8e8);
|
||||||
|
font-family: inherit; font-size: .85rem; min-width: 0;
|
||||||
|
}
|
||||||
|
.rec-sel { flex: 1; }
|
||||||
|
.rec-num { width: 70px; }
|
||||||
|
.rec-add-act { padding: 9px 12px; border-radius: 8px; background: var(--border-bright, #4a2f15); color: #fff; border: none; font-weight: 600; }
|
||||||
|
.rec-result { display: flex; gap: 12px; }
|
||||||
|
.rec-result label { display: flex; flex-direction: column; gap: 4px; font-size: .72rem; color: var(--text-fade, #8a8a8a); }
|
||||||
|
|
||||||
|
.rec-log { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
|
||||||
|
.rec-ln { font-size: .82rem; color: var(--text-main, #e8e8e8); }
|
||||||
|
.rec-ln.brd { display: flex; gap: 4px; align-items: center; color: var(--text-fade, #8a8a8a); }
|
||||||
|
.rec-ln b { color: var(--accent, #ff7a00); font-weight: 700; }
|
||||||
|
|
||||||
|
.rec-foot { padding: 12px 14px; border-top: 1px solid var(--border, #2a1d12); }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* floating launcher (V1) */
|
||||||
|
#recLaunch {
|
||||||
|
position: fixed; right: 16px; bottom: 84px; z-index: 900;
|
||||||
|
background: var(--accent, #ff7a00); color: #111; border: none;
|
||||||
|
border-radius: 22px; padding: 11px 16px; font-weight: 700; font-size: .9rem;
|
||||||
|
font-family: var(--font-console, ui-monospace, monospace);
|
||||||
|
box-shadow: 0 3px 12px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
body.kb #recLaunch { display: none; } /* hide when the keyboard is up */
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
/* 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
|
||||||
|
* contract (docs/HAND_HISTORY.md); the server's normalize_structured() is the final
|
||||||
|
* authority on shape, 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.
|
||||||
|
*/
|
||||||
|
(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 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 ACTIONS = ["fold", "check", "call", "bet", "raise", "allin"];
|
||||||
|
const SIZED = { bet: true, raise: true, allin: true }; // actions that carry an amount
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// One flat chronological action list; each street opens with its board reveal.
|
||||||
|
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 || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialState(hud) {
|
||||||
|
const sess = (hud && hud.session) || {};
|
||||||
|
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)) {
|
||||||
|
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: "" },
|
||||||
|
// 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) {
|
||||||
|
hero = { pos: state.heroPos, name: "Hero", stack: state.heroStack, cards: [] };
|
||||||
|
state.seats.push(hero);
|
||||||
|
}
|
||||||
|
return hero || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// expose pure + helpers for testing / V2 reuse
|
||||||
|
window.Recorder = {
|
||||||
|
buildStructured,
|
||||||
|
parseBlinds,
|
||||||
|
initialState,
|
||||||
|
_internals: { onSuitTap, onRankTap, onUnknownSuit, onUnknownCard, pushCard, POSITIONS, STREETS },
|
||||||
|
mount,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- mount / render (DOM shell) ------------------------------------------
|
||||||
|
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 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 || [];
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
</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-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>
|
||||||
|
<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 board = s.board[st] || [];
|
||||||
|
const need = STREET_BOARD[st] || 0;
|
||||||
|
const boardSlots =
|
||||||
|
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");
|
||||||
|
return `
|
||||||
|
${boardSlots}
|
||||||
|
<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}: ${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>
|
||||||
|
</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");
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
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 "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 "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 === "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" };
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (!pos || !action) return;
|
||||||
|
const entry = { street: ctx.state.street, pos, action };
|
||||||
|
if (SIZED[action] && amt !== "") entry.amount = parseFloat(amt);
|
||||||
|
else entry.amount = null;
|
||||||
|
ctx.state.actions.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
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]));
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user