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
+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) {