From 739c0520f92384bee068712de7bc1824cb367517 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Tue, 7 Apr 2026 03:25:10 +0200 Subject: [PATCH] move dashboard script block content into own files with new classes to update, render to have a better code overview --- templates/files/js/DashboardWebSocket.js | 170 +++ templates/files/js/GameBoard.js | 309 +++++ templates/files/js/GameState.js | 140 +++ templates/files/js/GamesTable.js | 52 + templates/files/js/MoveTable.js | 14 + templates/files/js/OverallStats.js | 22 + templates/files/js/Snake.js | 221 ++++ templates/files/js/SnakeTable.js | 124 ++ templates/files/js/Thinking.js | 110 ++ templates/files/js/Utils.js | 25 + templates/side/dashboard.htm | 1343 ++-------------------- 11 files changed, 1261 insertions(+), 1269 deletions(-) create mode 100644 templates/files/js/DashboardWebSocket.js create mode 100644 templates/files/js/GameBoard.js create mode 100644 templates/files/js/GameState.js create mode 100644 templates/files/js/GamesTable.js create mode 100644 templates/files/js/MoveTable.js create mode 100644 templates/files/js/OverallStats.js create mode 100644 templates/files/js/Snake.js create mode 100644 templates/files/js/SnakeTable.js create mode 100644 templates/files/js/Thinking.js create mode 100644 templates/files/js/Utils.js diff --git a/templates/files/js/DashboardWebSocket.js b/templates/files/js/DashboardWebSocket.js new file mode 100644 index 0000000..2aa6116 --- /dev/null +++ b/templates/files/js/DashboardWebSocket.js @@ -0,0 +1,170 @@ +class DashboardWebSocket { + constructor({ onGamesUpdate, onShutdown } = {}) { + this._socket = null; + this._reconnectTimer = null; + this._shuttingDown = false; + this._pendingRequests = new Map(); + this._requestSeq = 0; + this._onGamesUpdate = onGamesUpdate || (() => {}); + this._onShutdown = onShutdown || (() => {}); + } + + get isShuttingDown() { return this._shuttingDown; } + + connect() { + if (this._shuttingDown) return; + if (this._socket && ( + this._socket.readyState === WebSocket.OPEN || + this._socket.readyState === WebSocket.CONNECTING + )) return; + + const wsUrl = this._buildUrl(); + try { + this._socket = new WebSocket(wsUrl); + } catch { + this._scheduleReconnect(); + return; + } + + this._socket.addEventListener("message", (event) => { + let payload = null; + try { payload = JSON.parse(event.data); } catch { return; } + if (!payload || !payload.type) return; + + if (payload.type === "dashboard_ws_shutdown") { + this._shuttingDown = true; + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + this._reconnectTimer = null; + } + this._rejectAll("Server shutting down"); + if (this._socket) this._socket.close(); + this._onShutdown(); + return; + } + + if (payload.type === "dashboard_game_replay") { + const requestId = String(payload.request_id || ""); + if (!requestId) return; + const pending = this._pendingRequests.get(requestId); + if (!pending) return; + this._pendingRequests.delete(requestId); + window.clearTimeout(pending.timeoutId); + if (payload.error) { + pending.reject(new Error(String(payload.error))); + return; + } + pending.resolve(payload.replay || null); + return; + } + + if (payload.type === "dashboard_games_update") { + this._onGamesUpdate(payload); + } + }); + + this._socket.addEventListener("close", () => { + this._socket = null; + this._rejectAll("Dashboard websocket disconnected"); + if (!this._shuttingDown) this._scheduleReconnect(); + }); + + this._socket.addEventListener("error", () => { + if (this._socket) this._socket.close(); + }); + } + + waitForOpen(timeoutMs = 4000) { + if (this._shuttingDown) return Promise.resolve(false); + if (!this._socket || this._socket.readyState === WebSocket.CLOSED) this.connect(); + if (this._socket && this._socket.readyState === WebSocket.OPEN) return Promise.resolve(true); + + return new Promise((resolve) => { + if (!this._socket) { resolve(false); return; } + const socketRef = this._socket; + let settled = false; + const cleanup = () => { + socketRef.removeEventListener("open", onOpen); + socketRef.removeEventListener("close", onClose); + socketRef.removeEventListener("error", onError); + window.clearTimeout(timeoutId); + }; + const finish = (value) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + const onOpen = () => finish(true); + const onClose = () => finish(false); + const onError = () => finish(false); + const timeoutId = window.setTimeout(() => finish(false), timeoutMs); + socketRef.addEventListener("open", onOpen); + socketRef.addEventListener("close", onClose); + socketRef.addEventListener("error", onError); + }); + } + + async requestReplay(gameId) { + const isOpen = await this.waitForOpen(); + if (!isOpen || !this._socket || this._socket.readyState !== WebSocket.OPEN) { + throw new Error("Dashboard websocket unavailable"); + } + + const requestId = `replay-${Date.now()}-${this._requestSeq++}`; + return await new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + this._pendingRequests.delete(requestId); + reject(new Error(`Replay websocket timeout for ${gameId}`)); + }, 4000); + + this._pendingRequests.set(requestId, { resolve, reject, timeoutId }); + try { + this._socket.send(JSON.stringify({ + type: "dashboard_game_replay_request", + request_id: requestId, + game_id: gameId, + })); + } catch (error) { + window.clearTimeout(timeoutId); + this._pendingRequests.delete(requestId); + reject(error); + } + }); + } + + shutdown() { + this._shuttingDown = true; + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + this._reconnectTimer = null; + } + this._rejectAll("Dashboard unloading"); + if (this._socket) { + this._socket.close(); + this._socket = null; + } + } + + _buildUrl() { + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + return `${protocol}://${window.location.host}/dashboard/ws/games`; + } + + _scheduleReconnect() { + if (this._shuttingDown) return; + if (this._reconnectTimer) return; + this._reconnectTimer = window.setTimeout(() => { + this._reconnectTimer = null; + this.connect(); + }, 1500); + } + + _rejectAll(message) { + for (const pending of this._pendingRequests.values()) { + window.clearTimeout(pending.timeoutId); + pending.reject(new Error(message)); + } + this._pendingRequests.clear(); + } +} diff --git a/templates/files/js/GameBoard.js b/templates/files/js/GameBoard.js new file mode 100644 index 0000000..003c2a7 --- /dev/null +++ b/templates/files/js/GameBoard.js @@ -0,0 +1,309 @@ +class GameBoard { + constructor(boardEl) { + this._boardEl = boardEl; + this._svgCache = new Map(); + } + + clearBoard() { + this._boardEl.innerHTML = ""; + this._boardEl.style.gridTemplateColumns = "none"; + } + + async preloadSvgs(replay) { + 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 = SnakeUtils.buildCustomizationIconUrl("heads", custom.head); + const tailUrl = SnakeUtils.buildCustomizationIconUrl("tails", custom.tail); + if (headUrl) urls.add(headUrl); + if (tailUrl) urls.add(tailUrl); + } + } + await Promise.all([...urls].map((url) => this._loadSvg(url))); + } + + async _loadSvg(url) { + if (this._svgCache.has(url)) return this._svgCache.get(url); + try { + const res = await fetch(url); + const text = res.ok ? await res.text() : null; + this._svgCache.set(url, text); + return text; + } catch { + this._svgCache.set(url, null); + return null; + } + } + + _parseViewBox(svgEl) { + const raw = String(svgEl.getAttribute("viewBox") || "").trim(); + const parts = raw.split(/\s+/).map((item) => Number(item)); + if (parts.length !== 4 || parts.some((v) => Number.isNaN(v))) { + return { minX: 0, minY: 0, width: 100, height: 100 }; + } + return { minX: parts[0], minY: parts[1], width: parts[2], height: parts[3] }; + } + + _groupLooksOffCanvas(groupEl, viewBox) { + const attrNames = new Set(["x", "y", "cx", "cy", "x1", "y1", "x2", "y2", "d", "points"]); + const allElements = [groupEl, ...groupEl.querySelectorAll("*")]; + let farOutsideCount = 0; + let numericCount = 0; + const minAllowedX = viewBox.minX - Math.max(40, viewBox.width * 0.8); + const minAllowedY = viewBox.minY - Math.max(40, viewBox.height * 0.8); + const maxAllowedX = viewBox.minX + viewBox.width + Math.max(40, viewBox.width * 0.8); + const maxAllowedY = viewBox.minY + viewBox.height + Math.max(40, viewBox.height * 0.8); + + for (const node of allElements) { + for (const attr of node.getAttributeNames()) { + if (!attrNames.has(attr)) continue; + const value = node.getAttribute(attr); + if (!value) continue; + const matches = value.match(/-?\d*\.?\d+/g); + if (!matches) continue; + for (let idx = 0; idx < matches.length; idx += 1) { + const num = Number(matches[idx]); + if (Number.isNaN(num)) continue; + numericCount += 1; + const isXCoord = idx % 2 === 0; + if (isXCoord) { + if (num < minAllowedX || num > maxAllowedX) farOutsideCount += 1; + } else { + if (num < minAllowedY || num > maxAllowedY) farOutsideCount += 1; + } + } + } + } + + if (numericCount < 10) return false; + return farOutsideCount / numericCount > 0.55; + } + + _maxNestedGroupDepth(groupEl) { + let maxDepth = 1; + const stack = [{ node: groupEl, depth: 1 }]; + while (stack.length > 0) { + const entry = stack.pop(); + if (!entry) continue; + maxDepth = Math.max(maxDepth, entry.depth); + for (const child of Array.from(entry.node.children)) { + if (!child.tagName || child.tagName.toLowerCase() !== "g") continue; + stack.push({ node: child, depth: entry.depth + 1 }); + } + } + return maxDepth; + } + + _normalizeHeadSvgMarkup(svgMarkup) { + if (!svgMarkup) return null; + try { + const parser = new DOMParser(); + const parsed = parser.parseFromString(svgMarkup, "image/svg+xml"); + const svgEl = parsed.querySelector("svg"); + if (!svgEl) return svgMarkup; + const topLevelGroups = Array.from(svgEl.children).filter( + (el) => el.tagName && el.tagName.toLowerCase() === "g" + ); + if (topLevelGroups.length > 1) { + const viewBox = this._parseViewBox(svgEl); + const firstGroup = topLevelGroups[0]; + if (this._groupLooksOffCanvas(firstGroup, viewBox) || this._maxNestedGroupDepth(firstGroup) >= 3) { + firstGroup.remove(); + } + } + return new XMLSerializer().serializeToString(svgEl); + } catch { + return svgMarkup; + } + } + + _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-transform", transformValue || "rotate(0deg)"); + if (type === "head") { + const svgMarkup = this._svgCache.get(iconUrl); + if (svgMarkup) { + layer.innerHTML = this._normalizeHeadSvgMarkup(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; + } + + _cellKey(x, y) { + return `${x}:${y}`; + } + + paintBoard(turnData, width, height, selectedSnakeId, replay) { + this.clearBoard(); + if (!turnData || !width || !height) return; + const colorById = SnakeUtils.buildSnakeColorById(turnData, replay); + const customById = SnakeUtils.buildSnakeCustomizationById(turnData, replay); + + this._boardEl.style.gridTemplateColumns = `repeat(${width}, 1fr)`; + const foods = new Set((turnData.food || []).map((p) => this._cellKey(p.x, p.y))); + const hazards = new Set((turnData.hazards || []).map((p) => this._cellKey(p.x, p.y))); + + const snakeBody = new Map(); + const snakeHead = new Set(); + const snakeTail = new Map(); + const headVariantByCell = new Map(); + const tailVariantByCell = new Map(); + const headIconByCell = new Map(); + const tailIconByCell = new Map(); + const headTransformByCell = 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 || `${Utils.safeString(snake.snake_name)}-${idx}`; + const bodyColor = SnakeUtils.resolveSnakeColor(snakeId, snake.is_you, colorById); + const custom = customById.get(snakeId) || {}; + const headVariant = SnakeUtils.stableVariantFromString(custom.head); + const tailVariant = SnakeUtils.stableVariantFromString(custom.tail); + const headIcon = SnakeUtils.buildCustomizationIconUrl("heads", custom.head); + const tailIcon = SnakeUtils.buildCustomizationIconUrl("tails", custom.tail); + const headTransform = SnakeUtils.directionToHeadTransform(SnakeUtils.inferHeadDirection(snake)); + const tailTransform = SnakeUtils.directionToTailTransform(SnakeUtils.inferTailDirection(snake)); + + for (const part of (snake.body || [])) { + snakeBody.set(this._cellKey(part.x, part.y), bodyColor); + snakeIdByCell.set(this._cellKey(part.x, part.y), snakeId); + } + if (snake.head) { + const headKey = this._cellKey(snake.head.x, snake.head.y); + snakeHead.add(headKey); + headVariantByCell.set(headKey, headVariant); + headTransformByCell.set(headKey, headTransform); + snakeColorByCell.set(headKey, bodyColor); + if (headIcon) headIconByCell.set(headKey, headIcon); + } + if (Array.isArray(snake.body) && snake.body.length > 0) { + const tail = snake.body[snake.body.length - 1]; + const tailKey = this._cellKey(tail.x, tail.y); + snakeTail.set(tailKey, snake.is_you ? "snake-tail-you" : "snake-tail-enemy"); + tailVariantByCell.set(tailKey, tailVariant); + tailTransformByCell.set(tailKey, tailTransform); + snakeColorByCell.set(tailKey, bodyColor); + if (tailIcon) tailIconByCell.set(tailKey, tailIcon); + } + }); + + for (let y = height - 1; y >= 0; y--) { + for (let x = 0; x < width; x++) { + const key = this._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)) { + const bodyColor = snakeBody.get(key); + const hasHeadIcon = headIconByCell.has(key); + const hasTailIcon = tailIconByCell.has(key); + const isIconCell = hasHeadIcon || hasTailIcon; + cell.style.borderRadius = "0"; + if (!isIconCell) cell.style.background = bodyColor; + if (selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) { + cell.style.opacity = "0.2"; + } + + const snakeId = snakeIdByCell.get(key); + if (snakeId) { + const up = snakeIdByCell.get(this._cellKey(x, y + 1)) === snakeId; + const down = snakeIdByCell.get(this._cellKey(x, y - 1)) === snakeId; + const left = snakeIdByCell.get(this._cellKey(x - 1, y)) === snakeId; + const right = snakeIdByCell.get(this._cellKey(x + 1, y)) === snakeId; + + if (!snakeHead.has(key) && !snakeTail.has(key)) { + if (up && right && !down && !left) { + cell.classList.add("snake-turn-cell", "snake-turn-dl"); + cell.style.setProperty("--turn-color", bodyColor); + cell.style.background = "var(--cell)"; + } else if (up && left && !down && !right) { + cell.classList.add("snake-turn-cell", "snake-turn-dr"); + cell.style.setProperty("--turn-color", bodyColor); + cell.style.background = "var(--cell)"; + } else if (down && right && !up && !left) { + cell.classList.add("snake-turn-cell", "snake-turn-ul"); + cell.style.setProperty("--turn-color", bodyColor); + cell.style.background = "var(--cell)"; + } else if (down && left && !up && !right) { + cell.classList.add("snake-turn-cell", "snake-turn-ur"); + cell.style.setProperty("--turn-color", bodyColor); + cell.style.background = "var(--cell)"; + } + } + + // Outward shadows bridge the 2px gap to adjacent snake cells. + // For icon cells (head/tail), also add inset shadows to color the + // connecting edge of the cell itself, since the background stays + // transparent so the icon remains visible. + const bridgeShadows = []; + if (up) { + bridgeShadows.push(`0 -2px 0 ${bodyColor}`); + if (isIconCell) bridgeShadows.push(`inset 0 2px 0 ${bodyColor}`); + } + if (down) { + bridgeShadows.push(`0 2px 0 ${bodyColor}`); + if (isIconCell) bridgeShadows.push(`inset 0 -2px 0 ${bodyColor}`); + } + if (left) { + bridgeShadows.push(`-2px 0 0 ${bodyColor}`); + if (isIconCell) bridgeShadows.push(`inset 2px 0 0 ${bodyColor}`); + } + if (right) { + bridgeShadows.push(`2px 0 0 ${bodyColor}`); + if (isIconCell) bridgeShadows.push(`inset -2px 0 0 ${bodyColor}`); + } + if (bridgeShadows.length > 0) cell.style.boxShadow = bridgeShadows.join(", "); + } + } + if (snakeTail.has(key)) { + cell.classList.add(snakeTail.get(key)); + cell.classList.add(`tail-style-${tailVariantByCell.get(key) || 1}`); + const tailIcon = tailIconByCell.get(key); + if (tailIcon && !snakeHead.has(key)) { + cell.classList.add("has-tail-icon", "icon-tail"); + cell.appendChild(this._createIconLayer( + tailIcon, + snakeColorByCell.get(key) || "var(--you)", + tailTransformByCell.get(key) || "scaleX(-1)", + "tail", + )); + } + } + if (snakeHead.has(key)) { + cell.classList.add("snake-head"); + cell.classList.add(`head-style-${headVariantByCell.get(key) || 1}`); + const headIcon = headIconByCell.get(key); + if (headIcon) { + cell.classList.add("has-head-icon", "icon-head"); + cell.appendChild(this._createIconLayer( + headIcon, + snakeColorByCell.get(key) || "var(--you)", + headTransformByCell.get(key) || "rotate(0deg)", + "head", + )); + } + } + this._boardEl.appendChild(cell); + } + } + } +} diff --git a/templates/files/js/GameState.js b/templates/files/js/GameState.js new file mode 100644 index 0000000..3d78785 --- /dev/null +++ b/templates/files/js/GameState.js @@ -0,0 +1,140 @@ +class GameState { + constructor({ gameBoard, thinkingPanel, gamesTable, sliderEl, turnLabelEl }) { + this._gameBoard = gameBoard; + this._thinkingPanel = thinkingPanel; + this._gamesTable = gamesTable; + this._sliderEl = sliderEl; + this._turnLabelEl = turnLabelEl; + this._webSocket = null; + + this.replay = null; + this.turnIndex = 0; + this.activeGameId = ""; + this.selectedSnakeId = null; + this._timer = null; + this._hasLoadedReplayOnce = false; + } + + setWebSocket(webSocket) { + this._webSocket = webSocket; + } + + get isPlaying() { return Boolean(this._timer); } + + async loadReplay(gameId) { + let nextReplay = null; + try { + nextReplay = await this._webSocket.requestReplay(gameId); + } catch { + if (!this._hasLoadedReplayOnce) { + this._thinkingPanel.render( + { my_move: "-", my_thinking: { error: `Replay websocket unavailable for ${gameId}` } }, + null, + ); + return; + } + const response = await fetch(`/dashboard/game/${gameId}`); + if (!response.ok) { + this._thinkingPanel.render( + { my_move: "-", my_thinking: { error: `Replay load failed for ${gameId}` } }, + null, + ); + return; + } + nextReplay = await response.json(); + } + + this.replay = nextReplay; + this._hasLoadedReplayOnce = true; + this.activeGameId = String(gameId || ""); + await this._gameBoard.preloadSvgs(this.replay); + this.turnIndex = 0; + const count = Array.isArray(this.replay.turns) ? this.replay.turns.length : 0; + this._sliderEl.max = String(Math.max(0, count - 1)); + this._sliderEl.value = "0"; + this._gamesTable.setActive(gameId); + this.renderTurn(); + } + + renderTurn() { + if (!this.replay || !Array.isArray(this.replay.turns) || this.replay.turns.length === 0) { + this._turnLabelEl.textContent = "Turn -"; + this._gameBoard.clearBoard(); + this._thinkingPanel.render(null, null); + return; + } + + const game = this.replay.game || {}; + const turns = this.replay.turns; + const turn = turns[this.turnIndex]; + this._turnLabelEl.textContent = `Turn ${turn.turn} / ${turns[turns.length - 1].turn}`; + this._sliderEl.value = String(this.turnIndex); + this._gameBoard.paintBoard(turn, game.width, game.height, this.selectedSnakeId, this.replay); + this._thinkingPanel.render(turn, this.replay); + if (this.selectedSnakeId) { + this._thinkingPanel.highlightSnake(this.selectedSnakeId); + } + } + + stopPlayback() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + const playBtn = document.getElementById("play-btn"); + playBtn.textContent = "▶"; + playBtn.setAttribute("title", "Play"); + playBtn.setAttribute("aria-label", "Play"); + } + + startPlayback() { + if (!this.replay || !Array.isArray(this.replay.turns) || this.replay.turns.length < 2) return; + if (this.turnIndex >= this.replay.turns.length - 1) { + this.turnIndex = 0; + this.renderTurn(); + } + this.stopPlayback(); + const interval = Number(document.getElementById("speed").value || 650); + this._timer = setInterval(() => { + if (!this.replay || this.turnIndex >= this.replay.turns.length - 1) { + this.stopPlayback(); + return; + } + this.turnIndex += 1; + this.renderTurn(); + }, interval); + const playBtn = document.getElementById("play-btn"); + playBtn.textContent = "❚❚"; + playBtn.setAttribute("title", "Pause"); + playBtn.setAttribute("aria-label", "Pause"); + } + + stepBackward() { + this.stopPlayback(); + if (!this.replay || this.turnIndex <= 0) return; + this.turnIndex -= 1; + this.renderTurn(); + } + + stepForward() { + this.stopPlayback(); + if (!this.replay || !Array.isArray(this.replay.turns) || this.turnIndex >= this.replay.turns.length - 1) return; + this.turnIndex += 1; + this.renderTurn(); + } + + adjustSpeed(direction) { + const speedEl = document.getElementById("speed"); + const optionCount = speedEl.options.length; + if (optionCount <= 1) return; + const currentIndex = speedEl.selectedIndex; + const nextIndex = Math.max(0, Math.min(optionCount - 1, currentIndex + direction)); + if (nextIndex === currentIndex) return; + speedEl.selectedIndex = nextIndex; + speedEl.dispatchEvent(new Event("change")); + } + + setSelectedSnakeId(id) { + this.selectedSnakeId = id; + } +} diff --git a/templates/files/js/GamesTable.js b/templates/files/js/GamesTable.js new file mode 100644 index 0000000..ad987ec --- /dev/null +++ b/templates/files/js/GamesTable.js @@ -0,0 +1,52 @@ +class GamesTable { + constructor(gamesBodyEl, battlesnakeUrl, onGameClick) { + this._el = gamesBodyEl; + this._battlesnakeUrl = battlesnakeUrl; + this._onGameClick = onGameClick; + } + + render(games, activeGameId) { + this._el.innerHTML = games.map((g) => ` + + ${GamesTable._shortId(g.game_id)}
${Utils.safeString(GamesTable._displayGameTypeOrMap(g))} + ${Utils.toTitle(g.status)} + ${g.status === "running" ? "-" : g.winner_you ? "Win" : "Loss"} + ${Utils.safeString(g.final_turn)} + + `).join(""); + + for (const row of this._el.querySelectorAll("tr")) { + row.addEventListener("click", (event) => { + event.preventDefault(); + const gameId = row.getAttribute("data-game-id"); + if (gameId) this._onGameClick(gameId); + }); + } + + if (activeGameId) this.setActive(activeGameId); + } + + setActive(gameId) { + this._clearActive(); + const active = this._el.querySelector(`tr[data-game-id="${gameId}"]`); + if (active) active.classList.add("active"); + } + + _clearActive() { + for (const row of this._el.querySelectorAll("tr")) { + row.classList.remove("active"); + } + } + + static _shortId(gameId) { + return String(gameId || "-").slice(0, 8); + } + + static _displayGameTypeOrMap(game) { + const mapName = String((game && game.map) || "").trim(); + const gameType = String((game && game.game_type) || "").trim(); + if (gameType.toLowerCase() === "duel" && mapName.toLowerCase() === "standard") return "duel"; + if (mapName && mapName.toLowerCase() !== "empty") return mapName; + return gameType || "-"; + } +} diff --git a/templates/files/js/MoveTable.js b/templates/files/js/MoveTable.js new file mode 100644 index 0000000..b2527fe --- /dev/null +++ b/templates/files/js/MoveTable.js @@ -0,0 +1,14 @@ +class MoveTable { + static buildScoresRows(reasoning) { + const scores = reasoning && typeof reasoning === "object" ? reasoning.scores : null; + const moveOrder = ["up", "down", "left", "right"]; + return moveOrder.map((move) => { + const hasScore = scores + && typeof scores === "object" + && !Array.isArray(scores) + && Object.prototype.hasOwnProperty.call(scores, move); + const value = hasScore ? scores[move] : "-"; + return `${move}${Utils.safeString(value)}`; + }).join(""); + } +} diff --git a/templates/files/js/OverallStats.js b/templates/files/js/OverallStats.js new file mode 100644 index 0000000..4c7d024 --- /dev/null +++ b/templates/files/js/OverallStats.js @@ -0,0 +1,22 @@ +class OverallStats { + constructor(statsEl) { + this._el = statsEl; + } + + render(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", finished], + ["Wins", wins], + ["Losses", summary.losses || 0], + ["Win Rate", winRate], + ["Avg Turns", summary.avg_turns_finished || 0], + ]; + this._el.innerHTML = items.map(([k, v]) => ( + `
${k}${v}
` + )).join(""); + } +} diff --git a/templates/files/js/Snake.js b/templates/files/js/Snake.js new file mode 100644 index 0000000..7d7f48c --- /dev/null +++ b/templates/files/js/Snake.js @@ -0,0 +1,221 @@ +class SnakeUtils { + static snakeColor(index) { + return `var(--snake-${((index % 10) + 1)})`; + } + + static stableColorIndexFromId(snakeId) { + const raw = String(snakeId || ""); + let hash = 0; + for (let i = 0; i < raw.length; i += 1) { + hash = ((hash * 31) + raw.charCodeAt(i)) >>> 0; + } + return hash % 10; + } + + static resolveSnakeColor(snakeId, isYou, colorById) { + if (snakeId && colorById && colorById.has(snakeId)) { + return colorById.get(snakeId); + } + if (isYou) return "var(--you)"; + return SnakeUtils.snakeColor(SnakeUtils.stableColorIndexFromId(snakeId)); + } + + static extractSnakeColor(rawSnake) { + if (!rawSnake || typeof rawSnake !== "object") return null; + const direct = rawSnake.color; + const custom = rawSnake.customizations && rawSnake.customizations.color; + const appearance = rawSnake.appearance && rawSnake.appearance.color; + const color = direct || custom || appearance; + if (!color) return null; + return String(color).trim(); + } + + static buildSnakeColorById(turnData, replay) { + const colorById = new Map(); + const replayTurns = replay && Array.isArray(replay.turns) ? replay.turns : []; + const boardSnakes = turnData && turnData.board && Array.isArray(turnData.board.snakes) + ? turnData.board.snakes + : []; + const replaySnakes = Array.isArray(turnData && turnData.snakes) ? turnData.snakes : []; + + const historicalSnakes = []; + for (const replayTurn of replayTurns) { + if (replayTurn && Array.isArray(replayTurn.snakes)) { + historicalSnakes.push(...replayTurn.snakes); + } + if (replayTurn && replayTurn.board && Array.isArray(replayTurn.board.snakes)) { + historicalSnakes.push(...replayTurn.board.snakes); + } + } + + for (const snake of [...historicalSnakes, ...boardSnakes, ...replaySnakes]) { + if (!snake) continue; + const snakeId = snake.id || snake.snake_id; + if (!snakeId) continue; + const color = SnakeUtils.extractSnakeColor(snake); + if (color) colorById.set(snakeId, color); + } + return colorById; + } + + static buildSnakeCustomizationById(turnData, replay) { + const customById = new Map(); + const replayTurns = replay && Array.isArray(replay.turns) ? replay.turns : []; + const sources = []; + + for (const replayTurn of replayTurns) { + if (replayTurn && replayTurn.board && Array.isArray(replayTurn.board.snakes)) { + sources.push(...replayTurn.board.snakes); + } + } + + if (turnData && turnData.board && Array.isArray(turnData.board.snakes)) { + sources.push(...turnData.board.snakes); + } + + for (const snake of sources) { + if (!snake) continue; + const snakeId = snake.id || snake.snake_id; + if (!snakeId) continue; + const custom = snake.customizations || {}; + const head = custom.head || null; + const tail = custom.tail || null; + if (head || tail) { + customById.set(snakeId, { head, tail }); + } + } + + return customById; + } + + static buildCustomizationIconUrl(kind, value) { + const raw = String(value || "").trim().toLowerCase(); + if (!raw) return null; + if (!/^[a-z0-9-]+$/.test(raw)) return null; + return `/dashboard/customizations/${kind}/${raw}.svg`; + } + + static parseSnakeColor(color) { + if (!color) return null; + const value = String(color).trim(); + if (value.startsWith("#")) { + const hex = value.slice(1); + if (hex.length === 3) { + const r = parseInt(hex[0] + hex[0], 16); + const g = parseInt(hex[1] + hex[1], 16); + const b = parseInt(hex[2] + hex[2], 16); + return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b }; + } + if (hex.length === 6) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b }; + } + } + const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i); + if (rgb) { + return { + r: Math.max(0, Math.min(255, Number(rgb[1]))), + g: Math.max(0, Math.min(255, Number(rgb[2]))), + b: Math.max(0, Math.min(255, Number(rgb[3]))), + }; + } + return null; + } + + static snakeRowBackground(color) { + const parsed = SnakeUtils.parseSnakeColor(color); + if (!parsed) return "transparent"; + const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; + const alpha = isDark ? 0.26 : 0.16; + return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})`; + } + + static inferHeadDirection(snake) { + const body = Array.isArray(snake && snake.body) ? snake.body : []; + if (body.length >= 2) { + const head = body[0]; + const neck = body[1]; + if (head && neck) { + const dx = Number(head.x) - Number(neck.x); + const dy = Number(head.y) - Number(neck.y); + if (dx > 0) return "right"; + if (dx < 0) return "left"; + if (dy > 0) return "up"; + if (dy < 0) return "down"; + } + } + + const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase(); + if (["up", "down", "left", "right"].includes(inferred)) return inferred; + + if (body.length < 2) return "right"; + const head = body[0]; + const neck = body[1]; + if (!head || !neck) return "right"; + const dx = Number(head.x) - Number(neck.x); + const dy = Number(head.y) - Number(neck.y); + if (dx > 0) return "right"; + if (dx < 0) return "left"; + if (dy > 0) return "up"; + if (dy < 0) return "down"; + return "right"; + } + + static inferTailDirection(snake) { + const body = Array.isArray(snake && snake.body) ? snake.body : []; + if (body.length < 2) return "right"; + const tail = body[body.length - 1]; + if (!tail) return "right"; + + let beforeTail = null; + for (let idx = body.length - 2; idx >= 0; idx -= 1) { + const candidate = body[idx]; + if (!candidate) continue; + if (Number(candidate.x) !== Number(tail.x) || Number(candidate.y) !== Number(tail.y)) { + beforeTail = candidate; + break; + } + } + + if (!beforeTail) { + const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase(); + if (["up", "down", "left", "right"].includes(inferred)) return inferred; + return "right"; + } + + const dx = Number(beforeTail.x) - Number(tail.x); + const dy = Number(beforeTail.y) - Number(tail.y); + if (dx > 0) return "right"; + if (dx < 0) return "left"; + if (dy > 0) return "up"; + if (dy < 0) return "down"; + return "right"; + } + + static directionToHeadTransform(direction) { + if (direction === "left") return "scaleX(-1)"; + if (direction === "up") return "rotate(270deg)"; + if (direction === "down") return "rotate(90deg)"; + return "rotate(0deg)"; + } + + static 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)"; + } + + static stableVariantFromString(value) { + const raw = String(value || ""); + if (!raw) return 1; + let hash = 0; + for (let i = 0; i < raw.length; i += 1) { + hash = ((hash * 33) + raw.charCodeAt(i)) >>> 0; + } + return (hash % 5) + 1; + } +} diff --git a/templates/files/js/SnakeTable.js b/templates/files/js/SnakeTable.js new file mode 100644 index 0000000..8bad2a4 --- /dev/null +++ b/templates/files/js/SnakeTable.js @@ -0,0 +1,124 @@ +class SnakeTable { + static buildSnakesRows(turn, replay) { + 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(); + const lastSeenTurnById = new Map(); + const aliveById = new Map(); + + for (const historyTurn of turns) { + const historyTurnNumber = Number(historyTurn && historyTurn.turn !== undefined ? historyTurn.turn : 0); + if (historyTurnNumber > currentTurn) continue; + for (const snake of (historyTurn.snakes || [])) { + if (!snake) continue; + const snakeId = snake.snake_id || snake.id || `${Utils.safeString(snake.snake_name)}-${historyTurnNumber}`; + lastSeenById.set(snakeId, snake); + lastSeenTurnById.set(snakeId, historyTurnNumber); + if (historyTurnNumber === currentTurn) { + aliveById.set(snakeId, snake); + } + } + } + + const snakes = []; + for (const [snakeId, snake] of lastSeenById.entries()) { + const aliveSnake = aliveById.get(snakeId); + snakes.push({ + ...(aliveSnake || snake), + _snake_id: snakeId, + _is_dead: !aliveSnake, + _last_seen_turn: Number(lastSeenTurnById.get(snakeId) ?? currentTurn), + }); + } + + snakes.sort((a, b) => { + const youDelta = Number(Boolean(b.is_you)) - Number(Boolean(a.is_you)); + if (youDelta !== 0) return youDelta; + const deadDelta = Number(Boolean(a._is_dead)) - Number(Boolean(b._is_dead)); + if (deadDelta !== 0) return deadDelta; + return Utils.safeString(a.snake_name).localeCompare(Utils.safeString(b.snake_name)); + }); + + const colorById = SnakeUtils.buildSnakeColorById(turn, replay); + if (snakes.length === 0) { + return "No snake data available"; + } + + return snakes.map((snake, idx) => { + const healthValue = Number(snake.health ?? 0); + const healthClamped = snake._is_dead ? 0 : Math.max(0, Math.min(100, healthValue)); + const healthColor = healthClamped > 60 ? "#28a264" : (healthClamped > 30 ? "#d39a1c" : "#c34939"); + const healthText = snake._is_dead ? "dead" : Utils.safeString(snake.health); + const healthCell = `${healthText}`; + const snakeId = snake._snake_id || snake.snake_id || snake.id || `${Utils.safeString(snake.snake_name)}-${idx}`; + const rowColor = SnakeUtils.resolveSnakeColor(snakeId, snake.is_you, colorById); + const rowBg = SnakeUtils.snakeRowBackground(rowColor); + const rowStyle = `style="--snake-row-color:${rowColor};--snake-row-bg:${rowBg};"`; + const diedTurn = Number(snake._last_seen_turn ?? currentTurn) + 1; + const moveText = snake._is_dead ? `dead @ ${diedTurn}` : Utils.safeString(snake.inferred_move); + const deadClass = snake._is_dead ? " dead-row" : ""; + const causeLabel = SnakeTable._getCauseLabel(snake, turns, replay); + const deadLabel = snake._is_dead ? ` (dead${causeLabel})` : ""; + return ` + + ${Utils.safeString(snake.snake_name)}${snake.is_you ? " (you)" : ""}${deadLabel} + ${Utils.safeString(snake.latency)} + ${moveText} + ${healthCell} + ${Utils.safeString(snake.length)} + ${snake.head ? `${snake.head.x},${snake.head.y}` : "-"} + ${Array.isArray(snake.body) && snake.body.length > 0 ? `${snake.body[snake.body.length - 1].x},${snake.body[snake.body.length - 1].y}` : "-"} + `; + }).join(""); + } + + static _getCauseLabel(snake, turns, replay) { + 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 ${Utils.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 ${Utils.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 ""; + } +} diff --git a/templates/files/js/Thinking.js b/templates/files/js/Thinking.js new file mode 100644 index 0000000..75b19cb --- /dev/null +++ b/templates/files/js/Thinking.js @@ -0,0 +1,110 @@ +class ThinkingPanel { + constructor(thinkingEl) { + this._el = thinkingEl; + } + + get element() { return this._el; } + + render(turn, replay) { + if (!turn) { + this._el.innerHTML = "

