feat: add replay server and web client

This commit is contained in:
qianrui.mmmy
2026-05-13 16:48:00 +08:00
parent 09c42e9fa3
commit 3c027eae0b
8 changed files with 1967 additions and 0 deletions
+832
View File
@@ -0,0 +1,832 @@
// =============================================================================
// Texas Hold X Replay — viewer logic
// -----------------------------------------------------------------------------
// Architecture overview:
// * `state` — single mutable runtime store; never read directly by render
// helpers, instead passed via the active frame snapshot.
// * `el` — cached DOM references (set once on load, not per render).
// * Frames — pre-computed, immutable per-hand snapshots produced by
// buildFrames(). Each frame carries the full visual state
// (board cards, pot, players, active speaker, action kind),
// making renderFrame() a pure function of state.frameIndex.
// * Avatars — pixel-art sprites styled in CSS via 8x8 box-shadow grids.
// Per-player palette is derived deterministically from
// player_id so the same player always looks identical.
// * Animation — seat sprites get a transient `act-${kind}` class that maps
// to an avatar @keyframes animation in styles.css.
// =============================================================================
const state = {
game: null,
hands: [],
currentHandIndex: 0,
frames: [],
frameIndex: 0,
playing: false,
timer: null,
pollTimer: null,
source: "",
};
const el = {
subtitle: document.querySelector("#subtitle"),
sourceBadge: document.querySelector("#sourceBadge"),
pollBadge: document.querySelector("#pollBadge"),
serverUrl: document.querySelector("#serverUrl"),
gameId: document.querySelector("#gameId"),
fetchBtn: document.querySelector("#fetchBtn"),
fileInput: document.querySelector("#fileInput"),
autoPoll: document.querySelector("#autoPoll"),
pollSeconds: document.querySelector("#pollSeconds"),
handSelect: document.querySelector("#handSelect"),
pace: document.querySelector("#pace"),
prevBtn: document.querySelector("#prevBtn"),
playBtn: document.querySelector("#playBtn"),
nextBtn: document.querySelector("#nextBtn"),
resetBtn: document.querySelector("#resetBtn"),
progressBar: document.querySelector("#progressBar"),
gameStatus: document.querySelector("#gameStatus"),
playerCount: document.querySelector("#playerCount"),
handCount: document.querySelector("#handCount"),
blindLevel: document.querySelector("#blindLevel"),
handBadge: document.querySelector("#handBadge"),
streetLabel: document.querySelector("#streetLabel"),
potLabel: document.querySelector("#potLabel"),
boardCards: document.querySelector("#boardCards"),
tableMessage: document.querySelector("#tableMessage"),
seatLayer: document.querySelector("#seatLayer"),
eventLog: document.querySelector("#eventLog"),
};
// ---------------------------------------------------------------------------
// Static label tables. Kept module-scope so render functions are fully pure.
// ---------------------------------------------------------------------------
const STREET_LABELS = {
setup: "准备",
preflop: "翻牌前",
flop: "翻牌",
turn: "转牌",
river: "河牌",
showdown: "摊牌",
awards: "结算",
};
const ACTION_LABELS = {
small_blind: "小盲",
big_blind: "大盲",
fold: "弃牌",
check: "过牌",
call: "跟注",
bet: "下注",
raise: "加注",
all_in: "全下",
award: "赢得",
showdown: "亮牌",
deal: "发牌",
};
// Bubble color category — bucket many actions into a few visual classes.
// (The .speech.kind-* CSS classes paint distinct backgrounds for fold/
// call/check/bet/raise/all_in/award. Blinds are rendered as bet-style.)
// ---------------------------------------------------------------------------
// Pixel avatar palette — picked from a small 8-bit-friendly set so every
// player is visually distinct. Derivation is deterministic via FNV-1a over
// player_id, so a given player always renders with the same colors across
// frames and hands.
// ---------------------------------------------------------------------------
const AVATAR_PALETTE = {
skin: ["#f7c98a", "#e2a96b", "#c98c5a", "#8b5a3c", "#f2d4ad"],
hair: ["#2a1e16", "#5b3a23", "#a85d2a", "#d8a13a", "#7a3b8e", "#3a4d8a", "#b9362f"],
shirt: ["#c44c4c", "#3a7fbf", "#3a9b62", "#b87a1f", "#7a3b8e", "#2a3a5a", "#d8a13a"],
};
/**
* Hash a string into a non-negative 32-bit integer using FNV-1a.
* Used to deterministically pick avatar palette entries from player_id.
*/
function fnv1a(value) {
let hash = 0x811c9dc5;
const text = String(value);
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
/**
* Pick a deterministic avatar palette for the given player_id.
* Returns an object with { skin, hair, shirt } CSS colors.
*/
function avatarPalette(playerId) {
const hash = fnv1a(playerId);
const pickFrom = (list, salt) => list[(hash >>> salt) % list.length];
return {
skin: pickFrom(AVATAR_PALETTE.skin, 0),
hair: pickFrom(AVATAR_PALETTE.hair, 5),
shirt: pickFrom(AVATAR_PALETTE.shirt, 11),
};
}
// 8x8 sprite map for the player portrait.
// . = transparent / dark frame backdrop
// H = hair S = skin E = eye M = mouth (red) T = shirt body
// Two side hair pixels on rows 2-3 give a cohesive helmet shape; row 5 mouth
// adds personality. Row 7 is full shirt body so the avatar reads as a bust.
const AVATAR_SPRITE = [
".HHHHHH.",
"HHHHHHHH",
"HSSSSSSH",
".SESSESS",
".SSSSSS.",
".SSMMSS.",
".TTTTTT.",
"TTTTTTTT",
];
/**
* Build an inline SVG string for a pixel-art avatar using the player's
* deterministic palette. Each filled cell becomes a 1x1 <rect> in an 8x8
* viewBox; SVG `shape-rendering="crispEdges"` keeps the squares sharp.
*/
function avatarSvg(playerId) {
const palette = avatarPalette(playerId);
const colors = {
H: palette.hair,
S: palette.skin,
E: "#1c0e08",
M: "#b9362f",
T: palette.shirt,
};
let rects = "";
for (let y = 0; y < AVATAR_SPRITE.length; y += 1) {
const row = AVATAR_SPRITE[y];
for (let x = 0; x < row.length; x += 1) {
const ch = row[x];
const fill = colors[ch];
if (!fill) continue;
rects += `<rect x="${x}" y="${y}" width="1" height="1" fill="${fill}"/>`;
}
}
return `<svg viewBox="0 0 8 8" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">${rects}</svg>`;
}
// ---------------------------------------------------------------------------
// Card rendering helpers.
// ---------------------------------------------------------------------------
function cardParts(raw) {
if (!raw || raw === "back") return { rank: "", suit: "", red: false, back: true };
const suitCode = raw.slice(-1).toLowerCase();
const rank = raw.slice(0, -1).toUpperCase();
const suits = { h: "♥", d: "♦", c: "♣", s: "♠" };
return {
rank,
suit: suits[suitCode] || suitCode,
red: suitCode === "h" || suitCode === "d",
back: false,
};
}
function renderCard(raw) {
const parts = cardParts(raw);
const card = document.createElement("div");
card.className = `card${parts.red ? " red" : ""}${parts.back ? " back" : ""}`;
if (!parts.back) {
card.innerHTML = `<span class="rank">${parts.rank}</span><span class="suit">${parts.suit}</span><span class="rank bottom">${parts.rank}</span>`;
}
return card;
}
function money(value) {
return Number(value || 0).toLocaleString("en-US");
}
// ---------------------------------------------------------------------------
// Game normalization: hydrate the raw JSON into a uniform shape with
// player list and per-hand starting stacks.
// ---------------------------------------------------------------------------
function uniquePlayers(game) {
const byId = new Map();
for (const player of game.players || []) {
byId.set(player.player_id, {
player_id: player.player_id,
name: player.name || player.player_id,
seat: Number(player.seat || 0),
stack: Number(player.stack || 0),
});
}
for (const hand of game.hands || []) {
for (const action of hand.actions || []) {
if (!byId.has(action.player_id)) {
byId.set(action.player_id, {
player_id: action.player_id,
name: action.player_id,
seat: byId.size,
stack: Number(game.starting_stack || 0),
});
}
}
}
return Array.from(byId.values()).sort((a, b) => a.seat - b.seat);
}
function inferHandStarts(game) {
const players = uniquePlayers(game);
const stacks = new Map(players.map((player) => [player.player_id, Number(game.starting_stack || player.stack || 0)]));
const starts = new Map();
for (const hand of game.hands || []) {
starts.set(hand.hand_number, new Map(stacks));
for (const action of hand.actions || []) {
if (stacks.has(action.player_id)) stacks.set(action.player_id, Number(action.stack || 0));
}
for (const award of hand.awards || []) {
const winners = award.winners || [];
const share = winners.length ? Math.floor(Number(award.amount || 0) / winners.length) : 0;
for (const winner of winners) stacks.set(winner, Number(stacks.get(winner) || 0) + share);
}
}
return starts;
}
function normalizeGame(raw) {
const game = raw.game && raw.game.hands ? raw.game : raw;
if (!game || !Array.isArray(game.hands)) throw new Error("JSON 中未找到 hands 数组");
const players = uniquePlayers(game);
const starts = inferHandStarts(game);
const hands = game.hands.map((hand) => ({ ...hand, startingStacks: starts.get(hand.hand_number) || new Map() }));
return { ...game, players, hands };
}
function clonePlayersForHand(game, hand) {
return game.players.map((player) => ({
...player,
stack: Number(hand.startingStacks.get(player.player_id) ?? game.starting_stack ?? player.stack ?? 0),
folded: false,
all_in: false,
in_hand: Number(hand.startingStacks.get(player.player_id) ?? game.starting_stack ?? player.stack ?? 0) > 0,
street_bet: 0,
total_bet: 0,
hole_cards: [],
}));
}
// ---------------------------------------------------------------------------
// Frame builder — converts a hand's chronological action list into a list of
// fully-resolved frame snapshots. Each frame is independent and renderable.
// ---------------------------------------------------------------------------
function buildFrames(game, hand) {
const players = clonePlayersForHand(game, hand);
const byId = new Map(players.map((player) => [player.player_id, player]));
const frames = [];
const pot = { value: 0 };
let street = "preflop";
let boardCount = 0;
// `actionKind` is what drives the seat sprite animation classes. It maps
// the raw action verb (fold/call/raise/...) onto a CSS animation token.
const snapshot = (type, message, activePlayerId = null, key = `${type}:${frames.length}`, extras = {}) => ({
key: `hand:${hand.hand_number}:${key}`,
type,
message,
activePlayerId,
street,
pot: pot.value,
board: (hand.board || []).slice(0, boardCount),
players: players.map((player) => ({ ...player, hole_cards: [...player.hole_cards] })),
...extras,
});
frames.push(snapshot("setup", `${hand.hand_number} 手牌开始`, null, "setup"));
const revealStreet = (nextStreet) => {
const counts = { flop: 3, turn: 4, river: 5 };
const nextCount = counts[nextStreet] || boardCount;
for (const player of players) player.street_bet = 0;
if (nextCount > boardCount) {
street = nextStreet;
boardCount = Math.min(nextCount, (hand.board || []).length);
frames.push(snapshot("deal", `${STREET_LABELS[nextStreet]}发出`, null, `deal:${nextStreet}:${boardCount}`));
} else {
street = nextStreet;
}
};
for (const [actionIndex, action] of (hand.actions || []).entries()) {
if (action.street !== street && action.street !== "preflop") revealStreet(action.street);
const player = byId.get(action.player_id);
if (!player) continue;
const previousTotal = Number(player.total_bet || 0);
player.street_bet = Number(action.street_bet || 0);
player.stack = Number(action.stack || 0);
if (action.action === "fold") player.folded = true;
if (action.action === "all_in" || player.stack === 0) player.all_in = true;
player.total_bet = previousTotal + Math.max(0, Number(action.amount || 0));
pot.value += Math.max(0, Number(action.amount || 0));
frames.push(snapshot(
"action",
actionText(action),
action.player_id,
actionKey(action, actionIndex),
{ actionKind: action.action, bubbleText: bubbleTextFor(action) },
));
}
if ((hand.board || []).length > boardCount) {
for (const nextStreet of ["flop", "turn", "river"]) {
if (({ flop: 3, turn: 4, river: 5 }[nextStreet] || 0) > boardCount) revealStreet(nextStreet);
}
}
const shown = hand.showdown_hands || {};
for (const [playerId, cards] of Object.entries(shown)) {
const player = byId.get(playerId);
if (player) player.hole_cards = cards;
}
if (Object.keys(shown).length) {
street = "showdown";
frames.push(snapshot("showdown", "摊牌亮牌", null, `showdown:${Object.keys(shown).sort().join(",")}`));
}
street = "awards";
for (const [awardIndex, award] of (hand.awards || []).entries()) {
const winners = award.winners || [];
const share = winners.length ? Math.floor(Number(award.amount || 0) / winners.length) : 0;
const remainder = winners.length ? Number(award.amount || 0) % winners.length : 0;
winners.forEach((winnerId, index) => {
const player = byId.get(winnerId);
if (player) player.stack += share + (index < remainder ? 1 : 0);
});
pot.value = Math.max(0, pot.value - Number(award.amount || 0));
frames.push(snapshot(
"award",
awardText(award),
winners[0] || null,
awardKey(award, awardIndex),
{ actionKind: "award", bubbleText: `赢得 ${money(award.amount)}` },
));
}
if (frames.length === 1) frames.push(snapshot("empty", "这手牌没有可回放动作", null, "empty"));
return frames;
}
function actionKey(action, index) {
return [
"action", index, action.street, action.player_id, action.action,
Number(action.amount || 0), Number(action.street_bet || 0), Number(action.stack || 0),
].join(":");
}
function awardKey(award, index) {
return [
"award", index, Number(award.amount || 0),
(award.winners || []).join(","),
award.hand_value?.name || "",
].join(":");
}
function actionText(action) {
const label = ACTION_LABELS[action.action] || action.action;
if (["check", "fold"].includes(action.action)) return `${action.player_id} ${label}`;
return `${action.player_id} ${label} ${money(action.amount)}`;
}
/**
* Build a SHORT speech-bubble label (player_id is implicit since the bubble
* already points at the seat). Keeps text readable inside narrow bubbles.
*/
function bubbleTextFor(action) {
const label = ACTION_LABELS[action.action] || action.action;
if (["check", "fold"].includes(action.action)) return label;
return `${label} ${money(action.amount)}`;
}
function awardText(award) {
const winners = (award.winners || []).join(", ");
const handName = award.hand_value?.name ? ` · ${award.hand_value.name}` : "";
return `${winners} 赢得 ${money(award.amount)}${handName}`;
}
// ---------------------------------------------------------------------------
// Seat layout — six radial presets, with mobile fallback.
// Returned (x, y) values are percentages relative to the poker-table box
// (NOT the felt-shell). The poker-table reserves vertical padding so the
// top/bottom seats and their bubbles never overlap the table headers.
// ---------------------------------------------------------------------------
function seatPosition(index, count) {
const mobile = window.matchMedia("(max-width: 760px)").matches;
const presets = mobile ? mobileSeatPreset(count) : desktopSeatPreset(count);
if (presets[index]) return presets[index];
const radiusX = mobile ? 36 : 39;
const radiusY = mobile ? 41 : 39;
const start = -90;
const angle = (start + index * (360 / Math.max(count, 1))) * Math.PI / 180;
return {
x: 50 + Math.cos(angle) * radiusX,
y: 50 + Math.sin(angle) * radiusY,
};
}
// Coordinates expressed in percent of the poker-table box (which already
// includes vertical padding around the felt). `y < ~25` lands above the
// felt (top rail), `y > ~75` lands below the felt (bottom rail).
function desktopSeatPreset(count) {
const presets = {
2: [{ x: 50, y: 18 }, { x: 50, y: 82 }],
3: [{ x: 50, y: 18 }, { x: 80, y: 72 }, { x: 20, y: 72 }],
4: [{ x: 50, y: 18 }, { x: 84, y: 60 }, { x: 50, y: 84 }, { x: 16, y: 60 }],
5: [{ x: 50, y: 18 }, { x: 84, y: 44 }, { x: 72, y: 84 }, { x: 28, y: 84 }, { x: 16, y: 44 }],
6: [{ x: 50, y: 18 }, { x: 82, y: 33 }, { x: 82, y: 70 }, { x: 50, y: 86 }, { x: 18, y: 70 }, { x: 18, y: 33 }],
};
return presets[count] || [];
}
function mobileSeatPreset(count) {
const presets = {
2: [{ x: 50, y: 16 }, { x: 50, y: 84 }],
3: [{ x: 50, y: 15 }, { x: 80, y: 78 }, { x: 20, y: 78 }],
4: [{ x: 50, y: 14 }, { x: 83, y: 60 }, { x: 50, y: 86 }, { x: 17, y: 60 }],
};
return presets[count] || [];
}
/**
* Map a seat coordinate to a `seat-top|bottom|left|right|mid` zone class.
* The CSS uses this to flip the speech bubble below the seat for top-rail
* seats so it never extends outside the visible table area.
*/
function seatZone(pos) {
if (pos.y < 32) return "seat-top";
if (pos.y > 70) return "seat-bottom";
if (pos.x < 34) return "seat-left";
if (pos.x > 66) return "seat-right";
return "seat-mid";
}
// ---------------------------------------------------------------------------
// DOM rendering: assemble a single seat element from a frame's player snapshot.
// Extracted into its own function so renderFrame stays declarative.
// ---------------------------------------------------------------------------
function renderSeat(player, position, frame, hand) {
const zone = seatZone(position);
const isActive = player.player_id === frame.activePlayerId;
const isDealer = player.seat === hand.button_seat;
const isCurrentActor = isActive && (frame.type === "action" || frame.type === "award");
const seat = document.createElement("div");
seat.className = [
"seat",
zone,
isActive ? "active" : "",
player.folded ? "folded" : "",
isDealer ? "dealer-seat" : "",
// The transient act-* class is what triggers the avatar's reaction
// animation (shake/nod/fold/cheer). Only apply on the actor's frame.
isCurrentActor && frame.actionKind ? `act-${frame.actionKind}` : "",
].filter(Boolean).join(" ");
seat.style.setProperty("--x", `${position.x}%`);
seat.style.setProperty("--y", `${position.y}%`);
// Speech bubble — shown on the active player's action/award frames using
// a SHORT label (e.g. "加注 50") so it fits the small bubble width.
const bubbleHtml = (isCurrentActor && frame.bubbleText)
? `<div class="speech kind-${frame.actionKind || "info"}">${escapeHtml(frame.bubbleText)}</div>`
: "";
// Avatar — inline SVG pixel-art sprite, deterministic per player_id so
// the same player keeps a stable look across hands.
const avatarMarkup = `<div class="avatar" aria-hidden="true">${avatarSvg(player.player_id)}</div>`;
seat.innerHTML = `
${bubbleHtml}
<div class="player-box">
<div class="player-head">
${avatarMarkup}
<span class="player-name">${escapeHtml(player.name || player.player_id)}</span>
<span class="dealer">D</span>
</div>
<div class="player-meta">
<span class="stack">Stack ${money(player.stack)}</span>
<span class="bet">${player.folded ? "Folded" : player.all_in ? "All In" : player.street_bet ? `Bet ${money(player.street_bet)}` : ""}</span>
</div>
</div>
`;
// Hole cards live inside the player-box so they share its layout flow.
const hole = document.createElement("div");
hole.className = "card-row hole-cards";
for (const card of knownCardsForPlayer(player, hand, frame)) hole.appendChild(renderCard(card));
seat.querySelector(".player-box").appendChild(hole);
return seat;
}
function renderFrame() {
const hand = state.hands[state.currentHandIndex];
const frame = state.frames[state.frameIndex];
const game = state.game;
const hasData = Boolean(game && hand && frame);
el.handBadge.textContent = hasData ? `Hand ${hand.hand_number}` : "Hand -";
el.streetLabel.textContent = hasData ? (STREET_LABELS[frame.street] || frame.street) : "未加载";
el.potLabel.textContent = hasData ? `Pot ${money(frame.pot)}` : "Pot 0";
el.tableMessage.textContent = hasData ? frame.message : "上传 JSON 或从游戏服务获取快照";
el.tableMessage.style.display = hasData ? "" : "block";
el.progressBar.style.width = hasData ? `${((state.frameIndex + 1) / state.frames.length) * 100}%` : "0";
el.boardCards.replaceChildren();
for (const card of hasData ? frame.board : []) el.boardCards.appendChild(renderCard(card));
el.seatLayer.replaceChildren();
if (hasData) {
frame.players.forEach((player, index) => {
const position = seatPosition(index, frame.players.length);
el.seatLayer.appendChild(renderSeat(player, position, frame, hand));
});
}
renderLog();
syncControls();
}
function knownCardsForPlayer(player, hand, frame) {
const futureHoleCards = hand.hole_cards?.[player.player_id] || hand.private_hands?.[player.player_id];
const canRevealShowdown = ["showdown", "awards"].includes(frame.street) || ["showdown", "award"].includes(frame.type);
const showdownCards = canRevealShowdown ? hand.showdown_hands?.[player.player_id] : null;
const shown = futureHoleCards || showdownCards || player.hole_cards;
if (shown && shown.length) return shown;
if (!player.in_hand && !player.folded) return [];
return ["back", "back"];
}
// ---------------------------------------------------------------------------
// Event log — one line per visible event, with auto-scroll keeping the
// current item in view. Items past the current frame index are dimmed via
// the `.past` class.
// ---------------------------------------------------------------------------
function renderLog() {
const hand = state.hands[state.currentHandIndex];
el.eventLog.replaceChildren();
if (!hand) return;
const events = [
{ text: `${hand.hand_number} 手牌开始`, kind: "setup" },
...(hand.actions || []).map((action) => ({ text: actionText(action), kind: "action" })),
...(hand.awards || []).map((award) => ({ text: awardText(award), kind: "award" })),
];
let currentLi = null;
events.forEach((event, index) => {
const li = document.createElement("li");
li.textContent = `${index + 1}. ${event.text}`;
if (index < state.frameIndex) li.classList.add("past");
if (index === state.frameIndex) {
li.classList.add("current");
currentLi = li;
}
el.eventLog.appendChild(li);
});
// Keep the focused event visible without yanking the page when an event
// is already in view.
if (currentLi) {
currentLi.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
function syncControls() {
const loaded = Boolean(state.game);
const hasPreviousFrame = state.frameIndex > 0 || state.currentHandIndex > 0;
const hasNextFrame = state.frameIndex < state.frames.length - 1 || state.currentHandIndex < state.hands.length - 1;
el.playBtn.disabled = !loaded;
el.prevBtn.disabled = !loaded || !hasPreviousFrame;
el.nextBtn.disabled = !loaded || !hasNextFrame;
el.resetBtn.disabled = !loaded;
el.playBtn.textContent = state.playing ? "Ⅱ" : "▶";
}
// ---------------------------------------------------------------------------
// Game lifecycle: load / merge / select hand / step / play.
// ---------------------------------------------------------------------------
function loadGame(raw, source, options = {}) {
const nextGame = normalizeGame(raw);
const wasPlaying = state.playing;
const mergeResult = options.allowMerge !== false ? mergeGame(nextGame) : { merged: false, advanced: false };
if (!mergeResult.merged) {
pause();
state.game = nextGame;
state.hands = state.game.hands;
state.currentHandIndex = Math.max(0, state.hands.length - 1);
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
state.frameIndex = 0;
}
state.source = source;
el.sourceBadge.textContent = source;
el.subtitle.textContent = `${state.game.game_id || "未命名游戏"} · ${state.hands.length} hands`;
renderSummary();
populateHands();
el.handSelect.value = String(state.currentHandIndex);
renderFrame();
if (mergeResult.merged && (wasPlaying || mergeResult.advanced)) play();
}
function mergeGame(nextGame) {
if (!state.game || state.game.game_id !== nextGame.game_id) return { merged: false, advanced: false };
const currentHand = state.hands[state.currentHandIndex];
const nextHandIndex = nextGame.hands.findIndex((hand) => hand.hand_number === currentHand?.hand_number);
if (nextHandIndex < 0) return { merged: false, advanced: false };
const oldFrame = state.frames[state.frameIndex];
const nextFrames = buildFrames(nextGame, nextGame.hands[nextHandIndex]);
const oldKeyIndex = oldFrame ? nextFrames.findIndex((frame) => frame.key === oldFrame.key) : -1;
const atKnownFrame = oldKeyIndex >= 0;
const wasAtTail = state.frameIndex >= state.frames.length - 1;
const hadNewFramesOnCurrentHand = nextFrames.length > state.frames.length;
const currentWasLatestHand = state.currentHandIndex >= state.hands.length - 1;
const hasNewLaterHandFromCurrent = currentWasLatestHand && nextGame.hands.length > state.hands.length;
let advanced = false;
state.game = nextGame;
state.hands = nextGame.hands;
state.currentHandIndex = nextHandIndex;
state.frames = nextFrames;
if (atKnownFrame) {
advanced = shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasNewLaterHandFromCurrent);
state.frameIndex = advanced
? Math.min(oldKeyIndex + 1, nextFrames.length - 1)
: oldKeyIndex;
} else {
state.frameIndex = Math.min(state.frameIndex, nextFrames.length - 1);
}
if (state.frameIndex >= state.frames.length - 1 && hasNewLaterHandFromCurrent) {
state.currentHandIndex += 1;
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
state.frameIndex = 0;
advanced = true;
}
return { merged: true, advanced };
}
function shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasLaterHands) {
return wasAtTail && (hadNewFramesOnCurrentHand || hasLaterHands);
}
function renderSummary() {
const game = state.game;
const hand = state.hands[state.currentHandIndex];
const blinds = hand?.blinds || game;
el.gameStatus.textContent = game?.status || "-";
el.playerCount.textContent = game?.players?.length ?? "-";
el.handCount.textContent = game?.hands?.length ?? "-";
el.blindLevel.textContent = game ? `${money(blinds.small_blind)} / ${money(blinds.big_blind)}` : "-";
}
function populateHands() {
el.handSelect.replaceChildren();
state.hands.forEach((hand, index) => {
const option = document.createElement("option");
option.value = String(index);
option.textContent = `Hand ${hand.hand_number} · ${hand.actions?.length || 0} actions`;
el.handSelect.appendChild(option);
});
}
function selectHand(index) {
pause();
state.currentHandIndex = Math.max(0, Math.min(index, state.hands.length - 1));
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
state.frameIndex = 0;
el.handSelect.value = String(state.currentHandIndex);
renderSummary();
renderFrame();
}
function nextFrame() {
if (!state.frames.length) return;
if (state.frameIndex < state.frames.length - 1) {
state.frameIndex += 1;
renderFrame();
return;
}
if (state.currentHandIndex < state.hands.length - 1) {
state.currentHandIndex += 1;
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
state.frameIndex = 0;
el.handSelect.value = String(state.currentHandIndex);
renderSummary();
renderFrame();
return;
}
pause();
}
function prevFrame() {
pause();
if (state.frameIndex === 0 && state.currentHandIndex > 0) {
state.currentHandIndex -= 1;
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
state.frameIndex = state.frames.length - 1;
el.handSelect.value = String(state.currentHandIndex);
renderSummary();
renderFrame();
return;
}
state.frameIndex = Math.max(0, state.frameIndex - 1);
renderFrame();
}
function play() {
if (!state.game || state.playing) return;
state.playing = true;
tick();
renderFrame();
}
function pause() {
state.playing = false;
if (state.timer) window.clearTimeout(state.timer);
state.timer = null;
syncControls();
}
function tick() {
if (!state.playing) return;
const frame = state.frames[state.frameIndex];
const baseDelay = frame?.type === "deal" || frame?.type === "award" ? 1500 : 1150;
const delay = baseDelay * Number(el.pace.value || 1);
state.timer = window.setTimeout(() => {
nextFrame();
tick();
}, delay);
}
// ---------------------------------------------------------------------------
// Network / file I/O.
// ---------------------------------------------------------------------------
async function fetchFromServer() {
const base = el.serverUrl.value.trim();
const gameId = el.gameId.value.trim();
if (!base || !gameId) throw new Error("请填写游戏服务地址和 Game ID");
const url = `/api/fetch-game?${new URLSearchParams({ base_url: base, game_id: gameId })}`;
const response = await fetch(url);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "获取失败");
loadGame(payload.game, `Server · ${gameId}`);
}
function setAutoPoll(enabled) {
if (state.pollTimer) window.clearInterval(state.pollTimer);
state.pollTimer = null;
el.pollBadge.textContent = enabled ? `Auto ${el.pollSeconds.value}s` : "Auto Off";
if (!enabled) return;
const interval = Math.max(5, Number(el.pollSeconds.value || 12)) * 1000;
state.pollTimer = window.setInterval(() => {
fetchFromServer().catch((error) => showMessage(error.message));
}, interval);
}
function showMessage(message) {
el.tableMessage.textContent = message;
el.sourceBadge.textContent = "Error";
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
// ---------------------------------------------------------------------------
// Wiring.
// ---------------------------------------------------------------------------
el.fetchBtn.addEventListener("click", () => {
fetchFromServer().catch((error) => showMessage(error.message));
});
el.fileInput.addEventListener("change", async (event) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const raw = JSON.parse(await file.text());
loadGame(raw, `File · ${file.name}`);
} catch (error) {
showMessage(error.message);
} finally {
event.target.value = "";
}
});
el.autoPoll.addEventListener("change", () => setAutoPoll(el.autoPoll.checked));
el.pollSeconds.addEventListener("change", () => setAutoPoll(el.autoPoll.checked));
el.handSelect.addEventListener("change", () => selectHand(Number(el.handSelect.value)));
el.playBtn.addEventListener("click", () => state.playing ? pause() : play());
el.nextBtn.addEventListener("click", () => nextFrame());
el.prevBtn.addEventListener("click", () => prevFrame());
el.resetBtn.addEventListener("click", () => selectHand(state.currentHandIndex));
window.addEventListener("resize", () => renderFrame());
syncControls();