const STREET_ORDER = ["preflop", "flop", "turn", "river"]; const STREET_BOARD_COUNTS = { preflop: 0, flop: 3, turn: 4, river: 5 }; const SUITS = { c: "♣", d: "♦", h: "♥", s: "♠" }; const state = { rawGame: null, replay: null, handIndex: 0, frameIndex: 0, playing: false, timer: null, polling: false, pollTimer: null, }; const els = { connectionStatus: document.getElementById("connectionStatus"), gameStatus: document.getElementById("gameStatus"), handCounter: document.getElementById("handCounter"), baseUrlInput: document.getElementById("baseUrlInput"), gameIdInput: document.getElementById("gameIdInput"), fetchButton: document.getElementById("fetchButton"), togglePollButton: document.getElementById("togglePollButton"), runCountInput: document.getElementById("runCountInput"), pollSecondsInput: document.getElementById("pollSecondsInput"), smallBlindInput: document.getElementById("smallBlindInput"), bigBlindInput: document.getElementById("bigBlindInput"), untilOneLeftInput: document.getElementById("untilOneLeftInput"), runButton: document.getElementById("runButton"), createGameInput: document.getElementById("createGameInput"), createGameButton: document.getElementById("createGameButton"), fileInput: document.getElementById("fileInput"), jsonInput: document.getElementById("jsonInput"), loadJsonButton: document.getElementById("loadJsonButton"), handSelect: document.getElementById("handSelect"), speedInput: document.getElementById("speedInput"), tableFelt: document.getElementById("tableFelt"), seatLayer: document.getElementById("seatLayer"), potDisplay: document.getElementById("potDisplay"), boardCards: document.getElementById("boardCards"), frameCaption: document.getElementById("frameCaption"), resetButton: document.getElementById("resetButton"), prevButton: document.getElementById("prevButton"), playButton: document.getElementById("playButton"), nextButton: document.getElementById("nextButton"), frameCounter: document.getElementById("frameCounter"), tableStats: document.getElementById("tableStats"), playerList: document.getElementById("playerList"), eventLog: document.getElementById("eventLog"), }; function init() { const params = new URLSearchParams(window.location.search); if (params.get("base_url")) els.baseUrlInput.value = params.get("base_url"); if (params.get("game_id")) els.gameIdInput.value = params.get("game_id"); els.fetchButton.addEventListener("click", () => fetchGame()); els.togglePollButton.addEventListener("click", togglePolling); els.runButton.addEventListener("click", runHands); els.createGameButton.addEventListener("click", createGame); els.loadJsonButton.addEventListener("click", loadJsonText); els.fileInput.addEventListener("change", loadJsonFile); els.handSelect.addEventListener("change", () => { state.handIndex = Number(els.handSelect.value || 0); state.frameIndex = 0; stopPlayback(); render(); }); els.resetButton.addEventListener("click", () => setFrame(0)); els.prevButton.addEventListener("click", () => setFrame(state.frameIndex - 1)); els.nextButton.addEventListener("click", () => setFrame(state.frameIndex + 1)); els.playButton.addEventListener("click", togglePlayback); els.createGameInput.value = JSON.stringify(defaultGameSpec(), null, 2); renderEmpty(); } async function fetchGame(options = {}) { const baseUrl = els.baseUrlInput.value.trim(); const gameId = els.gameIdInput.value.trim(); if (!gameId) { setStatus("error", "Missing game id"); return; } setStatus("neutral", "Fetching"); try { const params = new URLSearchParams({ base_url: baseUrl, game_id: gameId }); const response = await fetch(`/api/fetch-game?${params.toString()}`); const payload = await readJsonResponse(response); loadGame(payload, { preserveTail: Boolean(options.preserveTail) }); setStatus("ok", "Connected"); } catch (error) { setStatus("error", error.message); } } async function runHands() { const gameId = els.gameIdInput.value.trim(); if (!gameId) { setStatus("error", "Missing game id"); return; } const payload = { base_url: els.baseUrlInput.value.trim(), game_id: gameId, count: Number(els.runCountInput.value || 1), until_one_left: els.untilOneLeftInput.checked, }; const smallBlind = els.smallBlindInput.value.trim(); const bigBlind = els.bigBlindInput.value.trim(); if (smallBlind || bigBlind) { payload.small_blind = Number(smallBlind); payload.big_blind = Number(bigBlind); } setStatus("neutral", "Running"); els.runButton.disabled = true; try { const response = await fetch("/api/run-hands", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const result = await readJsonResponse(response); loadGame(result.game || result, { preserveTail: true }); setStatus("ok", "Updated"); } catch (error) { setStatus("error", error.message); } finally { els.runButton.disabled = false; } } async function createGame() { let gameSpec; try { gameSpec = JSON.parse(els.createGameInput.value); } catch (error) { setStatus("error", error.message); return; } if (!gameSpec || typeof gameSpec !== "object" || Array.isArray(gameSpec)) { setStatus("error", "Game spec must be a JSON object"); return; } const payload = { ...gameSpec, base_url: els.baseUrlInput.value.trim() }; setStatus("neutral", "Creating"); els.createGameButton.disabled = true; try { const response = await fetch("/api/create-game", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const created = await readJsonResponse(response); loadGame(created); setStatus("ok", "Created"); } catch (error) { setStatus("error", error.message); } finally { els.createGameButton.disabled = false; } } function togglePolling() { state.polling = !state.polling; els.togglePollButton.classList.toggle("primary", state.polling); els.togglePollButton.textContent = state.polling ? "Stop" : "Auto"; if (state.polling) { fetchGame({ preserveTail: true }); schedulePoll(); } else if (state.pollTimer) { clearTimeout(state.pollTimer); state.pollTimer = null; } } function schedulePoll() { if (!state.polling) return; const delay = Math.max(1, Number(els.pollSecondsInput.value || 3)) * 1000; state.pollTimer = setTimeout(async () => { await fetchGame({ preserveTail: true }); schedulePoll(); }, delay); } async function readJsonResponse(response) { const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } return payload; } function loadJsonText() { try { const payload = JSON.parse(els.jsonInput.value); loadGame(payload); setStatus("ok", "Loaded JSON"); } catch (error) { setStatus("error", error.message); } } function loadJsonFile() { const file = els.fileInput.files && els.fileInput.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const payload = JSON.parse(String(reader.result || "{}")); els.jsonInput.value = JSON.stringify(payload, null, 2); loadGame(payload); setStatus("ok", "Loaded file"); } catch (error) { setStatus("error", error.message); } }; reader.readAsText(file); } function loadGame(rawGame, options = {}) { const wasAtTail = isAtReplayTail(); const currentHandNumber = currentHand()?.hand_number; const currentFrameKey = currentFrame()?.key; state.rawGame = rawGame; state.replay = buildReplay(rawGame); syncInputsFromGame(rawGame); populateHandSelect(); if (options.preserveTail && wasAtTail && state.replay.hands.length) { state.handIndex = state.replay.hands.length - 1; state.frameIndex = Math.max(0, currentHand().frames.length - 1); } else if (currentHandNumber) { const handIndex = state.replay.hands.findIndex((hand) => hand.hand_number === currentHandNumber); state.handIndex = handIndex >= 0 ? handIndex : Math.max(0, state.replay.hands.length - 1); const frameIndex = currentFrameKey ? currentHand().frames.findIndex((frame) => frame.key === currentFrameKey) : 0; state.frameIndex = frameIndex >= 0 ? frameIndex : 0; } else { state.handIndex = Math.max(0, state.replay.hands.length - 1); state.frameIndex = 0; } render(); } function buildReplay(game) { const players = normalizePlayers(game.players || []); const hands = []; let stacks = Object.fromEntries(players.map((player) => [player.player_id, numberOr(player.stack, game.starting_stack || 0)])); const historicalHands = Array.isArray(game.hands) ? game.hands : []; if (historicalHands.length > 0) { stacks = Object.fromEntries(players.map((player) => [player.player_id, numberOr(game.starting_stack, player.stack)])); } for (const hand of historicalHands) { const startStacks = { ...stacks }; const replayHand = buildHandReplay(game, players, hand, startStacks); hands.push(replayHand); stacks = { ...replayHand.endingStacks }; } const currentFrame = buildCurrentTableFrame(game, players); return { game, players, hands, currentFrame }; } function normalizePlayers(players) { return players .map((player, index) => ({ player_id: String(player.player_id || player.id || `p${index + 1}`), name: String(player.name || player.player_id || player.id || `Player ${index + 1}`), seat: numberOr(player.seat, index), stack: numberOr(player.stack, 0), street_bet: numberOr(player.street_bet, 0), total_bet: numberOr(player.total_bet, 0), folded: Boolean(player.folded), all_in: Boolean(player.all_in), in_hand: Boolean(player.in_hand), })) .sort((a, b) => a.seat - b.seat); } function buildHandReplay(game, players, hand, startStacks) { const handPlayers = players.map((player) => ({ ...player, stack: numberOr(startStacks[player.player_id], player.stack), street_bet: 0, total_bet: 0, folded: false, all_in: false, in_hand: numberOr(startStacks[player.player_id], player.stack) > 0, hole_cards: holeCardsFor(hand, player.player_id), })); const playerState = new Map(handPlayers.map((player) => [player.player_id, player])); const frames = []; const actions = Array.isArray(hand.actions) ? hand.actions : []; const blinds = hand.blinds || {}; frames.push(snapshotFrame({ key: `h${hand.hand_number}:start`, type: "start", hand, players: handPlayers, board: [], pot: 0, activePlayerId: null, caption: `Hand ${hand.hand_number} starts. Blinds ${blinds.small_blind || game.small_blind}/${blinds.big_blind || game.big_blind}.`, event: `Hand ${hand.hand_number} started`, })); let currentStreet = "preflop"; let boardCount = 0; actions.forEach((action, index) => { const street = String(action.street || currentStreet); if (street !== currentStreet) { currentStreet = street; boardCount = STREET_BOARD_COUNTS[street] || boardCount; frames.push(snapshotFrame({ key: `h${hand.hand_number}:${street}:deal`, type: "street", hand, players: handPlayers, board: (hand.board || []).slice(0, boardCount), pot: totalPot(handPlayers), activePlayerId: null, caption: `${streetLabel(street)} dealt.`, event: `${streetLabel(street)} board`, })); resetStreetBets(handPlayers); } const player = playerState.get(String(action.player_id)); if (player) { const amount = numberOr(action.amount, 0); player.stack = numberOr(action.stack, player.stack - amount); player.street_bet = numberOr(action.street_bet, player.street_bet + amount); player.total_bet += amount; if (action.action === "fold") player.folded = true; if (player.stack <= 0 && !player.folded) player.all_in = true; } frames.push(snapshotFrame({ key: `h${hand.hand_number}:a${index}`, type: "action", hand, players: handPlayers, board: (hand.board || []).slice(0, STREET_BOARD_COUNTS[street] || boardCount), pot: totalPot(handPlayers), activePlayerId: String(action.player_id), caption: formatAction(action, player), event: formatEvent(action, player), action, })); }); const finalBoard = hand.board || []; if (finalBoard.length && boardCount < finalBoard.length) { frames.push(snapshotFrame({ key: `h${hand.hand_number}:board-final`, type: "street", hand, players: handPlayers, board: finalBoard, pot: totalPot(handPlayers), activePlayerId: null, caption: "Final board is visible.", event: "Final board", })); } const showdown = hand.showdown_hands || {}; if (Object.keys(showdown).length > 0) { for (const [playerId, cards] of Object.entries(showdown)) { const player = playerState.get(playerId); if (player) player.hole_cards = Array.isArray(cards) ? cards : []; } frames.push(snapshotFrame({ key: `h${hand.hand_number}:showdown`, type: "showdown", hand, players: handPlayers, board: finalBoard, pot: totalPot(handPlayers), activePlayerId: null, caption: "Showdown.", event: "Showdown", })); } const awards = Array.isArray(hand.awards) ? hand.awards : []; for (const award of awards) { const amount = numberOr(award.amount, 0); const winners = Array.isArray(award.winners) ? award.winners : []; const share = winners.length ? Math.floor(amount / winners.length) : 0; let remainder = winners.length ? amount % winners.length : 0; winners.forEach((winnerId) => { const winner = playerState.get(String(winnerId)); if (!winner) return; winner.stack += share + (remainder > 0 ? 1 : 0); remainder -= 1; }); frames.push(snapshotFrame({ key: `h${hand.hand_number}:award:${frames.length}`, type: "award", hand, players: handPlayers, board: finalBoard, pot: 0, activePlayerId: winners[0] ? String(winners[0]) : null, caption: formatAward(award), event: formatAward(award), })); } const endingStacks = Object.fromEntries(handPlayers.map((player) => [player.player_id, player.stack])); if (frames.length === 1) { frames.push(snapshotFrame({ key: `h${hand.hand_number}:empty`, type: "empty", hand, players: handPlayers, board: finalBoard, pot: 0, activePlayerId: null, caption: "No actions recorded for this hand.", event: "No actions", })); } return { hand_number: hand.hand_number, hand, frames, endingStacks }; } function buildCurrentTableFrame(game, players) { return { key: "current-table", type: "current", hand_number: game.hand_number || 0, button_seat: game.button_seat, players: players.map((player) => ({ ...player, street_bet: numberOr(player.street_bet, 0), total_bet: numberOr(player.total_bet, 0), hole_cards: [], })), board: [], pot: players.reduce((sum, player) => sum + numberOr(player.total_bet, 0), 0), activePlayerId: null, caption: "Current table snapshot. Run hands or load history to replay actions.", event: "Current table snapshot", action: null, }; } function snapshotFrame({ key, type, hand, players, board, pot, activePlayerId, caption, event, action }) { return { key, type, hand_number: hand.hand_number, button_seat: hand.button_seat, players: players.map((player) => ({ ...player, hole_cards: [...(player.hole_cards || [])] })), board: [...(board || [])], pot, activePlayerId, caption, event, action: action ? { ...action } : null, }; } function holeCardsFor(hand, playerId) { const explicit = hand.hole_cards || hand.private_hands || {}; const showdown = hand.showdown_hands || {}; const cards = explicit[playerId] || showdown[playerId] || []; return Array.isArray(cards) ? cards : []; } function totalPot(players) { return players.reduce((sum, player) => sum + numberOr(player.total_bet, 0), 0); } function resetStreetBets(players) { players.forEach((player) => { player.street_bet = 0; }); } function syncInputsFromGame(game) { if (game.game_id) els.gameIdInput.value = game.game_id; } function populateHandSelect() { els.handSelect.innerHTML = ""; const hands = state.replay ? state.replay.hands : []; if (!hands.length) { const option = document.createElement("option"); option.value = "0"; option.textContent = "No hands"; els.handSelect.append(option); return; } hands.forEach((hand, index) => { const option = document.createElement("option"); option.value = String(index); option.textContent = `Hand ${hand.hand_number}`; els.handSelect.append(option); }); } function render() { if (!state.replay) { renderEmpty(); return; } const hand = currentHand(); const frame = currentFrame(); els.handSelect.value = state.replay.hands.length ? String(state.handIndex) : "0"; renderHeader(); renderStats(); renderPlayers(frame); renderSeats(frame); renderBoard(frame); renderEvents(hand, frame); renderTransport(hand, frame); } function renderEmpty() { els.gameStatus.textContent = "No game"; els.handCounter.textContent = "Hand 0"; els.tableStats.innerHTML = ""; els.playerList.innerHTML = ""; els.seatLayer.innerHTML = ""; els.boardCards.innerHTML = ""; els.potDisplay.textContent = "Pot 0"; els.frameCaption.textContent = "Load a game snapshot"; els.frameCounter.textContent = "0 / 0"; els.eventLog.innerHTML = ""; } function renderHeader() { const game = state.replay.game; els.gameStatus.textContent = `${game.game_id || "game"} · ${game.status || "unknown"}`; els.gameStatus.className = `pill ${game.status === "complete" ? "ok" : ""}`; els.handCounter.textContent = `Hand ${game.hand_number || 0}`; } function renderStats() { const game = state.replay.game; const hands = state.replay.hands.length; const current = currentHand(); const stats = [ ["Players", state.replay.players.length], ["Hands", hands], ["Blinds", `${game.small_blind || 0}/${game.big_blind || 0}`], ["Button", current && current.hand ? `Seat ${current.hand.button_seat}` : game.button_seat == null ? "-" : `Seat ${game.button_seat}`], ]; els.tableStats.innerHTML = stats .map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`) .join(""); } function renderPlayers(frame) { const players = frame ? frame.players : state.replay.players; els.playerList.innerHTML = players .map((player) => `
${player.seat} ${escapeHtml(player.name)} ${formatChips(player.stack)}
`) .join(""); } function renderSeats(frame) { if (!frame) { els.seatLayer.innerHTML = ""; return; } const count = Math.max(2, frame.players.length); els.seatLayer.innerHTML = frame.players .map((player, index) => { const pos = seatPosition(index, count); const classes = ["seat"]; if (frame.activePlayerId === player.player_id) classes.push("active"); if (player.folded) classes.push("folded"); const tags = []; if (player.seat === frame.button_seat) tags.push("BTN"); if (player.folded) tags.push("FOLD"); if (player.all_in) tags.push("ALL IN"); return `
${escapeHtml(player.name)} ${escapeHtml(tags[0] || `S${player.seat}`)}
stk ${formatChips(player.stack)} bet ${formatChips(player.street_bet)} tot ${formatChips(player.total_bet)} ${player.in_hand ? "in" : "out"}
${renderHoleCards(player)}
`; }) .join(""); } function seatPosition(index, count) { const angle = -90 + (360 / count) * index; const rad = (angle * Math.PI) / 180; return { x: 50 + 42 * Math.cos(rad), y: 50 + 39 * Math.sin(rad), }; } function renderBoard(frame) { if (!frame) return; els.potDisplay.textContent = `Pot ${formatChips(frame.pot)}`; els.boardCards.innerHTML = renderCards(frame.board, 5); els.frameCaption.textContent = frame.caption || ""; } function renderTransport(hand, frame) { const frames = hand ? hand.frames : []; els.frameCounter.textContent = frames.length ? `${state.frameIndex + 1} / ${frames.length}` : "0 / 0"; els.prevButton.disabled = state.frameIndex <= 0; els.resetButton.disabled = state.frameIndex <= 0; els.nextButton.disabled = !frames.length || state.frameIndex >= frames.length - 1; els.playButton.disabled = frames.length <= 1; els.playButton.textContent = state.playing ? "Pause" : "Play"; if (!frame) els.frameCaption.textContent = "No hand selected"; } function renderEvents(hand, frame) { const frames = hand ? hand.frames : []; els.eventLog.innerHTML = frames .map((item, index) => `
  • ${escapeHtml(item.event || item.caption || `Frame ${index + 1}`)}
  • `) .join(""); } function setFrame(index) { const hand = currentHand(); if (!hand) return; state.frameIndex = clamp(index, 0, hand.frames.length - 1); render(); } function togglePlayback() { if (state.playing) { stopPlayback(); render(); } else { state.playing = true; render(); scheduleNextFrame(); } } function stopPlayback() { state.playing = false; if (state.timer) { clearTimeout(state.timer); state.timer = null; } } function scheduleNextFrame() { if (!state.playing) return; const hand = currentHand(); if (!hand || state.frameIndex >= hand.frames.length - 1) { stopPlayback(); render(); return; } const pace = Number(els.speedInput.value || 1); const delay = Math.round(1100 / pace); state.timer = setTimeout(() => { state.frameIndex += 1; render(); scheduleNextFrame(); }, delay); } function currentHand() { if (!state.replay || !state.replay.hands.length) return null; return state.replay.hands[clamp(state.handIndex, 0, state.replay.hands.length - 1)]; } function currentFrame() { const hand = currentHand(); if (!hand || !hand.frames.length) return state.replay ? state.replay.currentFrame : null; return hand.frames[clamp(state.frameIndex, 0, hand.frames.length - 1)]; } function isAtReplayTail() { const hand = currentHand(); if (!state.replay || !hand) return true; return state.handIndex === state.replay.hands.length - 1 && state.frameIndex >= hand.frames.length - 1; } function renderHoleCards(player) { if (player.hole_cards && player.hole_cards.length) return renderCards(player.hole_cards, 2); if (player.in_hand && !player.folded) return `${renderCardBack()}${renderCardBack()}`; return ""; } function renderCards(cards, padTo = 0) { const rendered = (cards || []).map(renderCard).join(""); const missing = Math.max(0, padTo - (cards || []).length); return rendered + Array.from({ length: missing }, () => ``).join(""); } function renderCardBack() { return ``; } function renderCard(label) { const value = String(label || ""); const rank = value.slice(0, 1).toUpperCase(); const suit = value.slice(1, 2).toLowerCase(); const red = suit === "h" || suit === "d"; return `${escapeHtml(rank + (SUITS[suit] || suit))}`; } function formatAction(action, player) { const name = player ? player.name : action.player_id; const verb = String(action.action || "").replace("_", " "); const amount = numberOr(action.amount, 0); if (amount > 0) return `${name} ${verb} ${formatChips(amount)}.`; return `${name} ${verb}.`; } function formatEvent(action, player) { const street = streetLabel(action.street || "preflop"); return `${street}: ${formatAction(action, player)}`; } function formatAward(award) { const winners = (award.winners || []).join(", ") || "unknown"; const handValue = award.hand_value && award.hand_value.name ? ` with ${award.hand_value.name}` : ""; return `${winners} win ${formatChips(award.amount)}${handValue}.`; } function streetLabel(street) { const index = STREET_ORDER.indexOf(street); if (index < 0) return String(street || "").toUpperCase(); return STREET_ORDER[index].toUpperCase(); } function numberOr(value, fallback) { const num = Number(value); return Number.isFinite(num) ? num : fallback; } function formatChips(value) { return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(numberOr(value, 0)); } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function setStatus(kind, text) { els.connectionStatus.className = `pill ${kind === "neutral" ? "neutral" : kind}`; els.connectionStatus.textContent = text; } function defaultGameSpec() { return { game_id: "demo", seed: 42, starting_stack: 1000, small_blind: 5, big_blind: 10, players: [ { id: "agent_1", name: "Agent 1", type: "calling" }, { id: "agent_2", name: "Agent 2", type: "random" }, { id: "agent_3", name: "Agent 3", type: "calling" }, ], }; } init();