add GameplayDatabase database with dashboard
This commit is contained in:
+115
-4
@@ -7,13 +7,14 @@ from server.GameBoard import GameBoard
|
||||
from snakes import SnakeBuilder
|
||||
|
||||
from server.storage import StorageLoader
|
||||
from server.database import GameplayDatabase
|
||||
|
||||
from server.metrics import (
|
||||
MetricsStoreBuilder,
|
||||
MetricsCollector,
|
||||
)
|
||||
|
||||
from quart import Quart, request, jsonify
|
||||
from quart import Quart, request, jsonify, render_template
|
||||
import logging, json, os, re, time
|
||||
from typing import cast
|
||||
|
||||
@@ -27,7 +28,7 @@ class Server:
|
||||
'version': '1.0.0',
|
||||
}
|
||||
|
||||
def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, check_tls_security:bool=False, game_state_backend:str='memory', game_state_redis_url:str='redis://localhost:6379/0', game_state_ttl_sec:int=900, game_state_local_cache:bool=True, metrics_backend:str='memory', metrics_redis_url:str='redis://localhost:6379/0', metrics_ttl_sec:int|None=None):
|
||||
def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, check_tls_security:bool=False, game_state_backend:str='memory', game_state_redis_url:str='redis://localhost:6379/0', game_state_ttl_sec:int=900, game_state_local_cache:bool=True, metrics_backend:str='memory', metrics_redis_url:str='redis://localhost:6379/0', metrics_ttl_sec:int|None=None, gameplay_db_enabled:bool=True, gameplay_db_path:str|None=None, gameplay_db_busy_timeout_ms:int=5000):
|
||||
self.debug = debug
|
||||
self.snake_type = snake_type
|
||||
self.storage_type = storage_type
|
||||
@@ -71,8 +72,15 @@ class Server:
|
||||
|
||||
self.logger = build_logger('Battlesnake', debug_env_var='DEBUG_SERVER')
|
||||
self.snake_version = self._get_snake_version()
|
||||
self.gameplay_database = None
|
||||
if gameplay_db_enabled:
|
||||
db_path = gameplay_db_path or os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3')
|
||||
self.gameplay_database = GameplayDatabase(
|
||||
db_path=db_path,
|
||||
busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
||||
)
|
||||
|
||||
self.app = Quart('Battlesnake')
|
||||
self.app = Quart('Battlesnake', template_folder=os.path.join(data_path, 'server', 'templates'))
|
||||
|
||||
# info is called when you create your Battlesnake on play.battlesnake.com
|
||||
# and controls your Battlesnake's appearance
|
||||
@@ -92,6 +100,7 @@ class Server:
|
||||
await self._prune_stale_games()
|
||||
game_state = await request.get_json()
|
||||
await self._create_game_board(game_state)
|
||||
await self._record_gameplay_start(game_state)
|
||||
await await_log(self.logger.info(f'GAME START: {game_state['game']}'))
|
||||
return 'ok'
|
||||
|
||||
@@ -104,6 +113,7 @@ class Server:
|
||||
game_board = cast(GameBoard, await self._get_game_board(game_state))
|
||||
next_move = game_board.snake_neat_make_a_move()
|
||||
await self._persist_game_board(game_state['game']['id'], game_board)
|
||||
await self._record_gameplay_turn(game_state, next_move, game_board)
|
||||
elapsed_ms = (time.perf_counter() - move_started) * 1000.0
|
||||
await self.metrics_collector.record_move(next_move, elapsed_ms)
|
||||
|
||||
@@ -158,6 +168,8 @@ class Server:
|
||||
async def shutdown_state_storage():
|
||||
await self.game_state_store.close()
|
||||
await self.metrics_collector.close()
|
||||
if self.gameplay_database is not None:
|
||||
await self.gameplay_database.close()
|
||||
|
||||
@self.app.get('/cleanup')
|
||||
async def cleanup():
|
||||
@@ -178,6 +190,36 @@ class Server:
|
||||
{'Content-Type': 'text/plain; version=0.0.4; charset=utf-8'},
|
||||
)
|
||||
|
||||
@self.app.get('/dashboard/summary')
|
||||
async def dashboard_summary():
|
||||
summary = await self._get_dashboard_summary()
|
||||
return jsonify(summary)
|
||||
|
||||
@self.app.get('/dashboard')
|
||||
async def dashboard_view():
|
||||
initial_game_id = request.args.get('game_id', '')
|
||||
return await render_template(
|
||||
'dashboard.html',
|
||||
initial_game_id=initial_game_id,
|
||||
)
|
||||
|
||||
@self.app.get('/dashboard/games')
|
||||
async def dashboard_games():
|
||||
raw_limit = request.args.get('limit', '50')
|
||||
try:
|
||||
limit = max(1, min(200, int(raw_limit)))
|
||||
except ValueError:
|
||||
limit = 50
|
||||
games = await self._get_dashboard_games(limit)
|
||||
return jsonify(games)
|
||||
|
||||
@self.app.get('/dashboard/game/<string:game_id>')
|
||||
async def dashboard_game_replay(game_id:str):
|
||||
replay = await self._get_dashboard_game_replay(game_id)
|
||||
if replay is None:
|
||||
return jsonify({'error':'game_not_found', 'game_id':game_id}), 404
|
||||
return jsonify(replay)
|
||||
|
||||
async def run(self, host:str='0.0.0.0', port:int=8000, debug:bool=False):
|
||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||
|
||||
@@ -278,7 +320,7 @@ class Server:
|
||||
|
||||
async def _get_game_board(self, game_state:dict, end:bool=False) -> GameBoard:
|
||||
game_id = game_state['game']['id']
|
||||
game_board: GameBoard
|
||||
game_board:GameBoard
|
||||
if self.game_state_local_cache and game_id in self.running_games:
|
||||
game_board = self.running_games[game_id]
|
||||
else:
|
||||
@@ -325,3 +367,72 @@ class Server:
|
||||
self.game_move_counts.pop(game_id, None)
|
||||
self.game_last_seen_unix.pop(game_id, None)
|
||||
await self.metrics_collector.record_stuck_removed()
|
||||
|
||||
async def _record_gameplay_start(self, game_state:dict) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
try:
|
||||
await self.gameplay_database.record_game_start(game_state)
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB start record failed:{error}'))
|
||||
|
||||
def _extract_latest_snake_thinking(self, game_board:GameBoard) -> dict | None:
|
||||
try:
|
||||
history = game_board.snake_class.get_history()
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(history, list) or len(history) == 0:
|
||||
return None
|
||||
latest = history[-1]
|
||||
return latest if isinstance(latest, dict) else None
|
||||
|
||||
async def _record_gameplay_turn(self, game_state:dict, my_move:str, game_board:GameBoard) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
try:
|
||||
thinking = self._extract_latest_snake_thinking(game_board)
|
||||
await self.gameplay_database.record_turn(game_state, my_move, thinking)
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB turn record failed:{error}'))
|
||||
|
||||
async def _record_gameplay_end(self, game_state:dict) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
try:
|
||||
await self.gameplay_database.record_game_end(game_state)
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB end record failed:{error}'))
|
||||
|
||||
async def _get_dashboard_summary(self) -> dict:
|
||||
if self.gameplay_database is None:
|
||||
return {'enabled':False}
|
||||
try:
|
||||
summary = await self.gameplay_database.get_summary()
|
||||
summary['enabled'] = True
|
||||
return summary
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB summary failed:{error}'))
|
||||
return {'enabled':True, 'error':'summary_unavailable'}
|
||||
|
||||
async def _get_dashboard_games(self, limit:int=50) -> dict:
|
||||
if self.gameplay_database is None:
|
||||
return {'enabled':False, 'games':[]}
|
||||
try:
|
||||
games = await self.gameplay_database.list_games(limit=limit)
|
||||
return {'enabled':True, 'games':games}
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB game list failed:{error}'))
|
||||
return {'enabled':True, 'error':'games_unavailable', 'games':[]}
|
||||
|
||||
async def _get_dashboard_game_replay(self, game_id:str) -> dict | None:
|
||||
if self.gameplay_database is None:
|
||||
return {'enabled':False, 'error':'database_disabled', 'game_id':game_id}
|
||||
try:
|
||||
replay = await self.gameplay_database.get_game_replay(game_id)
|
||||
if replay is None:
|
||||
return None
|
||||
replay['enabled'] = True
|
||||
return replay
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB replay failed:{error}'))
|
||||
return {'enabled':True, 'error':'replay_unavailable', 'game_id':game_id}
|
||||
|
||||
@@ -32,6 +32,13 @@ def build_server_from_env(default_snake_type:str) -> Server:
|
||||
else:
|
||||
metrics_ttl_sec = int(metrics_ttl_sec_raw)
|
||||
|
||||
gameplay_db_enabled = env_bool('GAMEPLAY_DB_ENABLED', True)
|
||||
gameplay_db_path = os.environ.get(
|
||||
'GAMEPLAY_DB_PATH',
|
||||
os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3'),
|
||||
)
|
||||
gameplay_db_busy_timeout_ms = int(os.environ.get('GAMEPLAY_DB_BUSY_TIMEOUT_MS', '5000'))
|
||||
|
||||
server = Server(
|
||||
data_path=data_path,
|
||||
snake_type=os.environ.get('SNAKE', default_snake_type),
|
||||
@@ -45,6 +52,9 @@ def build_server_from_env(default_snake_type:str) -> Server:
|
||||
metrics_backend=metrics_backend,
|
||||
metrics_redis_url=metrics_redis_url,
|
||||
metrics_ttl_sec=metrics_ttl_sec,
|
||||
gameplay_db_enabled=gameplay_db_enabled,
|
||||
gameplay_db_path=gameplay_db_path,
|
||||
gameplay_db_busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
||||
)
|
||||
|
||||
if env_bool('STORE_GAME_HISTORY'):
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
from datetime import datetime, timezone
|
||||
import asyncio, sqlite3, json
|
||||
from pathlib import Path
|
||||
|
||||
class GameplayDatabase:
|
||||
def __init__(self, db_path:str, busy_timeout_ms:int = 5000):
|
||||
self.db_path = db_path
|
||||
self.busy_timeout_ms = max(1000, int(busy_timeout_ms))
|
||||
self._initialize_database()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(
|
||||
self.db_path,
|
||||
timeout=max(1, self.busy_timeout_ms // 1000),
|
||||
isolation_level=None,
|
||||
)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
connection.execute("PRAGMA journal_mode = WAL")
|
||||
connection.execute("PRAGMA synchronous = NORMAL")
|
||||
connection.execute(f"PRAGMA busy_timeout = {self.busy_timeout_ms}")
|
||||
return connection
|
||||
|
||||
def _initialize_database(self) -> None:
|
||||
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as connection:
|
||||
connection.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS 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,
|
||||
winner_names_json TEXT,
|
||||
winner_you INTEGER NOT NULL DEFAULT 0,
|
||||
final_turn INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'running'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS turns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id TEXT NOT NULL,
|
||||
turn INTEGER NOT NULL,
|
||||
observed_at TEXT NOT NULL,
|
||||
my_move TEXT,
|
||||
my_thinking_json TEXT,
|
||||
board_state_json TEXT NOT NULL,
|
||||
snakes_json TEXT NOT NULL,
|
||||
you_json TEXT NOT NULL,
|
||||
food_json TEXT NOT NULL,
|
||||
hazards_json TEXT NOT NULL,
|
||||
UNIQUE (game_id, turn),
|
||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snake_turns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id TEXT NOT NULL,
|
||||
turn INTEGER NOT NULL,
|
||||
snake_id TEXT NOT NULL,
|
||||
snake_name TEXT,
|
||||
health INTEGER,
|
||||
length INTEGER,
|
||||
head_x INTEGER,
|
||||
head_y INTEGER,
|
||||
body_json TEXT NOT NULL,
|
||||
is_you INTEGER NOT NULL DEFAULT 0,
|
||||
inferred_move TEXT,
|
||||
UNIQUE (game_id, turn, snake_id),
|
||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_turns_game_turn ON turns(game_id, turn);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_status ON games(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_snake_turns_game_turn ON snake_turns(game_id, turn);
|
||||
""")
|
||||
self._ensure_column_exists(connection, "turns", "my_thinking_json", "TEXT")
|
||||
|
||||
def _ensure_column_exists(self, connection:sqlite3.Connection, table_name:str, column_name:str, column_type:str) -> None:
|
||||
existing = connection.execute(f"PRAGMA table_info({table_name})").fetchall()
|
||||
if any(row["name"] == column_name for row in existing):
|
||||
return
|
||||
|
||||
connection.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}")
|
||||
|
||||
def _utc_now(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _to_json(self, payload:dict) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
def _from_json(self, payload:str|None):
|
||||
if payload is None or payload == "":
|
||||
return None
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
def _extract_snakes(self, game_state:dict) -> list[dict]:
|
||||
return list(game_state.get("board", {}).get("snakes", []))
|
||||
|
||||
def _extract_you(self, game_state:dict) -> dict:
|
||||
return dict(game_state.get("you", {}))
|
||||
|
||||
def _infer_direction(self, old_head:tuple[int, int]|None, new_head:tuple[int, int]|None) -> str|None:
|
||||
if old_head is None or new_head is None:
|
||||
return None
|
||||
|
||||
delta_x = new_head[0] - old_head[0]
|
||||
delta_y = new_head[1] - old_head[1]
|
||||
if delta_x == 1 and delta_y == 0:
|
||||
return "right"
|
||||
if delta_x == -1 and delta_y == 0:
|
||||
return "left"
|
||||
if delta_x == 0 and delta_y == 1:
|
||||
return "up"
|
||||
if delta_x == 0 and delta_y == -1:
|
||||
return "down"
|
||||
return None
|
||||
|
||||
def _record_game_start_sync(self, game_state:dict) -> None:
|
||||
game = game_state.get("game", {})
|
||||
board = game_state.get("board", {})
|
||||
you = self._extract_you(game_state)
|
||||
ruleset = game.get("ruleset", {})
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("""
|
||||
INSERT INTO games (
|
||||
game_id, started_at, width, height, source, map_name,
|
||||
ruleset_name, ruleset_version, your_snake_id, your_snake_name, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running')
|
||||
ON CONFLICT(game_id) DO UPDATE SET
|
||||
width = excluded.width,
|
||||
height = excluded.height,
|
||||
source = excluded.source,
|
||||
map_name = excluded.map_name,
|
||||
ruleset_name = excluded.ruleset_name,
|
||||
ruleset_version = excluded.ruleset_version,
|
||||
your_snake_id = excluded.your_snake_id,
|
||||
your_snake_name = excluded.your_snake_name,
|
||||
status = 'running'
|
||||
""",
|
||||
(
|
||||
game.get("id"),
|
||||
self._utc_now(),
|
||||
board.get("width"),
|
||||
board.get("height"),
|
||||
game.get("source"),
|
||||
game.get("map"),
|
||||
ruleset.get("name"),
|
||||
ruleset.get("version"),
|
||||
you.get("id"),
|
||||
you.get("name"),
|
||||
)
|
||||
)
|
||||
|
||||
def _record_turn_sync(self, game_state:dict, my_move:str|None, my_thinking:dict|None) -> None:
|
||||
game = game_state.get("game", {})
|
||||
board = game_state.get("board", {})
|
||||
snakes = self._extract_snakes(game_state)
|
||||
you = self._extract_you(game_state)
|
||||
game_id = game.get("id")
|
||||
turn = int(game_state.get("turn", 0))
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("""
|
||||
INSERT INTO turns (
|
||||
game_id, turn, observed_at, my_move, my_thinking_json,
|
||||
board_state_json, snakes_json, you_json, food_json, hazards_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(game_id, turn) DO UPDATE SET
|
||||
observed_at = excluded.observed_at,
|
||||
my_move = excluded.my_move,
|
||||
my_thinking_json = excluded.my_thinking_json,
|
||||
board_state_json = excluded.board_state_json,
|
||||
snakes_json = excluded.snakes_json,
|
||||
you_json = excluded.you_json,
|
||||
food_json = excluded.food_json,
|
||||
hazards_json = excluded.hazards_json
|
||||
""",
|
||||
(
|
||||
game_id,
|
||||
turn,
|
||||
self._utc_now(),
|
||||
my_move,
|
||||
self._to_json(my_thinking) if my_thinking is not None else None,
|
||||
self._to_json(board),
|
||||
self._to_json(snakes),
|
||||
self._to_json(you),
|
||||
self._to_json(board.get("food", [])),
|
||||
self._to_json(board.get("hazards", [])),
|
||||
),
|
||||
)
|
||||
|
||||
previous_positions:dict[str, tuple[int, int]] = {}
|
||||
if turn > 0:
|
||||
previous_rows = connection.execute("""
|
||||
SELECT snake_id, head_x, head_y
|
||||
FROM snake_turns
|
||||
WHERE game_id = ? AND turn = ?
|
||||
""",
|
||||
(game_id, turn - 1),
|
||||
).fetchall()
|
||||
|
||||
previous_positions = {
|
||||
row["snake_id"]: (int(row["head_x"]), int(row["head_y"]))
|
||||
for row in previous_rows
|
||||
if row["head_x"] is not None and row["head_y"] is not None
|
||||
}
|
||||
|
||||
you_id = you.get("id")
|
||||
for snake in snakes:
|
||||
snake_id = snake.get("id")
|
||||
head = snake.get("head", {})
|
||||
head_x = head.get("x")
|
||||
head_y = head.get("y")
|
||||
if snake_id is None:
|
||||
continue
|
||||
|
||||
new_head = (
|
||||
(int(head_x), int(head_y))
|
||||
if head_x is not None and head_y is not None
|
||||
else None
|
||||
)
|
||||
inferred = self._infer_direction(
|
||||
previous_positions.get(snake_id), new_head
|
||||
)
|
||||
|
||||
connection.execute("""
|
||||
INSERT INTO snake_turns (
|
||||
game_id, turn, snake_id, snake_name, health, length,
|
||||
head_x, head_y, body_json, is_you, inferred_move
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(game_id, turn, snake_id) DO UPDATE SET
|
||||
snake_name = excluded.snake_name,
|
||||
health = excluded.health,
|
||||
length = excluded.length,
|
||||
head_x = excluded.head_x,
|
||||
head_y = excluded.head_y,
|
||||
body_json = excluded.body_json,
|
||||
is_you = excluded.is_you,
|
||||
inferred_move = excluded.inferred_move
|
||||
""",
|
||||
(
|
||||
game_id,
|
||||
turn,
|
||||
snake_id,
|
||||
snake.get("name"),
|
||||
snake.get("health"),
|
||||
snake.get("length"),
|
||||
head_x,
|
||||
head_y,
|
||||
self._to_json(snake.get("body", [])),
|
||||
1 if snake_id == you_id else 0,
|
||||
inferred,
|
||||
),
|
||||
)
|
||||
|
||||
connection.execute("""
|
||||
UPDATE games
|
||||
SET final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END
|
||||
WHERE game_id = ?
|
||||
""",
|
||||
(turn, turn, game_id),
|
||||
)
|
||||
|
||||
def _record_game_end_sync(self, game_state:dict) -> None:
|
||||
game = game_state.get("game", {})
|
||||
game_id = game.get("id")
|
||||
board = game_state.get("board", {})
|
||||
snakes = list(board.get("snakes", []))
|
||||
you = self._extract_you(game_state)
|
||||
winner_names = [snake.get("name") for snake in snakes if snake.get("name")]
|
||||
you_id = you.get("id")
|
||||
winner_you = any(snake.get("id") == you_id for snake in snakes)
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("""
|
||||
UPDATE games
|
||||
SET ended_at = ?,
|
||||
winner_names_json = ?,
|
||||
winner_you = ?,
|
||||
final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END,
|
||||
status = 'finished'
|
||||
WHERE game_id = ?
|
||||
""",
|
||||
(
|
||||
self._utc_now(),
|
||||
self._to_json(winner_names),
|
||||
1 if winner_you else 0,
|
||||
int(game_state.get("turn", 0)),
|
||||
int(game_state.get("turn", 0)),
|
||||
game_id,
|
||||
),
|
||||
)
|
||||
|
||||
def _get_summary_sync(self, recent_limit:int=15) -> dict:
|
||||
with self._connect() as connection:
|
||||
totals = connection.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total_games,
|
||||
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running_games,
|
||||
SUM(CASE WHEN status = 'finished' THEN 1 ELSE 0 END) AS finished_games,
|
||||
SUM(CASE WHEN status = 'finished' AND winner_you = 1 THEN 1 ELSE 0 END) AS wins,
|
||||
SUM(CASE WHEN status = 'finished' AND winner_you = 0 THEN 1 ELSE 0 END) AS losses,
|
||||
AVG(CASE WHEN status = 'finished' THEN final_turn ELSE NULL END) AS avg_turns
|
||||
FROM games
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
recent = connection.execute("""
|
||||
SELECT game_id, started_at, ended_at, map_name, your_snake_name, winner_you, final_turn, status
|
||||
FROM games
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(max(1, int(recent_limit)),),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"database": self.db_path,
|
||||
"total_games": int(totals["total_games"] or 0),
|
||||
"running_games": int(totals["running_games"] or 0),
|
||||
"finished_games": int(totals["finished_games"] or 0),
|
||||
"wins": int(totals["wins"] or 0),
|
||||
"losses": int(totals["losses"] or 0),
|
||||
"avg_turns_finished": round(float(totals["avg_turns"] or 0.0), 2),
|
||||
"recent_games": [{
|
||||
"game_id": row["game_id"],
|
||||
"started_at": row["started_at"],
|
||||
"ended_at": row["ended_at"],
|
||||
"map": row["map_name"],
|
||||
"snake": row["your_snake_name"],
|
||||
"winner_you": bool(row["winner_you"]),
|
||||
"final_turn": int(row["final_turn"] or 0),
|
||||
"status": row["status"],
|
||||
} for row in recent ],
|
||||
}
|
||||
|
||||
def _list_games_sync(self, limit:int=50) -> list[dict]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute("""
|
||||
SELECT game_id, started_at, ended_at, map_name, source, ruleset_name,
|
||||
your_snake_name, winner_you, winner_names_json, final_turn, status
|
||||
FROM games
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(max(1, int(limit)),),
|
||||
).fetchall()
|
||||
|
||||
return [{
|
||||
"game_id": row["game_id"],
|
||||
"started_at": row["started_at"],
|
||||
"ended_at": row["ended_at"],
|
||||
"map": row["map_name"],
|
||||
"source": row["source"],
|
||||
"ruleset": row["ruleset_name"],
|
||||
"snake": row["your_snake_name"],
|
||||
"winner_you": bool(row["winner_you"]),
|
||||
"winner_names": self._from_json(row["winner_names_json"]) or [],
|
||||
"final_turn": int(row["final_turn"] or 0),
|
||||
"status": row["status"],
|
||||
} for row in rows]
|
||||
|
||||
def _get_game_replay_sync(self, game_id:str) -> dict | None:
|
||||
with self._connect() as connection:
|
||||
game_row = connection.execute("""
|
||||
SELECT game_id, started_at, ended_at, width, height, source, map_name,
|
||||
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
|
||||
winner_names_json, winner_you, final_turn, status
|
||||
FROM games
|
||||
WHERE game_id = ?
|
||||
""",
|
||||
(game_id,),
|
||||
).fetchone()
|
||||
|
||||
if game_row is None:
|
||||
return None
|
||||
|
||||
turn_rows = connection.execute("""
|
||||
SELECT turn, observed_at, my_move, my_thinking_json,
|
||||
board_state_json, food_json, hazards_json, you_json
|
||||
FROM turns
|
||||
WHERE game_id = ?
|
||||
ORDER BY turn ASC
|
||||
""",
|
||||
(game_id,),
|
||||
).fetchall()
|
||||
|
||||
snake_rows = connection.execute("""
|
||||
SELECT turn, snake_id, snake_name, health, length, head_x, head_y,
|
||||
body_json, is_you, inferred_move
|
||||
FROM snake_turns
|
||||
WHERE game_id = ?
|
||||
ORDER BY turn ASC, is_you DESC, snake_name ASC
|
||||
""",
|
||||
(game_id,),
|
||||
).fetchall()
|
||||
|
||||
snakes_by_turn:dict[int, list[dict]] = {}
|
||||
for row in snake_rows:
|
||||
turn = int(row["turn"])
|
||||
snakes_by_turn.setdefault(turn, []).append({
|
||||
"snake_id": row["snake_id"],
|
||||
"snake_name": row["snake_name"],
|
||||
"health": row["health"],
|
||||
"length": row["length"],
|
||||
"head": {"x": row["head_x"], "y": row["head_y"]},
|
||||
"body": self._from_json(row["body_json"]) or [],
|
||||
"is_you": bool(row["is_you"]),
|
||||
"inferred_move": row["inferred_move"],
|
||||
})
|
||||
|
||||
replay_turns = []
|
||||
for row in turn_rows:
|
||||
turn_number = int(row["turn"])
|
||||
replay_turns.append({
|
||||
"turn": turn_number,
|
||||
"observed_at": row["observed_at"],
|
||||
"my_move": row["my_move"],
|
||||
"my_thinking": self._from_json(row["my_thinking_json"]),
|
||||
"board": self._from_json(row["board_state_json"]),
|
||||
"food": self._from_json(row["food_json"]) or [],
|
||||
"hazards": self._from_json(row["hazards_json"]) or [],
|
||||
"you": self._from_json(row["you_json"]) or {},
|
||||
"snakes": snakes_by_turn.get(turn_number, []),
|
||||
})
|
||||
|
||||
return {
|
||||
"game": {
|
||||
"game_id": game_row["game_id"],
|
||||
"started_at": game_row["started_at"],
|
||||
"ended_at": game_row["ended_at"],
|
||||
"width": game_row["width"],
|
||||
"height": game_row["height"],
|
||||
"source": game_row["source"],
|
||||
"map": game_row["map_name"],
|
||||
"ruleset_name": game_row["ruleset_name"],
|
||||
"ruleset_version": game_row["ruleset_version"],
|
||||
"your_snake_id": game_row["your_snake_id"],
|
||||
"your_snake_name": game_row["your_snake_name"],
|
||||
"winner_names": self._from_json(game_row["winner_names_json"]) or [],
|
||||
"winner_you": bool(game_row["winner_you"]),
|
||||
"final_turn": int(game_row["final_turn"] or 0),
|
||||
"status": game_row["status"],
|
||||
},
|
||||
"turns": replay_turns,
|
||||
}
|
||||
|
||||
async def record_game_start(self, game_state:dict) -> None:
|
||||
await asyncio.to_thread(self._record_game_start_sync, game_state)
|
||||
|
||||
async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None:
|
||||
await asyncio.to_thread(self._record_turn_sync, game_state, my_move, my_thinking)
|
||||
|
||||
async def record_game_end(self, game_state:dict) -> None:
|
||||
await asyncio.to_thread(self._record_game_end_sync, game_state)
|
||||
|
||||
async def get_summary(self, recent_limit:int=15) -> dict:
|
||||
return await asyncio.to_thread(self._get_summary_sync, recent_limit)
|
||||
|
||||
async def list_games(self, limit:int=50) -> list[dict]:
|
||||
return await asyncio.to_thread(self._list_games_sync, limit)
|
||||
|
||||
async def get_game_replay(self, game_id:str) -> dict|None:
|
||||
return await asyncio.to_thread(self._get_game_replay_sync, game_id)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
@@ -0,0 +1 @@
|
||||
from .GameplayDatabase import GameplayDatabase
|
||||
@@ -0,0 +1,756 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Snake Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-1: #f2eee6;
|
||||
--bg-2: #e7dcc8;
|
||||
--panel: #fffcf6;
|
||||
--line: #d9ccb6;
|
||||
--ink: #252119;
|
||||
--muted: #6f6657;
|
||||
--accent: #146a4b;
|
||||
--accent-soft: #e5f2ed;
|
||||
--danger: #b0492a;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #fffdf8;
|
||||
--row-hover: #fdf4e7;
|
||||
--row-active: #edf8f3;
|
||||
--shadow: rgba(41, 29, 11, 0.08);
|
||||
--you: #1a7a56;
|
||||
--enemy: #bf5b33;
|
||||
--food: #cca100;
|
||||
--hazard: #6a5a9b;
|
||||
--grid: #e6dbc8;
|
||||
--cell: #ffffff;
|
||||
--mono-bg: #1d1b18;
|
||||
--mono-ink: #ecdfcb;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-1: #151819;
|
||||
--bg-2: #1b2022;
|
||||
--panel: #1f2527;
|
||||
--line: #374144;
|
||||
--ink: #e6e8e9;
|
||||
--muted: #a8b1b3;
|
||||
--accent: #4ec894;
|
||||
--accent-soft: #233e35;
|
||||
--danger: #d1734f;
|
||||
--surface: #232b2e;
|
||||
--surface-soft: #273134;
|
||||
--row-hover: #2b3538;
|
||||
--row-active: #224338;
|
||||
--shadow: rgba(0, 0, 0, 0.35);
|
||||
--you: #4ec894;
|
||||
--enemy: #e2815a;
|
||||
--food: #ebc14b;
|
||||
--hazard: #9b86d8;
|
||||
--grid: #3b464a;
|
||||
--cell: #1a2022;
|
||||
--mono-bg: #101416;
|
||||
--mono-ink: #dce7e9;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
color: var(--ink);
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background: linear-gradient(180deg, var(--bg-1), var(--bg-2));
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
box-shadow: 0 8px 28px var(--shadow);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.12rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(90px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
border: 1px solid #eadfcd;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat .k {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat .v {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 330px 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 28px var(--shadow);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eadfcd;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.games {
|
||||
overflow: auto;
|
||||
height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #efe5d5;
|
||||
}
|
||||
|
||||
tbody tr { cursor: pointer; }
|
||||
tbody tr:hover { background: var(--row-hover); }
|
||||
tbody tr.active { background: var(--row-active); }
|
||||
|
||||
.right {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eadfcd;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid #d2c3ab;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: #0f5a3f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="range"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.turn-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 42%) 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin-right: 5px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.board {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
background: var(--grid);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
background: var(--cell);
|
||||
min-width: 14px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.food { background: var(--food); }
|
||||
.hazard { background: var(--hazard); opacity: 0.82; }
|
||||
.snake-you { background: var(--you); }
|
||||
.snake-enemy { background: var(--enemy); }
|
||||
.snake-head { outline: 2px solid #111; outline-offset: -2px; }
|
||||
|
||||
.thinking {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #e8dcc8;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.think-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid #ebdfcb;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.chip .k {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chip .v {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.reason-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.score-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.score-table td,
|
||||
.score-table th {
|
||||
border-bottom: 1px solid #f0e7d7;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", "Consolas", monospace;
|
||||
font-size: 0.75rem;
|
||||
background: var(--mono-bg);
|
||||
color: var(--mono-ink);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.topbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.stats {
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(5, minmax(80px, 1fr));
|
||||
}
|
||||
.main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.games {
|
||||
height: 320px;
|
||||
}
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.turn-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
.think-grid {
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1 class="title">Battlesnake Replay Dashboard</h1>
|
||||
<p class="subtitle">Full-screen replay with turn-by-turn move reasoning and snake state.</p>
|
||||
</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Games</h2>
|
||||
<p>Pick a game to inspect the match, move timeline, and why your snake chose each turn.</p>
|
||||
</div>
|
||||
<div class="games">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Game</th>
|
||||
<th>Status</th>
|
||||
<th>W/L</th>
|
||||
<th>Turns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="games-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel right">
|
||||
<div class="controls">
|
||||
<button id="prev-btn">Prev</button>
|
||||
<button id="play-btn" class="primary">Play</button>
|
||||
<button id="next-btn">Next</button>
|
||||
<label>
|
||||
Speed
|
||||
<select id="speed">
|
||||
<option value="900">0.75x</option>
|
||||
<option value="650" selected>1x</option>
|
||||
<option value="400">1.5x</option>
|
||||
<option value="250">2x</option>
|
||||
</select>
|
||||
</label>
|
||||
<input type="range" min="0" max="0" step="1" id="turn-slider" value="0">
|
||||
<strong class="turn-badge" id="turn-label">Turn -</strong>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="board-wrap">
|
||||
<div class="legend">
|
||||
<span><i class="dot" style="background:var(--you)"></i>You</span>
|
||||
<span><i class="dot" style="background:var(--enemy)"></i>Enemy</span>
|
||||
<span><i class="dot" style="background:var(--food)"></i>Food</span>
|
||||
<span><i class="dot" style="background:var(--hazard)"></i>Hazard</span>
|
||||
</div>
|
||||
<div class="board" id="board"></div>
|
||||
</div>
|
||||
|
||||
<div class="thinking" id="thinking"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const initialGameId = {{ initial_game_id|tojson }};
|
||||
let replay = null;
|
||||
let turnIndex = 0;
|
||||
let timer = null;
|
||||
|
||||
const statsEl = document.getElementById("stats");
|
||||
const gamesBodyEl = document.getElementById("games-body");
|
||||
const boardEl = document.getElementById("board");
|
||||
const thinkingEl = document.getElementById("thinking");
|
||||
const turnLabelEl = document.getElementById("turn-label");
|
||||
const sliderEl = document.getElementById("turn-slider");
|
||||
|
||||
function toTitle(value) {
|
||||
return String(value || "").replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||||
}
|
||||
|
||||
function safeString(value) {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderStats(summary) {
|
||||
const items = [
|
||||
["Games", summary.total_games || 0],
|
||||
["Finished", summary.finished_games || 0],
|
||||
["Wins", summary.wins || 0],
|
||||
["Losses", summary.losses || 0],
|
||||
["Avg Turns", summary.avg_turns_finished || 0],
|
||||
];
|
||||
statsEl.innerHTML = items.map(([k, v]) => (
|
||||
`<div class="stat"><span class="k">${k}</span><span class="v">${v}</span></div>`
|
||||
)).join("");
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
const response = await fetch("/dashboard/summary");
|
||||
const data = await response.json();
|
||||
renderStats(data);
|
||||
}
|
||||
|
||||
function shortId(gameId) {
|
||||
return String(gameId || "-").slice(0, 8);
|
||||
}
|
||||
|
||||
function extractReasoningList(reasoning) {
|
||||
const parts = [];
|
||||
if (!reasoning || typeof reasoning !== "object") {
|
||||
return ["No reasoning recorded by this snake implementation."];
|
||||
}
|
||||
if (reasoning.reason) parts.push(`Reason: ${reasoning.reason}`);
|
||||
if (reasoning.mode) parts.push(`Mode: ${reasoning.mode}`);
|
||||
if (reasoning.health !== undefined) parts.push(`Health: ${reasoning.health}`);
|
||||
if (reasoning.length !== undefined) parts.push(`Length: ${reasoning.length}`);
|
||||
if (reasoning.occupancy !== undefined) parts.push(`Occupancy: ${reasoning.occupancy}`);
|
||||
if (reasoning.ms_remaining !== undefined) parts.push(`Time left: ${reasoning.ms_remaining}ms`);
|
||||
if (parts.length === 0) parts.push("Structured reasoning not provided; showing raw payload below.");
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildScoresRows(reasoning) {
|
||||
const scores = reasoning && typeof reasoning === "object" ? reasoning.scores : null;
|
||||
if (!scores || typeof scores !== "object" || Array.isArray(scores)) {
|
||||
return "<tr><td colspan=\"2\">No score table available</td></tr>";
|
||||
}
|
||||
const entries = Object.entries(scores).sort((a, b) => Number(b[1]) - Number(a[1]));
|
||||
if (entries.length === 0) {
|
||||
return "<tr><td colspan=\"2\">No score table available</td></tr>";
|
||||
}
|
||||
return entries.map(([move, score]) => (
|
||||
`<tr><td>${move}</td><td>${safeString(score)}</td></tr>`
|
||||
)).join("");
|
||||
}
|
||||
|
||||
function buildSnakesRows(turn) {
|
||||
const snakes = Array.isArray(turn.snakes) ? turn.snakes : [];
|
||||
if (snakes.length === 0) {
|
||||
return "<tr><td colspan=\"5\">No snake data available</td></tr>";
|
||||
}
|
||||
return snakes.map((snake) => (
|
||||
`<tr>
|
||||
<td>${safeString(snake.snake_name)}${snake.is_you ? " (you)" : ""}</td>
|
||||
<td>${safeString(snake.inferred_move)}</td>
|
||||
<td>${safeString(snake.health)}</td>
|
||||
<td>${safeString(snake.length)}</td>
|
||||
<td>${snake.head ? `${snake.head.x},${snake.head.y}` : "-"}</td>
|
||||
</tr>`
|
||||
)).join("");
|
||||
}
|
||||
|
||||
function renderThinking(turn) {
|
||||
if (!turn) {
|
||||
thinkingEl.innerHTML = "<p class=\"section-title\">Select a game to inspect reasoning.</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
const reasoning = turn.my_thinking;
|
||||
const reasons = extractReasoningList(reasoning);
|
||||
const reasonList = reasons.map((item) => `<li>${item}</li>`).join("");
|
||||
|
||||
thinkingEl.innerHTML = `
|
||||
<div class="think-grid">
|
||||
<div class="chip"><span class="k">Chosen Move</span><span class="v">${safeString(turn.my_move)}</span></div>
|
||||
<div class="chip"><span class="k">Observed At</span><span class="v">${safeString(turn.observed_at).slice(11, 19)}</span></div>
|
||||
<div class="chip"><span class="k">Food Count</span><span class="v">${Array.isArray(turn.food) ? turn.food.length : 0}</span></div>
|
||||
<div class="chip"><span class="k">Hazard Count</span><span class="v">${Array.isArray(turn.hazards) ? turn.hazards.length : 0}</span></div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<p class="section-title">Decision Summary</p>
|
||||
<ul class="reason-list">${reasonList}</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="section-title">Move Scores</p>
|
||||
<table class="score-table">
|
||||
<thead><tr><th>Move</th><th>Score</th></tr></thead>
|
||||
<tbody>${buildScoresRows(reasoning)}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="section-title">Snake State This Turn</p>
|
||||
<table class="score-table">
|
||||
<thead><tr><th>Snake</th><th>Move</th><th>Health</th><th>Length</th><th>Head</th></tr></thead>
|
||||
<tbody>${buildSnakesRows(turn)}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="section-title">Raw Reasoning Payload</p>
|
||||
<pre class="mono">${JSON.stringify(reasoning, null, 2)}</pre>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadGames() {
|
||||
const response = await fetch("/dashboard/games?limit=100");
|
||||
const data = await response.json();
|
||||
const games = data.games || [];
|
||||
|
||||
gamesBodyEl.innerHTML = games.map((g) => `
|
||||
<tr data-game-id="${g.game_id}">
|
||||
<td><code>${shortId(g.game_id)}</code><br><small>${safeString(g.map)}</small></td>
|
||||
<td>${toTitle(g.status)}</td>
|
||||
<td>${g.winner_you ? "Win" : "Loss"}</td>
|
||||
<td>${safeString(g.final_turn)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
for (const row of gamesBodyEl.querySelectorAll("tr")) {
|
||||
row.addEventListener("click", () => {
|
||||
const gameId = row.getAttribute("data-game-id");
|
||||
if (gameId) loadReplay(gameId);
|
||||
});
|
||||
}
|
||||
|
||||
if (initialGameId) {
|
||||
await loadReplay(initialGameId);
|
||||
} else if (games.length > 0) {
|
||||
await loadReplay(games[0].game_id);
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveGame() {
|
||||
for (const row of gamesBodyEl.querySelectorAll("tr")) {
|
||||
row.classList.remove("active");
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveGame(gameId) {
|
||||
clearActiveGame();
|
||||
const active = gamesBodyEl.querySelector(`tr[data-game-id="${gameId}"]`);
|
||||
if (active) active.classList.add("active");
|
||||
}
|
||||
|
||||
function clearBoard() {
|
||||
boardEl.innerHTML = "";
|
||||
boardEl.style.gridTemplateColumns = "none";
|
||||
}
|
||||
|
||||
function cellKey(x, y) {
|
||||
return `${x}:${y}`;
|
||||
}
|
||||
|
||||
function paintBoard(turnData, width, height) {
|
||||
clearBoard();
|
||||
if (!turnData || !width || !height) return;
|
||||
|
||||
boardEl.style.gridTemplateColumns = `repeat(${width}, minmax(12px, 1fr))`;
|
||||
const foods = new Set((turnData.food || []).map((p) => cellKey(p.x, p.y)));
|
||||
const hazards = new Set((turnData.hazards || []).map((p) => cellKey(p.x, p.y)));
|
||||
|
||||
const snakeBody = new Map();
|
||||
const snakeHead = new Set();
|
||||
for (const snake of (turnData.snakes || [])) {
|
||||
for (const part of (snake.body || [])) {
|
||||
snakeBody.set(cellKey(part.x, part.y), snake.is_you ? "snake-you" : "snake-enemy");
|
||||
}
|
||||
if (snake.head) snakeHead.add(cellKey(snake.head.x, snake.head.y));
|
||||
}
|
||||
|
||||
for (let y = height - 1; y >= 0; y--) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const key = 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)) cell.classList.add(snakeBody.get(key));
|
||||
if (snakeHead.has(key)) cell.classList.add("snake-head");
|
||||
boardEl.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTurn() {
|
||||
if (!replay || !Array.isArray(replay.turns) || replay.turns.length === 0) {
|
||||
turnLabelEl.textContent = "Turn -";
|
||||
clearBoard();
|
||||
renderThinking(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const game = replay.game || {};
|
||||
const turns = replay.turns;
|
||||
const turn = turns[turnIndex];
|
||||
turnLabelEl.textContent = `Turn ${turn.turn} / ${turns[turns.length - 1].turn}`;
|
||||
sliderEl.value = String(turnIndex);
|
||||
paintBoard(turn, game.width, game.height);
|
||||
renderThinking(turn);
|
||||
}
|
||||
|
||||
async function loadReplay(gameId) {
|
||||
const response = await fetch(`/dashboard/game/${gameId}`);
|
||||
if (!response.ok) {
|
||||
renderThinking({ my_move: "-", my_thinking: { error: `Replay load failed for ${gameId}` } });
|
||||
return;
|
||||
}
|
||||
replay = await response.json();
|
||||
turnIndex = 0;
|
||||
const count = Array.isArray(replay.turns) ? replay.turns.length : 0;
|
||||
sliderEl.max = String(Math.max(0, count - 1));
|
||||
sliderEl.value = "0";
|
||||
setActiveGame(gameId);
|
||||
renderTurn();
|
||||
window.history.replaceState({}, "", `/dashboard?game_id=${encodeURIComponent(gameId)}`);
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
document.getElementById("play-btn").textContent = "Play";
|
||||
}
|
||||
|
||||
function startPlayback() {
|
||||
if (!replay || !Array.isArray(replay.turns) || replay.turns.length < 2) return;
|
||||
stopPlayback();
|
||||
const interval = Number(document.getElementById("speed").value || 650);
|
||||
timer = setInterval(() => {
|
||||
if (!replay || turnIndex >= replay.turns.length - 1) {
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
turnIndex += 1;
|
||||
renderTurn();
|
||||
}, interval);
|
||||
document.getElementById("play-btn").textContent = "Pause";
|
||||
}
|
||||
|
||||
document.getElementById("play-btn").addEventListener("click", () => {
|
||||
if (timer) stopPlayback();
|
||||
else startPlayback();
|
||||
});
|
||||
|
||||
document.getElementById("prev-btn").addEventListener("click", () => {
|
||||
stopPlayback();
|
||||
if (!replay || turnIndex <= 0) return;
|
||||
turnIndex -= 1;
|
||||
renderTurn();
|
||||
});
|
||||
|
||||
document.getElementById("next-btn").addEventListener("click", () => {
|
||||
stopPlayback();
|
||||
if (!replay || !Array.isArray(replay.turns) || turnIndex >= replay.turns.length - 1) return;
|
||||
turnIndex += 1;
|
||||
renderTurn();
|
||||
});
|
||||
|
||||
sliderEl.addEventListener("input", () => {
|
||||
stopPlayback();
|
||||
turnIndex = Number(sliderEl.value || 0);
|
||||
renderTurn();
|
||||
});
|
||||
|
||||
document.getElementById("speed").addEventListener("change", () => {
|
||||
if (timer) startPlayback();
|
||||
});
|
||||
|
||||
async function boot() {
|
||||
renderThinking(null);
|
||||
await loadSummary();
|
||||
await loadGames();
|
||||
}
|
||||
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
import unittest
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile, sqlite3
|
||||
|
||||
from server.database import GameplayDatabase
|
||||
|
||||
class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase):
|
||||
def _build_state(self, turn:int, me_head:tuple[int, int], enemy_head:tuple[int, int], include_enemy:bool=True) -> dict:
|
||||
snakes = [
|
||||
{
|
||||
"id": "me",
|
||||
"name": "Me",
|
||||
"health": 90,
|
||||
"length": 3,
|
||||
"head": {"x": me_head[0], "y": me_head[1]},
|
||||
"body": [
|
||||
{"x": me_head[0], "y": me_head[1]},
|
||||
{"x": me_head[0] - 1, "y": me_head[1]},
|
||||
{"x": me_head[0] - 2, "y": me_head[1]},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
if include_enemy:
|
||||
snakes.append({
|
||||
"id": "enemy",
|
||||
"name": "Enemy",
|
||||
"health": 90,
|
||||
"length": 3,
|
||||
"head": {"x": enemy_head[0], "y": enemy_head[1]},
|
||||
"body": [
|
||||
{"x": enemy_head[0], "y": enemy_head[1]},
|
||||
{"x": enemy_head[0], "y": enemy_head[1] + 1},
|
||||
{"x": enemy_head[0], "y": enemy_head[1] + 2},
|
||||
],
|
||||
})
|
||||
|
||||
return {
|
||||
"turn": turn,
|
||||
"game": {
|
||||
"id": "game-abc",
|
||||
"source": "league",
|
||||
"map": "standard",
|
||||
"ruleset": {"name": "standard", "version": "v1.0.0"},
|
||||
},
|
||||
"board": {
|
||||
"width": 11,
|
||||
"height": 11,
|
||||
"food": [{"x": 2, "y": 2}],
|
||||
"hazards": [],
|
||||
"snakes": snakes,
|
||||
},
|
||||
"you": snakes[0],
|
||||
}
|
||||
|
||||
async def test_records_gameplay_with_wal_and_inferred_moves(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
db_path = Path(temp_dir) / "gameplay.sqlite3"
|
||||
database = GameplayDatabase(str(db_path), busy_timeout_ms=4000)
|
||||
|
||||
await database.record_game_start(self._build_state(turn=0, me_head=(1, 1), enemy_head=(5, 5)))
|
||||
await database.record_turn(
|
||||
self._build_state(turn=1, me_head=(2, 1), enemy_head=(5, 4)),
|
||||
my_move="right",
|
||||
my_thinking={
|
||||
"turn": 1,
|
||||
"reason": "safe_space",
|
||||
"scores": {"right": 1.0},
|
||||
},
|
||||
)
|
||||
await database.record_turn(
|
||||
self._build_state(turn=2, me_head=(2, 2), enemy_head=(4, 4)),
|
||||
my_move="up",
|
||||
my_thinking={"turn": 2, "reason": "food", "scores": {"up": 1.4}},
|
||||
)
|
||||
await database.record_game_end(self._build_state(turn=2, me_head=(2, 2), enemy_head=(4, 4), include_enemy=False))
|
||||
|
||||
connection = sqlite3.connect(str(db_path))
|
||||
journal_mode = connection.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
self.assertEqual(str(journal_mode).lower(), "wal")
|
||||
|
||||
games = connection.execute("SELECT status, winner_you, final_turn FROM games WHERE game_id = ?", ("game-abc",)).fetchone()
|
||||
self.assertEqual(games[0], "finished")
|
||||
self.assertEqual(games[1], 1)
|
||||
self.assertEqual(games[2], 2)
|
||||
|
||||
turns_count = connection.execute("SELECT COUNT(*) FROM turns WHERE game_id = ?", ("game-abc",)).fetchone()[0]
|
||||
self.assertEqual(turns_count, 2)
|
||||
|
||||
me_inferred = connection.execute("SELECT inferred_move FROM snake_turns WHERE game_id = ? AND turn = ? AND snake_id = ?", ("game-abc", 2, "me")).fetchone()[0]
|
||||
enemy_inferred = connection.execute("SELECT inferred_move FROM snake_turns WHERE game_id = ? AND turn = ? AND snake_id = ?", ("game-abc", 2, "enemy")).fetchone()[0]
|
||||
self.assertEqual(me_inferred, "up")
|
||||
self.assertEqual(enemy_inferred, "left")
|
||||
|
||||
summary = await database.get_summary()
|
||||
self.assertEqual(summary["finished_games"], 1)
|
||||
self.assertEqual(summary["wins"], 1)
|
||||
|
||||
replay = await database.get_game_replay("game-abc")
|
||||
self.assertIsNotNone(replay)
|
||||
replay = replay or {}
|
||||
self.assertEqual(replay["game"]["final_turn"], 2)
|
||||
self.assertEqual(len(replay["turns"]), 2)
|
||||
self.assertEqual(replay["turns"][1]["my_move"], "up")
|
||||
self.assertEqual(replay["turns"][1]["my_thinking"]["reason"], "food")
|
||||
|
||||
connection.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user