feat: add live replays and PostgreSQL maintenance tools
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
- 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.
This commit is contained in:
+17
-4
@@ -13,8 +13,8 @@ from server.metrics import (
|
||||
MetricsCollector,
|
||||
)
|
||||
|
||||
import asyncio, signal, logging, os, re, time
|
||||
from quart import Quart
|
||||
import asyncio, signal, logging, time, os, re
|
||||
from quart import Quart, url_for
|
||||
|
||||
from server.blueprints import (
|
||||
create_battlesnake_blueprint,
|
||||
@@ -110,6 +110,17 @@ class Server:
|
||||
self.app.register_blueprint(create_metrics_blueprint(self))
|
||||
self.app.register_blueprint(create_dashboard_blueprint(self))
|
||||
|
||||
@self.app.template_global()
|
||||
def static_url(filename:str) -> str:
|
||||
# Static assets are served with a long max-age, so the URL carries the
|
||||
# file mtime to make browsers pick up dashboard changes immediately.
|
||||
static_root = self.app.static_folder or ''
|
||||
try:
|
||||
version = int(os.path.getmtime(os.path.join(static_root, filename)))
|
||||
except OSError:
|
||||
version = 0
|
||||
return f'{url_for("static", filename=filename)}?v={version}'
|
||||
|
||||
@self.app.after_request
|
||||
async def identify_server(response):
|
||||
response.headers.set('server', 'battlesnake/gitea/snake-python')
|
||||
@@ -185,5 +196,7 @@ class Server:
|
||||
storage = StorageLoader.build(self.storage_type)
|
||||
return storage.cleanup()
|
||||
|
||||
async def _on_dashboard_games_update_notice(self, trigger:str) -> None:
|
||||
await self.dashboard_query.on_dashboard_games_update_notice(trigger)
|
||||
async def _on_dashboard_games_update_notice(
|
||||
self, trigger:str, game_id:str|None=None,
|
||||
) -> None:
|
||||
await self.dashboard_query.on_dashboard_games_update_notice(trigger, game_id)
|
||||
|
||||
@@ -52,6 +52,9 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
|
||||
game_state = await request.get_json()
|
||||
game_board = await server.game_runtime.create_game_board(game_state)
|
||||
await server.gameplay_tracking.record_gameplay_start(game_state, game_board)
|
||||
await server.dashboard_query.push_dashboard_games_update(
|
||||
game_state, trigger='game_started',
|
||||
)
|
||||
await await_log(server.logger.info(f'GAME START: {game_state['game']}'))
|
||||
return 'ok'
|
||||
|
||||
@@ -78,6 +81,7 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
|
||||
await await_log(server.logger.warning(f'MOVE TIMEOUT: turn={game_state.get("turn")}, game={game_id}, returning fallback {next_move!r}'))
|
||||
|
||||
await server.gameplay_tracking.record_gameplay_turn(game_state, next_move, game_board)
|
||||
await server.dashboard_query.push_dashboard_game_replay_update(game_id)
|
||||
elapsed_ms = (time.perf_counter() - move_started) * 1000.0
|
||||
await server.metrics_collector.record_move(next_move, elapsed_ms)
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ def create_dashboard_blueprint(server:'Server') -> Blueprint:
|
||||
battlesnake_url=os.getenv('BATTLESNAKE_GAMEBOARD_URL', 'https://play.battlesnake.com/game')
|
||||
)
|
||||
|
||||
@blueprint.get('/dashboard/game/<game_id>')
|
||||
async def dashboard_game_replay(game_id:str):
|
||||
# Fallback the dashboard falls back to when the replay websocket is down.
|
||||
replay = await server.dashboard_query.get_dashboard_game_replay(game_id)
|
||||
if replay is None:
|
||||
return {'error': 'game_not_found', 'game_id': game_id}, 404
|
||||
return replay
|
||||
|
||||
@blueprint.get('/dashboard/customizations/<path:asset_path>')
|
||||
async def dashboard_customizations_asset(asset_path:str):
|
||||
customization_root = os.path.join(
|
||||
|
||||
@@ -12,7 +12,7 @@ Connection: pass a DSN via the `dsn` constructor argument, e.g.
|
||||
or set GAMEPLAY_DB_PG_DSN in the environment.
|
||||
"""
|
||||
|
||||
import asyncio, json, logging, sqlite3, sys
|
||||
import asyncio, logging, sqlite3, json, sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
@@ -263,11 +263,20 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
try:
|
||||
games = conn.execute("""
|
||||
game_columns = {
|
||||
row["name"] for row in conn.execute("PRAGMA table_info(games)").fetchall()
|
||||
}
|
||||
if "winner_name" in game_columns:
|
||||
winner_name_expression = "winner_name"
|
||||
elif "winner_names_json" in game_columns:
|
||||
winner_name_expression = "winner_names_json"
|
||||
else:
|
||||
winner_name_expression = "NULL"
|
||||
games = conn.execute(f"""
|
||||
SELECT game_id, started_at, ended_at, width, height, source, map_name,
|
||||
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
|
||||
your_snake_type, your_snake_version, game_type,
|
||||
winner_names_json, winner_you, final_turn, status
|
||||
{winner_name_expression} AS winner_name, winner_you, final_turn, status
|
||||
FROM games
|
||||
ORDER BY started_at ASC
|
||||
""").fetchall()
|
||||
@@ -301,6 +310,15 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
def _migrated_winner_name(self, value:str|None) -> str|None:
|
||||
"""Accept both the current winner_name and legacy winner_names_json value."""
|
||||
if not value:
|
||||
return None
|
||||
parsed = self._parse_json(value)
|
||||
if isinstance(parsed, list):
|
||||
return next((str(name) for name in parsed if name), None)
|
||||
return value
|
||||
|
||||
async def _insert_migrated_data(self, games:list, turns:list, snake_turns:list) -> None:
|
||||
assert self._pool is not None
|
||||
async with self._pool.acquire() as conn:
|
||||
@@ -332,7 +350,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
||||
row["your_snake_type"],
|
||||
row["your_snake_version"],
|
||||
row["game_type"],
|
||||
(self._parse_json(row["winner_names_json"]) or [None])[0],
|
||||
self._migrated_winner_name(row["winner_name"]),
|
||||
bool(row["winner_you"]),
|
||||
row["final_turn"],
|
||||
row["status"],
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Load sampled gameplay positions from SQLite or PostgreSQL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def is_postgresql_source(source: str) -> bool:
|
||||
return urlparse(source).scheme.lower() in {"postgres", "postgresql"}
|
||||
|
||||
def _decode_json(value, default):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
def _build_state(row, snake_rows) -> tuple[dict, dict] | None:
|
||||
board = _decode_json(row[1], {})
|
||||
you = _decode_json(row[2], {})
|
||||
if not board.get("snakes"):
|
||||
snakes = []
|
||||
for snake_row in snake_rows:
|
||||
snake_id = snake_row[0]
|
||||
snake_name = snake_row[1] or (row[6] if snake_id == row[5] else snake_id)
|
||||
snakes.append({
|
||||
"id": snake_id,
|
||||
"name": snake_name,
|
||||
"health": snake_row[2],
|
||||
"length": snake_row[3],
|
||||
"head": {"x": snake_row[4], "y": snake_row[5]},
|
||||
"body": _decode_json(snake_row[6], []),
|
||||
"customizations": _decode_json(snake_row[7], {}),
|
||||
})
|
||||
board = {
|
||||
"width": row[7],
|
||||
"height": row[8],
|
||||
"food": _decode_json(row[3], []),
|
||||
"hazards": _decode_json(row[4], []),
|
||||
"snakes": snakes,
|
||||
}
|
||||
if not you:
|
||||
you = next(
|
||||
(snake for snake in board.get("snakes", []) if snake.get("id") == row[5]),
|
||||
{},
|
||||
)
|
||||
if not you or not board.get("snakes"):
|
||||
return None
|
||||
return board, {
|
||||
"you": you,
|
||||
"game_id": row[9],
|
||||
"source": row[10] or "custom",
|
||||
"map": row[11] or "standard",
|
||||
"ruleset": {
|
||||
"name": row[12] or "standard",
|
||||
"version": row[13] or "v1.0.0",
|
||||
"settings": {},
|
||||
},
|
||||
"turn": int(row[14]),
|
||||
}
|
||||
|
||||
async def _load_postgresql_states(dsn: str, samples: int, stride: int) -> list[tuple[dict, dict]]:
|
||||
try:
|
||||
import asyncpg
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("asyncpg is required for PostgreSQL benchmark sources") from exc
|
||||
|
||||
connection = await asyncpg.connect(dsn=dsn)
|
||||
try:
|
||||
max_id = int(await connection.fetchval("SELECT max(id) FROM turns") or 0)
|
||||
if max_id == 0:
|
||||
return []
|
||||
query = """
|
||||
SELECT t.id, t.board_state, t.you, t.food, t.hazards,
|
||||
g.your_snake_id, g.your_snake_name, g.width, g.height,
|
||||
g.game_id, g.source, g.map_name,
|
||||
g.ruleset_name, g.ruleset_version, t.turn
|
||||
FROM turns AS t
|
||||
JOIN games AS g ON g.game_id = t.game_id
|
||||
WHERE t.id >= $1
|
||||
ORDER BY t.id
|
||||
LIMIT 1
|
||||
"""
|
||||
snake_query = """
|
||||
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name),
|
||||
st.health, st.length, st.head_x, st.head_y, st.body,
|
||||
COALESCE(gs.customizations, '{}'::jsonb)
|
||||
FROM snake_turns AS st
|
||||
LEFT JOIN game_snakes AS gs
|
||||
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
|
||||
WHERE st.game_id = $1 AND st.turn = $2
|
||||
ORDER BY st.id
|
||||
"""
|
||||
states = []
|
||||
next_id = max(1, max_id - (samples - 1) * stride)
|
||||
while len(states) < samples and next_id <= max_id:
|
||||
row = await connection.fetchrow(query, next_id)
|
||||
if row is None:
|
||||
break
|
||||
snake_rows = await connection.fetch(snake_query, row[9], row[14])
|
||||
state = _build_state(row, snake_rows)
|
||||
if state is not None:
|
||||
states.append(state)
|
||||
next_id = int(row[0]) + stride
|
||||
return states
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
def load_postgresql_states(dsn: str, samples: int, stride: int) -> list[tuple[dict, dict]]:
|
||||
return asyncio.run(_load_postgresql_states(dsn, samples, stride))
|
||||
@@ -4,7 +4,7 @@ from typing import Awaitable, Callable
|
||||
import asyncio, inspect, json, time
|
||||
|
||||
class DashboardEventsService:
|
||||
def __init__(self, enabled:bool, redis_url:str, channel:str, event_origin:str, shutdown_event:asyncio.Event, on_notice:Callable[[str], Awaitable[None]], logger):
|
||||
def __init__(self, enabled:bool, redis_url:str, channel:str, event_origin:str, shutdown_event:asyncio.Event, on_notice:Callable[[str, str|None], Awaitable[None]], logger):
|
||||
self.enabled = enabled
|
||||
self.redis_url = redis_url
|
||||
self.channel = channel
|
||||
@@ -71,18 +71,19 @@ class DashboardEventsService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def publish_notice(self, trigger:str) -> None:
|
||||
async def publish_notice(self, trigger:str, game_id:str|None=None) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
if self.redis is None:
|
||||
return
|
||||
if trigger not in {'game_saved', 'stale_finalized', 'manual'}:
|
||||
if trigger not in {'game_started', 'game_turn', 'game_saved', 'stale_finalized', 'manual'}:
|
||||
return
|
||||
|
||||
message = {
|
||||
'type': 'dashboard_games_update_notice',
|
||||
'origin': self.event_origin,
|
||||
'trigger': trigger,
|
||||
'game_id': game_id,
|
||||
'sent_at': int(time.time()),
|
||||
}
|
||||
try:
|
||||
@@ -120,7 +121,11 @@ class DashboardEventsService:
|
||||
continue
|
||||
|
||||
notice_trigger = str(payload.get('trigger') or 'game_saved')
|
||||
await self.on_notice(notice_trigger)
|
||||
notice_game_id_raw = payload.get('game_id')
|
||||
notice_game_id = (
|
||||
None if notice_game_id_raw is None else str(notice_game_id_raw)
|
||||
)
|
||||
await self.on_notice(notice_trigger, notice_game_id)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as error:
|
||||
|
||||
@@ -12,12 +12,22 @@ class DashboardQueryService:
|
||||
self.ws_hub = ws_hub
|
||||
self.logger = logger
|
||||
self.dashboard_running_game_stale_sec = dashboard_running_game_stale_sec
|
||||
self.publish_notice:Callable[[str], Awaitable[None]] | None = None
|
||||
self.publish_notice:Callable[[str, str|None], Awaitable[None]] | None = None
|
||||
|
||||
def set_publish_notice(self, publish_notice:Callable[[str], Awaitable[None]]) -> None:
|
||||
def set_publish_notice(
|
||||
self, publish_notice:Callable[[str, str|None], Awaitable[None]],
|
||||
) -> None:
|
||||
self.publish_notice = publish_notice
|
||||
|
||||
async def on_dashboard_games_update_notice(self, trigger:str) -> None:
|
||||
async def on_dashboard_games_update_notice(
|
||||
self, trigger:str, game_id:str|None=None,
|
||||
) -> None:
|
||||
if trigger == 'game_turn' and game_id:
|
||||
await self.push_dashboard_game_replay_update(
|
||||
game_id,
|
||||
publish_cluster=False,
|
||||
)
|
||||
return
|
||||
await self.push_dashboard_games_update(
|
||||
game_state=None,
|
||||
publish_cluster=False,
|
||||
@@ -56,6 +66,22 @@ class DashboardQueryService:
|
||||
'replay': replay_payload,
|
||||
}
|
||||
|
||||
async def build_dashboard_game_replay_update_event(self, game_id:str) -> dict:
|
||||
replay_payload = await self.get_dashboard_game_replay(game_id)
|
||||
if replay_payload is None:
|
||||
return {
|
||||
'type': 'dashboard_game_replay_update',
|
||||
'game_id': game_id,
|
||||
'error': 'game_not_found',
|
||||
}
|
||||
turns = replay_payload.get('turns', [])
|
||||
return {
|
||||
'type': 'dashboard_game_replay_update',
|
||||
'game_id': game_id,
|
||||
'game': replay_payload.get('game', {}),
|
||||
'turn': turns[-1] if turns else None,
|
||||
}
|
||||
|
||||
async def handle_dashboard_ws_request(self, payload_raw:object) -> dict|None:
|
||||
if not isinstance(payload_raw, str):
|
||||
return None
|
||||
@@ -95,7 +121,24 @@ class DashboardQueryService:
|
||||
)
|
||||
await self.ws_hub.broadcast_payload(event_payload)
|
||||
if publish_cluster and self.publish_notice is not None:
|
||||
await self.publish_notice(str(event_payload.get('trigger') or ''))
|
||||
game_id = None
|
||||
if game_state is not None:
|
||||
game_id = game_state.get('game', {}).get('id')
|
||||
await self.publish_notice(
|
||||
str(event_payload.get('trigger') or ''), game_id,
|
||||
)
|
||||
|
||||
async def push_dashboard_game_replay_update(
|
||||
self, game_id:str, publish_cluster:bool=True,
|
||||
) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
event_payload = await self.build_dashboard_game_replay_update_event(game_id)
|
||||
if event_payload.get('error'):
|
||||
return
|
||||
await self.ws_hub.broadcast_payload(event_payload)
|
||||
if publish_cluster and self.publish_notice is not None:
|
||||
await self.publish_notice('game_turn', game_id)
|
||||
|
||||
async def get_dashboard_summary(self) -> dict:
|
||||
if self.gameplay_database is None:
|
||||
|
||||
Reference in New Issue
Block a user