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; } }