add GameplayDatabase database with dashboard

This commit is contained in:
2026-04-05 16:48:12 +02:00
parent 2601c2dcff
commit 4151810f1b
6 changed files with 1471 additions and 4 deletions
+756
View File
@@ -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>