diff --git a/server/Server.py b/server/Server.py index ea6e433..d74d8e6 100644 --- a/server/Server.py +++ b/server/Server.py @@ -14,8 +14,15 @@ from server.metrics import ( MetricsCollector, ) -from quart import Quart, request, jsonify, render_template, send_from_directory -import logging, json, os, re, time +from quart import ( + Quart, + request, + jsonify, + render_template, + send_from_directory, + websocket, +) +import asyncio, logging, json, os, re, time from typing import cast class Server: @@ -51,6 +58,8 @@ class Server: self.running_games:dict[str, GameBoard] = {} self.game_move_counts:dict[str, int] = {} self.game_last_seen_unix:dict[str, int] = {} + self.dashboard_game_subscribers:set[asyncio.Queue[str]] = set() + self.dashboard_game_subscribers_lock=asyncio.Lock() self.metrics_collector = MetricsCollector( metrics_manager=MetricsStoreBuilder.build( @@ -68,6 +77,7 @@ class Server: ) self.clear_worker_metrics_on_startup = self._env_bool('METRICS_CLEAR_WORKERS_ON_STARTUP', True) self.worker_metrics_startup_lock_ttl_sec = self._env_int('METRICS_STARTUP_CLEANUP_LOCK_TTL_SEC', 300) + self.dashboard_running_game_stale_sec = 600 self._startup_worker_metrics_cleared = False self.logger = build_logger('Battlesnake', debug_env_var='DEBUG_SERVER') @@ -145,6 +155,7 @@ class Server: ) await self._record_gameplay_end(game_state) + await self._push_dashboard_games_update(game_state) await await_log(self.logger.info(f'GAME ENDED: Winner is {[x['name'] for x in game_state['board']['snakes']]}')) await self._delete_game_board(game_state) await self.metrics_collector.record_game_end(game_state) @@ -215,6 +226,19 @@ class Server: customization_root = os.path.join(self.data_path, 'server', 'static', 'customizations') return await send_from_directory(customization_root, asset_path) + @self.app.websocket('/dashboard/ws/games') + async def dashboard_games_ws(): + subscriber_queue: asyncio.Queue[str] = asyncio.Queue(maxsize=20) + await self._register_dashboard_game_subscriber(subscriber_queue) + try: + initial_payload = await self._build_dashboard_games_event() + await websocket.send(json.dumps(initial_payload)) + while True: + event_payload = await subscriber_queue.get() + await websocket.send(event_payload) + finally: + await self._unregister_dashboard_game_subscriber(subscriber_queue) + async def run(self, host:str='0.0.0.0', port:int=8000, debug:bool=False): logging.getLogger('werkzeug').setLevel(logging.ERROR) @@ -402,28 +426,84 @@ class Server: except Exception as error: await await_log(self.logger.warning(f'Gameplay DB end record failed:{error}')) + async def _register_dashboard_game_subscriber(self, subscriber_queue:asyncio.Queue[str]) -> None: + async with self.dashboard_game_subscribers_lock: + self.dashboard_game_subscribers.add(subscriber_queue) + + async def _unregister_dashboard_game_subscriber(self, subscriber_queue:asyncio.Queue[str]) -> None: + async with self.dashboard_game_subscribers_lock: + self.dashboard_game_subscribers.discard(subscriber_queue) + + async def _broadcast_dashboard_game_event(self, payload:dict) -> None: + encoded_payload = json.dumps(payload) + async with self.dashboard_game_subscribers_lock: + subscribers = tuple(self.dashboard_game_subscribers) + + for subscriber_queue in subscribers: + if subscriber_queue.full(): + try: + subscriber_queue.get_nowait() + except asyncio.QueueEmpty: + pass + + try: + subscriber_queue.put_nowait(encoded_payload) + except asyncio.QueueFull: + continue + + async def _build_dashboard_games_event(self, game_state:dict|None=None) -> dict: + games_payload = await self._get_dashboard_games(limit=100) + summary_payload = await self._get_dashboard_summary() + game_id = None + if game_state is not None: + game_id = game_state.get('game', {}).get('id') + + return { + 'type': 'dashboard_games_update', + 'trigger': 'game_saved' if game_id else 'snapshot', + 'game_id': game_id, + 'games': games_payload, + 'summary': summary_payload, + } + + async def _push_dashboard_games_update(self, game_state:dict|None=None) -> None: + if self.gameplay_database is None: + return + event_payload = await self._build_dashboard_games_event(game_state) + await self._broadcast_dashboard_game_event(event_payload) + async def _get_dashboard_summary(self) -> dict: if self.gameplay_database is None: return {'enabled': False} try: + await self._finalize_stale_dashboard_games() summary = await self.gameplay_database.get_summary() summary['enabled'] = True return summary except Exception as error: await await_log(self.logger.warning(f'Gameplay DB summary failed:{error}')) - return {'enabled': True, 'error':' summary_unavailable'} + return {'enabled': True, 'error': ' summary_unavailable'} async def _get_dashboard_games(self, limit:int=50) -> dict: if self.gameplay_database is None: return {'enabled': False, 'games': []} try: + await self._finalize_stale_dashboard_games() games = await self.gameplay_database.list_games(limit=limit) return {'enabled': True, 'games': games} except Exception as error: await await_log(self.logger.warning(f'Gameplay DB game list failed:{error}')) return {'enabled': True, 'error': 'games_unavailable', 'games': []} - async def _get_dashboard_game_replay(self, game_id:str) -> dict | None: + async def _finalize_stale_dashboard_games(self) -> None: + if self.gameplay_database is None: + return + try: + await self.gameplay_database.finalize_stale_running_games(stale_after_seconds=self.dashboard_running_game_stale_sec) + except Exception as error: + await await_log(self.logger.warning(f'Gameplay DB stale running game finalize failed:{error}')) + + async def _get_dashboard_game_replay(self, game_id:str) -> dict|None: if self.gameplay_database is None: return {'enabled': False, 'error': 'database_disabled', 'game_id': game_id} try: diff --git a/server/database/GameplayDatabase.py b/server/database/GameplayDatabase.py index cfb7d5b..46fe657 100644 --- a/server/database/GameplayDatabase.py +++ b/server/database/GameplayDatabase.py @@ -102,7 +102,21 @@ class GameplayDatabase: def _utc_now(self) -> str: return datetime.now(timezone.utc).isoformat() - def _to_json(self, payload:dict) -> str: + def _parse_utc_timestamp(self, value:str|None) -> datetime|None: + if not value: + return None + normalized = value.strip() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + def _to_json(self, payload:object) -> str: return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) def _from_json(self, payload:str|None): @@ -331,6 +345,95 @@ class GameplayDatabase: ), ) + def _finalize_stale_running_games_sync(self, stale_after_seconds:int=600) -> int: + threshold = max(60, int(stale_after_seconds)) + now_utc = datetime.now(timezone.utc) + finalized = 0 + + with self._connect() as connection: + rows = connection.execute(""" + SELECT game_id, started_at, final_turn, your_snake_id + FROM games + WHERE status = 'running' + ORDER BY started_at ASC + """).fetchall() + + for row in rows: + started_at = self._parse_utc_timestamp(row["started_at"]) + if started_at is None: + continue + age_seconds = (now_utc - started_at).total_seconds() + if age_seconds < threshold: + continue + + game_id = row["game_id"] + your_snake_id = row["your_snake_id"] + final_turn = int(row["final_turn"] or 0) + + snake_rows = connection.execute(""" + SELECT snake_id, snake_name + FROM snake_turns + WHERE game_id = ? AND turn = ? + ORDER BY is_you DESC, snake_name ASC + """, + (game_id, final_turn), + ).fetchall() + + if len(snake_rows) == 0: + latest_turn_row = connection.execute(""" + SELECT MAX(turn) AS latest_turn + FROM snake_turns + WHERE game_id = ? + """, + (game_id,), + ).fetchone() + latest_turn = ( + latest_turn_row["latest_turn"] + if latest_turn_row is not None + else None + ) + if latest_turn is not None: + final_turn = int(latest_turn) + snake_rows = connection.execute(""" + SELECT snake_id, snake_name + FROM snake_turns + WHERE game_id = ? AND turn = ? + ORDER BY is_you DESC, snake_name ASC + """, + (game_id, final_turn), + ).fetchall() + + survivor_ids = [snake["snake_id"] for snake in snake_rows if snake["snake_id"]] + survivor_names = [snake["snake_name"] for snake in snake_rows if snake["snake_name"]] + winner_you = bool( + your_snake_id + and your_snake_id in survivor_ids + and len(survivor_ids) == 1 + ) + + update_result = connection.execute(""" + UPDATE games + SET ended_at = ?, + winner_names_json = ?, + winner_you = ?, + final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END, + status = 'finished' + WHERE game_id = ? AND status = 'running' + """, + ( + self._utc_now(), + self._to_json(survivor_names), + 1 if winner_you else 0, + final_turn, + final_turn, + game_id, + ), + ) + if update_result.rowcount > 0: + finalized += 1 + + return finalized + def _get_summary_sync(self, recent_limit:int=15) -> dict: with self._connect() as connection: totals = connection.execute(""" @@ -518,7 +621,7 @@ class GameplayDatabase: "turns": replay_turns, } - async def record_game_start(self, game_state: dict, snake_type:str|None=None, snake_version:str|None=None) -> None: + async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None: await asyncio.to_thread(self._record_game_start_sync, game_state, snake_type, snake_version) async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None: @@ -533,6 +636,9 @@ class GameplayDatabase: async def list_games(self, limit:int=50) -> list[dict]: return await asyncio.to_thread(self._list_games_sync, limit) + async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int: + return await asyncio.to_thread(self._finalize_stale_running_games_sync, stale_after_seconds) + async def get_game_replay(self, game_id:str) -> dict|None: return await asyncio.to_thread(self._get_game_replay_sync, game_id) diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html index 4cd38ea..44aac2a 100644 --- a/server/templates/dashboard.html +++ b/server/templates/dashboard.html @@ -390,20 +390,19 @@ } .hazard { background-color: var(--hazard); - filter: grayscale(0.35) brightness(0.62); } - .hazard::after { + .hazard::before { content: ""; position: absolute; inset: 0; - background-image: repeating-linear-gradient( + background: rgba(20, 10, 50, 0.38) repeating-linear-gradient( 135deg, - rgba(106, 90, 155, 0.55) 0, - rgba(106, 90, 155, 0.55) 2px, + rgba(80, 60, 140, 0.6) 0, + rgba(80, 60, 140, 0.6) 2px, transparent 2px, transparent 6px ); - z-index: 2; + z-index: 4; pointer-events: none; } .snake-you { background: var(--you); } @@ -419,6 +418,7 @@ border-radius: 50%; background: var(--head-ring); opacity: 0.9; + z-index: 2; } .snake-head.head-style-1::after { border-radius: 50%; } .snake-head.head-style-2::after { border-radius: 2px; transform: rotate(45deg); } @@ -437,6 +437,7 @@ height: 36%; border-radius: 3px; background: rgba(255, 255, 255, 0.55); + z-index: 2; } .snake-tail-you.tail-style-1::after, .snake-tail-enemy.tail-style-1::after { width: 38%; height: 36%; } @@ -472,7 +473,7 @@ } .icon-layer--tail { - z-index: 1; + z-index: 2; opacity: 0.92; } @@ -741,9 +742,14 @@ const initialGameId = {{ initial_game_id|tojson }}; const initialSummary = {{ initial_summary|tojson }}; const initialGamesPayload = {{ initial_games|tojson }}; + let dashboardSummary = (initialSummary && typeof initialSummary === "object") ? initialSummary : {}; + let dashboardGamesPayload = (initialGamesPayload && typeof initialGamesPayload === "object") ? initialGamesPayload : { games: [] }; let replay = null; let turnIndex = 0; let timer = null; + let activeGameId = String(initialGameId || ""); + let gamesWebSocket = null; + let gamesWebSocketReconnectTimer = null; let selectedSnakeId = null; const svgCache = new Map(); @@ -756,6 +762,7 @@ const sliderEl = document.getElementById("turn-slider"); function toTitle(value) { + if (String(value || "").toLowerCase() === "finished") return "Done"; return String(value || "").replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase()); } @@ -782,13 +789,51 @@ } function loadSummary() { - renderStats(initialSummary || {}); + renderStats(dashboardSummary); + } + + function renderGamesTable(games) { + gamesBodyEl.innerHTML = games.map((g) => ` + + ${shortId(g.game_id)}
${safeString(displayGameTypeOrMap(g))} + ${toTitle(g.status)} + ${g.status === "running" ? "-" : g.winner_you ? "Win" : "Loss"} + ${safeString(g.final_turn)} + + `).join(""); + + for (const row of gamesBodyEl.querySelectorAll("tr")) { + row.addEventListener("click", (event) => { + event.preventDefault(); + const gameId = row.getAttribute("data-game-id"); + if (gameId) { + activeGameId = gameId; + loadReplay(gameId); + } + }); + } + + if (activeGameId) { + setActiveGame(activeGameId); + } } function shortId(gameId) { return String(gameId || "-").slice(0, 8); } + function displayGameTypeOrMap(game) { + const mapName = String((game && game.map) || "").trim(); + const gameType = String((game && game.game_type) || "").trim(); + if (gameType.toLowerCase() === "duel" && mapName.toLowerCase() === "standard") { + return "duel"; + } + if (mapName && mapName.toLowerCase() !== "empty") { + return mapName; + } + return gameType || "-"; + } + function extractReasoningList(reasoning) { const parts = []; if (!reasoning || typeof reasoning !== "object") { @@ -1308,34 +1353,105 @@ } async function loadGames() { - const games = (initialGamesPayload && Array.isArray(initialGamesPayload.games)) - ? initialGamesPayload.games + const games = (dashboardGamesPayload && Array.isArray(dashboardGamesPayload.games)) + ? dashboardGamesPayload.games : []; - gamesBodyEl.innerHTML = games.map((g) => ` - - ${shortId(g.game_id)}
${safeString((g.map && g.map.trim() && g.map.trim() !== "empty") ? g.map.trim() : g.game_type)} - ${toTitle(g.status)} - ${g.status === "running" ? "-" : g.winner_you ? "Win" : "Loss"} - ${safeString(g.final_turn)} - - `).join(""); + renderGamesTable(games); - for (const row of gamesBodyEl.querySelectorAll("tr")) { - row.addEventListener("click", (event) => { - event.preventDefault(); - const gameId = row.getAttribute("data-game-id"); - if (gameId) loadReplay(gameId); - }); - } - - if (initialGameId) { - await loadReplay(initialGameId); + if (activeGameId) { + await loadReplay(activeGameId); } else if (games.length > 0) { - await loadReplay(games[0].game_id); + activeGameId = games[0].game_id; + await loadReplay(activeGameId); } } + function dashboardGamesWebSocketUrl() { + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + return `${protocol}://${window.location.host}/dashboard/ws/games`; + } + + function scheduleDashboardGamesWebSocketReconnect() { + if (gamesWebSocketReconnectTimer) return; + gamesWebSocketReconnectTimer = window.setTimeout(() => { + gamesWebSocketReconnectTimer = null; + connectDashboardGamesWebSocket(); + }, 1500); + } + + function applyDashboardGamesUpdate(payload) { + const nextSummary = payload && payload.summary && typeof payload.summary === "object" + ? payload.summary + : null; + const nextGames = payload && payload.games && typeof payload.games === "object" + ? payload.games + : null; + + if (nextSummary) { + dashboardSummary = nextSummary; + renderStats(dashboardSummary); + } + + if (nextGames) { + dashboardGamesPayload = nextGames; + const games = Array.isArray(dashboardGamesPayload.games) ? dashboardGamesPayload.games : []; + renderGamesTable(games); + + const gameIds = new Set(games.map((g) => g.game_id)); + if (activeGameId && !gameIds.has(activeGameId)) { + activeGameId = ""; + replay = null; + turnIndex = 0; + renderTurn(); + clearActiveGame(); + } + + const payloadGameId = payload && payload.game_id ? String(payload.game_id) : ""; + if (payloadGameId && payloadGameId === activeGameId) { + loadReplay(payloadGameId); + return; + } + + if (!activeGameId && games.length > 0) { + activeGameId = games[0].game_id; + loadReplay(activeGameId); + } + } + } + + function connectDashboardGamesWebSocket() { + const wsUrl = dashboardGamesWebSocketUrl(); + try { + gamesWebSocket = new WebSocket(wsUrl); + } catch { + scheduleDashboardGamesWebSocketReconnect(); + return; + } + + gamesWebSocket.addEventListener("message", (event) => { + let payload = null; + try { + payload = JSON.parse(event.data); + } catch { + return; + } + if (!payload || payload.type !== "dashboard_games_update") return; + applyDashboardGamesUpdate(payload); + }); + + gamesWebSocket.addEventListener("close", () => { + gamesWebSocket = null; + scheduleDashboardGamesWebSocketReconnect(); + }); + + gamesWebSocket.addEventListener("error", () => { + if (gamesWebSocket) { + gamesWebSocket.close(); + } + }); + } + function clearActiveGame() { for (const row of gamesBodyEl.querySelectorAll("tr")) { row.classList.remove("active"); @@ -1427,7 +1543,7 @@ const hasHeadIcon = headIconByCell.has(key); const hasTailIcon = tailIconByCell.has(key); if (hasHeadIcon || hasTailIcon) { - cell.style.background = "var(--cell)"; + //cell.style.background = "var(--cell)"; } else { cell.style.background = snakeBody.get(key); } @@ -1499,6 +1615,7 @@ return; } replay = await response.json(); + activeGameId = String(gameId || ""); await preloadReplaySvgs(); turnIndex = 0; const count = Array.isArray(replay.turns) ? replay.turns.length : 0; @@ -1520,6 +1637,33 @@ playBtn.setAttribute("aria-label", "Play"); } + function stepTurnBackward() { + stopPlayback(); + if (!replay || turnIndex <= 0) return; + turnIndex -= 1; + renderTurn(); + } + + function stepTurnForward() { + stopPlayback(); + if (!replay || !Array.isArray(replay.turns) || turnIndex >= replay.turns.length - 1) return; + turnIndex += 1; + renderTurn(); + } + + function adjustPlaybackSpeed(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")); + } + function startPlayback() { if (!replay || !Array.isArray(replay.turns) || replay.turns.length < 2) return; if (turnIndex >= replay.turns.length - 1) { @@ -1548,17 +1692,48 @@ }); document.getElementById("prev-btn").addEventListener("click", () => { - stopPlayback(); - if (!replay || turnIndex <= 0) return; - turnIndex -= 1; - renderTurn(); + stepTurnBackward(); }); document.getElementById("next-btn").addEventListener("click", () => { - stopPlayback(); - if (!replay || !Array.isArray(replay.turns) || turnIndex >= replay.turns.length - 1) return; - turnIndex += 1; - renderTurn(); + stepTurnForward(); + }); + + window.addEventListener("keydown", (event) => { + const target = event.target; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) { + return; + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + stepTurnBackward(); + return; + } + + if (event.key === "ArrowRight") { + event.preventDefault(); + stepTurnForward(); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + adjustPlaybackSpeed(1); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + adjustPlaybackSpeed(-1); + return; + } + + if (event.key === " " || event.key === "Spacebar") { + event.preventDefault(); + if (timer) stopPlayback(); + else startPlayback(); + } }); sliderEl.addEventListener("input", () => { @@ -1575,6 +1750,7 @@ renderThinking(null); loadSummary(); await loadGames(); + connectDashboardGamesWebSocket(); syncMonoOffset(); } @@ -1603,6 +1779,17 @@ syncMonoOffset(); }); + window.addEventListener("beforeunload", () => { + if (gamesWebSocketReconnectTimer) { + clearTimeout(gamesWebSocketReconnectTimer); + gamesWebSocketReconnectTimer = null; + } + if (gamesWebSocket) { + gamesWebSocket.close(); + gamesWebSocket = null; + } + }); + boot();