// ============================================================================= // 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. // * Terminal — renderFrame() writes a fixed-width command-line view into //
, keeping all fetch/upload/replay processing unchanged.
// =============================================================================

const state = {
  game: null,
  hands: [],
  currentHandIndex: 0,
  frames: [],
  frameIndex: 0,
  playing: false,
  timer: null,
  pollTimer: null,
  source: "",
};

const THEME_STORAGE_KEY = "txh-replay-theme";

const el = {
  subtitle: document.querySelector("#subtitle"),
  sourceBadge: document.querySelector("#sourceBadge"),
  pollBadge: document.querySelector("#pollBadge"),
  themeMode: document.querySelector("#themeMode"),
  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"),
  frameLabel: document.querySelector("#frameLabel"),
  tableOutput: document.querySelector("#tableOutput"),
  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  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 += ``;
    }
  }
  return `${rects}`;
}

// ---------------------------------------------------------------------------
// 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 = `${parts.rank}${parts.suit}${parts.rank}`;
  }
  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)
    ? `
${escapeHtml(frame.bubbleText)}
` : ""; // Avatar — inline SVG pixel-art sprite, deterministic per player_id so // the same player keeps a stable look across hands. const avatarMarkup = ``; seat.innerHTML = ` ${bubbleHtml}
${avatarMarkup} ${escapeHtml(player.name || player.player_id)} D
Stack ${money(player.stack)} ${player.folded ? "Folded" : player.all_in ? "All In" : player.street_bet ? `Bet ${money(player.street_bet)}` : ""}
`; // 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) : "idle"; el.potLabel.textContent = hasData ? `pot ${money(frame.pot)}` : "pot 0"; el.frameLabel.textContent = hasData ? `frame ${state.frameIndex + 1}/${state.frames.length}` : "frame 0/0"; el.progressBar.style.width = hasData ? `${((state.frameIndex + 1) / state.frames.length) * 100}%` : "0"; el.tableOutput.textContent = hasData ? renderTerminalFrame(game, hand, frame) : [ "$ txh-replay --status idle", "", "source: none", "event : waiting for game data", "", "load: fetch from a game service or upload a replay json snapshot", ].join("\n"); renderLog(); syncControls(); } function renderTerminalFrame(game, hand, frame) { const active = frame.activePlayerId || "-"; const command = [ "$ txh-replay", `--game ${game.game_id || "-"}`, `--hand ${hand.hand_number}`, `--frame ${state.frameIndex + 1}/${state.frames.length}`, ].join(" "); const lines = [ command, "", `source: ${state.source || "-"}`, `status: ${game.status || "-"} | street: ${STREET_LABELS[frame.street] || frame.street} | pot: ${money(frame.pot)}`, `board : ${formatCards(frame.board, 5)}`, `event : ${frame.message}`, `actor : ${active}`, "", "players", `${pad("seat", 4)} ${pad("flag", 4)} ${pad("player", 22)} ${pad("stack", 10, "right")} ${pad("street", 10, "right")} ${pad("total", 10, "right")} cards`, `${"-".repeat(4)} ${"-".repeat(4)} ${"-".repeat(22)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(13)}`, ]; for (const player of frame.players) { const playerName = player.name && player.name !== player.player_id ? `${player.name}/${player.player_id}` : player.player_id; lines.push([ pad(player.seat, 4), pad(playerFlags(player, hand, frame), 4), pad(playerName, 22), pad(money(player.stack), 10, "right"), pad(money(player.street_bet), 10, "right"), pad(money(player.total_bet), 10, "right"), formatCards(knownCardsForPlayer(player, hand, frame), 2), ].join(" ")); } lines.push( "", "flags: > active | D dealer | F folded | A all-in", ); return lines.join("\n"); } function playerFlags(player, hand, frame) { const flags = []; if (player.player_id === frame.activePlayerId) flags.push(">"); if (player.seat === hand.button_seat) flags.push("D"); if (player.folded) flags.push("F"); if (player.all_in) flags.push("A"); return flags.join("") || "."; } function formatCards(cards, minimum = 0) { const output = (cards || []).map(cardText); while (output.length < minimum) output.push("[--]"); return output.join(" "); } function cardText(raw) { if (!raw || raw === "back") return "[??]"; const parts = cardParts(raw); return `[${parts.rank}${parts.suit}]`; } function pad(value, width, align = "left") { const text = truncate(value, width); const gap = Math.max(0, width - text.length); return align === "right" ? `${" ".repeat(gap)}${text}` : `${text}${" ".repeat(gap)}`; } function truncate(value, width) { const text = String(value ?? ""); if (text.length <= width) return text; return `${text.slice(0, Math.max(0, width - 1))}~`; } 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() { el.eventLog.replaceChildren(); if (!state.frames.length) return; let currentLi = null; state.frames.forEach((frame, index) => { const li = document.createElement("li"); li.textContent = `${String(index + 1).padStart(2, "0")} ${frame.type.padEnd(8, " ")} ${frame.message}`; 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) { const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"; currentLi.scrollIntoView({ block: "nearest", behavior }); } } 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 ? "pause" : "play"; } // --------------------------------------------------------------------------- // 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?.blinds?.current || game?.blinds || game || {}; const smallBlind = blinds.small_blind ?? blinds.small ?? blinds.sb; const bigBlind = blinds.big_blind ?? blinds.big ?? blinds.bb; el.gameStatus.textContent = game?.status || "-"; el.playerCount.textContent = game?.players?.length ?? "-"; el.handCount.textContent = game?.hands?.length ?? "-"; el.blindLevel.textContent = game ? `${money(smallBlind)} / ${money(bigBlind)}` : "-"; } 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.tableOutput.textContent = [ "$ txh-replay --status error", "", `error: ${message}`, ].join("\n"); el.sourceBadge.textContent = "ERROR"; el.frameLabel.textContent = "frame 0/0"; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function applyTheme(mode) { const theme = ["auto", "dark", "light"].includes(mode) ? mode : "auto"; document.documentElement.dataset.theme = theme; if (el.themeMode) el.themeMode.value = theme; } function initTheme() { let stored = "auto"; try { stored = window.localStorage.getItem(THEME_STORAGE_KEY) || "auto"; } catch { stored = "auto"; } applyTheme(stored); } // --------------------------------------------------------------------------- // Wiring. // --------------------------------------------------------------------------- el.themeMode.addEventListener("change", () => { const mode = el.themeMode.value; applyTheme(mode); try { window.localStorage.setItem(THEME_STORAGE_KEY, mode); } catch { // Ignore storage failures; the current session still applies the theme. } }); 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()); initTheme(); syncControls(); renderFrame();