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
+35 -2
View File
@@ -62,13 +62,33 @@ bench-best-snake iterations="1000":
PYTHONPATH="{{justfile_directory()}}" python "{{justfile_directory()}}/tests/bench_best_battle_snake.py" --iterations "{{iterations}}"
bench-snake-arena positions="100" output="":
bench-snake-arena positions="100" output="" database="":
#!/usr/bin/env bash
set -euo pipefail
args=(--positions "{{positions}}")
if [ -n "{{database}}" ]; then args+=(--database "{{database}}"); fi
if [ -n "{{output}}" ]; then args+=(--json-output "{{output}}"); fi
PYTHONPATH="{{justfile_directory()}}" python "{{justfile_directory()}}/scripts/benchmark_snake_arena.py" "${args[@]}"
PYTHONPATH="{{justfile_directory()}}" uv run python "{{justfile_directory()}}/scripts/benchmark_snake_arena.py" "${args[@]}"
bench-snake-arena-postgres positions="100" output="":
#!/usr/bin/env bash
set -euo pipefail
: "${GAMEPLAY_DB_PG_DSN:?Set GAMEPLAY_DB_PG_DSN in .env or the environment}"
args=(--positions "{{positions}}" --database "$GAMEPLAY_DB_PG_DSN")
if [ -n "{{output}}" ]; then args+=(--json-output "{{output}}"); fi
PYTHONPATH="{{justfile_directory()}}" uv run python "{{justfile_directory()}}/scripts/benchmark_snake_arena.py" "${args[@]}"
cleanup-gameplay-postgres older_than_days="30" execute="false" vacuum="false":
#!/usr/bin/env bash
set -euo pipefail
: "${GAMEPLAY_DB_PG_DSN:?Set GAMEPLAY_DB_PG_DSN in .env or the environment}"
args=(--dsn "$GAMEPLAY_DB_PG_DSN" --older-than-days "{{older_than_days}}")
if [ "{{execute}}" = "true" ]; then args+=(--execute); fi
if [ "{{vacuum}}" = "true" ]; then args+=(--vacuum); fi
PYTHONPATH="{{justfile_directory()}}" uv run python "{{justfile_directory()}}/scripts/cleanup_postgresql_gameplay.py" "${args[@]}"
bench-snake-tournament games="20" gametype="standard" map="standard" output="":
#!/usr/bin/env bash
@@ -109,6 +129,19 @@ battlesnake-cli-version:
# Testing helpers
# ------------------------------------------------------------------------------
test-unit:
#!/usr/bin/env bash
set -euo pipefail
PYTHONPATH="{{justfile_directory()}}" python -m unittest discover -s "{{justfile_directory()}}/tests" -p "test_*.py"
# Dashboard front-end logic (head/tail orientation, board geometry).
test-js:
#!/usr/bin/env bash
set -euo pipefail
node --test "{{justfile_directory()}}"/tests/js/*.test.mjs
test-constrictor: build-battlesnake-cli
#!/usr/bin/env bash
set -euo pipefail
+14 -46
View File
@@ -4,7 +4,6 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sqlite3
from statistics import mean, median
@@ -13,6 +12,9 @@ from time import perf_counter
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from server.database.benchmark_states import (
_build_state, is_postgresql_source, load_postgresql_states,
)
from server.GameBoard import GameBoard
from snakes import SnakeBuilder
@@ -22,6 +24,9 @@ def percentile(values: list[float], quantile: float) -> float:
return ordered[index]
def load_states(db_path: str, samples: int, stride: int) -> list[tuple[dict, dict]]:
if is_postgresql_source(db_path):
return load_postgresql_states(db_path, samples, stride)
connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
connection.execute("PRAGMA query_only = ON")
max_id = int(connection.execute("SELECT max(id) FROM turns").fetchone()[0] or 0)
@@ -55,50 +60,10 @@ def load_states(db_path: str, samples: int, stride: int) -> list[tuple[dict, dic
row = connection.execute(query, (next_id,)).fetchone()
if row is None:
break
board = json.loads(row[1])
you = json.loads(row[2])
if not board.get("snakes"):
snakes = []
for snake_row in connection.execute(snake_query, (row[9], row[14])):
snake_id = snake_row[0]
snake_name = snake_row[1] or (row[6] if snake_id == row[5] else snake_id)
body = json.loads(snake_row[6])
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": body,
"customizations": json.loads(snake_row[7]),
})
board = {
"width": row[7],
"height": row[8],
"food": json.loads(row[3]),
"hazards": json.loads(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"):
next_id = int(row[0]) + stride
continue
metadata = {
"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]),
}
states.append((board, {"you": you, **metadata}))
snake_rows = connection.execute(snake_query, (row[9], row[14])).fetchall()
state = _build_state(row, snake_rows)
if state is not None:
states.append(state)
next_id = int(row[0]) + stride
connection.close()
return states
@@ -148,7 +113,10 @@ def benchmark(snake_name: str, states: list[tuple[dict, dict]], repeat: int) ->
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--database", required=True)
parser.add_argument(
"--database", required=True,
help="SQLite path or postgresql:// DSN",
)
parser.add_argument("--snake", action="append", default=[])
parser.add_argument("--samples", type=int, default=100)
parser.add_argument("--stride", type=int, default=997)
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Delete old low-quality replay payloads while preserving game results."""
from __future__ import annotations
import argparse
import asyncio
from datetime import datetime, timedelta, timezone
async def cleanup(dsn: str, older_than_days: int, dry_run: bool, vacuum: bool) -> None:
try:
import asyncpg
except ImportError as exc:
raise RuntimeError("asyncpg is required for PostgreSQL cleanup") from exc
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, older_than_days))
connection = await asyncpg.connect(dsn=dsn)
try:
candidates = int(await connection.fetchval("""
SELECT count(*)
FROM games
WHERE has_replay
AND quality_status = 'low_quality'
AND COALESCE(ended_at, started_at) < $1
""", cutoff))
rows = await connection.fetchrow("""
SELECT
(SELECT count(*) FROM turns t JOIN games g USING (game_id)
WHERE g.has_replay AND g.quality_status = 'low_quality'
AND COALESCE(g.ended_at, g.started_at) < $1) AS turns,
(SELECT count(*) FROM snake_turns t JOIN games g USING (game_id)
WHERE g.has_replay AND g.quality_status = 'low_quality'
AND COALESCE(g.ended_at, g.started_at) < $1) AS snake_turns,
(SELECT count(*) FROM game_snakes t JOIN games g USING (game_id)
WHERE g.has_replay AND g.quality_status = 'low_quality'
AND COALESCE(g.ended_at, g.started_at) < $1) AS game_snakes
""", cutoff)
print(
f"candidates before {cutoff.isoformat()}: games={candidates:,}, "
f"turns={rows['turns']:,}, snake_turns={rows['snake_turns']:,}, "
f"game_snakes={rows['game_snakes']:,}"
)
if dry_run or candidates == 0:
print("dry run: no rows changed" if dry_run else "nothing to clean")
return
async with connection.transaction():
game_ids = await connection.fetch("""
SELECT game_id FROM games
WHERE has_replay
AND quality_status = 'low_quality'
AND COALESCE(ended_at, started_at) < $1
FOR UPDATE
""", cutoff)
ids = [row["game_id"] for row in game_ids]
await connection.execute("DELETE FROM snake_turns WHERE game_id = ANY($1::text[])", ids)
await connection.execute("DELETE FROM turns WHERE game_id = ANY($1::text[])", ids)
await connection.execute("DELETE FROM game_snakes WHERE game_id = ANY($1::text[])", ids)
await connection.execute("""
UPDATE games
SET has_replay = FALSE, quality_status = 'low_quality'
WHERE game_id = ANY($1::text[])
""", ids)
print(f"cleaned replay payloads for {len(ids):,} games; result rows preserved")
if vacuum:
await connection.execute("VACUUM (ANALYZE) games")
await connection.execute("VACUUM (ANALYZE) game_snakes")
await connection.execute("VACUUM (ANALYZE) turns")
await connection.execute("VACUUM (ANALYZE) snake_turns")
print("vacuum/analyze complete")
finally:
await connection.close()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dsn", required=True)
parser.add_argument("--older-than-days", type=int, default=30)
parser.add_argument("--execute", action="store_true", help="Apply deletion; default is dry-run")
parser.add_argument("--vacuum", action="store_true")
args = parser.parse_args()
asyncio.run(cleanup(args.dsn, args.older_than_days, not args.execute, args.vacuum))
if __name__ == "__main__":
main()
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Stream a normalized Battlesnake SQLite database into PostgreSQL.
The source is opened read-only. Rows are copied in bounded batches through
temporary PostgreSQL tables, then inserted idempotently with ON CONFLICT. The
script verifies source/inserted row counts and never modifies the SQLite file.
"""
from __future__ import annotations
import argparse
import asyncio
from datetime import datetime
import json
from pathlib import Path
import sqlite3
from time import perf_counter
from server.database.backend.PostgresqlGameplayBackend import PostgresqlGameplayBackend
TABLES = (
(
"games",
(
"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_name", "winner_you", "final_turn", "status", "has_replay",
"quality_status", "quality_score", "quality_tier", "quality_reasons",
),
"game_id",
),
(
"game_snakes",
("game_id", "snake_id", "snake_name", "is_you", "customizations"),
"game_id, snake_id",
),
(
"turns",
(
"game_id", "turn", "observed_at", "my_move", "my_thinking",
"board_state", "snakes", "you", "food", "hazards",
),
"game_id, turn",
),
(
"snake_turns",
(
"game_id", "turn", "snake_id", "snake_name", "health", "length",
"head_x", "head_y", "body", "is_you", "inferred_move", "latency",
),
"game_id, turn, snake_id",
),
)
SQLITE_SELECTS = {
"games": """
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_name, winner_you,
final_turn, status, has_replay, quality_status, quality_score,
quality_tier, quality_reasons_json
FROM games ORDER BY game_id
""",
"game_snakes": """
SELECT game_id, snake_id, snake_name, is_you, customizations_json
FROM game_snakes ORDER BY game_id, snake_id
""",
"turns": """
SELECT game_id, turn, observed_at, my_move, my_thinking_json,
board_state_json, snakes_json, you_json, food_json, hazards_json
FROM turns ORDER BY id
""",
"snake_turns": """
SELECT game_id, turn, snake_id, snake_name, health, length, head_x, head_y,
body_json, is_you, inferred_move, latency
FROM snake_turns ORDER BY id
""",
}
TIMESTAMP_FIELDS = {"started_at", "ended_at", "observed_at"}
BOOLEAN_FIELDS = {"winner_you", "has_replay", "is_you"}
JSON_FIELDS = {
"quality_reasons", "customizations", "my_thinking", "board_state",
"snakes", "you", "food", "hazards", "body",
}
JSON_DEFAULTS = {
"customizations": {}, "board_state": {}, "snakes": [], "you": {},
"food": [], "hazards": [], "body": [],
}
def parse_timestamp(value: str | None) -> datetime | None:
if not value:
return None
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
return parsed
def parse_json(value: str | None, default: object = None) -> object:
if value in (None, ""):
return default
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError) as exc:
raise ValueError(f"Invalid JSON value: {str(value)[:120]}") from exc
def transform_row(columns: tuple[str, ...], row: sqlite3.Row) -> tuple:
output = []
for column, value in zip(columns, row, strict=True):
if column in TIMESTAMP_FIELDS:
value = parse_timestamp(value)
elif column in BOOLEAN_FIELDS:
value = bool(value)
elif column in JSON_FIELDS:
parsed = parse_json(value, JSON_DEFAULTS.get(column))
value = None if parsed is None else json.dumps(parsed, separators=(",", ":"))
output.append(value)
return tuple(output)
def open_source(path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=60)
connection.row_factory = sqlite3.Row
return connection
async def import_table(pool, source: sqlite3.Connection, table: str,
columns: tuple[str, ...], conflict_columns: str,
batch_size: int) -> tuple[int, int]:
source_count = int(source.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
async with pool.acquire() as connection:
before = int(await connection.fetchval(f"SELECT COUNT(*) FROM {table}"))
stage = f"migration_{table}"
await connection.execute(
f"CREATE TEMP TABLE {stage} (LIKE {table} INCLUDING DEFAULTS)"
)
json_columns = [column for column in columns if column in JSON_FIELDS]
for column in json_columns:
await connection.execute(
f"ALTER TABLE {stage} ALTER COLUMN {column} TYPE TEXT USING {column}::text"
)
cursor = source.execute(SQLITE_SELECTS[table])
copied = 0
while rows := cursor.fetchmany(batch_size):
records = [transform_row(columns, row) for row in rows]
async with connection.transaction():
await connection.copy_records_to_table(stage, records=records, columns=columns)
selected = ", ".join(columns)
source_expressions = ", ".join(
f"{column}::jsonb" if column in JSON_FIELDS else column
for column in columns
)
await connection.execute(
f"INSERT INTO {table} ({selected}) SELECT {source_expressions} FROM {stage} "
f"ON CONFLICT ({conflict_columns}) DO NOTHING"
)
await connection.execute(f"TRUNCATE {stage}")
copied += len(records)
print(f"{table}: streamed {copied:,}/{source_count:,}", flush=True)
after = int(await connection.fetchval(f"SELECT COUNT(*) FROM {table}"))
inserted = after - before
if copied != source_count:
raise RuntimeError(f"{table}: source changed while reading ({source_count} -> {copied})")
print(f"{table}: source={source_count:,}, inserted={inserted:,}, conflicts={source_count - inserted:,}")
return source_count, inserted
async def migrate(source_path: Path, dsn: str, batch_size: int) -> None:
source = open_source(source_path)
try:
quick_check = source.execute("PRAGMA quick_check").fetchone()[0]
if quick_check != "ok":
raise RuntimeError(f"SQLite quick_check failed: {quick_check}")
backend = PostgresqlGameplayBackend(dsn=dsn)
await backend.initialize()
pool = await backend._get_pool()
started = perf_counter()
results = {}
try:
for table, columns, conflicts in TABLES:
results[table] = await import_table(
pool, source, table, columns, conflicts, max(100, batch_size),
)
async with pool.acquire() as connection:
invalid = int(await connection.fetchval("""
SELECT
(SELECT COUNT(*) FROM turns t LEFT JOIN games g USING (game_id) WHERE g.game_id IS NULL) +
(SELECT COUNT(*) FROM game_snakes s LEFT JOIN games g USING (game_id) WHERE g.game_id IS NULL) +
(SELECT COUNT(*) FROM snake_turns s LEFT JOIN games g USING (game_id) WHERE g.game_id IS NULL)
"""))
if invalid:
raise RuntimeError(f"PostgreSQL foreign-key verification found {invalid} orphan rows")
counts = {
table: int(await connection.fetchval(f"SELECT COUNT(*) FROM {table}"))
for table, _, _ in TABLES
}
print(f"verified PostgreSQL counts: {counts}")
print(f"migration elapsed: {perf_counter() - started:.1f}s")
finally:
await backend.close()
finally:
source.close()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--dsn", required=True)
parser.add_argument("--batch-size", type=int, default=10_000)
args = parser.parse_args()
asyncio.run(migrate(args.source.expanduser().resolve(), args.dsn, args.batch_size))
if __name__ == "__main__":
main()
+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:
+1 -1
View File
@@ -10,7 +10,7 @@ SNAKE_REGISTRATIONS = {
"TemplateSnake": SnakeRegistration("snakes.core.template", "1.0.0"),
"ApexBattleSnake": SnakeRegistration("snakes.strategies.apex", "1.0.0"),
"PrismBattleSnake_GPT_5_6_Sol": SnakeRegistration(
"snakes.strategies.prism", "1.4.0"
"snakes.strategies.prism", "1.5.0"
),
"DummSnake": SnakeRegistration("snakes.legacy.DummSnake", "1.0.0"),
"LogicSnake": SnakeRegistration("snakes.legacy.LogicSnake", "1.1.0"),
+79
View File
@@ -0,0 +1,79 @@
"""Safety-gated board geometry scoring for perimeter-aware snakes."""
from __future__ import annotations
def _on_edge(point: tuple[int, int], width: int, height: int) -> bool:
x, y = point
return x in {0, width - 1} or y in {0, height - 1}
def _same_edge(
first: tuple[int, int], second: tuple[int, int], width: int, height: int,
) -> bool:
boundaries = ((0, 0), (0, width - 1), (1, 0), (1, height - 1))
return any(
first[index] == boundary and second[index] == boundary
for index, boundary in boundaries
)
def perimeter_geometry_score(
*,
point: tuple[int, int],
current_head: tuple[int, int],
width: int,
height: int,
occupancy: float,
snake_length: int,
reachable_space: int,
required_space: int,
liberties: int,
next_options: int,
safe_next_options: int,
tail_escape: bool,
dead_end: bool,
losing_head_to_head: bool,
) -> float:
"""Reward useful perimeter lanes without overriding tactical safety.
The normal move scorer already values liberties heavily, which naturally
makes wall cells less attractive. This adjustment offsets that bias only
when the wall position has room, a tail route, and multiple safe exits.
"""
x, y = point
cx, cy = (width - 1) / 2.0, (height - 1) / 2.0
center_score = 1.0 - (abs(x - cx) + abs(y - cy)) / max(1.0, cx + cy)
center_weight = max(2.0, 6.0 * (1.0 - min(1.0, occupancy / 0.5)))
score = center_score * center_weight
if not _on_edge(point, width, height):
return score
safely_usable = (
not dead_end
and not losing_head_to_head
and tail_escape
and liberties >= 2
and next_options >= 2
and safe_next_options >= 2
and reachable_space >= required_space + max(4, required_space // 2)
)
if not safely_usable:
return score
phase = min(1.0, occupancy / 0.34)
length_factor = min(1.0, snake_length / 12.0)
space_margin = min(
1.0,
max(0, reachable_space - required_space) / max(1, required_space),
)
score += 24.0 + phase * 12.0 + length_factor * 8.0 + space_margin * 8.0
# Continuing along one edge is more useful than repeatedly entering and
# leaving it: it keeps the body ordered and leaves the interior available.
if _same_edge(current_head, point, width, height):
score += 10.0
# Corners remove two exits. They remain usable, but should not become goals.
if x in {0, width - 1} and y in {0, height - 1}:
score -= 18.0
return score
+19 -16
View File
@@ -8,6 +8,7 @@ from quart_common.web.env import env_int
from server.dataset.RLBootstrapDataset import RLBootstrapDataset
from snakes.core.template import TemplateSnake
from snakes.engine.perimeter import perimeter_geometry_score
from server.GameBoard import GameBoard
class ApexBattleSnake(TemplateSnake):
@@ -686,19 +687,22 @@ class ApexBattleSnake(TemplateSnake):
or (next_opts == 0 and not has_tail_escape)
)
cx, cy = (width - 1) / 2.0, (height - 1) / 2.0
center_score = 1.0 - (abs(point[0] - cx) + abs(point[1] - cy)) / max(1.0, cx + cy)
min_wall_dist = min(point[0], width - 1 - point[0], point[1], height - 1 - point[1])
if total_occupancy > 0.25:
if min_wall_dist == 0:
edge_penalty = 35.0 * total_occupancy
elif min_wall_dist == 1:
edge_penalty = 15.0 * total_occupancy
else:
edge_penalty = 0.0
else:
edge_penalty = 0.0
geometry_score = perimeter_geometry_score(
point=point,
current_head=(my_body[0]["x"], my_body[0]["y"]),
width=width,
height=height,
occupancy=total_occupancy,
snake_length=len(future_body),
reachable_space=reachable_space,
required_space=required_space,
liberties=liberties,
next_options=next_opts,
safe_next_options=en_safe_opts,
tail_escape=has_tail_escape,
dead_end=dead_end,
losing_head_to_head=losing_h2h,
)
hunger = max(0.0, (60.0 - my_health) / 60.0)
@@ -719,7 +723,7 @@ class ApexBattleSnake(TemplateSnake):
score += liberties * 20.0
score += next_opts * 10.0
score += en_safe_opts * 24.0
score += center_score * 14.0
score += geometry_score
if en_safe_opts == 0:
score -= 1700.0
@@ -727,7 +731,6 @@ class ApexBattleSnake(TemplateSnake):
score -= 420.0
score -= art_penalty
score -= edge_penalty
score -= h2h_dist2_penalty
if dead_end:
@@ -1421,7 +1424,7 @@ class ApexBattleSnake(TemplateSnake):
can_grow = self._enemy_can_grow_this_turn(snake, food_set)
if not can_grow:
enemy_vacating_tails.add((snake["body"][-1]["x"], snake["body"][-1]["y"]))
safe: MoveMap = {}
safe: ApexBattleSnake.MoveMap = {}
for move, (dx, dy) in self.DIRECTIONS.items():
pt = (my_head["x"] + dx, my_head["y"] + dy)
if not self._in_bounds(pt, width, height):
+2 -2
View File
@@ -1,4 +1,4 @@
"""PrismBattleSnake_GPT_5_6_Sol v1.4.0
"""PrismBattleSnake_GPT_5_6_Sol v1.5.0
Built on ApexBattleSnake v1.0.0. All strategic logic is inherited.
Performance improvement: all spatial primitives (flood fill, territory,
@@ -54,7 +54,7 @@ class PrismBattleSnake_GPT_5_6_Sol(
BitboardSpatialMixin,
ApexBattleSnake,
):
VERSION = "1.4.0"
VERSION = "1.5.0"
def __init__(self) -> None:
super().__init__()
+76 -70
View File
@@ -262,11 +262,12 @@ input[type="range"] {
}
.board-wrap {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
gap: 8px;
min-height: 520px;
display: flex;
align-items: flex-start;
justify-content: center;
overflow: hidden;
}
.legend {
@@ -291,54 +292,38 @@ input[type="range"] {
}
.board {
position: relative;
min-width: 0;
min-height: 0;
height: auto;
width: 100%;
display: grid;
gap: 2px;
flex: none;
background: var(--grid);
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px;
align-content: start;
overflow: hidden;
}
/* Snake bodies are drawn as one rounded polyline per snake on top of the grid,
so bends and cell gaps need no per-cell patching. */
.snake-layer {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.cell {
background: var(--cell);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
aspect-ratio: 1 / 1;
position: relative;
border-radius: 2px;
}
.snake-turn-cell::after {
content: "";
position: absolute;
inset: 0;
background: var(--turn-color, transparent);
z-index: 0;
pointer-events: none;
}
/* 50% = quarter-circle at the inner corner of the bend */
.snake-turn-cell.snake-turn-ur::after {
border-top-right-radius: 50%;
}
.snake-turn-cell.snake-turn-ul::after {
border-top-left-radius: 50%;
}
.snake-turn-cell.snake-turn-dr::after {
border-bottom-right-radius: 50%;
}
.snake-turn-cell.snake-turn-dl::after {
border-bottom-left-radius: 50%;
}
.food {
background-image: radial-gradient(circle at center, #d73a31 0 45%, transparent 48%);
background-repeat: no-repeat;
@@ -350,6 +335,12 @@ input[type="range"] {
background-color: var(--hazard);
}
/* The hatch overlay (::before, z-index 4) still paints above the body stroke,
but the opaque fill would hide it. */
.hazard.hazard-over-snake {
background-color: transparent;
}
.hazard::before {
content: "";
position: absolute;
@@ -364,17 +355,11 @@ input[type="range"] {
border-radius: inherit;
}
.snake-you {
background: var(--you);
}
.snake-enemy {
background: var(--enemy);
}
/* Head/tail markers and icon layers use z-index >= 2 so they paint above
.snake-layer. The cells themselves stay unstacked, keeping their background
below the body stroke. */
.snake-head {
outline: 2px solid var(--head-ring);
outline-offset: -2px;
outline: none;
}
.snake-head::after {
@@ -494,43 +479,41 @@ input[type="range"] {
display: none;
}
.snake-tail-you.has-tail-icon,
.snake-tail-enemy.has-tail-icon {
box-shadow: none;
}
/* Spans the full cell along the travel axis and the body stroke width across
it, so the icon is exactly as thick as the body and its leading edge lands on
the cell border where the stroke ends. */
.icon-layer {
position: absolute;
inset: 2%;
background: var(--icon-color, currentColor);
-webkit-mask-image: var(--icon-url);
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
mask-image: var(--icon-url);
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
left: 0;
right: 0;
top: var(--icon-cross-inset, 7%);
bottom: var(--icon-cross-inset, 7%);
transform: var(--icon-transform, rotate(0deg));
transform-origin: center;
pointer-events: none;
z-index: 2;
}
.icon-layer--tail {
z-index: 2;
opacity: 0.92;
}
.icon-layer--head {
z-index: 3;
opacity: 1;
background: none;
-webkit-mask-image: none;
mask-image: none;
}
.icon-layer--head>svg {
/* Fallback for artwork that has not been fetched yet. An SVG used as a mask
image keeps its own preserveAspectRatio and letterboxes inside this
non-square box, so the inlined variant above is preferred. */
.icon-layer--masked {
background: var(--icon-color, currentColor);
-webkit-mask-image: var(--icon-url);
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: 100% 100%;
mask-image: var(--icon-url);
mask-repeat: no-repeat;
mask-position: center;
mask-size: 100% 100%;
}
.icon-layer>svg {
width: 100%;
height: 100%;
display: block;
@@ -693,6 +676,19 @@ input[type="range"] {
}
@media (max-width: 1100px) {
html,
body {
height: auto;
min-height: 100%;
overflow: auto;
}
.page {
height: auto;
min-height: 100vh;
overflow: visible;
}
.topbar {
grid-template-columns: 1fr;
}
@@ -704,6 +700,12 @@ input[type="range"] {
.main {
grid-template-columns: 1fr;
overflow: visible;
}
.panel,
.right {
overflow: visible;
}
.games {
@@ -714,8 +716,12 @@ input[type="range"] {
grid-template-columns: 1fr;
}
.thinking {
overflow: visible;
}
.board-wrap {
min-height: 360px;
min-height: min(70vw, 520px);
}
.turn-badge {
+7 -1
View File
@@ -1,11 +1,12 @@
class DashboardWebSocket {
constructor({ onGamesUpdate, onShutdown } = {}) {
constructor({ onGamesUpdate, onReplayUpdate, onShutdown } = {}) {
this._socket = null;
this._reconnectTimer = null;
this._shuttingDown = false;
this._pendingRequests = new Map();
this._requestSeq = 0;
this._onGamesUpdate = onGamesUpdate || (() => {});
this._onReplayUpdate = onReplayUpdate || (() => {});
this._onShutdown = onShutdown || (() => {});
}
@@ -58,6 +59,11 @@ class DashboardWebSocket {
return;
}
if (payload.type === "dashboard_game_replay_update") {
this._onReplayUpdate(payload);
return;
}
if (payload.type === "dashboard_games_update") {
this._onGamesUpdate(payload);
}
+345 -93
View File
@@ -1,12 +1,73 @@
class GameBoard {
static SVG_NS = "http://www.w3.org/2000/svg";
// Stroke width as a fraction of the cell size. Slightly wider than the cell
// interior so the stroke bridges the 2px grid gap between adjacent cells.
static BODY_WIDTH_RATIO = 0.86;
// How far the body stroke runs past the point where the icon artwork starts.
static SEAM_OVERLAP_PX = 1;
constructor(boardEl) {
this._boardEl = boardEl;
this._svgCache = new Map();
this._iconLeadInset = new Map();
this._measureCanvas = null;
this._boardWidth = 0;
this._boardHeight = 0;
this._snakeLayer = null;
this._lastPaint = null;
this._lastArgs = null;
this._resizeObserver = typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => {
this._fitBoard();
this._renderSnakeLayer();
});
if (this._resizeObserver && this._boardEl.parentElement) {
this._resizeObserver.observe(this._boardEl.parentElement);
}
}
clearBoard() {
this._boardEl.innerHTML = "";
this._boardEl.style.gridTemplateColumns = "none";
this._boardEl.style.gridTemplateRows = "none";
this._boardEl.style.width = "";
this._boardEl.style.height = "";
this._boardWidth = 0;
this._boardHeight = 0;
this._snakeLayer = null;
this._lastPaint = null;
this._lastArgs = null;
}
_fitBoard() {
const container = this._boardEl.parentElement;
if (!container || !this._boardWidth || !this._boardHeight) return;
const availableWidth = container.clientWidth;
const availableHeight = container.clientHeight;
if (availableWidth <= 0 || availableHeight <= 0) return;
const style = window.getComputedStyle(this._boardEl);
const horizontalChrome = Number.parseFloat(style.paddingLeft)
+ Number.parseFloat(style.paddingRight)
+ Number.parseFloat(style.borderLeftWidth)
+ Number.parseFloat(style.borderRightWidth);
const verticalChrome = Number.parseFloat(style.paddingTop)
+ Number.parseFloat(style.paddingBottom)
+ Number.parseFloat(style.borderTopWidth)
+ Number.parseFloat(style.borderBottomWidth);
const columnGap = Number.parseFloat(style.columnGap) || 0;
const rowGap = Number.parseFloat(style.rowGap) || 0;
const fixedWidth = horizontalChrome + (columnGap * Math.max(0, this._boardWidth - 1));
const fixedHeight = verticalChrome + (rowGap * Math.max(0, this._boardHeight - 1));
const cellSize = Math.max(0, Math.min(
(availableWidth - fixedWidth) / this._boardWidth,
(availableHeight - fixedHeight) / this._boardHeight,
));
this._boardEl.style.width = `${(cellSize * this._boardWidth) + fixedWidth}px`;
this._boardEl.style.height = `${(cellSize * this._boardHeight) + fixedHeight}px`;
}
async preloadSvgs(replay) {
@@ -27,14 +88,70 @@ class GameBoard {
async _loadSvg(url) {
if (this._svgCache.has(url)) return this._svgCache.get(url);
let text = null;
try {
const res = await fetch(url);
const text = res.ok ? await res.text() : null;
this._svgCache.set(url, text);
return text;
text = res.ok ? await res.text() : null;
} catch {
this._svgCache.set(url, null);
return null;
text = null;
}
this._svgCache.set(url, text);
await this._measureLeadInset(url, text);
return text;
}
// How far the artwork sits back from the edge that meets the body, as a
// fraction of the cell. Most icons touch it (0), but a handful of designs
// start further in and would leave a visible seam if the stroke stopped at
// the cell border. Measured once per icon by rasterising it at the same
// aspect ratio the layer uses. A null result means the artwork never spans
// the full body width, so the stroke should not be pulled back at all.
async _measureLeadInset(url, svgMarkup) {
if (this._iconLeadInset.has(url)) return;
this._iconLeadInset.set(url, null);
if (!svgMarkup) return;
const width = 200;
const height = Math.round(width * GameBoard.BODY_WIDTH_RATIO);
try {
const parsed = new DOMParser().parseFromString(
this._normalizeIconSvgMarkup(svgMarkup) || svgMarkup, "image/svg+xml",
);
const svgEl = parsed.querySelector("svg");
if (!svgEl) return;
svgEl.setAttribute("preserveAspectRatio", "none");
svgEl.setAttribute("width", String(width));
svgEl.setAttribute("height", String(height));
const image = new Image();
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svgEl))}`;
await image.decode();
if (!this._measureCanvas) this._measureCanvas = document.createElement("canvas");
const canvas = this._measureCanvas;
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.clearRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
// The edge must be covered across its whole width. Accepting "almost
// covered" leaves a notch at the seam corners that reads as a line, so
// the bar is every row inked, with a low alpha cut-off so antialiased
// edge pixels still count.
for (let x = 0; x < width / 2; x += 1) {
let covered = 0;
for (let y = 0; y < height; y += 1) {
if (pixels[(((y * width) + x) * 4) + 3] > 8) covered += 1;
}
if (covered === height) {
this._iconLeadInset.set(url, x / width);
return;
}
}
} catch {
// Leave the inset unknown; the stroke then runs to the cell centre.
}
}
@@ -97,7 +214,7 @@ class GameBoard {
return maxDepth;
}
_normalizeHeadSvgMarkup(svgMarkup) {
_normalizeIconSvgMarkup(svgMarkup) {
if (!svgMarkup) return null;
try {
const parser = new DOMParser();
@@ -124,41 +241,203 @@ class GameBoard {
const layer = document.createElement("div");
layer.className = type === "head" ? "icon-layer icon-layer--head" : "icon-layer icon-layer--tail";
layer.style.setProperty("--icon-transform", transformValue || "rotate(0deg)");
if (type === "head") {
const svgMarkup = this._svgCache.get(iconUrl);
if (svgMarkup) {
layer.innerHTML = this._normalizeHeadSvgMarkup(svgMarkup);
const svgEl = layer.querySelector("svg");
if (svgEl) {
svgEl.style.width = "100%";
svgEl.style.height = "100%";
svgEl.style.fill = color || "currentColor";
svgEl.removeAttribute("width");
svgEl.removeAttribute("height");
}
// The icon artwork joins the body along its full leading edge, so the layer
// is squeezed to the body stroke width and stretched (no aspect ratio) to
// meet the stroke end flush.
layer.style.setProperty("--icon-cross-inset", `${(1 - GameBoard.BODY_WIDTH_RATIO) * 50}%`);
// Both head and tail artwork is inlined rather than used as a CSS mask: an
// SVG referenced as an image keeps its own preserveAspectRatio, so it would
// letterbox inside the non-square layer and leave a gap towards the body.
const svgMarkup = this._svgCache.get(iconUrl);
if (svgMarkup) {
layer.innerHTML = this._normalizeIconSvgMarkup(svgMarkup);
const svgEl = layer.querySelector("svg");
if (svgEl) {
svgEl.setAttribute("preserveAspectRatio", "none");
svgEl.style.width = "100%";
svgEl.style.height = "100%";
svgEl.style.fill = color || "currentColor";
svgEl.removeAttribute("width");
svgEl.removeAttribute("height");
}
} else {
layer.style.setProperty("--icon-url", `url(${iconUrl})`);
layer.style.setProperty("--icon-color", color || "var(--you)");
return layer;
}
// Artwork not cached yet (live turn arriving before the preload finished):
// fall back to the mask so the icon still shows, and repaint once loaded.
layer.classList.add("icon-layer--masked");
layer.style.setProperty("--icon-url", `url(${iconUrl})`);
layer.style.setProperty("--icon-color", color || "var(--you)");
this._loadSvg(iconUrl).then((markup) => {
if (markup) this._repaintLast();
});
return layer;
}
_repaintLast() {
const args = this._lastArgs;
if (!args) return;
this.paintBoard(args.turnData, args.width, args.height, args.selectedSnakeId, args.replay);
}
_cellKey(x, y) {
return `${x}:${y}`;
}
// Collapses the stacked duplicate segments Battlesnake emits at spawn and
// right after eating, so the body becomes a clean orthogonal polyline.
_bodyPolyline(snake) {
const body = Array.isArray(snake && snake.body) ? snake.body : [];
const points = [];
for (const part of body) {
if (!part) continue;
const point = { x: Number(part.x), y: Number(part.y) };
if (Number.isNaN(point.x) || Number.isNaN(point.y)) continue;
const previous = points[points.length - 1];
if (previous && previous.x === point.x && previous.y === point.y) continue;
points.push(point);
}
return points;
}
// Pixel geometry of the grid, derived from the same box metrics the CSS grid
// uses, so SVG coordinates land exactly on cell centres.
_gridMetrics() {
if (!this._boardWidth || !this._boardHeight) return null;
const style = window.getComputedStyle(this._boardEl);
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
const paddingTop = Number.parseFloat(style.paddingTop) || 0;
const paddingRight = Number.parseFloat(style.paddingRight) || 0;
const paddingBottom = Number.parseFloat(style.paddingBottom) || 0;
const columnGap = Number.parseFloat(style.columnGap) || 0;
const rowGap = Number.parseFloat(style.rowGap) || 0;
const boxWidth = this._boardEl.clientWidth;
const boxHeight = this._boardEl.clientHeight;
const cellWidth = (boxWidth - paddingLeft - paddingRight - (columnGap * (this._boardWidth - 1))) / this._boardWidth;
const cellHeight = (boxHeight - paddingTop - paddingBottom - (rowGap * (this._boardHeight - 1))) / this._boardHeight;
if (!(cellWidth > 0) || !(cellHeight > 0)) return null;
return { paddingLeft, paddingTop, columnGap, rowGap, cellWidth, cellHeight, boxWidth, boxHeight };
}
_cellCenter(point, metrics) {
const row = this._boardHeight - 1 - point.y;
return {
x: metrics.paddingLeft + (point.x * (metrics.cellWidth + metrics.columnGap)) + (metrics.cellWidth / 2),
y: metrics.paddingTop + (row * (metrics.cellHeight + metrics.rowGap)) + (metrics.cellHeight / 2),
};
}
// Pulls the polyline end back so the customization icon owns its cell.
// Combined with a butt cap the stroke stops exactly where the artwork starts:
// at the cell border for the usual icon, deeper into the cell for artwork
// that sits back from that border (leadInset).
_retractEnd(endCenter, neighbourCenter, metrics, leadInset) {
const dx = endCenter.x - neighbourCenter.x;
const dy = endCenter.y - neighbourCenter.y;
const distance = Math.hypot(dx, dy);
if (!(distance > 0)) return endCenter;
const halfCell = Math.abs(dx) >= Math.abs(dy)
? metrics.cellWidth / 2
: metrics.cellHeight / 2;
// A full CSS pixel of overlap: both sides are the same colour, so overlap
// is free, and it keeps sub-pixel rounding from opening a hairline seam on
// displays with a fractional device pixel ratio.
const artworkOffset = (leadInset || 0) * halfCell * 2;
const pullBack = Math.min(distance, Math.max(0, halfCell - artworkOffset - GameBoard.SEAM_OVERLAP_PX));
return {
x: endCenter.x - ((dx / distance) * pullBack),
y: endCenter.y - ((dy / distance) * pullBack),
};
}
_appendRoundEnd(group, center, radius, color) {
const dot = document.createElementNS(GameBoard.SVG_NS, "circle");
dot.setAttribute("cx", `${center.x}`);
dot.setAttribute("cy", `${center.y}`);
dot.setAttribute("r", `${radius}`);
dot.setAttribute("fill", color);
group.appendChild(dot);
}
_renderSnakeLayer() {
if (!this._snakeLayer || !this._lastPaint) return;
const metrics = this._gridMetrics();
while (this._snakeLayer.firstChild) this._snakeLayer.removeChild(this._snakeLayer.firstChild);
if (!metrics) return;
this._snakeLayer.setAttribute("viewBox", `0 0 ${metrics.boxWidth} ${metrics.boxHeight}`);
this._snakeLayer.setAttribute("width", `${metrics.boxWidth}`);
this._snakeLayer.setAttribute("height", `${metrics.boxHeight}`);
const cellSize = Math.min(metrics.cellWidth, metrics.cellHeight);
const strokeWidth = cellSize * GameBoard.BODY_WIDTH_RATIO;
const { snakes, selectedSnakeId } = this._lastPaint;
for (const entry of snakes) {
const points = entry.points;
if (points.length === 0) continue;
const centers = points.map((point) => this._cellCenter(point, metrics));
const dimmed = Boolean(selectedSnakeId) && entry.snakeId !== selectedSnakeId;
// One group per snake so dimming applies once instead of stacking up on
// overlapping shapes.
const group = document.createElementNS(GameBoard.SVG_NS, "g");
if (dimmed) group.setAttribute("opacity", "0.2");
this._snakeLayer.appendChild(group);
if (centers.length === 1) {
// Fully stacked body (spawn turn): a single round blob, unless the head
// icon already fills that cell.
if (!entry.headIcon) {
this._appendRoundEnd(group, centers[0], strokeWidth / 2, entry.color);
}
continue;
}
const last = centers.length - 1;
// An icon only takes over its cell if its artwork spans the full body
// width somewhere; otherwise the stroke runs to the cell centre and keeps
// its rounded end, with the icon drawn on top. Ends without an icon keep
// the rounded look via an explicit cap circle, because linecap applies to
// both ends of the path at once.
if (entry.headIcon && entry.headLeadInset !== null) {
centers[0] = this._retractEnd(centers[0], centers[1], metrics, entry.headLeadInset);
} else {
this._appendRoundEnd(group, centers[0], strokeWidth / 2, entry.color);
}
if (entry.tailIcon && entry.tailLeadInset !== null) {
centers[last] = this._retractEnd(centers[last], centers[last - 1], metrics, entry.tailLeadInset);
} else {
this._appendRoundEnd(group, centers[last], strokeWidth / 2, entry.color);
}
const path = document.createElementNS(GameBoard.SVG_NS, "path");
path.setAttribute("d", centers.map((point, idx) => `${idx === 0 ? "M" : "L"}${point.x} ${point.y}`).join(" "));
path.setAttribute("fill", "none");
path.setAttribute("stroke", entry.color);
path.setAttribute("stroke-width", `${strokeWidth}`);
path.setAttribute("stroke-linecap", "butt");
path.setAttribute("stroke-linejoin", "round");
group.appendChild(path);
}
}
paintBoard(turnData, width, height, selectedSnakeId, replay) {
this.clearBoard();
if (!turnData || !width || !height) return;
this._lastArgs = { turnData, width, height, selectedSnakeId, replay };
const colorById = SnakeUtils.buildSnakeColorById(turnData, replay);
const customById = SnakeUtils.buildSnakeCustomizationById(turnData, replay);
this._boardEl.style.gridTemplateColumns = `repeat(${width}, 1fr)`;
this._boardWidth = Number(width);
this._boardHeight = Number(height);
this._boardEl.style.gridTemplateColumns = `repeat(${width}, minmax(0, 1fr))`;
this._boardEl.style.gridTemplateRows = `repeat(${height}, minmax(0, 1fr))`;
this._fitBoard();
const foods = new Set((turnData.food || []).map((p) => this._cellKey(p.x, p.y)));
const hazards = new Set((turnData.hazards || []).map((p) => this._cellKey(p.x, p.y)));
const snakeBody = new Map();
const occupiedCells = new Set();
const snakeHead = new Set();
const snakeTail = new Map();
const headVariantByCell = new Map();
@@ -169,6 +448,7 @@ class GameBoard {
const tailTransformByCell = new Map();
const snakeColorByCell = new Map();
const snakeIdByCell = new Map();
const snakeEntries = [];
(turnData.snakes || []).forEach((snake, idx) => {
if (!snake) return;
@@ -181,28 +461,48 @@ class GameBoard {
const tailIcon = SnakeUtils.buildCustomizationIconUrl("tails", custom.tail);
const headTransform = SnakeUtils.directionToHeadTransform(SnakeUtils.inferHeadDirection(snake));
const tailTransform = SnakeUtils.directionToTailTransform(SnakeUtils.inferTailDirection(snake));
const points = this._bodyPolyline(snake);
for (const part of (snake.body || [])) {
snakeBody.set(this._cellKey(part.x, part.y), bodyColor);
occupiedCells.add(this._cellKey(part.x, part.y));
snakeIdByCell.set(this._cellKey(part.x, part.y), snakeId);
}
if (snake.head) {
const headKey = this._cellKey(snake.head.x, snake.head.y);
// `head` is authoritative when present, but some payloads omit it; the
// polyline start is the same cell.
const headPoint = snake.head || points[0] || null;
const headKey = headPoint ? this._cellKey(headPoint.x, headPoint.y) : null;
if (headKey !== null) {
snakeHead.add(headKey);
headVariantByCell.set(headKey, headVariant);
headTransformByCell.set(headKey, headTransform);
snakeColorByCell.set(headKey, bodyColor);
if (headIcon) headIconByCell.set(headKey, headIcon);
}
if (Array.isArray(snake.body) && snake.body.length > 0) {
const tail = snake.body[snake.body.length - 1];
// A tail stacked under this snake's own head has no free cell to draw in.
// Another snake's head landing there must not suppress it, so the check is
// snake-local rather than against every head seen so far.
let drawTailIcon = false;
if (points.length > 0) {
const tail = points[points.length - 1];
const tailKey = this._cellKey(tail.x, tail.y);
drawTailIcon = Boolean(tailIcon) && tailKey !== headKey;
snakeTail.set(tailKey, snake.is_you ? "snake-tail-you" : "snake-tail-enemy");
tailVariantByCell.set(tailKey, tailVariant);
tailTransformByCell.set(tailKey, tailTransform);
snakeColorByCell.set(tailKey, bodyColor);
if (tailIcon) tailIconByCell.set(tailKey, tailIcon);
if (drawTailIcon) tailIconByCell.set(tailKey, tailIcon);
}
const leadInset = (url) => (this._iconLeadInset.has(url) ? this._iconLeadInset.get(url) : 0);
snakeEntries.push({
snakeId,
color: bodyColor,
points,
headIcon: Boolean(headIcon),
tailIcon: drawTailIcon,
headLeadInset: headIcon ? leadInset(headIcon) : 0,
tailLeadInset: drawTailIcon ? leadInset(tailIcon) : 0,
});
});
for (let y = height - 1; y >= 0; y--) {
@@ -210,75 +510,21 @@ class GameBoard {
const key = this._cellKey(x, y);
const cell = document.createElement("div");
cell.className = "cell";
if (hazards.has(key)) cell.classList.add("hazard");
if (foods.has(key)) cell.classList.add("food");
if (snakeBody.has(key)) {
const bodyColor = snakeBody.get(key);
const hasHeadIcon = headIconByCell.has(key);
const hasTailIcon = tailIconByCell.has(key);
const isIconCell = hasHeadIcon || hasTailIcon;
cell.style.borderRadius = "0";
if (!isIconCell) cell.style.background = bodyColor;
if (selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
cell.style.opacity = "0.2";
}
const snakeId = snakeIdByCell.get(key);
if (snakeId) {
const up = snakeIdByCell.get(this._cellKey(x, y + 1)) === snakeId;
const down = snakeIdByCell.get(this._cellKey(x, y - 1)) === snakeId;
const left = snakeIdByCell.get(this._cellKey(x - 1, y)) === snakeId;
const right = snakeIdByCell.get(this._cellKey(x + 1, y)) === snakeId;
if (!snakeHead.has(key) && !snakeTail.has(key)) {
if (up && right && !down && !left) {
cell.classList.add("snake-turn-cell", "snake-turn-dl");
cell.style.setProperty("--turn-color", bodyColor);
cell.style.background = "var(--cell)";
} else if (up && left && !down && !right) {
cell.classList.add("snake-turn-cell", "snake-turn-dr");
cell.style.setProperty("--turn-color", bodyColor);
cell.style.background = "var(--cell)";
} else if (down && right && !up && !left) {
cell.classList.add("snake-turn-cell", "snake-turn-ul");
cell.style.setProperty("--turn-color", bodyColor);
cell.style.background = "var(--cell)";
} else if (down && left && !up && !right) {
cell.classList.add("snake-turn-cell", "snake-turn-ur");
cell.style.setProperty("--turn-color", bodyColor);
cell.style.background = "var(--cell)";
}
}
// Outward shadows bridge the 2px gap to adjacent snake cells.
// For icon cells (head/tail), also add inset shadows to color the
// connecting edge of the cell itself, since the background stays
// transparent so the icon remains visible.
const bridgeShadows = [];
if (up) {
bridgeShadows.push(`0 -2px 0 ${bodyColor}`);
if (isIconCell) bridgeShadows.push(`inset 0 2px 0 ${bodyColor}`);
}
if (down) {
bridgeShadows.push(`0 2px 0 ${bodyColor}`);
if (isIconCell) bridgeShadows.push(`inset 0 -2px 0 ${bodyColor}`);
}
if (left) {
bridgeShadows.push(`-2px 0 0 ${bodyColor}`);
if (isIconCell) bridgeShadows.push(`inset 2px 0 0 ${bodyColor}`);
}
if (right) {
bridgeShadows.push(`2px 0 0 ${bodyColor}`);
if (isIconCell) bridgeShadows.push(`inset -2px 0 0 ${bodyColor}`);
}
if (bridgeShadows.length > 0) cell.style.boxShadow = bridgeShadows.join(", ");
}
const occupied = occupiedCells.has(key);
if (hazards.has(key)) {
cell.classList.add("hazard");
// Keep the hazard hatch readable on top of the snake stroke.
if (occupied) cell.classList.add("hazard-over-snake");
}
if (foods.has(key) && !occupied) cell.classList.add("food");
if (occupied && selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
cell.style.opacity = "0.2";
}
if (snakeTail.has(key)) {
cell.classList.add(snakeTail.get(key));
cell.classList.add(`tail-style-${tailVariantByCell.get(key) || 1}`);
const tailIcon = tailIconByCell.get(key);
if (tailIcon && !snakeHead.has(key)) {
if (tailIcon) {
cell.classList.add("has-tail-icon", "icon-tail");
cell.appendChild(this._createIconLayer(
tailIcon,
@@ -305,5 +551,11 @@ class GameBoard {
this._boardEl.appendChild(cell);
}
}
this._snakeLayer = document.createElementNS(GameBoard.SVG_NS, "svg");
this._snakeLayer.setAttribute("class", "snake-layer");
this._boardEl.appendChild(this._snakeLayer);
this._lastPaint = { snakes: snakeEntries, selectedSnakeId: selectedSnakeId || null };
this._renderSnakeLayer();
}
}
+70 -3
View File
@@ -12,6 +12,7 @@ class GameState {
this.activeGameId = "";
this.selectedSnakeId = null;
this._timer = null;
this._followLive = false;
this._hasLoadedReplayOnce = false;
}
@@ -19,7 +20,20 @@ class GameState {
this._webSocket = webSocket;
}
get isPlaying() { return Boolean(this._timer); }
get isPlaying() { return Boolean(this._timer) || this._followLive; }
_isRunningReplay(replay = this.replay) {
return Boolean(replay && replay.game && replay.game.status === "running");
}
_isAtLatestTurn() {
return Boolean(
this.replay
&& Array.isArray(this.replay.turns)
&& this.replay.turns.length > 0
&& this.turnIndex >= this.replay.turns.length - 1
);
}
async loadReplay(gameId) {
let nextReplay = null;
@@ -44,6 +58,7 @@ class GameState {
nextReplay = await response.json();
}
this.stopPlayback();
this.replay = nextReplay;
this._hasLoadedReplayOnce = true;
this.activeGameId = String(gameId || "");
@@ -76,11 +91,44 @@ class GameState {
}
}
async applyLiveTurn(gameId, game, turn) {
if (!turn || String(gameId || "") !== this.activeGameId || !this.replay) return;
const wasFollowingLive = this._followLive;
const wasAtLatest = this._isAtLatestTurn();
const turnsBefore = Array.isArray(this.replay.turns) ? this.replay.turns : [];
const previousCount = turnsBefore.length;
const turnNumber = Number(turn.turn);
const existingIndex = turnsBefore.findIndex((item) => Number(item.turn) === turnNumber);
if (existingIndex >= 0) turnsBefore[existingIndex] = turn;
else turnsBefore.push(turn);
turnsBefore.sort((left, right) => Number(left.turn) - Number(right.turn));
this.replay.turns = turnsBefore;
if (game && typeof game === "object") {
this.replay.game = { ...(this.replay.game || {}), ...game };
}
await this._gameBoard.preloadSvgs({ turns: [turn] });
const turns = this.replay.turns;
this._sliderEl.max = String(Math.max(0, turns.length - 1));
if (wasFollowingLive || wasAtLatest || previousCount === 0) {
this.turnIndex = Math.max(0, turns.length - 1);
this.renderTurn();
} else {
this.turnIndex = Math.min(this.turnIndex, Math.max(0, turns.length - 1));
this.renderTurn();
}
if (!this._isRunningReplay()) this.stopPlayback();
}
stopPlayback() {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
this._followLive = false;
const playBtn = document.getElementById("play-btn");
playBtn.textContent = "▶";
playBtn.setAttribute("title", "Play");
@@ -88,15 +136,34 @@ class GameState {
}
startPlayback() {
if (!this.replay || !Array.isArray(this.replay.turns) || this.replay.turns.length < 2) return;
if (!this.replay || !Array.isArray(this.replay.turns) || this.replay.turns.length === 0) return;
this.stopPlayback();
if (this._isRunningReplay() && this._isAtLatestTurn()) {
this._followLive = true;
const playBtn = document.getElementById("play-btn");
playBtn.textContent = "●";
playBtn.setAttribute("title", "Following live game");
playBtn.setAttribute("aria-label", "Following live game");
return;
}
if (this.replay.turns.length < 2) return;
if (this.turnIndex >= this.replay.turns.length - 1) {
this.turnIndex = 0;
this.renderTurn();
}
this.stopPlayback();
const interval = Number(document.getElementById("speed").value || 650);
this._timer = setInterval(() => {
if (!this.replay || this.turnIndex >= this.replay.turns.length - 1) {
if (this._isRunningReplay()) {
clearInterval(this._timer);
this._timer = null;
this._followLive = true;
const liveBtn = document.getElementById("play-btn");
liveBtn.textContent = "●";
liveBtn.setAttribute("title", "Following live game");
liveBtn.setAttribute("aria-label", "Following live game");
return;
}
this.stopPlayback();
return;
}
+41 -46
View File
@@ -132,66 +132,61 @@ class SnakeUtils {
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})`;
}
static inferHeadDirection(snake) {
const body = Array.isArray(snake && snake.body) ? snake.body : [];
if (body.length >= 2) {
const head = body[0];
const neck = body[1];
if (head && neck) {
const dx = Number(head.x) - Number(neck.x);
const dy = Number(head.y) - Number(neck.y);
if (dx > 0) return "right";
if (dx < 0) return "left";
if (dy > 0) return "up";
if (dy < 0) return "down";
}
}
const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase();
if (["up", "down", "left", "right"].includes(inferred)) return inferred;
if (body.length < 2) return "right";
const head = body[0];
const neck = body[1];
if (!head || !neck) return "right";
const dx = Number(head.x) - Number(neck.x);
const dy = Number(head.y) - Number(neck.y);
// Board coordinates are y-up, so a positive dy means "up".
static _deltaToDirection(dx, dy) {
if (dx > 0) return "right";
if (dx < 0) return "left";
if (dy > 0) return "up";
if (dy < 0) return "down";
return null;
}
static _fallbackDirection(snake) {
const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase();
if (["up", "down", "left", "right"].includes(inferred)) return inferred;
return "right";
}
// Segments stack on spawn and right after eating, so both ends scan past
// duplicates to find the first cell that actually differs.
static inferHeadDirection(snake) {
const body = Array.isArray(snake && snake.body) ? snake.body : [];
const head = body[0];
if (!head) return SnakeUtils._fallbackDirection(snake);
for (let idx = 1; idx < body.length; idx += 1) {
const neck = body[idx];
if (!neck) continue;
if (Number(neck.x) === Number(head.x) && Number(neck.y) === Number(head.y)) continue;
const direction = SnakeUtils._deltaToDirection(
Number(head.x) - Number(neck.x),
Number(head.y) - Number(neck.y),
);
if (direction) return direction;
break;
}
return SnakeUtils._fallbackDirection(snake);
}
static inferTailDirection(snake) {
const body = Array.isArray(snake && snake.body) ? snake.body : [];
if (body.length < 2) return "right";
const tail = body[body.length - 1];
if (!tail) return "right";
if (!tail) return SnakeUtils._fallbackDirection(snake);
let beforeTail = null;
for (let idx = body.length - 2; idx >= 0; idx -= 1) {
const candidate = body[idx];
if (!candidate) continue;
if (Number(candidate.x) !== Number(tail.x) || Number(candidate.y) !== Number(tail.y)) {
beforeTail = candidate;
break;
}
const beforeTail = body[idx];
if (!beforeTail) continue;
if (Number(beforeTail.x) === Number(tail.x) && Number(beforeTail.y) === Number(tail.y)) continue;
const direction = SnakeUtils._deltaToDirection(
Number(beforeTail.x) - Number(tail.x),
Number(beforeTail.y) - Number(tail.y),
);
if (direction) return direction;
break;
}
if (!beforeTail) {
const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase();
if (["up", "down", "left", "right"].includes(inferred)) return inferred;
return "right";
}
const dx = Number(beforeTail.x) - Number(tail.x);
const dy = Number(beforeTail.y) - Number(tail.y);
if (dx > 0) return "right";
if (dx < 0) return "left";
if (dy > 0) return "up";
if (dy < 0) return "down";
return "right";
return SnakeUtils._fallbackDirection(snake);
}
static directionToHeadTransform(direction) {
+32 -12
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/root.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<link rel="stylesheet" href="{{ static_url('css/root.css') }}">
<link rel="stylesheet" href="{{ static_url('css/styles.css') }}">
</head>
<body>
<div class="page">
@@ -51,6 +51,7 @@
<option value="400">1.5x</option>
<option value="250">2x</option>
<option value="160">2.5x</option>
<option value="120">3x</option>
</select>
</label>
<input type="range" min="0" max="0" step="1" id="turn-slider" value="0">
@@ -68,16 +69,16 @@
</main>
</div>
<script src="{{ url_for('static', filename='js/Utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/Snake.js') }}"></script>
<script src="{{ url_for('static', filename='js/MoveTable.js') }}"></script>
<script src="{{ url_for('static', filename='js/SnakeTable.js') }}"></script>
<script src="{{ url_for('static', filename='js/GameBoard.js') }}"></script>
<script src="{{ url_for('static', filename='js/OverallStats.js') }}"></script>
<script src="{{ url_for('static', filename='js/GamesTable.js') }}"></script>
<script src="{{ url_for('static', filename='js/Thinking.js') }}"></script>
<script src="{{ url_for('static', filename='js/DashboardWebSocket.js') }}"></script>
<script src="{{ url_for('static', filename='js/GameState.js') }}"></script>
<script src="{{ static_url('js/Utils.js') }}"></script>
<script src="{{ static_url('js/Snake.js') }}"></script>
<script src="{{ static_url('js/MoveTable.js') }}"></script>
<script src="{{ static_url('js/SnakeTable.js') }}"></script>
<script src="{{ static_url('js/GameBoard.js') }}"></script>
<script src="{{ static_url('js/OverallStats.js') }}"></script>
<script src="{{ static_url('js/GamesTable.js') }}"></script>
<script src="{{ static_url('js/Thinking.js') }}"></script>
<script src="{{ static_url('js/DashboardWebSocket.js') }}"></script>
<script src="{{ static_url('js/GameState.js') }}"></script>
<script>
const initialGameId = {{ initial_game_id|tojson }};
const initialSummary = {{ initial_summary|tojson }};
@@ -102,6 +103,20 @@
gameState._gamesTable = gamesTable;
const dashboardWS = new DashboardWebSocket({
onReplayUpdate: (payload) => {
const games = Array.isArray(dashboardGamesPayload.games)
? dashboardGamesPayload.games
: [];
const listedGame = games.find(
(game) => String(game.game_id) === String(payload.game_id),
);
if (listedGame && payload.game) {
listedGame.final_turn = payload.game.final_turn;
listedGame.status = payload.game.status;
gamesTable.render(games, gameState.activeGameId);
}
gameState.applyLiveTurn(payload.game_id, payload.game, payload.turn);
},
onGamesUpdate: (payload) => {
if (payload.summary) {
dashboardSummary = payload.summary;
@@ -111,6 +126,11 @@
dashboardGamesPayload = payload.games;
const games = Array.isArray(dashboardGamesPayload.games) ? dashboardGamesPayload.games : [];
gamesTable.render(games, gameState.activeGameId);
const activeGame = games.find((game) => String(game.game_id) === gameState.activeGameId);
if (activeGame && gameState.replay && gameState.replay.game) {
gameState.replay.game.status = activeGame.status;
gameState.replay.game.final_turn = activeGame.final_turn;
}
}
},
});
+51
View File
@@ -0,0 +1,51 @@
import unittest
from snakes.engine.perimeter import perimeter_geometry_score
class TestPerimeterGeometryScore(unittest.TestCase):
def score(self, point, **overrides):
values = {
"point": point,
"current_head": (0, 5),
"width": 11,
"height": 11,
"occupancy": 0.35,
"snake_length": 12,
"reachable_space": 80,
"required_space": 12,
"liberties": 2,
"next_options": 2,
"safe_next_options": 2,
"tail_escape": True,
"dead_end": False,
"losing_head_to_head": False,
}
values.update(overrides)
return perimeter_geometry_score(**values)
def test_safe_wall_lane_can_outscore_adjacent_inner_lane(self):
wall = self.score((0, 6))
inner = self.score((1, 5))
self.assertGreater(wall, inner)
def test_unsafe_wall_does_not_receive_perimeter_reward(self):
safe_wall = self.score((0, 6))
unsafe_wall = self.score((0, 6), safe_next_options=1)
self.assertGreater(safe_wall, unsafe_wall + 30.0)
def test_corner_is_less_attractive_than_straight_edge(self):
corner = self.score((0, 0), current_head=(0, 1))
straight_edge = self.score((0, 6))
self.assertGreater(straight_edge, corner)
def test_losing_head_to_head_never_gets_perimeter_reward(self):
safe_wall = self.score((0, 6))
contested_wall = self.score((0, 6), losing_head_to_head=True)
self.assertGreater(safe_wall, contested_wall + 30.0)
if __name__ == "__main__":
unittest.main()
@@ -52,8 +52,8 @@ class TestPrismBattleSnake_GPT_5_6_Sol(unittest.TestCase):
snake = PrismBattleSnake_GPT_5_6_Sol()
self.assertEqual(snake.name, "PrismBattleSnake")
self.assertEqual(snake.version, "1.4.0")
self.assertEqual(get_snake_version("PrismBattleSnake_GPT_5_6_Sol"), "1.4.0")
self.assertEqual(snake.version, "1.5.0")
self.assertEqual(get_snake_version("PrismBattleSnake_GPT_5_6_Sol"), "1.5.0")
self.assertGreaterEqual(snake._planning_depth, 4)
self.assertIsInstance(SnakeBuilder.build("PrismBattleSnake_GPT_5_6_Sol"), PrismBattleSnake_GPT_5_6_Sol)
+30
View File
@@ -0,0 +1,30 @@
import unittest
from server.database.benchmark_states import _build_state, is_postgresql_source
class TestBenchmarkStates(unittest.TestCase):
def test_detects_postgresql_dsn(self):
self.assertTrue(is_postgresql_source("postgresql://user:pass@example.com/db"))
self.assertTrue(is_postgresql_source("postgres://user:pass@example.com/db"))
self.assertFalse(is_postgresql_source("/tmp/gameplay.sqlite3"))
def test_builds_normalized_state_from_postgresql_json_values(self):
row = (
1, {}, {}, [{"x": 5, "y": 5}], [], "you-id", "You", 11, 11,
"game-id", "league", "standard", "standard", "v1", 7,
)
snake_rows = [
("you-id", "You", 90, 3, 1, 2, [{"x": 1, "y": 2}], {"head": "default"}),
]
state = _build_state(row, snake_rows)
self.assertIsNotNone(state)
board, metadata = state
self.assertEqual(board["food"], [{"x": 5, "y": 5}])
self.assertEqual(board["snakes"][0]["id"], "you-id")
self.assertEqual(metadata["you"]["id"], "you-id")
self.assertEqual(metadata["turn"], 7)
if __name__ == "__main__":
unittest.main()
+59
View File
@@ -0,0 +1,59 @@
import unittest
from unittest.mock import AsyncMock
from server.services.dashboard_query import DashboardQueryService
class _Logger:
def warning(self, message):
return message
class TestDashboardQueryService(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.database = AsyncMock()
self.hub = AsyncMock()
self.service = DashboardQueryService(
gameplay_database=self.database,
ws_hub=self.hub,
logger=_Logger(),
dashboard_running_game_stale_sec=600,
)
async def test_pushes_compact_latest_turn_event(self):
self.database.get_game_replay.return_value = {
"game": {"game_id": "game-1", "status": "running"},
"turns": [{"turn": 1}, {"turn": 2, "my_move": "up"}],
}
await self.service.push_dashboard_game_replay_update("game-1")
payload = self.hub.broadcast_payload.await_args.args[0]
self.assertEqual(payload["type"], "dashboard_game_replay_update")
self.assertEqual(payload["game_id"], "game-1")
self.assertEqual(payload["turn"], {"turn": 2, "my_move": "up"})
self.assertNotIn("turns", payload)
async def test_publishes_game_id_for_cluster_live_updates(self):
self.database.get_game_replay.return_value = {
"game": {"game_id": "game-1", "status": "running"},
"turns": [{"turn": 3}],
}
publish = AsyncMock()
self.service.set_publish_notice(publish)
await self.service.push_dashboard_game_replay_update("game-1")
publish.assert_awaited_once_with("game_turn", "game-1")
async def test_remote_turn_notice_loads_and_broadcasts_turn(self):
self.database.get_game_replay.return_value = {
"game": {"game_id": "game-1", "status": "running"},
"turns": [{"turn": 4}],
}
await self.service.on_dashboard_games_update_notice("game_turn", "game-1")
payload = self.hub.broadcast_payload.await_args.args[0]
self.assertEqual(payload["turn"]["turn"], 4)
if __name__ == "__main__":
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
import os
import unittest
from unittest.mock import AsyncMock
from server.Server import Server
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REPLAY = {
'game': {'game_id': 'game-1', 'width': 11, 'height': 11},
'turns': [{'turn': 0, 'snakes': []}],
}
class TestDashboardReplayRoute(unittest.IsolatedAsyncioTestCase):
"""The dashboard falls back to this route when the replay websocket is down."""
def setUp(self):
self.server = Server(
data_path=REPO_ROOT,
snake_type='PrismBattleSnake',
storage_type='memory',
metrics_backend='memory',
gameplay_db_enabled=False,
)
self.database = AsyncMock()
self.server.dashboard_query.gameplay_database = self.database
async def test_serves_replay_payload(self):
self.database.get_game_replay.return_value = dict(REPLAY)
response = await self.server.app.test_client().get('/dashboard/game/game-1')
self.assertEqual(response.status_code, 200)
payload = await response.get_json()
self.assertEqual(payload['game']['game_id'], 'game-1')
self.assertEqual(len(payload['turns']), 1)
self.database.get_game_replay.assert_awaited_once_with('game-1')
async def test_unknown_game_returns_404(self):
self.database.get_game_replay.return_value = None
response = await self.server.app.test_client().get('/dashboard/game/nope')
self.assertEqual(response.status_code, 404)
payload = await response.get_json()
self.assertEqual(payload['error'], 'game_not_found')
if __name__ == '__main__':
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
import os
import re
import unittest
from server.Server import Server
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
class TestDashboardStaticAssets(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.server = Server(
data_path=REPO_ROOT,
snake_type='PrismBattleSnake',
storage_type='memory',
metrics_backend='memory',
gameplay_db_enabled=False,
)
def _static_url(self):
return self.server.app.jinja_env.globals['static_url']
async def test_static_url_carries_file_mtime(self):
asset = 'js/GameBoard.js'
expected_version = int(os.path.getmtime(
os.path.join(self.server.app.static_folder, asset)
))
async with self.server.app.test_request_context('/dashboard'):
url = self._static_url()(asset)
self.assertEqual(url, f'/files/{asset}?v={expected_version}')
async def test_static_url_tolerates_missing_asset(self):
async with self.server.app.test_request_context('/dashboard'):
url = self._static_url()('js/DoesNotExist.js')
self.assertEqual(url, '/files/js/DoesNotExist.js?v=0')
async def test_dashboard_page_versions_every_static_asset(self):
client = self.server.app.test_client()
response = await client.get('/dashboard')
self.assertEqual(response.status_code, 200)
body = await response.get_data(as_text=True)
references = re.findall(r'(?:href|src)="(/files/[^"]+)"', body)
self.assertTrue(references, 'dashboard page referenced no static assets')
for reference in references:
self.assertRegex(reference, r'\?v=\d+$', f'unversioned static asset: {reference}')
if __name__ == '__main__':
unittest.main()
+34
View File
@@ -0,0 +1,34 @@
import unittest
from scripts.migrate_sqlite_to_postgresql import parse_json, parse_timestamp, transform_row
class TestMigrateSqliteToPostgresql(unittest.TestCase):
def test_transform_row_converts_timestamp_boolean_and_json(self):
columns = ("started_at", "winner_you", "quality_reasons", "game_id")
row = ("2026-08-01T10:20:30+00:00", 1, '["complete"]', "game-1")
transformed = transform_row(columns, row)
self.assertEqual(transformed[0].isoformat(), "2026-08-01T10:20:30+00:00")
self.assertIs(transformed[1], True)
self.assertEqual(transformed[2], '["complete"]')
self.assertEqual(transformed[3], "game-1")
def test_json_defaults_match_not_null_postgresql_columns(self):
columns = ("customizations", "board_state", "snakes", "you", "food", "hazards", "body")
self.assertEqual(
transform_row(columns, (None,) * len(columns)),
("{}", "{}", "[]", "{}", "[]", "[]", "[]"),
)
def test_invalid_json_aborts_instead_of_silently_losing_data(self):
with self.assertRaisesRegex(ValueError, "Invalid JSON"):
parse_json("{invalid")
def test_z_suffix_timestamp_is_timezone_aware(self):
parsed = parse_timestamp("2026-08-01T10:20:30Z")
self.assertIsNotNone(parsed.tzinfo)
self.assertEqual(parsed.utcoffset().total_seconds(), 0)
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from server.database.backend.PostgresqlGameplayBackend import PostgresqlGameplayBackend
class TestPostgresqlGameplayMigration(unittest.TestCase):
def _create_source(self, path:Path, winner_column:str, winner_value:str|None) -> None:
with sqlite3.connect(path) as connection:
connection.executescript(f"""
CREATE TABLE games (
game_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, ended_at TEXT,
width INTEGER, height INTEGER, source TEXT, map_name TEXT,
ruleset_name TEXT, ruleset_version TEXT, your_snake_id TEXT,
your_snake_name TEXT, your_snake_type TEXT, your_snake_version TEXT,
game_type TEXT, {winner_column} TEXT, winner_you INTEGER,
final_turn INTEGER, status TEXT
);
CREATE TABLE turns (
game_id TEXT, turn INTEGER, observed_at TEXT, my_move TEXT,
my_thinking_json TEXT, board_state_json TEXT, snakes_json TEXT,
you_json TEXT, food_json TEXT, hazards_json TEXT
);
CREATE TABLE snake_turns (
game_id TEXT, turn INTEGER, snake_id TEXT, snake_name TEXT,
health INTEGER, length INTEGER, head_x INTEGER, head_y INTEGER,
body_json TEXT, is_you INTEGER, inferred_move TEXT, latency TEXT
);
""")
connection.execute(
f"""INSERT INTO games (
game_id, started_at, {winner_column}, winner_you, final_turn, status
) VALUES (?, ?, ?, ?, ?, ?)""",
("game-1", "2026-08-01T10:00:00+00:00", winner_value, 1, 4, "finished"),
)
def test_reads_current_winner_name_schema(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "gameplay.sqlite3"
self._create_source(path, "winner_name", "Prism")
backend = PostgresqlGameplayBackend("postgresql://example", sqlite_migration_path=str(path))
games, turns, snake_turns = backend._read_sqlite_data_sync(str(path))
self.assertEqual(games[0]["winner_name"], "Prism")
self.assertEqual(turns, [])
self.assertEqual(snake_turns, [])
self.assertEqual(backend._migrated_winner_name(games[0]["winner_name"]), "Prism")
def test_reads_legacy_winner_names_json_schema(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "gameplay.sqlite3"
self._create_source(path, "winner_names_json", '["Prism"]')
backend = PostgresqlGameplayBackend("postgresql://example", sqlite_migration_path=str(path))
games, _, _ = backend._read_sqlite_data_sync(str(path))
self.assertEqual(
backend._migrated_winner_name(games[0]["winner_name"]),
"Prism",
)
if __name__ == "__main__":
unittest.main()