fix svg color problem show dead resions when very sure and allow to highlight snakes when clicking into them store timout and calcumate Snake Win Rate Overall, fix to use full page of games-body, get win lloss or unknown

This commit is contained in:
2026-04-05 23:01:27 +02:00
parent 050dd1083c
commit b7c6a0e345
2 changed files with 246 additions and 34 deletions
+199 -25
View File
@@ -88,17 +88,19 @@
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
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;
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
gap: 12px;
padding: 12px;
overflow: hidden;
}
.topbar {
@@ -127,7 +129,7 @@
.stats {
display: grid;
grid-template-columns: repeat(5, minmax(90px, 1fr));
grid-template-columns: repeat(6, minmax(90px, 1fr));
gap: 8px;
min-width: 520px;
}
@@ -164,6 +166,9 @@
border-radius: 12px;
box-shadow: 0 8px 28px var(--shadow);
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
@@ -183,8 +188,10 @@
}
.games {
overflow: auto;
height: clamp(280px, 62vh, 760px);
overflow-y: auto;
overflow-x: hidden;
flex: 1;
min-height: 0;
}
table {
@@ -193,6 +200,13 @@
font-size: 0.88rem;
}
thead th {
position: sticky;
top: 0;
background: var(--bg);
z-index: 1;
}
th, td {
text-align: left;
vertical-align: top;
@@ -203,6 +217,7 @@
tbody tr { cursor: pointer; }
tbody tr:hover { background: var(--row-hover); }
tbody tr.active { background: var(--row-active); }
#games-body tr:last-child td { border-bottom: none; }
.right {
min-height: 0;
@@ -457,6 +472,16 @@
.icon-layer--head {
z-index: 3;
opacity: 1;
background: none;
-webkit-mask-image: none;
mask-image: none;
}
.icon-layer--head > svg {
width: 100%;
height: 100%;
display: block;
overflow: visible;
}
.thinking {
@@ -535,6 +560,8 @@
.snake-row {
background: var(--snake-row-bg, transparent);
cursor: pointer;
transition: opacity 0.15s, filter 0.15s;
}
.snake-row td {
@@ -546,6 +573,9 @@
padding-left: 8px;
}
.snake-row.highlighted { outline: 2px solid var(--snake-row-color, var(--line)); outline-offset: -1px; }
.snakes-section.has-highlight .snake-row:not(.highlighted) { opacity: 0.25; filter: grayscale(0.6); }
.snake-row.dead-row {
filter: grayscale(0.55);
opacity: 0.78;
@@ -608,13 +638,13 @@
}
.stats {
min-width: 0;
grid-template-columns: repeat(5, minmax(80px, 1fr));
grid-template-columns: repeat(6, minmax(80px, 1fr));
}
.main {
grid-template-columns: 1fr;
}
.games {
height: 320px;
min-height: 200px;
}
.content {
grid-template-columns: 1fr;
@@ -707,6 +737,8 @@
let replay = null;
let turnIndex = 0;
let timer = null;
let selectedSnakeId = null;
const svgCache = new Map();
const statsEl = document.getElementById("stats");
const gamesBodyEl = document.getElementById("games-body");
@@ -726,11 +758,15 @@
}
function renderStats(summary) {
const finished = summary.finished_games || 0;
const wins = summary.wins || 0;
const winRate = finished > 0 ? ((wins / finished) * 100).toFixed(1) + "%" : "-";
const items = [
["Games", summary.total_games || 0],
["Finished", summary.finished_games || 0],
["Wins", summary.wins || 0],
["Finished", finished],
["Wins", wins],
["Losses", summary.losses || 0],
["Win Rate", winRate],
["Avg Turns", summary.avg_turns_finished || 0],
];
statsEl.innerHTML = items.map(([k, v]) => (
@@ -772,6 +808,8 @@
}
function buildSnakesRows(turn) {
console.log(turn);
const currentTurn = Number(turn && turn.turn !== undefined ? turn.turn : 0);
const turns = replay && Array.isArray(replay.turns) ? replay.turns : [];
const lastSeenById = new Map();
@@ -815,7 +853,7 @@
const colorById = buildSnakeColorById(turn);
if (snakes.length === 0) {
return "<tr><td colspan=\"6\">No snake data available</td></tr>";
return "<tr><td colspan=\"7\">No snake data available</td></tr>";
}
return snakes.map((snake, idx) => (
(() => {
@@ -831,10 +869,58 @@
const diedTurn = Number(snake._last_seen_turn ?? currentTurn) + 1;
const moveText = snake._is_dead ? `dead @ ${diedTurn}` : safeString(snake.inferred_move);
const deadClass = snake._is_dead ? " dead-row" : "";
const deadLabel = snake._is_dead ? " (dead)" : "";
const causeLabel = (() => {
if (!snake._is_dead) return "";
const lastTurn = turns.find((t) => t && t.turn === snake._last_seen_turn);
if (!lastTurn) return "";
const lastSelf = (lastTurn.snakes || []).find((s) => (s.snake_id || s.id) === snake._snake_id);
if (!lastSelf) return "";
// Starvation: health ≤ 1 at last seen turn
if (Number(lastSelf.health) <= 1) return " · starved";
// Project head to next position based on body direction
const body = Array.isArray(lastSelf.body) ? lastSelf.body : [];
const h = lastSelf.head || (body[0] || null);
const neck = body[1] || null;
const projX = h && neck ? h.x + (h.x - neck.x) : null;
const projY = h && neck ? h.y + (h.y - neck.y) : null;
const nextTurn = turns.find((t) => t && t.turn === snake._last_seen_turn + 1);
if (projX !== null) {
const game = replay && replay.game ? replay.game : {};
const bw = Number(game.width || 0);
const bh = Number(game.height || 0);
// Wall collision: projected head out of bounds
if (bw > 0 && bh > 0 && (projX < 0 || projX >= bw || projY < 0 || projY >= bh)) {
return " · wall";
}
if (nextTurn) {
// Head-to-head: another alive snake's head at projected position
for (const other of (nextTurn.snakes || [])) {
const otherId = other.snake_id || other.id;
if (otherId === snake._snake_id) continue;
if (other.head && other.head.x === projX && other.head.y === projY) {
return ` · head-to-head ${safeString(other.snake_name)}`;
}
}
// Body collision: projected head inside another snake's body
for (const other of (nextTurn.snakes || [])) {
const otherId = other.snake_id || other.id;
if (otherId === snake._snake_id) continue;
const otherBody = Array.isArray(other.body) ? other.body : [];
const hitBody = otherBody.some((seg, i) => i > 0 && seg.x === projX && seg.y === projY);
if (hitBody) return ` · hit ${safeString(other.snake_name)}`;
}
// Hazard: projected position is in hazard list
const hazards = nextTurn.hazards || [];
if (hazards.some((hz) => hz.x === projX && hz.y === projY)) return " · Hazard";
}
}
return "";
})();
const deadLabel = snake._is_dead ? ` (dead${causeLabel})` : "";
return `
<tr class="snake-row${deadClass}" ${rowStyle}>
<tr class="snake-row${deadClass}" data-snake-id="${snakeId}" ${rowStyle}>
<td class="name-cell">${safeString(snake.snake_name)}${snake.is_you ? " (you)" : ""}${deadLabel}</td>
<td class="num-cell">${safeString(snake.latency)}</td>
<td class="num-cell">${moveText}</td>
<td class="num-cell">${healthCell}</td>
<td class="num-cell">${safeString(snake.length)}</td>
@@ -943,12 +1029,56 @@
return `/dashboard/customizations/${kind}/${raw}.svg`;
}
async function loadSvg(url) {
if (svgCache.has(url)) return svgCache.get(url);
try {
const res = await fetch(url);
const text = res.ok ? await res.text() : null;
svgCache.set(url, text);
return text;
} catch {
svgCache.set(url, null);
return null;
}
}
async function preloadReplaySvgs() {
if (!replay || !Array.isArray(replay.turns)) return;
const urls = new Set();
for (const turn of replay.turns) {
const snakes = turn && turn.board && Array.isArray(turn.board.snakes) ? turn.board.snakes : [];
for (const snake of snakes) {
const custom = snake && (snake.customizations || {});
const headUrl = buildCustomizationIconUrl("heads", custom.head);
const tailUrl = buildCustomizationIconUrl("tails", custom.tail);
if (headUrl) urls.add(headUrl);
if (tailUrl) urls.add(tailUrl);
}
}
await Promise.all([...urls].map(loadSvg));
}
function createIconLayer(iconUrl, color, transformValue, type) {
const layer = document.createElement("div");
layer.className = type === "head" ? "icon-layer icon-layer--head" : "icon-layer icon-layer--tail";
layer.style.setProperty("--icon-url", `url(${iconUrl})`);
layer.style.setProperty("--icon-color", color || "var(--you)");
layer.style.setProperty("--icon-transform", transformValue || "rotate(0deg)");
if (type === "head") {
const svgMarkup = svgCache.get(iconUrl);
if (svgMarkup) {
layer.innerHTML = svgMarkup;
const svgEl = layer.querySelector("svg");
if (svgEl) {
svgEl.style.width = "100%";
svgEl.style.height = "100%";
svgEl.style.fill = color || "currentColor";
svgEl.removeAttribute("width");
svgEl.removeAttribute("height");
}
}
} else {
layer.style.setProperty("--icon-url", `url(${iconUrl})`);
layer.style.setProperty("--icon-color", color || "var(--you)");
}
return layer;
}
@@ -959,6 +1089,14 @@
return "rotate(0deg)";
}
function directionToTailTransform(direction) {
if (direction === "right") return "scaleX(-1)";
if (direction === "left") return "rotate(0deg)";
if (direction === "up") return "rotate(270deg) scaleX(-1)";
if (direction === "down") return "rotate(90deg) scaleX(-1)";
return "scaleX(-1)";
}
function directionToAngle(direction) {
if (direction === "right") return 0;
if (direction === "down") return 90;
@@ -1124,14 +1262,15 @@
<p class="section-title">Snake State This Turn</p>
<table class="score-table">
<colgroup>
<col style="width:34%">
<col style="width:32%">
<col style="width:10%">
<col style="width:18%">
<col style="width:8%">
<col style="width:15%">
<col style="width:15%">
<col style="width:10%">
<col style="width:20%">
<col style="width:6%">
<col style="width:7%">
<col style="width:7%">
</colgroup>
<thead><tr><th>Snake</th><th>Move</th><th>Health</th><th>Length</th><th>Head</th><th>Tail</th></tr></thead>
<thead><tr><th>Snake</th><th>Latency</th><th>Move</th><th>Health</th><th>Length</th><th>Head</th><th>Tail</th></tr></thead>
<tbody>${buildSnakesRows(turn)}</tbody>
</table>
</section>
@@ -1141,6 +1280,14 @@
<pre class="mono">${JSON.stringify(reasoning, null, 2)}</pre>
</section>
`;
if (selectedSnakeId) {
const section = thinkingEl.querySelector(".snakes-section");
const row = section && section.querySelector(`[data-snake-id="${CSS.escape(selectedSnakeId)}"]`);
if (row) {
row.classList.add("highlighted");
section.classList.add("has-highlight");
}
}
syncMonoOffset();
}
@@ -1160,9 +1307,9 @@
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><code>${shortId(g.game_id)}</code><br><small>${safeString((g.map && g.map.trim() && g.map.trim() !== "empty") ? g.map.trim() : g.game_type)}</small></td>
<td>${toTitle(g.status)}</td>
<td>${g.winner_you ? "Win" : "Loss"}</td>
<td>${g.status === "running" ? "-" : g.winner_you ? "Win" : "Loss"}</td>
<td>${safeString(g.final_turn)}</td>
</tr>
`).join("");
@@ -1221,8 +1368,9 @@
const headIconByCell = new Map();
const tailIconByCell = new Map();
const headTransformByCell = new Map();
const tailAngleByCell = new Map();
const tailTransformByCell = new Map();
const snakeColorByCell = new Map();
const snakeIdByCell = new Map();
(turnData.snakes || []).forEach((snake, idx) => {
if (!snake) return;
const snakeId = snake.snake_id || snake.id || `${safeString(snake.snake_name)}-${idx}`;
@@ -1233,9 +1381,10 @@
const headIcon = buildCustomizationIconUrl("heads", custom.head);
const tailIcon = buildCustomizationIconUrl("tails", custom.tail);
const headTransform = directionToHeadTransform(inferHeadDirection(snake));
const tailAngle = (directionToAngle(inferTailDirection(snake)) + 180) % 360;
const tailTransform = directionToTailTransform(inferTailDirection(snake));
for (const part of (snake.body || [])) {
snakeBody.set(cellKey(part.x, part.y), bodyColor);
snakeIdByCell.set(cellKey(part.x, part.y), snakeId);
}
if (snake.head) {
const headKey = cellKey(snake.head.x, snake.head.y);
@@ -1252,7 +1401,7 @@
const tailKey = cellKey(tail.x, tail.y);
snakeTail.set(tailKey, snake.is_you ? "snake-tail-you" : "snake-tail-enemy");
tailVariantByCell.set(tailKey, tailVariant);
tailAngleByCell.set(tailKey, tailAngle);
tailTransformByCell.set(tailKey, tailTransform);
snakeColorByCell.set(tailKey, bodyColor);
if (tailIcon) {
tailIconByCell.set(tailKey, tailIcon);
@@ -1275,6 +1424,9 @@
} else {
cell.style.background = snakeBody.get(key);
}
if (selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
cell.style.opacity = "0.2";
}
}
if (snakeTail.has(key)) {
cell.classList.add(snakeTail.get(key));
@@ -1286,7 +1438,7 @@
const layer = createIconLayer(
tailIcon,
snakeColorByCell.get(key) || "var(--you)",
`rotate(${tailAngleByCell.get(key) || 0}deg)`,
tailTransformByCell.get(key) || "scaleX(-1)",
"tail",
);
cell.appendChild(layer);
@@ -1340,6 +1492,7 @@
return;
}
replay = await response.json();
await preloadReplaySvgs();
turnIndex = 0;
const count = Array.isArray(replay.turns) ? replay.turns.length : 0;
sliderEl.max = String(Math.max(0, count - 1));
@@ -1418,6 +1571,27 @@
syncMonoOffset();
}
thinkingEl.addEventListener("click", (event) => {
const row = event.target.closest(".snake-row");
if (!row) return;
const section = row.closest(".snakes-section");
if (!section) return;
const clickedId = row.dataset.snakeId || null;
if (row.classList.contains("highlighted")) {
row.classList.remove("highlighted");
section.classList.remove("has-highlight");
selectedSnakeId = null;
} else {
section.querySelectorAll(".snake-row.highlighted").forEach((r) => r.classList.remove("highlighted"));
row.classList.add("highlighted");
section.classList.add("has-highlight");
selectedSnakeId = clickedId;
}
const game = replay && replay.game ? replay.game : {};
const turns = replay && Array.isArray(replay.turns) ? replay.turns : [];
if (turns[turnIndex]) paintBoard(turns[turnIndex], game.width, game.height);
});
window.addEventListener("resize", () => {
syncMonoOffset();
});