Select a game to inspect reasoning.

"; + this._syncMonoOffset(); + return; + } + + const reasoning = turn.my_thinking; + const reasons = this._extractReasoningList(reasoning); + const reasonList = reasons.map((item) => `
  • ${item}
  • `).join(""); + const gameMeta = replay && replay.game ? replay.game : {}; + const snakeType = Utils.safeString(gameMeta.your_snake_type); + const snakeVersion = Utils.safeString(gameMeta.your_snake_version); + + this._el.innerHTML = ` +
    +
    Chosen Move${Utils.safeString(turn.my_move)}
    +
    Snake Type${snakeType}
    +
    Snake Version${snakeVersion}
    +
    Observed At${Utils.formatObservedAtLocal(turn.observed_at)}
    +
    Food Count${Array.isArray(turn.food) ? turn.food.length : 0}
    +
    Hazard Count${Array.isArray(turn.hazards) ? turn.hazards.length : 0}
    +
    + +
    + + + + + + + + + + + + ${SnakeTable.buildSnakesRows(turn, replay)} +
    SnakeLatencyMoveHealthLengthHeadTail
    +
    + +
    +

    Move Scores

    + + + + ${MoveTable.buildScoresRows(reasoning)} +
    MoveScore
    +
    + +
    +

    Decision Summary

    + +
    + +
    +

    Raw Reasoning Payload

    +
    ${JSON.stringify(reasoning, null, 2)}
    +
    + `; + this._syncMonoOffset(); + } + + highlightSnake(snakeId) { + const section = this._el.querySelector(".snakes-section"); + if (!section) return; + section.querySelectorAll(".snake-row.highlighted").forEach((r) => r.classList.remove("highlighted")); + section.classList.remove("has-highlight"); + if (!snakeId) return; + const row = section.querySelector(`[data-snake-id="${CSS.escape(snakeId)}"]`); + if (row) { + row.classList.add("highlighted"); + section.classList.add("has-highlight"); + } + } + + syncMonoOffset() { + this._syncMonoOffset(); + } + + _syncMonoOffset() { + const mono = this._el.querySelector(".mono"); + if (!mono) return; + const rect = mono.getBoundingClientRect(); + const bottomPaddingPx = 36; + const offset = Math.max(120, Math.round(rect.top + bottomPaddingPx)); + document.documentElement.style.setProperty("--mono-vh-offset", `${offset}px`); + } + + _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; + } +} diff --git a/templates/files/js/Utils.js b/templates/files/js/Utils.js new file mode 100644 index 0000000..701ca04 --- /dev/null +++ b/templates/files/js/Utils.js @@ -0,0 +1,25 @@ +class Utils { + static safeString(value) { + if (value === null || value === undefined || value === "") return "-"; + return String(value); + } + + static toTitle(value) { + if (String(value || "").toLowerCase() === "finished") return "Done"; + return String(value || "").replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase()); + } + + static formatObservedAtLocal(value) { + if (value === null || value === undefined || value === "") return "-"; + const raw = String(value).trim(); + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) { + return Utils.safeString(raw).slice(11, 19); + } + return parsed.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } +} diff --git a/templates/side/dashboard.htm b/templates/side/dashboard.htm index c97edee..e57e63b 100644 --- a/templates/side/dashboard.htm +++ b/templates/side/dashboard.htm @@ -68,1295 +68,92 @@ + + + + + + + + + +