add GameplayDatabase database with dashboard
This commit is contained in:
@@ -0,0 +1,756 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Snake Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-1: #f2eee6;
|
||||
--bg-2: #e7dcc8;
|
||||
--panel: #fffcf6;
|
||||
--line: #d9ccb6;
|
||||
--ink: #252119;
|
||||
--muted: #6f6657;
|
||||
--accent: #146a4b;
|
||||
--accent-soft: #e5f2ed;
|
||||
--danger: #b0492a;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #fffdf8;
|
||||
--row-hover: #fdf4e7;
|
||||
--row-active: #edf8f3;
|
||||
--shadow: rgba(41, 29, 11, 0.08);
|
||||
--you: #1a7a56;
|
||||
--enemy: #bf5b33;
|
||||
--food: #cca100;
|
||||
--hazard: #6a5a9b;
|
||||
--grid: #e6dbc8;
|
||||
--cell: #ffffff;
|
||||
--mono-bg: #1d1b18;
|
||||
--mono-ink: #ecdfcb;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-1: #151819;
|
||||
--bg-2: #1b2022;
|
||||
--panel: #1f2527;
|
||||
--line: #374144;
|
||||
--ink: #e6e8e9;
|
||||
--muted: #a8b1b3;
|
||||
--accent: #4ec894;
|
||||
--accent-soft: #233e35;
|
||||
--danger: #d1734f;
|
||||
--surface: #232b2e;
|
||||
--surface-soft: #273134;
|
||||
--row-hover: #2b3538;
|
||||
--row-active: #224338;
|
||||
--shadow: rgba(0, 0, 0, 0.35);
|
||||
--you: #4ec894;
|
||||
--enemy: #e2815a;
|
||||
--food: #ebc14b;
|
||||
--hazard: #9b86d8;
|
||||
--grid: #3b464a;
|
||||
--cell: #1a2022;
|
||||
--mono-bg: #101416;
|
||||
--mono-ink: #dce7e9;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
color: var(--ink);
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background: linear-gradient(180deg, var(--bg-1), var(--bg-2));
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
box-shadow: 0 8px 28px var(--shadow);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.12rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(90px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
border: 1px solid #eadfcd;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat .k {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat .v {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 330px 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 28px var(--shadow);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eadfcd;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.games {
|
||||
overflow: auto;
|
||||
height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #efe5d5;
|
||||
}
|
||||
|
||||
tbody tr { cursor: pointer; }
|
||||
tbody tr:hover { background: var(--row-hover); }
|
||||
tbody tr.active { background: var(--row-active); }
|
||||
|
||||
.right {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eadfcd;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid #d2c3ab;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: #0f5a3f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="range"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.turn-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 42%) 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin-right: 5px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.board {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
background: var(--grid);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
background: var(--cell);
|
||||
min-width: 14px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.food { background: var(--food); }
|
||||
.hazard { background: var(--hazard); opacity: 0.82; }
|
||||
.snake-you { background: var(--you); }
|
||||
.snake-enemy { background: var(--enemy); }
|
||||
.snake-head { outline: 2px solid #111; outline-offset: -2px; }
|
||||
|
||||
.thinking {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #e8dcc8;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.think-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid #ebdfcb;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.chip .k {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chip .v {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.reason-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.score-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.score-table td,
|
||||
.score-table th {
|
||||
border-bottom: 1px solid #f0e7d7;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", "Consolas", monospace;
|
||||
font-size: 0.75rem;
|
||||
background: var(--mono-bg);
|
||||
color: var(--mono-ink);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.topbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.stats {
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(5, minmax(80px, 1fr));
|
||||
}
|
||||
.main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.games {
|
||||
height: 320px;
|
||||
}
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.turn-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
.think-grid {
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1 class="title">Battlesnake Replay Dashboard</h1>
|
||||
<p class="subtitle">Full-screen replay with turn-by-turn move reasoning and snake state.</p>
|
||||
</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Games</h2>
|
||||
<p>Pick a game to inspect the match, move timeline, and why your snake chose each turn.</p>
|
||||
</div>
|
||||
<div class="games">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Game</th>
|
||||
<th>Status</th>
|
||||
<th>W/L</th>
|
||||
<th>Turns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="games-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel right">
|
||||
<div class="controls">
|
||||
<button id="prev-btn">Prev</button>
|
||||
<button id="play-btn" class="primary">Play</button>
|
||||
<button id="next-btn">Next</button>
|
||||
<label>
|
||||
Speed
|
||||
<select id="speed">
|
||||
<option value="900">0.75x</option>
|
||||
<option value="650" selected>1x</option>
|
||||
<option value="400">1.5x</option>
|
||||
<option value="250">2x</option>
|
||||
</select>
|
||||
</label>
|
||||
<input type="range" min="0" max="0" step="1" id="turn-slider" value="0">
|
||||
<strong class="turn-badge" id="turn-label">Turn -</strong>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="board-wrap">
|
||||
<div class="legend">
|
||||
<span><i class="dot" style="background:var(--you)"></i>You</span>
|
||||
<span><i class="dot" style="background:var(--enemy)"></i>Enemy</span>
|
||||
<span><i class="dot" style="background:var(--food)"></i>Food</span>
|
||||
<span><i class="dot" style="background:var(--hazard)"></i>Hazard</span>
|
||||
</div>
|
||||
<div class="board" id="board"></div>
|
||||
</div>
|
||||
|
||||
<div class="thinking" id="thinking"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const initialGameId = {{ initial_game_id|tojson }};
|
||||
let replay = null;
|
||||
let turnIndex = 0;
|
||||
let timer = null;
|
||||
|
||||
const statsEl = document.getElementById("stats");
|
||||
const gamesBodyEl = document.getElementById("games-body");
|
||||
const boardEl = document.getElementById("board");
|
||||
const thinkingEl = document.getElementById("thinking");
|
||||
const turnLabelEl = document.getElementById("turn-label");
|
||||
const sliderEl = document.getElementById("turn-slider");
|
||||
|
||||
function toTitle(value) {
|
||||
return String(value || "").replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||||
}
|
||||
|
||||
function safeString(value) {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderStats(summary) {
|
||||
const items = [
|
||||
["Games", summary.total_games || 0],
|
||||
["Finished", summary.finished_games || 0],
|
||||
["Wins", summary.wins || 0],
|
||||
["Losses", summary.losses || 0],
|
||||
["Avg Turns", summary.avg_turns_finished || 0],
|
||||
];
|
||||
statsEl.innerHTML = items.map(([k, v]) => (
|
||||
`<div class="stat"><span class="k">${k}</span><span class="v">${v}</span></div>`
|
||||
)).join("");
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
const response = await fetch("/dashboard/summary");
|
||||
const data = await response.json();
|
||||
renderStats(data);
|
||||
}
|
||||
|
||||
function shortId(gameId) {
|
||||
return String(gameId || "-").slice(0, 8);
|
||||
}
|
||||
|
||||
function extractReasoningList(reasoning) {
|
||||
const parts = [];
|
||||
if (!reasoning || typeof reasoning !== "object") {
|
||||
return ["No reasoning recorded by this snake implementation."];
|
||||
}
|
||||
if (reasoning.reason) parts.push(`Reason: ${reasoning.reason}`);
|
||||
if (reasoning.mode) parts.push(`Mode: ${reasoning.mode}`);
|
||||
if (reasoning.health !== undefined) parts.push(`Health: ${reasoning.health}`);
|
||||
if (reasoning.length !== undefined) parts.push(`Length: ${reasoning.length}`);
|
||||
if (reasoning.occupancy !== undefined) parts.push(`Occupancy: ${reasoning.occupancy}`);
|
||||
if (reasoning.ms_remaining !== undefined) parts.push(`Time left: ${reasoning.ms_remaining}ms`);
|
||||
if (parts.length === 0) parts.push("Structured reasoning not provided; showing raw payload below.");
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildScoresRows(reasoning) {
|
||||
const scores = reasoning && typeof reasoning === "object" ? reasoning.scores : null;
|
||||
if (!scores || typeof scores !== "object" || Array.isArray(scores)) {
|
||||
return "<tr><td colspan=\"2\">No score table available</td></tr>";
|
||||
}
|
||||
const entries = Object.entries(scores).sort((a, b) => Number(b[1]) - Number(a[1]));
|
||||
if (entries.length === 0) {
|
||||
return "<tr><td colspan=\"2\">No score table available</td></tr>";
|
||||
}
|
||||
return entries.map(([move, score]) => (
|
||||
`<tr><td>${move}</td><td>${safeString(score)}</td></tr>`
|
||||
)).join("");
|
||||
}
|
||||
|
||||
function buildSnakesRows(turn) {
|
||||
const snakes = Array.isArray(turn.snakes) ? turn.snakes : [];
|
||||
if (snakes.length === 0) {
|
||||
return "<tr><td colspan=\"5\">No snake data available</td></tr>";
|
||||
}
|
||||
return snakes.map((snake) => (
|
||||
`<tr>
|
||||
<td>${safeString(snake.snake_name)}${snake.is_you ? " (you)" : ""}</td>
|
||||
<td>${safeString(snake.inferred_move)}</td>
|
||||
<td>${safeString(snake.health)}</td>
|
||||
<td>${safeString(snake.length)}</td>
|
||||
<td>${snake.head ? `${snake.head.x},${snake.head.y}` : "-"}</td>
|
||||
</tr>`
|
||||
)).join("");
|
||||
}
|
||||
|
||||
function renderThinking(turn) {
|
||||
if (!turn) {
|
||||
thinkingEl.innerHTML = "<p class=\"section-title\">Select a game to inspect reasoning.</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
const reasoning = turn.my_thinking;
|
||||
const reasons = extractReasoningList(reasoning);
|
||||
const reasonList = reasons.map((item) => `<li>${item}</li>`).join("");
|
||||
|
||||
thinkingEl.innerHTML = `
|
||||
<div class="think-grid">
|
||||
<div class="chip"><span class="k">Chosen Move</span><span class="v">${safeString(turn.my_move)}</span></div>
|
||||
<div class="chip"><span class="k">Observed At</span><span class="v">${safeString(turn.observed_at).slice(11, 19)}</span></div>
|
||||
<div class="chip"><span class="k">Food Count</span><span class="v">${Array.isArray(turn.food) ? turn.food.length : 0}</span></div>
|
||||
<div class="chip"><span class="k">Hazard Count</span><span class="v">${Array.isArray(turn.hazards) ? turn.hazards.length : 0}</span></div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<p class="section-title">Decision Summary</p>
|
||||
<ul class="reason-list">${reasonList}</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="section-title">Move Scores</p>
|
||||
<table class="score-table">
|
||||
<thead><tr><th>Move</th><th>Score</th></tr></thead>
|
||||
<tbody>${buildScoresRows(reasoning)}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="section-title">Snake State This Turn</p>
|
||||
<table class="score-table">
|
||||
<thead><tr><th>Snake</th><th>Move</th><th>Health</th><th>Length</th><th>Head</th></tr></thead>
|
||||
<tbody>${buildSnakesRows(turn)}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="section-title">Raw Reasoning Payload</p>
|
||||
<pre class="mono">${JSON.stringify(reasoning, null, 2)}</pre>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadGames() {
|
||||
const response = await fetch("/dashboard/games?limit=100");
|
||||
const data = await response.json();
|
||||
const games = data.games || [];
|
||||
|
||||
gamesBodyEl.innerHTML = games.map((g) => `
|
||||
<tr data-game-id="${g.game_id}">
|
||||
<td><code>${shortId(g.game_id)}</code><br><small>${safeString(g.map)}</small></td>
|
||||
<td>${toTitle(g.status)}</td>
|
||||
<td>${g.winner_you ? "Win" : "Loss"}</td>
|
||||
<td>${safeString(g.final_turn)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
for (const row of gamesBodyEl.querySelectorAll("tr")) {
|
||||
row.addEventListener("click", () => {
|
||||
const gameId = row.getAttribute("data-game-id");
|
||||
if (gameId) loadReplay(gameId);
|
||||
});
|
||||
}
|
||||
|
||||
if (initialGameId) {
|
||||
await loadReplay(initialGameId);
|
||||
} else if (games.length > 0) {
|
||||
await loadReplay(games[0].game_id);
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveGame() {
|
||||
for (const row of gamesBodyEl.querySelectorAll("tr")) {
|
||||
row.classList.remove("active");
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveGame(gameId) {
|
||||
clearActiveGame();
|
||||
const active = gamesBodyEl.querySelector(`tr[data-game-id="${gameId}"]`);
|
||||
if (active) active.classList.add("active");
|
||||
}
|
||||
|
||||
function clearBoard() {
|
||||
boardEl.innerHTML = "";
|
||||
boardEl.style.gridTemplateColumns = "none";
|
||||
}
|
||||
|
||||
function cellKey(x, y) {
|
||||
return `${x}:${y}`;
|
||||
}
|
||||
|
||||
function paintBoard(turnData, width, height) {
|
||||
clearBoard();
|
||||
if (!turnData || !width || !height) return;
|
||||
|
||||
boardEl.style.gridTemplateColumns = `repeat(${width}, minmax(12px, 1fr))`;
|
||||
const foods = new Set((turnData.food || []).map((p) => cellKey(p.x, p.y)));
|
||||
const hazards = new Set((turnData.hazards || []).map((p) => cellKey(p.x, p.y)));
|
||||
|
||||
const snakeBody = new Map();
|
||||
const snakeHead = new Set();
|
||||
for (const snake of (turnData.snakes || [])) {
|
||||
for (const part of (snake.body || [])) {
|
||||
snakeBody.set(cellKey(part.x, part.y), snake.is_you ? "snake-you" : "snake-enemy");
|
||||
}
|
||||
if (snake.head) snakeHead.add(cellKey(snake.head.x, snake.head.y));
|
||||
}
|
||||
|
||||
for (let y = height - 1; y >= 0; y--) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const key = cellKey(x, y);
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "cell";
|
||||
if (hazards.has(key)) cell.classList.add("hazard");
|
||||
if (foods.has(key)) cell.classList.add("food");
|
||||
if (snakeBody.has(key)) cell.classList.add(snakeBody.get(key));
|
||||
if (snakeHead.has(key)) cell.classList.add("snake-head");
|
||||
boardEl.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTurn() {
|
||||
if (!replay || !Array.isArray(replay.turns) || replay.turns.length === 0) {
|
||||
turnLabelEl.textContent = "Turn -";
|
||||
clearBoard();
|
||||
renderThinking(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const game = replay.game || {};
|
||||
const turns = replay.turns;
|
||||
const turn = turns[turnIndex];
|
||||
turnLabelEl.textContent = `Turn ${turn.turn} / ${turns[turns.length - 1].turn}`;
|
||||
sliderEl.value = String(turnIndex);
|
||||
paintBoard(turn, game.width, game.height);
|
||||
renderThinking(turn);
|
||||
}
|
||||
|
||||
async function loadReplay(gameId) {
|
||||
const response = await fetch(`/dashboard/game/${gameId}`);
|
||||
if (!response.ok) {
|
||||
renderThinking({ my_move: "-", my_thinking: { error: `Replay load failed for ${gameId}` } });
|
||||
return;
|
||||
}
|
||||
replay = await response.json();
|
||||
turnIndex = 0;
|
||||
const count = Array.isArray(replay.turns) ? replay.turns.length : 0;
|
||||
sliderEl.max = String(Math.max(0, count - 1));
|
||||
sliderEl.value = "0";
|
||||
setActiveGame(gameId);
|
||||
renderTurn();
|
||||
window.history.replaceState({}, "", `/dashboard?game_id=${encodeURIComponent(gameId)}`);
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
document.getElementById("play-btn").textContent = "Play";
|
||||
}
|
||||
|
||||
function startPlayback() {
|
||||
if (!replay || !Array.isArray(replay.turns) || replay.turns.length < 2) return;
|
||||
stopPlayback();
|
||||
const interval = Number(document.getElementById("speed").value || 650);
|
||||
timer = setInterval(() => {
|
||||
if (!replay || turnIndex >= replay.turns.length - 1) {
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
turnIndex += 1;
|
||||
renderTurn();
|
||||
}, interval);
|
||||
document.getElementById("play-btn").textContent = "Pause";
|
||||
}
|
||||
|
||||
document.getElementById("play-btn").addEventListener("click", () => {
|
||||
if (timer) stopPlayback();
|
||||
else startPlayback();
|
||||
});
|
||||
|
||||
document.getElementById("prev-btn").addEventListener("click", () => {
|
||||
stopPlayback();
|
||||
if (!replay || turnIndex <= 0) return;
|
||||
turnIndex -= 1;
|
||||
renderTurn();
|
||||
});
|
||||
|
||||
document.getElementById("next-btn").addEventListener("click", () => {
|
||||
stopPlayback();
|
||||
if (!replay || !Array.isArray(replay.turns) || turnIndex >= replay.turns.length - 1) return;
|
||||
turnIndex += 1;
|
||||
renderTurn();
|
||||
});
|
||||
|
||||
sliderEl.addEventListener("input", () => {
|
||||
stopPlayback();
|
||||
turnIndex = Number(sliderEl.value || 0);
|
||||
renderTurn();
|
||||
});
|
||||
|
||||
document.getElementById("speed").addEventListener("change", () => {
|
||||
if (timer) startPlayback();
|
||||
});
|
||||
|
||||
async function boot() {
|
||||
renderThinking(null);
|
||||
await loadSummary();
|
||||
await loadGames();
|
||||
}
|
||||
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user