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)}${shortId(g.game_id)}