diff --git a/justfile b/justfile index 809f64b..4ef4fb1 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/scripts/benchmark_snakes_from_db.py b/scripts/benchmark_snakes_from_db.py index d1942e8..38a470d 100644 --- a/scripts/benchmark_snakes_from_db.py +++ b/scripts/benchmark_snakes_from_db.py @@ -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) diff --git a/scripts/cleanup_postgresql_gameplay.py b/scripts/cleanup_postgresql_gameplay.py new file mode 100644 index 0000000..65ec5ab --- /dev/null +++ b/scripts/cleanup_postgresql_gameplay.py @@ -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() diff --git a/scripts/migrate_sqlite_to_postgresql.py b/scripts/migrate_sqlite_to_postgresql.py new file mode 100644 index 0000000..3577854 --- /dev/null +++ b/scripts/migrate_sqlite_to_postgresql.py @@ -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() diff --git a/server/Server.py b/server/Server.py index 5c70afd..84a2ce0 100644 --- a/server/Server.py +++ b/server/Server.py @@ -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) diff --git a/server/blueprints/battlesnake.py b/server/blueprints/battlesnake.py index c89fda6..cf3401a 100644 --- a/server/blueprints/battlesnake.py +++ b/server/blueprints/battlesnake.py @@ -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) diff --git a/server/blueprints/dashboard.py b/server/blueprints/dashboard.py index e2c4263..b1a4a30 100644 --- a/server/blueprints/dashboard.py +++ b/server/blueprints/dashboard.py @@ -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/') + 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/') async def dashboard_customizations_asset(asset_path:str): customization_root = os.path.join( diff --git a/server/database/backend/PostgresqlGameplayBackend.py b/server/database/backend/PostgresqlGameplayBackend.py index 0b8f8ca..087a42e 100644 --- a/server/database/backend/PostgresqlGameplayBackend.py +++ b/server/database/backend/PostgresqlGameplayBackend.py @@ -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"], diff --git a/server/database/benchmark_states.py b/server/database/benchmark_states.py new file mode 100644 index 0000000..a5b423a --- /dev/null +++ b/server/database/benchmark_states.py @@ -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)) diff --git a/server/services/dashboard_events.py b/server/services/dashboard_events.py index 5b10faf..f791443 100644 --- a/server/services/dashboard_events.py +++ b/server/services/dashboard_events.py @@ -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: diff --git a/server/services/dashboard_query.py b/server/services/dashboard_query.py index 557b2bb..6f5d13b 100644 --- a/server/services/dashboard_query.py +++ b/server/services/dashboard_query.py @@ -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: diff --git a/snakes/__init__.py b/snakes/__init__.py index 97ee760..4769544 100644 --- a/snakes/__init__.py +++ b/snakes/__init__.py @@ -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"), diff --git a/snakes/engine/perimeter.py b/snakes/engine/perimeter.py new file mode 100644 index 0000000..b79605c --- /dev/null +++ b/snakes/engine/perimeter.py @@ -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 diff --git a/snakes/strategies/apex.py b/snakes/strategies/apex.py index 25c5645..400d552 100644 --- a/snakes/strategies/apex.py +++ b/snakes/strategies/apex.py @@ -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): diff --git a/snakes/strategies/prism.py b/snakes/strategies/prism.py index 2645758..158dd79 100644 --- a/snakes/strategies/prism.py +++ b/snakes/strategies/prism.py @@ -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__() diff --git a/templates/files/css/styles.css b/templates/files/css/styles.css index c923c46..78e5525 100644 --- a/templates/files/css/styles.css +++ b/templates/files/css/styles.css @@ -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 { diff --git a/templates/files/js/DashboardWebSocket.js b/templates/files/js/DashboardWebSocket.js index 2aa6116..6ead62b 100644 --- a/templates/files/js/DashboardWebSocket.js +++ b/templates/files/js/DashboardWebSocket.js @@ -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); } diff --git a/templates/files/js/GameBoard.js b/templates/files/js/GameBoard.js index 003c2a7..faa597f 100644 --- a/templates/files/js/GameBoard.js +++ b/templates/files/js/GameBoard.js @@ -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(); } } diff --git a/templates/files/js/GameState.js b/templates/files/js/GameState.js index 3d78785..30665c6 100644 --- a/templates/files/js/GameState.js +++ b/templates/files/js/GameState.js @@ -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; } diff --git a/templates/files/js/Snake.js b/templates/files/js/Snake.js index 7d7f48c..48650ad 100644 --- a/templates/files/js/Snake.js +++ b/templates/files/js/Snake.js @@ -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) { diff --git a/templates/side/dashboard.htm b/templates/side/dashboard.htm index e57e63b..93963e1 100644 --- a/templates/side/dashboard.htm +++ b/templates/side/dashboard.htm @@ -4,8 +4,8 @@ Snake Dashboard - - + +
@@ -51,6 +51,7 @@ + @@ -68,16 +69,16 @@
- - - - - - - - - - + + + + + + + + + +