update Dashboard with new keyboard bindings fix errors with hazards, set win or loss on stale games (older) and push new games real time over the websocket connection when they are finished
Build and Push Docker Container / build-and-push (push) Failing after 12m39s
Build and Push Docker Container / build-and-push (push) Failing after 12m39s
This commit is contained in:
+82
-2
@@ -14,8 +14,15 @@ from server.metrics import (
|
|||||||
MetricsCollector,
|
MetricsCollector,
|
||||||
)
|
)
|
||||||
|
|
||||||
from quart import Quart, request, jsonify, render_template, send_from_directory
|
from quart import (
|
||||||
import logging, json, os, re, time
|
Quart,
|
||||||
|
request,
|
||||||
|
jsonify,
|
||||||
|
render_template,
|
||||||
|
send_from_directory,
|
||||||
|
websocket,
|
||||||
|
)
|
||||||
|
import asyncio, logging, json, os, re, time
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
class Server:
|
class Server:
|
||||||
@@ -51,6 +58,8 @@ class Server:
|
|||||||
self.running_games:dict[str, GameBoard] = {}
|
self.running_games:dict[str, GameBoard] = {}
|
||||||
self.game_move_counts:dict[str, int] = {}
|
self.game_move_counts:dict[str, int] = {}
|
||||||
self.game_last_seen_unix: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(
|
self.metrics_collector = MetricsCollector(
|
||||||
metrics_manager=MetricsStoreBuilder.build(
|
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.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.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._startup_worker_metrics_cleared = False
|
||||||
|
|
||||||
self.logger = build_logger('Battlesnake', debug_env_var='DEBUG_SERVER')
|
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._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 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._delete_game_board(game_state)
|
||||||
await self.metrics_collector.record_game_end(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')
|
customization_root = os.path.join(self.data_path, 'server', 'static', 'customizations')
|
||||||
return await send_from_directory(customization_root, asset_path)
|
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):
|
async def run(self, host:str='0.0.0.0', port:int=8000, debug:bool=False):
|
||||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||||
|
|
||||||
@@ -402,10 +426,57 @@ class Server:
|
|||||||
except Exception as error:
|
except Exception as error:
|
||||||
await await_log(self.logger.warning(f'Gameplay DB end record failed:{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:
|
async def _get_dashboard_summary(self) -> dict:
|
||||||
if self.gameplay_database is None:
|
if self.gameplay_database is None:
|
||||||
return {'enabled': False}
|
return {'enabled': False}
|
||||||
try:
|
try:
|
||||||
|
await self._finalize_stale_dashboard_games()
|
||||||
summary = await self.gameplay_database.get_summary()
|
summary = await self.gameplay_database.get_summary()
|
||||||
summary['enabled'] = True
|
summary['enabled'] = True
|
||||||
return summary
|
return summary
|
||||||
@@ -417,12 +488,21 @@ class Server:
|
|||||||
if self.gameplay_database is None:
|
if self.gameplay_database is None:
|
||||||
return {'enabled': False, 'games': []}
|
return {'enabled': False, 'games': []}
|
||||||
try:
|
try:
|
||||||
|
await self._finalize_stale_dashboard_games()
|
||||||
games = await self.gameplay_database.list_games(limit=limit)
|
games = await self.gameplay_database.list_games(limit=limit)
|
||||||
return {'enabled': True, 'games': games}
|
return {'enabled': True, 'games': games}
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
await await_log(self.logger.warning(f'Gameplay DB game list failed:{error}'))
|
await await_log(self.logger.warning(f'Gameplay DB game list failed:{error}'))
|
||||||
return {'enabled': True, 'error': 'games_unavailable', 'games': []}
|
return {'enabled': True, 'error': 'games_unavailable', 'games': []}
|
||||||
|
|
||||||
|
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:
|
async def _get_dashboard_game_replay(self, game_id:str) -> dict|None:
|
||||||
if self.gameplay_database is None:
|
if self.gameplay_database is None:
|
||||||
return {'enabled': False, 'error': 'database_disabled', 'game_id': game_id}
|
return {'enabled': False, 'error': 'database_disabled', 'game_id': game_id}
|
||||||
|
|||||||
@@ -102,7 +102,21 @@ class GameplayDatabase:
|
|||||||
def _utc_now(self) -> str:
|
def _utc_now(self) -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
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=(",", ":"))
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
def _from_json(self, payload:str|None):
|
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:
|
def _get_summary_sync(self, recent_limit:int=15) -> dict:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
totals = connection.execute("""
|
totals = connection.execute("""
|
||||||
@@ -533,6 +636,9 @@ class GameplayDatabase:
|
|||||||
async def list_games(self, limit:int=50) -> list[dict]:
|
async def list_games(self, limit:int=50) -> list[dict]:
|
||||||
return await asyncio.to_thread(self._list_games_sync, limit)
|
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:
|
async def get_game_replay(self, game_id:str) -> dict|None:
|
||||||
return await asyncio.to_thread(self._get_game_replay_sync, game_id)
|
return await asyncio.to_thread(self._get_game_replay_sync, game_id)
|
||||||
|
|
||||||
|
|||||||
+225
-38
@@ -390,20 +390,19 @@
|
|||||||
}
|
}
|
||||||
.hazard {
|
.hazard {
|
||||||
background-color: var(--hazard);
|
background-color: var(--hazard);
|
||||||
filter: grayscale(0.35) brightness(0.62);
|
|
||||||
}
|
}
|
||||||
.hazard::after {
|
.hazard::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image: repeating-linear-gradient(
|
background: rgba(20, 10, 50, 0.38) repeating-linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(106, 90, 155, 0.55) 0,
|
rgba(80, 60, 140, 0.6) 0,
|
||||||
rgba(106, 90, 155, 0.55) 2px,
|
rgba(80, 60, 140, 0.6) 2px,
|
||||||
transparent 2px,
|
transparent 2px,
|
||||||
transparent 6px
|
transparent 6px
|
||||||
);
|
);
|
||||||
z-index: 2;
|
z-index: 4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.snake-you { background: var(--you); }
|
.snake-you { background: var(--you); }
|
||||||
@@ -419,6 +418,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--head-ring);
|
background: var(--head-ring);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
.snake-head.head-style-1::after { border-radius: 50%; }
|
.snake-head.head-style-1::after { border-radius: 50%; }
|
||||||
.snake-head.head-style-2::after { border-radius: 2px; transform: rotate(45deg); }
|
.snake-head.head-style-2::after { border-radius: 2px; transform: rotate(45deg); }
|
||||||
@@ -437,6 +437,7 @@
|
|||||||
height: 36%;
|
height: 36%;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: rgba(255, 255, 255, 0.55);
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
.snake-tail-you.tail-style-1::after,
|
.snake-tail-you.tail-style-1::after,
|
||||||
.snake-tail-enemy.tail-style-1::after { width: 38%; height: 36%; }
|
.snake-tail-enemy.tail-style-1::after { width: 38%; height: 36%; }
|
||||||
@@ -472,7 +473,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.icon-layer--tail {
|
.icon-layer--tail {
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,9 +742,14 @@
|
|||||||
const initialGameId = {{ initial_game_id|tojson }};
|
const initialGameId = {{ initial_game_id|tojson }};
|
||||||
const initialSummary = {{ initial_summary|tojson }};
|
const initialSummary = {{ initial_summary|tojson }};
|
||||||
const initialGamesPayload = {{ initial_games|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 replay = null;
|
||||||
let turnIndex = 0;
|
let turnIndex = 0;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
let activeGameId = String(initialGameId || "");
|
||||||
|
let gamesWebSocket = null;
|
||||||
|
let gamesWebSocketReconnectTimer = null;
|
||||||
let selectedSnakeId = null;
|
let selectedSnakeId = null;
|
||||||
const svgCache = new Map();
|
const svgCache = new Map();
|
||||||
|
|
||||||
@@ -756,6 +762,7 @@
|
|||||||
const sliderEl = document.getElementById("turn-slider");
|
const sliderEl = document.getElementById("turn-slider");
|
||||||
|
|
||||||
function toTitle(value) {
|
function toTitle(value) {
|
||||||
|
if (String(value || "").toLowerCase() === "finished") return "Done";
|
||||||
return String(value || "").replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
|
return String(value || "").replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,13 +789,51 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadSummary() {
|
function loadSummary() {
|
||||||
renderStats(initialSummary || {});
|
renderStats(dashboardSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGamesTable(games) {
|
||||||
|
gamesBodyEl.innerHTML = games.map((g) => `
|
||||||
|
<tr data-game-id="${g.game_id}">
|
||||||
|
<td><code>${shortId(g.game_id)}</code><br><small>${safeString(displayGameTypeOrMap(g))}</small></td>
|
||||||
|
<td>${toTitle(g.status)}</td>
|
||||||
|
<td>${g.status === "running" ? "-" : g.winner_you ? "Win" : "Loss"}</td>
|
||||||
|
<td>${safeString(g.final_turn)}</td>
|
||||||
|
</tr>
|
||||||
|
`).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) {
|
function shortId(gameId) {
|
||||||
return String(gameId || "-").slice(0, 8);
|
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) {
|
function extractReasoningList(reasoning) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (!reasoning || typeof reasoning !== "object") {
|
if (!reasoning || typeof reasoning !== "object") {
|
||||||
@@ -1308,34 +1353,105 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadGames() {
|
async function loadGames() {
|
||||||
const games = (initialGamesPayload && Array.isArray(initialGamesPayload.games))
|
const games = (dashboardGamesPayload && Array.isArray(dashboardGamesPayload.games))
|
||||||
? initialGamesPayload.games
|
? dashboardGamesPayload.games
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
gamesBodyEl.innerHTML = games.map((g) => `
|
renderGamesTable(games);
|
||||||
<tr data-game-id="${g.game_id}">
|
|
||||||
<td><code>${shortId(g.game_id)}</code><br><small>${safeString((g.map && g.map.trim() && g.map.trim() !== "empty") ? g.map.trim() : g.game_type)}</small></td>
|
|
||||||
<td>${toTitle(g.status)}</td>
|
|
||||||
<td>${g.status === "running" ? "-" : g.winner_you ? "Win" : "Loss"}</td>
|
|
||||||
<td>${safeString(g.final_turn)}</td>
|
|
||||||
</tr>
|
|
||||||
`).join("");
|
|
||||||
|
|
||||||
for (const row of gamesBodyEl.querySelectorAll("tr")) {
|
if (activeGameId) {
|
||||||
row.addEventListener("click", (event) => {
|
await loadReplay(activeGameId);
|
||||||
event.preventDefault();
|
|
||||||
const gameId = row.getAttribute("data-game-id");
|
|
||||||
if (gameId) loadReplay(gameId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialGameId) {
|
|
||||||
await loadReplay(initialGameId);
|
|
||||||
} else if (games.length > 0) {
|
} 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() {
|
function clearActiveGame() {
|
||||||
for (const row of gamesBodyEl.querySelectorAll("tr")) {
|
for (const row of gamesBodyEl.querySelectorAll("tr")) {
|
||||||
row.classList.remove("active");
|
row.classList.remove("active");
|
||||||
@@ -1427,7 +1543,7 @@
|
|||||||
const hasHeadIcon = headIconByCell.has(key);
|
const hasHeadIcon = headIconByCell.has(key);
|
||||||
const hasTailIcon = tailIconByCell.has(key);
|
const hasTailIcon = tailIconByCell.has(key);
|
||||||
if (hasHeadIcon || hasTailIcon) {
|
if (hasHeadIcon || hasTailIcon) {
|
||||||
cell.style.background = "var(--cell)";
|
//cell.style.background = "var(--cell)";
|
||||||
} else {
|
} else {
|
||||||
cell.style.background = snakeBody.get(key);
|
cell.style.background = snakeBody.get(key);
|
||||||
}
|
}
|
||||||
@@ -1499,6 +1615,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
replay = await response.json();
|
replay = await response.json();
|
||||||
|
activeGameId = String(gameId || "");
|
||||||
await preloadReplaySvgs();
|
await preloadReplaySvgs();
|
||||||
turnIndex = 0;
|
turnIndex = 0;
|
||||||
const count = Array.isArray(replay.turns) ? replay.turns.length : 0;
|
const count = Array.isArray(replay.turns) ? replay.turns.length : 0;
|
||||||
@@ -1520,6 +1637,33 @@
|
|||||||
playBtn.setAttribute("aria-label", "Play");
|
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() {
|
function startPlayback() {
|
||||||
if (!replay || !Array.isArray(replay.turns) || replay.turns.length < 2) return;
|
if (!replay || !Array.isArray(replay.turns) || replay.turns.length < 2) return;
|
||||||
if (turnIndex >= replay.turns.length - 1) {
|
if (turnIndex >= replay.turns.length - 1) {
|
||||||
@@ -1548,17 +1692,48 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("prev-btn").addEventListener("click", () => {
|
document.getElementById("prev-btn").addEventListener("click", () => {
|
||||||
stopPlayback();
|
stepTurnBackward();
|
||||||
if (!replay || turnIndex <= 0) return;
|
|
||||||
turnIndex -= 1;
|
|
||||||
renderTurn();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("next-btn").addEventListener("click", () => {
|
document.getElementById("next-btn").addEventListener("click", () => {
|
||||||
stopPlayback();
|
stepTurnForward();
|
||||||
if (!replay || !Array.isArray(replay.turns) || turnIndex >= replay.turns.length - 1) return;
|
});
|
||||||
turnIndex += 1;
|
|
||||||
renderTurn();
|
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", () => {
|
sliderEl.addEventListener("input", () => {
|
||||||
@@ -1575,6 +1750,7 @@
|
|||||||
renderThinking(null);
|
renderThinking(null);
|
||||||
loadSummary();
|
loadSummary();
|
||||||
await loadGames();
|
await loadGames();
|
||||||
|
connectDashboardGamesWebSocket();
|
||||||
syncMonoOffset();
|
syncMonoOffset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1603,6 +1779,17 @@
|
|||||||
syncMonoOffset();
|
syncMonoOffset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
if (gamesWebSocketReconnectTimer) {
|
||||||
|
clearTimeout(gamesWebSocketReconnectTimer);
|
||||||
|
gamesWebSocketReconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (gamesWebSocket) {
|
||||||
|
gamesWebSocket.close();
|
||||||
|
gamesWebSocket = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user