feat: add live replays and PostgreSQL maintenance tools
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:
2026-08-02 00:50:46 +02:00
parent 9b99b526e4
commit f14d780f29
29 changed files with 1574 additions and 310 deletions
+17 -4
View File
@@ -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)
+4
View File
@@ -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)
+8
View File
@@ -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"],
+111
View File
@@ -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))
+9 -4
View File
@@ -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:
+47 -4
View File
@@ -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: