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