feat: add live replays and PostgreSQL maintenance tools
Build and Push Docker Container / build-and-push (push) Successful in 7m53s

- Stream compact live replay updates across local and clustered dashboards.
- Render responsive snake bodies as SVG paths with aligned custom icons.
- Add cache-busted assets, replay fallback routes, and live-follow playback.
- Support PostgreSQL benchmark sampling and idempotent SQLite migration.
- Add dry-run cleanup for old low-quality PostgreSQL replay payloads.
- Reward safe perimeter lanes and bump Prism to version 1.5.0.
- Add backend, migration, dashboard, and perimeter regression coverage.
This commit is contained in:
2026-08-02 00:50:46 +02:00
parent 9b99b526e4
commit f14d780f29
29 changed files with 1574 additions and 310 deletions
+76 -70
View File
@@ -262,11 +262,12 @@ input[type="range"] {
}
.board-wrap {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
gap: 8px;
min-height: 520px;
display: flex;
align-items: flex-start;
justify-content: center;
overflow: hidden;
}
.legend {
@@ -291,54 +292,38 @@ input[type="range"] {
}
.board {
position: relative;
min-width: 0;
min-height: 0;
height: auto;
width: 100%;
display: grid;
gap: 2px;
flex: none;
background: var(--grid);
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px;
align-content: start;
overflow: hidden;
}
/* Snake bodies are drawn as one rounded polyline per snake on top of the grid,
so bends and cell gaps need no per-cell patching. */
.snake-layer {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.cell {
background: var(--cell);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
aspect-ratio: 1 / 1;
position: relative;
border-radius: 2px;
}
.snake-turn-cell::after {
content: "";
position: absolute;
inset: 0;
background: var(--turn-color, transparent);
z-index: 0;
pointer-events: none;
}
/* 50% = quarter-circle at the inner corner of the bend */
.snake-turn-cell.snake-turn-ur::after {
border-top-right-radius: 50%;
}
.snake-turn-cell.snake-turn-ul::after {
border-top-left-radius: 50%;
}
.snake-turn-cell.snake-turn-dr::after {
border-bottom-right-radius: 50%;
}
.snake-turn-cell.snake-turn-dl::after {
border-bottom-left-radius: 50%;
}
.food {
background-image: radial-gradient(circle at center, #d73a31 0 45%, transparent 48%);
background-repeat: no-repeat;
@@ -350,6 +335,12 @@ input[type="range"] {
background-color: var(--hazard);
}
/* The hatch overlay (::before, z-index 4) still paints above the body stroke,
but the opaque fill would hide it. */
.hazard.hazard-over-snake {
background-color: transparent;
}
.hazard::before {
content: "";
position: absolute;
@@ -364,17 +355,11 @@ input[type="range"] {
border-radius: inherit;
}
.snake-you {
background: var(--you);
}
.snake-enemy {
background: var(--enemy);
}
/* Head/tail markers and icon layers use z-index >= 2 so they paint above
.snake-layer. The cells themselves stay unstacked, keeping their background
below the body stroke. */
.snake-head {
outline: 2px solid var(--head-ring);
outline-offset: -2px;
outline: none;
}
.snake-head::after {
@@ -494,43 +479,41 @@ input[type="range"] {
display: none;
}
.snake-tail-you.has-tail-icon,
.snake-tail-enemy.has-tail-icon {
box-shadow: none;
}
/* Spans the full cell along the travel axis and the body stroke width across
it, so the icon is exactly as thick as the body and its leading edge lands on
the cell border where the stroke ends. */
.icon-layer {
position: absolute;
inset: 2%;
background: var(--icon-color, currentColor);
-webkit-mask-image: var(--icon-url);
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
mask-image: var(--icon-url);
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
left: 0;
right: 0;
top: var(--icon-cross-inset, 7%);
bottom: var(--icon-cross-inset, 7%);
transform: var(--icon-transform, rotate(0deg));
transform-origin: center;
pointer-events: none;
z-index: 2;
}
.icon-layer--tail {
z-index: 2;
opacity: 0.92;
}
.icon-layer--head {
z-index: 3;
opacity: 1;
background: none;
-webkit-mask-image: none;
mask-image: none;
}
.icon-layer--head>svg {
/* Fallback for artwork that has not been fetched yet. An SVG used as a mask
image keeps its own preserveAspectRatio and letterboxes inside this
non-square box, so the inlined variant above is preferred. */
.icon-layer--masked {
background: var(--icon-color, currentColor);
-webkit-mask-image: var(--icon-url);
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: 100% 100%;
mask-image: var(--icon-url);
mask-repeat: no-repeat;
mask-position: center;
mask-size: 100% 100%;
}
.icon-layer>svg {
width: 100%;
height: 100%;
display: block;
@@ -693,6 +676,19 @@ input[type="range"] {
}
@media (max-width: 1100px) {
html,
body {
height: auto;
min-height: 100%;
overflow: auto;
}
.page {
height: auto;
min-height: 100vh;
overflow: visible;
}
.topbar {
grid-template-columns: 1fr;
}
@@ -704,6 +700,12 @@ input[type="range"] {
.main {
grid-template-columns: 1fr;
overflow: visible;
}
.panel,
.right {
overflow: visible;
}
.games {
@@ -714,8 +716,12 @@ input[type="range"] {
grid-template-columns: 1fr;
}
.thinking {
overflow: visible;
}
.board-wrap {
min-height: 360px;
min-height: min(70vw, 520px);
}
.turn-badge {
+7 -1
View File
@@ -1,11 +1,12 @@
class DashboardWebSocket {
constructor({ onGamesUpdate, onShutdown } = {}) {
constructor({ onGamesUpdate, onReplayUpdate, onShutdown } = {}) {
this._socket = null;
this._reconnectTimer = null;
this._shuttingDown = false;
this._pendingRequests = new Map();
this._requestSeq = 0;
this._onGamesUpdate = onGamesUpdate || (() => {});
this._onReplayUpdate = onReplayUpdate || (() => {});
this._onShutdown = onShutdown || (() => {});
}
@@ -58,6 +59,11 @@ class DashboardWebSocket {
return;
}
if (payload.type === "dashboard_game_replay_update") {
this._onReplayUpdate(payload);
return;
}
if (payload.type === "dashboard_games_update") {
this._onGamesUpdate(payload);
}
+345 -93
View File
@@ -1,12 +1,73 @@
class GameBoard {
static SVG_NS = "http://www.w3.org/2000/svg";
// Stroke width as a fraction of the cell size. Slightly wider than the cell
// interior so the stroke bridges the 2px grid gap between adjacent cells.
static BODY_WIDTH_RATIO = 0.86;
// How far the body stroke runs past the point where the icon artwork starts.
static SEAM_OVERLAP_PX = 1;
constructor(boardEl) {
this._boardEl = boardEl;
this._svgCache = new Map();
this._iconLeadInset = new Map();
this._measureCanvas = null;
this._boardWidth = 0;
this._boardHeight = 0;
this._snakeLayer = null;
this._lastPaint = null;
this._lastArgs = null;
this._resizeObserver = typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => {
this._fitBoard();
this._renderSnakeLayer();
});
if (this._resizeObserver && this._boardEl.parentElement) {
this._resizeObserver.observe(this._boardEl.parentElement);
}
}
clearBoard() {
this._boardEl.innerHTML = "";
this._boardEl.style.gridTemplateColumns = "none";
this._boardEl.style.gridTemplateRows = "none";
this._boardEl.style.width = "";
this._boardEl.style.height = "";
this._boardWidth = 0;
this._boardHeight = 0;
this._snakeLayer = null;
this._lastPaint = null;
this._lastArgs = null;
}
_fitBoard() {
const container = this._boardEl.parentElement;
if (!container || !this._boardWidth || !this._boardHeight) return;
const availableWidth = container.clientWidth;
const availableHeight = container.clientHeight;
if (availableWidth <= 0 || availableHeight <= 0) return;
const style = window.getComputedStyle(this._boardEl);
const horizontalChrome = Number.parseFloat(style.paddingLeft)
+ Number.parseFloat(style.paddingRight)
+ Number.parseFloat(style.borderLeftWidth)
+ Number.parseFloat(style.borderRightWidth);
const verticalChrome = Number.parseFloat(style.paddingTop)
+ Number.parseFloat(style.paddingBottom)
+ Number.parseFloat(style.borderTopWidth)
+ Number.parseFloat(style.borderBottomWidth);
const columnGap = Number.parseFloat(style.columnGap) || 0;
const rowGap = Number.parseFloat(style.rowGap) || 0;
const fixedWidth = horizontalChrome + (columnGap * Math.max(0, this._boardWidth - 1));
const fixedHeight = verticalChrome + (rowGap * Math.max(0, this._boardHeight - 1));
const cellSize = Math.max(0, Math.min(
(availableWidth - fixedWidth) / this._boardWidth,
(availableHeight - fixedHeight) / this._boardHeight,
));
this._boardEl.style.width = `${(cellSize * this._boardWidth) + fixedWidth}px`;
this._boardEl.style.height = `${(cellSize * this._boardHeight) + fixedHeight}px`;
}
async preloadSvgs(replay) {
@@ -27,14 +88,70 @@ class GameBoard {
async _loadSvg(url) {
if (this._svgCache.has(url)) return this._svgCache.get(url);
let text = null;
try {
const res = await fetch(url);
const text = res.ok ? await res.text() : null;
this._svgCache.set(url, text);
return text;
text = res.ok ? await res.text() : null;
} catch {
this._svgCache.set(url, null);
return null;
text = null;
}
this._svgCache.set(url, text);
await this._measureLeadInset(url, text);
return text;
}
// How far the artwork sits back from the edge that meets the body, as a
// fraction of the cell. Most icons touch it (0), but a handful of designs
// start further in and would leave a visible seam if the stroke stopped at
// the cell border. Measured once per icon by rasterising it at the same
// aspect ratio the layer uses. A null result means the artwork never spans
// the full body width, so the stroke should not be pulled back at all.
async _measureLeadInset(url, svgMarkup) {
if (this._iconLeadInset.has(url)) return;
this._iconLeadInset.set(url, null);
if (!svgMarkup) return;
const width = 200;
const height = Math.round(width * GameBoard.BODY_WIDTH_RATIO);
try {
const parsed = new DOMParser().parseFromString(
this._normalizeIconSvgMarkup(svgMarkup) || svgMarkup, "image/svg+xml",
);
const svgEl = parsed.querySelector("svg");
if (!svgEl) return;
svgEl.setAttribute("preserveAspectRatio", "none");
svgEl.setAttribute("width", String(width));
svgEl.setAttribute("height", String(height));
const image = new Image();
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svgEl))}`;
await image.decode();
if (!this._measureCanvas) this._measureCanvas = document.createElement("canvas");
const canvas = this._measureCanvas;
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.clearRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
// The edge must be covered across its whole width. Accepting "almost
// covered" leaves a notch at the seam corners that reads as a line, so
// the bar is every row inked, with a low alpha cut-off so antialiased
// edge pixels still count.
for (let x = 0; x < width / 2; x += 1) {
let covered = 0;
for (let y = 0; y < height; y += 1) {
if (pixels[(((y * width) + x) * 4) + 3] > 8) covered += 1;
}
if (covered === height) {
this._iconLeadInset.set(url, x / width);
return;
}
}
} catch {
// Leave the inset unknown; the stroke then runs to the cell centre.
}
}
@@ -97,7 +214,7 @@ class GameBoard {
return maxDepth;
}
_normalizeHeadSvgMarkup(svgMarkup) {
_normalizeIconSvgMarkup(svgMarkup) {
if (!svgMarkup) return null;
try {
const parser = new DOMParser();
@@ -124,41 +241,203 @@ class GameBoard {
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");
}
// The icon artwork joins the body along its full leading edge, so the layer
// is squeezed to the body stroke width and stretched (no aspect ratio) to
// meet the stroke end flush.
layer.style.setProperty("--icon-cross-inset", `${(1 - GameBoard.BODY_WIDTH_RATIO) * 50}%`);
// Both head and tail artwork is inlined rather than used as a CSS mask: an
// SVG referenced as an image keeps its own preserveAspectRatio, so it would
// letterbox inside the non-square layer and leave a gap towards the body.
const svgMarkup = this._svgCache.get(iconUrl);
if (svgMarkup) {
layer.innerHTML = this._normalizeIconSvgMarkup(svgMarkup);
const svgEl = layer.querySelector("svg");
if (svgEl) {
svgEl.setAttribute("preserveAspectRatio", "none");
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;
}
// Artwork not cached yet (live turn arriving before the preload finished):
// fall back to the mask so the icon still shows, and repaint once loaded.
layer.classList.add("icon-layer--masked");
layer.style.setProperty("--icon-url", `url(${iconUrl})`);
layer.style.setProperty("--icon-color", color || "var(--you)");
this._loadSvg(iconUrl).then((markup) => {
if (markup) this._repaintLast();
});
return layer;
}
_repaintLast() {
const args = this._lastArgs;
if (!args) return;
this.paintBoard(args.turnData, args.width, args.height, args.selectedSnakeId, args.replay);
}
_cellKey(x, y) {
return `${x}:${y}`;
}
// Collapses the stacked duplicate segments Battlesnake emits at spawn and
// right after eating, so the body becomes a clean orthogonal polyline.
_bodyPolyline(snake) {
const body = Array.isArray(snake && snake.body) ? snake.body : [];
const points = [];
for (const part of body) {
if (!part) continue;
const point = { x: Number(part.x), y: Number(part.y) };
if (Number.isNaN(point.x) || Number.isNaN(point.y)) continue;
const previous = points[points.length - 1];
if (previous && previous.x === point.x && previous.y === point.y) continue;
points.push(point);
}
return points;
}
// Pixel geometry of the grid, derived from the same box metrics the CSS grid
// uses, so SVG coordinates land exactly on cell centres.
_gridMetrics() {
if (!this._boardWidth || !this._boardHeight) return null;
const style = window.getComputedStyle(this._boardEl);
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
const paddingTop = Number.parseFloat(style.paddingTop) || 0;
const paddingRight = Number.parseFloat(style.paddingRight) || 0;
const paddingBottom = Number.parseFloat(style.paddingBottom) || 0;
const columnGap = Number.parseFloat(style.columnGap) || 0;
const rowGap = Number.parseFloat(style.rowGap) || 0;
const boxWidth = this._boardEl.clientWidth;
const boxHeight = this._boardEl.clientHeight;
const cellWidth = (boxWidth - paddingLeft - paddingRight - (columnGap * (this._boardWidth - 1))) / this._boardWidth;
const cellHeight = (boxHeight - paddingTop - paddingBottom - (rowGap * (this._boardHeight - 1))) / this._boardHeight;
if (!(cellWidth > 0) || !(cellHeight > 0)) return null;
return { paddingLeft, paddingTop, columnGap, rowGap, cellWidth, cellHeight, boxWidth, boxHeight };
}
_cellCenter(point, metrics) {
const row = this._boardHeight - 1 - point.y;
return {
x: metrics.paddingLeft + (point.x * (metrics.cellWidth + metrics.columnGap)) + (metrics.cellWidth / 2),
y: metrics.paddingTop + (row * (metrics.cellHeight + metrics.rowGap)) + (metrics.cellHeight / 2),
};
}
// Pulls the polyline end back so the customization icon owns its cell.
// Combined with a butt cap the stroke stops exactly where the artwork starts:
// at the cell border for the usual icon, deeper into the cell for artwork
// that sits back from that border (leadInset).
_retractEnd(endCenter, neighbourCenter, metrics, leadInset) {
const dx = endCenter.x - neighbourCenter.x;
const dy = endCenter.y - neighbourCenter.y;
const distance = Math.hypot(dx, dy);
if (!(distance > 0)) return endCenter;
const halfCell = Math.abs(dx) >= Math.abs(dy)
? metrics.cellWidth / 2
: metrics.cellHeight / 2;
// A full CSS pixel of overlap: both sides are the same colour, so overlap
// is free, and it keeps sub-pixel rounding from opening a hairline seam on
// displays with a fractional device pixel ratio.
const artworkOffset = (leadInset || 0) * halfCell * 2;
const pullBack = Math.min(distance, Math.max(0, halfCell - artworkOffset - GameBoard.SEAM_OVERLAP_PX));
return {
x: endCenter.x - ((dx / distance) * pullBack),
y: endCenter.y - ((dy / distance) * pullBack),
};
}
_appendRoundEnd(group, center, radius, color) {
const dot = document.createElementNS(GameBoard.SVG_NS, "circle");
dot.setAttribute("cx", `${center.x}`);
dot.setAttribute("cy", `${center.y}`);
dot.setAttribute("r", `${radius}`);
dot.setAttribute("fill", color);
group.appendChild(dot);
}
_renderSnakeLayer() {
if (!this._snakeLayer || !this._lastPaint) return;
const metrics = this._gridMetrics();
while (this._snakeLayer.firstChild) this._snakeLayer.removeChild(this._snakeLayer.firstChild);
if (!metrics) return;
this._snakeLayer.setAttribute("viewBox", `0 0 ${metrics.boxWidth} ${metrics.boxHeight}`);
this._snakeLayer.setAttribute("width", `${metrics.boxWidth}`);
this._snakeLayer.setAttribute("height", `${metrics.boxHeight}`);
const cellSize = Math.min(metrics.cellWidth, metrics.cellHeight);
const strokeWidth = cellSize * GameBoard.BODY_WIDTH_RATIO;
const { snakes, selectedSnakeId } = this._lastPaint;
for (const entry of snakes) {
const points = entry.points;
if (points.length === 0) continue;
const centers = points.map((point) => this._cellCenter(point, metrics));
const dimmed = Boolean(selectedSnakeId) && entry.snakeId !== selectedSnakeId;
// One group per snake so dimming applies once instead of stacking up on
// overlapping shapes.
const group = document.createElementNS(GameBoard.SVG_NS, "g");
if (dimmed) group.setAttribute("opacity", "0.2");
this._snakeLayer.appendChild(group);
if (centers.length === 1) {
// Fully stacked body (spawn turn): a single round blob, unless the head
// icon already fills that cell.
if (!entry.headIcon) {
this._appendRoundEnd(group, centers[0], strokeWidth / 2, entry.color);
}
continue;
}
const last = centers.length - 1;
// An icon only takes over its cell if its artwork spans the full body
// width somewhere; otherwise the stroke runs to the cell centre and keeps
// its rounded end, with the icon drawn on top. Ends without an icon keep
// the rounded look via an explicit cap circle, because linecap applies to
// both ends of the path at once.
if (entry.headIcon && entry.headLeadInset !== null) {
centers[0] = this._retractEnd(centers[0], centers[1], metrics, entry.headLeadInset);
} else {
this._appendRoundEnd(group, centers[0], strokeWidth / 2, entry.color);
}
if (entry.tailIcon && entry.tailLeadInset !== null) {
centers[last] = this._retractEnd(centers[last], centers[last - 1], metrics, entry.tailLeadInset);
} else {
this._appendRoundEnd(group, centers[last], strokeWidth / 2, entry.color);
}
const path = document.createElementNS(GameBoard.SVG_NS, "path");
path.setAttribute("d", centers.map((point, idx) => `${idx === 0 ? "M" : "L"}${point.x} ${point.y}`).join(" "));
path.setAttribute("fill", "none");
path.setAttribute("stroke", entry.color);
path.setAttribute("stroke-width", `${strokeWidth}`);
path.setAttribute("stroke-linecap", "butt");
path.setAttribute("stroke-linejoin", "round");
group.appendChild(path);
}
}
paintBoard(turnData, width, height, selectedSnakeId, replay) {
this.clearBoard();
if (!turnData || !width || !height) return;
this._lastArgs = { turnData, width, height, selectedSnakeId, replay };
const colorById = SnakeUtils.buildSnakeColorById(turnData, replay);
const customById = SnakeUtils.buildSnakeCustomizationById(turnData, replay);
this._boardEl.style.gridTemplateColumns = `repeat(${width}, 1fr)`;
this._boardWidth = Number(width);
this._boardHeight = Number(height);
this._boardEl.style.gridTemplateColumns = `repeat(${width}, minmax(0, 1fr))`;
this._boardEl.style.gridTemplateRows = `repeat(${height}, minmax(0, 1fr))`;
this._fitBoard();
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 occupiedCells = new Set();
const snakeHead = new Set();
const snakeTail = new Map();
const headVariantByCell = new Map();
@@ -169,6 +448,7 @@ class GameBoard {
const tailTransformByCell = new Map();
const snakeColorByCell = new Map();
const snakeIdByCell = new Map();
const snakeEntries = [];
(turnData.snakes || []).forEach((snake, idx) => {
if (!snake) return;
@@ -181,28 +461,48 @@ class GameBoard {
const tailIcon = SnakeUtils.buildCustomizationIconUrl("tails", custom.tail);
const headTransform = SnakeUtils.directionToHeadTransform(SnakeUtils.inferHeadDirection(snake));
const tailTransform = SnakeUtils.directionToTailTransform(SnakeUtils.inferTailDirection(snake));
const points = this._bodyPolyline(snake);
for (const part of (snake.body || [])) {
snakeBody.set(this._cellKey(part.x, part.y), bodyColor);
occupiedCells.add(this._cellKey(part.x, part.y));
snakeIdByCell.set(this._cellKey(part.x, part.y), snakeId);
}
if (snake.head) {
const headKey = this._cellKey(snake.head.x, snake.head.y);
// `head` is authoritative when present, but some payloads omit it; the
// polyline start is the same cell.
const headPoint = snake.head || points[0] || null;
const headKey = headPoint ? this._cellKey(headPoint.x, headPoint.y) : null;
if (headKey !== null) {
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];
// A tail stacked under this snake's own head has no free cell to draw in.
// Another snake's head landing there must not suppress it, so the check is
// snake-local rather than against every head seen so far.
let drawTailIcon = false;
if (points.length > 0) {
const tail = points[points.length - 1];
const tailKey = this._cellKey(tail.x, tail.y);
drawTailIcon = Boolean(tailIcon) && tailKey !== headKey;
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);
if (drawTailIcon) tailIconByCell.set(tailKey, tailIcon);
}
const leadInset = (url) => (this._iconLeadInset.has(url) ? this._iconLeadInset.get(url) : 0);
snakeEntries.push({
snakeId,
color: bodyColor,
points,
headIcon: Boolean(headIcon),
tailIcon: drawTailIcon,
headLeadInset: headIcon ? leadInset(headIcon) : 0,
tailLeadInset: drawTailIcon ? leadInset(tailIcon) : 0,
});
});
for (let y = height - 1; y >= 0; y--) {
@@ -210,75 +510,21 @@ class GameBoard {
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(", ");
}
const occupied = occupiedCells.has(key);
if (hazards.has(key)) {
cell.classList.add("hazard");
// Keep the hazard hatch readable on top of the snake stroke.
if (occupied) cell.classList.add("hazard-over-snake");
}
if (foods.has(key) && !occupied) cell.classList.add("food");
if (occupied && selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
cell.style.opacity = "0.2";
}
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)) {
if (tailIcon) {
cell.classList.add("has-tail-icon", "icon-tail");
cell.appendChild(this._createIconLayer(
tailIcon,
@@ -305,5 +551,11 @@ class GameBoard {
this._boardEl.appendChild(cell);
}
}
this._snakeLayer = document.createElementNS(GameBoard.SVG_NS, "svg");
this._snakeLayer.setAttribute("class", "snake-layer");
this._boardEl.appendChild(this._snakeLayer);
this._lastPaint = { snakes: snakeEntries, selectedSnakeId: selectedSnakeId || null };
this._renderSnakeLayer();
}
}
+70 -3
View File
@@ -12,6 +12,7 @@ class GameState {
this.activeGameId = "";
this.selectedSnakeId = null;
this._timer = null;
this._followLive = false;
this._hasLoadedReplayOnce = false;
}
@@ -19,7 +20,20 @@ class GameState {
this._webSocket = webSocket;
}
get isPlaying() { return Boolean(this._timer); }
get isPlaying() { return Boolean(this._timer) || this._followLive; }
_isRunningReplay(replay = this.replay) {
return Boolean(replay && replay.game && replay.game.status === "running");
}
_isAtLatestTurn() {
return Boolean(
this.replay
&& Array.isArray(this.replay.turns)
&& this.replay.turns.length > 0
&& this.turnIndex >= this.replay.turns.length - 1
);
}
async loadReplay(gameId) {
let nextReplay = null;
@@ -44,6 +58,7 @@ class GameState {
nextReplay = await response.json();
}
this.stopPlayback();
this.replay = nextReplay;
this._hasLoadedReplayOnce = true;
this.activeGameId = String(gameId || "");
@@ -76,11 +91,44 @@ class GameState {
}
}
async applyLiveTurn(gameId, game, turn) {
if (!turn || String(gameId || "") !== this.activeGameId || !this.replay) return;
const wasFollowingLive = this._followLive;
const wasAtLatest = this._isAtLatestTurn();
const turnsBefore = Array.isArray(this.replay.turns) ? this.replay.turns : [];
const previousCount = turnsBefore.length;
const turnNumber = Number(turn.turn);
const existingIndex = turnsBefore.findIndex((item) => Number(item.turn) === turnNumber);
if (existingIndex >= 0) turnsBefore[existingIndex] = turn;
else turnsBefore.push(turn);
turnsBefore.sort((left, right) => Number(left.turn) - Number(right.turn));
this.replay.turns = turnsBefore;
if (game && typeof game === "object") {
this.replay.game = { ...(this.replay.game || {}), ...game };
}
await this._gameBoard.preloadSvgs({ turns: [turn] });
const turns = this.replay.turns;
this._sliderEl.max = String(Math.max(0, turns.length - 1));
if (wasFollowingLive || wasAtLatest || previousCount === 0) {
this.turnIndex = Math.max(0, turns.length - 1);
this.renderTurn();
} else {
this.turnIndex = Math.min(this.turnIndex, Math.max(0, turns.length - 1));
this.renderTurn();
}
if (!this._isRunningReplay()) this.stopPlayback();
}
stopPlayback() {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
this._followLive = false;
const playBtn = document.getElementById("play-btn");
playBtn.textContent = "▶";
playBtn.setAttribute("title", "Play");
@@ -88,15 +136,34 @@ class GameState {
}
startPlayback() {
if (!this.replay || !Array.isArray(this.replay.turns) || this.replay.turns.length < 2) return;
if (!this.replay || !Array.isArray(this.replay.turns) || this.replay.turns.length === 0) return;
this.stopPlayback();
if (this._isRunningReplay() && this._isAtLatestTurn()) {
this._followLive = true;
const playBtn = document.getElementById("play-btn");
playBtn.textContent = "●";
playBtn.setAttribute("title", "Following live game");
playBtn.setAttribute("aria-label", "Following live game");
return;
}
if (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) {
if (this._isRunningReplay()) {
clearInterval(this._timer);
this._timer = null;
this._followLive = true;
const liveBtn = document.getElementById("play-btn");
liveBtn.textContent = "●";
liveBtn.setAttribute("title", "Following live game");
liveBtn.setAttribute("aria-label", "Following live game");
return;
}
this.stopPlayback();
return;
}
+41 -46
View File
@@ -132,66 +132,61 @@ class SnakeUtils {
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);
// Board coordinates are y-up, so a positive dy means "up".
static _deltaToDirection(dx, dy) {
if (dx > 0) return "right";
if (dx < 0) return "left";
if (dy > 0) return "up";
if (dy < 0) return "down";
return null;
}
static _fallbackDirection(snake) {
const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase();
if (["up", "down", "left", "right"].includes(inferred)) return inferred;
return "right";
}
// Segments stack on spawn and right after eating, so both ends scan past
// duplicates to find the first cell that actually differs.
static inferHeadDirection(snake) {
const body = Array.isArray(snake && snake.body) ? snake.body : [];
const head = body[0];
if (!head) return SnakeUtils._fallbackDirection(snake);
for (let idx = 1; idx < body.length; idx += 1) {
const neck = body[idx];
if (!neck) continue;
if (Number(neck.x) === Number(head.x) && Number(neck.y) === Number(head.y)) continue;
const direction = SnakeUtils._deltaToDirection(
Number(head.x) - Number(neck.x),
Number(head.y) - Number(neck.y),
);
if (direction) return direction;
break;
}
return SnakeUtils._fallbackDirection(snake);
}
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";
if (!tail) return SnakeUtils._fallbackDirection(snake);
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;
}
const beforeTail = body[idx];
if (!beforeTail) continue;
if (Number(beforeTail.x) === Number(tail.x) && Number(beforeTail.y) === Number(tail.y)) continue;
const direction = SnakeUtils._deltaToDirection(
Number(beforeTail.x) - Number(tail.x),
Number(beforeTail.y) - Number(tail.y),
);
if (direction) return direction;
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";
return SnakeUtils._fallbackDirection(snake);
}
static directionToHeadTransform(direction) {
+32 -12
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/root.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<link rel="stylesheet" href="{{ static_url('css/root.css') }}">
<link rel="stylesheet" href="{{ static_url('css/styles.css') }}">
</head>
<body>
<div class="page">
@@ -51,6 +51,7 @@
<option value="400">1.5x</option>
<option value="250">2x</option>
<option value="160">2.5x</option>
<option value="120">3x</option>
</select>
</label>
<input type="range" min="0" max="0" step="1" id="turn-slider" value="0">
@@ -68,16 +69,16 @@
</main>
</div>
<script src="{{ url_for('static', filename='js/Utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/Snake.js') }}"></script>
<script src="{{ url_for('static', filename='js/MoveTable.js') }}"></script>
<script src="{{ url_for('static', filename='js/SnakeTable.js') }}"></script>
<script src="{{ url_for('static', filename='js/GameBoard.js') }}"></script>
<script src="{{ url_for('static', filename='js/OverallStats.js') }}"></script>
<script src="{{ url_for('static', filename='js/GamesTable.js') }}"></script>
<script src="{{ url_for('static', filename='js/Thinking.js') }}"></script>
<script src="{{ url_for('static', filename='js/DashboardWebSocket.js') }}"></script>
<script src="{{ url_for('static', filename='js/GameState.js') }}"></script>
<script src="{{ static_url('js/Utils.js') }}"></script>
<script src="{{ static_url('js/Snake.js') }}"></script>
<script src="{{ static_url('js/MoveTable.js') }}"></script>
<script src="{{ static_url('js/SnakeTable.js') }}"></script>
<script src="{{ static_url('js/GameBoard.js') }}"></script>
<script src="{{ static_url('js/OverallStats.js') }}"></script>
<script src="{{ static_url('js/GamesTable.js') }}"></script>
<script src="{{ static_url('js/Thinking.js') }}"></script>
<script src="{{ static_url('js/DashboardWebSocket.js') }}"></script>
<script src="{{ static_url('js/GameState.js') }}"></script>
<script>
const initialGameId = {{ initial_game_id|tojson }};
const initialSummary = {{ initial_summary|tojson }};
@@ -102,6 +103,20 @@
gameState._gamesTable = gamesTable;
const dashboardWS = new DashboardWebSocket({
onReplayUpdate: (payload) => {
const games = Array.isArray(dashboardGamesPayload.games)
? dashboardGamesPayload.games
: [];
const listedGame = games.find(
(game) => String(game.game_id) === String(payload.game_id),
);
if (listedGame && payload.game) {
listedGame.final_turn = payload.game.final_turn;
listedGame.status = payload.game.status;
gamesTable.render(games, gameState.activeGameId);
}
gameState.applyLiveTurn(payload.game_id, payload.game, payload.turn);
},
onGamesUpdate: (payload) => {
if (payload.summary) {
dashboardSummary = payload.summary;
@@ -111,6 +126,11 @@
dashboardGamesPayload = payload.games;
const games = Array.isArray(dashboardGamesPayload.games) ? dashboardGamesPayload.games : [];
gamesTable.render(games, gameState.activeGameId);
const activeGame = games.find((game) => String(game.game_id) === gameState.activeGameId);
if (activeGame && gameState.replay && gameState.replay.game) {
gameState.replay.game.status = activeGame.status;
gameState.replay.game.final_turn = activeGame.final_turn;
}
}
},
});