diff --git a/server/database/GameplayDatabase.py b/server/database/GameplayDatabase.py index 2c413d7..cfb7d5b 100644 --- a/server/database/GameplayDatabase.py +++ b/server/database/GameplayDatabase.py @@ -88,6 +88,8 @@ class GameplayDatabase: self._ensure_column_exists(connection, "turns", "my_thinking_json", "TEXT") self._ensure_column_exists(connection, "games", "your_snake_type", "TEXT") self._ensure_column_exists(connection, "games", "your_snake_version", "TEXT") + self._ensure_column_exists(connection, "games", "game_type", "TEXT") + self._ensure_column_exists(connection, "snake_turns", "latency", "TEXT") connection.execute("PRAGMA optimize") def _ensure_column_exists(self, connection:sqlite3.Connection, table_name:str, column_name:str, column_type:str) -> None: @@ -133,19 +135,26 @@ class GameplayDatabase: return "down" return None + def _derive_game_type(self, board:dict, ruleset:dict) -> str: + initial_snake_count = len(board.get("snakes", [])) + if initial_snake_count == 2: + return "duel" + return ruleset.get("name") or "standard" + def _record_game_start_sync(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None: game = game_state.get("game", {}) board = game_state.get("board", {}) you = self._extract_you(game_state) ruleset = game.get("ruleset", {}) + game_type = self._derive_game_type(board, ruleset) with self._connect() as connection: connection.execute(""" INSERT INTO games ( game_id, started_at, width, height, source, map_name, ruleset_name, ruleset_version, your_snake_id, your_snake_name, - your_snake_type, your_snake_version, status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running') + your_snake_type, your_snake_version, game_type, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running') ON CONFLICT(game_id) DO UPDATE SET width = excluded.width, height = excluded.height, @@ -157,6 +166,7 @@ class GameplayDatabase: your_snake_name = excluded.your_snake_name, your_snake_type = excluded.your_snake_type, your_snake_version = excluded.your_snake_version, + game_type = excluded.game_type, status = 'running' """, ( @@ -172,6 +182,7 @@ class GameplayDatabase: you.get("name"), snake_type, snake_version, + game_type, ), ) connection.execute("PRAGMA wal_checkpoint(PASSIVE)") @@ -253,8 +264,8 @@ class GameplayDatabase: connection.execute(""" INSERT INTO snake_turns ( game_id, turn, snake_id, snake_name, health, length, - head_x, head_y, body_json, is_you, inferred_move - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + head_x, head_y, body_json, is_you, inferred_move, latency + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(game_id, turn, snake_id) DO UPDATE SET snake_name = excluded.snake_name, health = excluded.health, @@ -263,7 +274,8 @@ class GameplayDatabase: head_y = excluded.head_y, body_json = excluded.body_json, is_you = excluded.is_you, - inferred_move = excluded.inferred_move + inferred_move = excluded.inferred_move, + latency = excluded.latency """, ( game_id, @@ -277,6 +289,7 @@ class GameplayDatabase: self._to_json(snake.get("body", [])), 1 if snake_id == you_id else 0, inferred, + snake.get("latency"), ), ) @@ -332,8 +345,22 @@ class GameplayDatabase: """ ).fetchone() + by_type = connection.execute(""" + SELECT + COALESCE(game_type, ruleset_name, 'unknown') AS type_label, + COUNT(*) AS total, + SUM(CASE WHEN status = 'finished' AND winner_you = 1 THEN 1 ELSE 0 END) AS wins, + SUM(CASE WHEN status = 'finished' AND winner_you = 0 THEN 1 ELSE 0 END) AS losses + FROM games + WHERE status = 'finished' + GROUP BY type_label + ORDER BY total DESC + """ + ).fetchall() + recent = connection.execute(""" - SELECT game_id, started_at, ended_at, map_name, your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status + SELECT game_id, started_at, ended_at, map_name, ruleset_name, game_type, + your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status FROM games ORDER BY started_at DESC LIMIT ? @@ -349,11 +376,19 @@ class GameplayDatabase: "wins": int(totals["wins"] or 0), "losses": int(totals["losses"] or 0), "avg_turns_finished": round(float(totals["avg_turns"] or 0.0), 2), + "by_game_type": [{ + "game_type": row["type_label"], + "total": int(row["total"]), + "wins": int(row["wins"]), + "losses": int(row["losses"]), + } for row in by_type], "recent_games": [{ "game_id": row["game_id"], "started_at": row["started_at"], "ended_at": row["ended_at"], "map": row["map_name"], + "ruleset": row["ruleset_name"], + "game_type": row["game_type"], "snake": row["your_snake_name"], "snake_type": row["your_snake_type"], "snake_version": row["your_snake_version"], @@ -366,7 +401,7 @@ class GameplayDatabase: def _list_games_sync(self, limit:int=50) -> list[dict]: with self._connect() as connection: rows = connection.execute(""" - SELECT game_id, started_at, ended_at, map_name, source, ruleset_name, + SELECT game_id, started_at, ended_at, map_name, source, ruleset_name, game_type, your_snake_name, your_snake_type, your_snake_version, winner_you, winner_names_json, final_turn, status FROM games @@ -383,6 +418,7 @@ class GameplayDatabase: "map": row["map_name"], "source": row["source"], "ruleset": row["ruleset_name"], + "game_type": row["game_type"], "snake": row["your_snake_name"], "snake_type": row["your_snake_type"], "snake_version": row["your_snake_version"], @@ -396,7 +432,7 @@ class GameplayDatabase: with self._connect() as connection: game_row = connection.execute(""" SELECT game_id, started_at, ended_at, width, height, source, map_name, - ruleset_name, ruleset_version, your_snake_id, your_snake_name, + ruleset_name, ruleset_version, game_type, your_snake_id, your_snake_name, your_snake_type, your_snake_version, winner_names_json, winner_you, final_turn, status FROM games @@ -420,7 +456,7 @@ class GameplayDatabase: snake_rows = connection.execute(""" SELECT turn, snake_id, snake_name, health, length, head_x, head_y, - body_json, is_you, inferred_move + body_json, is_you, inferred_move, latency FROM snake_turns WHERE game_id = ? ORDER BY turn ASC, is_you DESC, snake_name ASC @@ -440,6 +476,7 @@ class GameplayDatabase: "body": self._from_json(row["body_json"]) or [], "is_you": bool(row["is_you"]), "inferred_move": row["inferred_move"], + "latency": row["latency"], }) replay_turns = [] @@ -468,6 +505,7 @@ class GameplayDatabase: "map": game_row["map_name"], "ruleset_name": game_row["ruleset_name"], "ruleset_version": game_row["ruleset_version"], + "game_type": game_row["game_type"], "your_snake_id": game_row["your_snake_id"], "your_snake_name": game_row["your_snake_name"], "your_snake_type": game_row["your_snake_type"], diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html index 5fdeea2..bc4576d 100644 --- a/server/templates/dashboard.html +++ b/server/templates/dashboard.html @@ -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 "No snake data available"; + return "No snake data available"; } 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 ` - + ${safeString(snake.snake_name)}${snake.is_you ? " (you)" : ""}${deadLabel} + ${safeString(snake.latency)} ${moveText} ${healthCell} ${safeString(snake.length)} @@ -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 @@

Snake State This Turn

- + - - - - + + + + + - + ${buildSnakesRows(turn)}
SnakeMoveHealthLengthHeadTail
SnakeLatencyMoveHealthLengthHeadTail
@@ -1141,6 +1280,14 @@
${JSON.stringify(reasoning, null, 2)}
`; + 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) => ` - ${shortId(g.game_id)}
${safeString(g.map)} + ${shortId(g.game_id)}
${safeString((g.map && g.map.trim() && g.map.trim() !== "empty") ? g.map.trim() : g.game_type)} ${toTitle(g.status)} - ${g.winner_you ? "Win" : "Loss"} + ${g.status === "running" ? "-" : g.winner_you ? "Win" : "Loss"} ${safeString(g.final_turn)} `).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(); });