Compare commits
2 Commits
f4c0ad193e
...
739c0520f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
739c0520f9
|
|||
|
03968fecdf
|
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) => `
|
||||
<tr data-game-id="${g.game_id}">
|
||||
<td><a href="${this._battlesnakeUrl}/${g.game_id}"><code>${GamesTable._shortId(g.game_id)}</code></a><br><small>${Utils.safeString(GamesTable._displayGameTypeOrMap(g))}</small></td>
|
||||
<td>${Utils.toTitle(g.status)}</td>
|
||||
<td>${g.status === "running" ? "-" : g.winner_you ? "Win" : "Loss"}</td>
|
||||
<td>${Utils.safeString(g.final_turn)}</td>
|
||||
</tr>
|
||||
`).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 || "-";
|
||||
}
|
||||
}
|
||||
@@ -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 `<tr><td>${move}</td><td>${Utils.safeString(value)}</td></tr>`;
|
||||
}).join("");
|
||||
}
|
||||
}
|
||||
@@ -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]) => (
|
||||
`<div class="stat"><span class="k">${k}</span><span class="v">${v}</span></div>`
|
||||
)).join("");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 "<tr><td colspan=\"7\">No snake data available</td></tr>";
|
||||
}
|
||||
|
||||
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 = `<span class="health-wrap"><span class="health-fill" style="width:${healthClamped}%;background:${healthColor};"></span></span><span class="health-text">${healthText}</span>`;
|
||||
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 `
|
||||
<tr class="snake-row${deadClass}" data-snake-id="${snakeId}" ${rowStyle}>
|
||||
<td class="name-cell">${Utils.safeString(snake.snake_name)}${snake.is_you ? " (you)" : ""}${deadLabel}</td>
|
||||
<td class="num-cell">${Utils.safeString(snake.latency)}</td>
|
||||
<td class="num-cell">${moveText}</td>
|
||||
<td class="num-cell">${healthCell}</td>
|
||||
<td class="num-cell">${Utils.safeString(snake.length)}</td>
|
||||
<td class="num-cell">${snake.head ? `${snake.head.x},${snake.head.y}` : "-"}</td>
|
||||
<td class="num-cell">${Array.isArray(snake.body) && snake.body.length > 0 ? `${snake.body[snake.body.length - 1].x},${snake.body[snake.body.length - 1].y}` : "-"}</td>
|
||||
</tr>`;
|
||||
}).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 "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
class ThinkingPanel {
|
||||
constructor(thinkingEl) {
|
||||
this._el = thinkingEl;
|
||||
}
|
||||
|
||||
get element() { return this._el; }
|
||||
|
||||
render(turn, replay) {
|
||||
if (!turn) {
|
||||
this._el.innerHTML = "<p class=\"section-title\">Select a game to inspect reasoning.</p>";
|
||||
this._syncMonoOffset();
|
||||
return;
|
||||
}
|
||||
|
||||
const reasoning = turn.my_thinking;
|
||||
const reasons = this._extractReasoningList(reasoning);
|
||||
const reasonList = reasons.map((item) => `<li>${item}</li>`).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 = `
|
||||
<div class="think-grid">
|
||||
<div class="chip"><span class="k">Chosen Move</span><span class="v">${Utils.safeString(turn.my_move)}</span></div>
|
||||
<div class="chip"><span class="k">Snake Type</span><span class="v">${snakeType}</span></div>
|
||||
<div class="chip"><span class="k">Snake Version</span><span class="v">${snakeVersion}</span></div>
|
||||
<div class="chip"><span class="k">Observed At</span><span class="v">${Utils.formatObservedAtLocal(turn.observed_at)}</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 class="snakes-section">
|
||||
<table class="score-table">
|
||||
<colgroup>
|
||||
<col style="width:32%">
|
||||
<col style="width:10%">
|
||||
<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>Latency</th><th>Move</th><th>Health</th><th>Length</th><th>Head</th><th>Tail</th></tr></thead>
|
||||
<tbody>${SnakeTable.buildSnakesRows(turn, replay)}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="scores-section">
|
||||
<p class="section-title">Move Scores</p>
|
||||
<table class="score-table">
|
||||
<colgroup><col style="width:50%"><col style="width:50%"></colgroup>
|
||||
<thead><tr><th>Move</th><th>Score</th></tr></thead>
|
||||
<tbody>${MoveTable.buildScoresRows(reasoning)}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="section-title">Decision Summary</p>
|
||||
<ul class="reason-list">${reasonList}</ul>
|
||||
</section>
|
||||
|
||||
<section class="raw-block">
|
||||
<p class="section-title">Raw Reasoning Payload</p>
|
||||
<pre class="mono">${JSON.stringify(reasoning, null, 2)}</pre>
|
||||
</section>
|
||||
`;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
+74
-1288
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user