Files
snake-python/templates/files/js/GameState.js
T
daniel156161 f14d780f29
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
feat: add live replays and PostgreSQL maintenance tools
- 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.
2026-08-02 00:50:46 +02:00

208 lines
6.8 KiB
JavaScript

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._followLive = false;
this._hasLoadedReplayOnce = false;
}
setWebSocket(webSocket) {
this._webSocket = webSocket;
}
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;
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.stopPlayback();
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);
}
}
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");
playBtn.setAttribute("aria-label", "Play");
}
startPlayback() {
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();
}
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;
}
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;
}
}