feat: add live replays and PostgreSQL maintenance tools
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user