11 Commits

Author SHA1 Message Date
Daniel Dolezal 60fe19c61c fix: rewrite SSH submodule URLs to HTTPS in CI
Build and Push Docker Container / build-and-push (push) Successful in 7m35s
2026-05-02 09:39:15 +02:00
daniel156161 79c6b00ace install python-dotenv and asyncpg for postgres
Build and Push Docker Container / build-and-push (push) Successful in 4m30s
2026-04-08 15:03:55 +02:00
daniel156161 3614625c56 remove tail and head of snakes-section 2026-04-08 15:03:04 +02:00
daniel156161 341bb27278 change that GameplayDatabase can have different backends, sqlite and postgresql with a Template example backend 2026-04-08 14:28:39 +02:00
daniel156161 a62501cf22 remove stroing of the Game Board State into Redis or Memory 2026-04-08 08:36:54 +02:00
daniel156161 f6e19e18e6 speed up loading and saving to redis that the move request are getting a answer when switching workers, strip data that neats to get recomuted every turn
Build and Push Docker Container / build-and-push (push) Successful in 4m49s
2026-04-07 12:13:11 +02:00
daniel156161 f479541c04 remove : space between function args 2026-04-07 12:02:50 +02:00
daniel156161 f0d62a6049 add new Snake with the Name ApexBattleSnake
Build and Push Docker Container / build-and-push (push) Successful in 4m41s
2026-04-07 10:46:45 +02:00
daniel156161 d2e1f2560e change metrics endpoint to return json by default and when the useragent contains prometheus to return the output as prometheus string format
Build and Push Docker Container / build-and-push (push) Successful in 4m53s
2026-04-07 07:57:52 +02:00
daniel156161 739c0520f9 move dashboard script block content into own files with new classes to update, render to have a better code overview
Build and Push Docker Container / build-and-push (push) Successful in 3m55s
2026-04-07 03:25:10 +02:00
daniel156161 03968fecdf removed reloading of full gameboard when pushing new game 2026-04-07 02:58:12 +02:00
39 changed files with 4617 additions and 2306 deletions
+4
View File
@@ -9,6 +9,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Rewrite SSH submodule URLs to HTTPS for CI
run: |
git config --global url."https://x-token:${{ secrets.ACTION_ACCESS_TOKEN }}@git.yiprawr.dev/".insteadOf "git@git.yiprawr.dev:"
- name: Checkout repository
uses: actions/checkout@v6
with:
+5
View File
@@ -12,6 +12,8 @@
# To get you started we've included code to prevent your Battlesnake from moving backwards.
# For more info see docs.battlesnake.com
from dotenv import load_dotenv
from server.CreateEnvironmentFile import CreateEnvironmentFile
from server.bootstrap import build_run_config, build_server_from_env
@@ -20,12 +22,15 @@ import os
# Start server when `python main.py` is run
if __name__ == "__main__":
if os.environ.get("CREATE_ENV_FILE", None):
CreateEnvironmentFile.load_dotenv({
"STORE_GAME_HISTORY": True,
"DEBUG": True,
"SNAKE": "TemplateSnake",
})
else:
load_dotenv()
server = build_server_from_env(default_snake_type="TemplateSnake")
asyncio.run(server.run(**build_run_config()))
+2
View File
@@ -10,4 +10,6 @@ dependencies = [
"gel>=3.1.0",
"redis>=5.2.1",
"quart>=0.20.0",
"python-dotenv>=1.2.2",
"asyncpg>=0.31.0",
]
+5
View File
@@ -151,6 +151,11 @@ class GameBoard:
return {"name": self.type, "is_ladder": self.is_ladder}
def __getstate__(self):
state = self.__dict__.copy()
state['turns'] = [] # strip turn history — grows linearly, not needed for move computation
return state
async def save(self, store_class, **kwargs):
store = store_class(**kwargs)
await store.save(self)
+12 -16
View File
@@ -1,11 +1,11 @@
from quart_common.web.logger import build_logger, await_log
from quart_common.web.env import env_bool, env_int
from server.game_state_store import GameStateStoreBuilder
from snakes import SnakeBuilder
from server.database import (
GameplayDatabase,
GameplayBackendBuilder,
StorageLoader,
)
from server.metrics import (
@@ -30,7 +30,7 @@ from server.services import (
)
class Server:
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):
def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, check_tls_security:bool=False, 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_backend:str='sqlite', gameplay_db_path:str|None=None, gameplay_db_busy_timeout_ms:int=5000, gameplay_db_pg_dsn:str|None=None):
self.debug = debug
self.data_path = data_path
@@ -41,22 +41,13 @@ class Server:
self.check_tls_security = check_tls_security
self.store_game_state = False
normalized_backend = (game_state_backend or 'memory').strip().lower()
self.game_state_local_cache = (game_state_local_cache and normalized_backend != 'memory')
self.game_state_store = GameStateStoreBuilder.build(
backend=game_state_backend,
redis_url=game_state_redis_url,
ttl_seconds=game_state_ttl_sec,
)
metrics_backend_normalized = (metrics_backend or 'memory').strip().lower()
self.metrics_backend_normalized = metrics_backend_normalized
self.metrics_redis_url = metrics_redis_url
self.stale_game_timeout_sec = self._get_stale_game_timeout_sec()
self.game_runtime = GameRuntimeService(
game_state_store=self.game_state_store,
snake_type=self.snake_type,
game_state_local_cache=self.game_state_local_cache,
stale_game_timeout_sec=self.stale_game_timeout_sec,
)
self.dashboard_ws_hub = DashboardWebSocketHub()
@@ -68,9 +59,7 @@ class Server:
ttl_seconds=metrics_ttl_sec,
key_prefix=os.environ.get('METRICS_REDIS_KEY_PREFIX', 'snake:metrics:worker'),
),
game_state_local_cache=self.game_state_local_cache,
metrics_backend=metrics_backend_normalized,
game_state_backend=game_state_backend,
stale_game_timeout_sec=self.stale_game_timeout_sec,
game_last_seen_unix=self.game_runtime.game_last_seen_unix,
game_move_counts=self.game_runtime.game_move_counts,
@@ -86,8 +75,12 @@ class Server:
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,
backend=GameplayBackendBuilder.build(
backend=gameplay_db_backend,
db_path=db_path,
busy_timeout_ms=gameplay_db_busy_timeout_ms,
pg_dsn=gameplay_db_pg_dsn,
)
)
self.gameplay_tracking = GameplayTrackingService(
@@ -127,16 +120,19 @@ class Server:
if self._startup_worker_metrics_cleared:
return
self._startup_worker_metrics_cleared = True
if env_bool('METRICS_CLEAR_WORKERS_ON_STARTUP', True):
should_clear = await self.metrics_collector.should_clear_worker_metrics_on_startup(env_int('METRICS_STARTUP_CLEANUP_LOCK_TTL_SEC', 300))
if should_clear:
await self.metrics_collector.clear_worker_metrics()
await self.dashboard_events_service.start_listener()
if self.gameplay_database is not None:
await self.gameplay_database.initialize()
@self.app.after_serving
async def shutdown_state_storage():
await self.dashboard_events_service.stop_listener()
await self.game_state_store.close()
await self.metrics_collector.close()
if self.gameplay_database is not None:
await self.gameplay_database.close()
+17 -4
View File
@@ -1,5 +1,5 @@
from typing import TYPE_CHECKING, cast
import json, time, os
import asyncio, json, time, os
from quart import Blueprint, request, jsonify
@@ -61,9 +61,22 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
server.metrics_collector.record_http_request('move')
game_state = await request.get_json()
move_started = time.perf_counter()
game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state))
next_move = game_board.snake_neat_make_a_move()
await server.game_runtime.persist_game_board(game_state['game']['id'], game_board)
game_id = game_state['game']['id']
timeout_ms = int(game_state.get('game', {}).get('timeout', 500))
budget_sec = max(0.05, (timeout_ms - 50) / 1000.0)
next_move = None
game_board = None
try:
async with asyncio.timeout(budget_sec):
game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state))
loop = asyncio.get_running_loop()
next_move = await loop.run_in_executor(None, game_board.snake_neat_make_a_move)
except TimeoutError:
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)
elapsed_ms = (time.perf_counter() - move_started) * 1000.0
await server.metrics_collector.record_move(next_move, elapsed_ms)
+9 -19
View File
@@ -1,6 +1,4 @@
from quart_common.web.decorators import restrict_ip_addresses, require_user_agent
from quart import Blueprint, jsonify
from quart import Blueprint, jsonify, request
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -10,26 +8,18 @@ def create_metrics_blueprint(server:'Server') -> Blueprint:
blueprint = Blueprint('metrics', __name__)
@blueprint.get('/metrics')
@restrict_ip_addresses(allow=['127.0.0.1', '192.168.200.0/24'], abort_code=404)
async def metrics():
snapshot = await server.metrics_collector.build_snapshot(
server.game_runtime.game_last_seen_unix,
server.game_runtime.game_move_counts,
)
if 'prometheus' in (request.headers.get('User-Agent') or '').lower():
return (
server.metrics_collector.build_prometheus_metrics(snapshot),
200,
{'Content-Type': 'text/plain; version=0.0.4; charset=utf-8'},
)
return jsonify(snapshot)
@blueprint.get('/metrics/prometheus')
@restrict_ip_addresses(allow=['127.0.0.1', '192.168.188.0/24', '192.168.200.0/24'], abort_code=404)
@require_user_agent("prometheus", abort_code=404)
async def metrics_prometheus():
snapshot = await server.metrics_collector.build_snapshot(
server.game_runtime.game_last_seen_unix,
server.game_runtime.game_move_counts,
)
return (
server.metrics_collector.build_prometheus_metrics(snapshot),
200,
{'Content-Type': 'text/plain; version=0.0.4; charset=utf-8'},
)
return blueprint
+6 -15
View File
@@ -12,31 +12,24 @@ class RunConfig(TypedDict):
def build_server_from_env(default_snake_type:str) -> Server:
data_path = str(Path(__file__).resolve().parent.parent)
backend_default = os.environ.get('BACKEND', 'memory')
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
game_state_backend = os.environ.get('GAME_STATE_BACKEND', backend_default)
game_state_redis_url = os.environ.get('GAME_STATE_REDIS_URL', redis_url)
game_state_ttl_sec = env_int('GAME_STATE_TTL_SEC', 900)
metrics_backend = os.environ.get('METRICS_BACKEND', None)
if metrics_backend is None:
metrics_backend = os.environ.get('BACKEND', None)
if metrics_backend is None:
metrics_backend = ('redis' if game_state_backend.strip().lower() == 'redis' else 'memory')
metrics_backend = os.environ.get('BACKEND', 'memory')
metrics_redis_url = os.environ.get('METRICS_REDIS_URL', redis_url)
metrics_ttl_sec_raw = os.environ.get('METRICS_TTL_SEC', None)
if metrics_ttl_sec_raw is None:
metrics_ttl_sec = (game_state_ttl_sec if metrics_backend.strip().lower() == 'redis' else None)
else:
metrics_ttl_sec = env_int('METRICS_TTL_SEC', game_state_ttl_sec)
metrics_ttl_sec = env_int('METRICS_TTL_SEC', 900) if metrics_ttl_sec_raw is not None else None
gameplay_db_enabled = env_bool('GAMEPLAY_DB_ENABLED', True)
gameplay_db_backend = os.environ.get('GAMEPLAY_DB_BACKEND', 'sqlite')
gameplay_db_path = os.environ.get(
'GAMEPLAY_DB_PATH',
os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3'),
)
gameplay_db_busy_timeout_ms = env_int('GAMEPLAY_DB_BUSY_TIMEOUT_MS', 5000)
gameplay_db_pg_dsn = os.environ.get('GAMEPLAY_DB_PG_DSN', None)
server = Server(
data_path=data_path,
@@ -44,16 +37,14 @@ def build_server_from_env(default_snake_type:str) -> Server:
storage_type=os.environ.get('STORAGE', 'LocalStorage'),
debug=env_bool('DEBUG_SERVER'),
check_tls_security=False,
game_state_backend=game_state_backend,
game_state_redis_url=game_state_redis_url,
game_state_ttl_sec=game_state_ttl_sec,
game_state_local_cache=env_bool('GAME_STATE_LOCAL_CACHE', default=True),
metrics_backend=metrics_backend,
metrics_redis_url=metrics_redis_url,
metrics_ttl_sec=metrics_ttl_sec,
gameplay_db_enabled=gameplay_db_enabled,
gameplay_db_backend=gameplay_db_backend,
gameplay_db_path=gameplay_db_path,
gameplay_db_busy_timeout_ms=gameplay_db_busy_timeout_ms,
gameplay_db_pg_dsn=gameplay_db_pg_dsn,
)
if env_bool('STORE_GAME_HISTORY'):
+16 -703
View File
@@ -1,724 +1,37 @@
from quart_common.web.env import env_bool
import asyncio, sqlite3, json, os, logging, sys
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
if not logger.handlers:
_handler = logging.StreamHandler(stream=sys.stdout)
_handler.setFormatter(logging.Formatter(fmt="%(levelname)s %(module)s: %(message)s"))
logger.addHandler(_handler)
logger.propagate = False
_ZSTD_EXT = Path(os.environ.get("SQLITE_ZSTD_EXT", "/usr/local/lib/libsqlite_zstd.so")).expanduser().resolve()
from .backend.Template import GameplayBackendTemplate
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._zstd_available = False
self._initialize_database()
"""Thin facade that delegates all operations to a GameplayBackendTemplate.
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
Construct via GameplayBackendBuilder.build() or pass a backend directly.
"""
if _ZSTD_EXT.exists() and not env_bool('DISABLE_GAMEPLAY_DB_COMPRESSION', True):
try:
connection.enable_load_extension(True)
connection.load_extension(str(_ZSTD_EXT))
self._zstd_available = True
except sqlite3.OperationalError as e:
logger.warning(f"sqlite-zstd extension skipped: {e}")
finally:
connection.enable_load_extension(False)
def __init__(self, backend:GameplayBackendTemplate):
self._backend = backend
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA journal_mode = WAL")
connection.execute("PRAGMA synchronous = NORMAL")
connection.execute("PRAGMA temp_store = MEMORY")
connection.execute("PRAGMA journal_size_limit = 1048576")
connection.execute(f"PRAGMA busy_timeout = {self.busy_timeout_ms}")
return connection
def _ensure_auto_vacuum_full(self, connection:sqlite3.Connection) -> None:
current = connection.execute("PRAGMA auto_vacuum").fetchone()[0]
if current != 1:
connection.execute("PRAGMA auto_vacuum = FULL")
connection.execute("VACUUM")
def _initialize_database(self) -> None:
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
with self._connect() as connection:
self._ensure_auto_vacuum_full(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,
your_snake_type TEXT,
your_snake_version 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
);
""")
self._create_indexes_if_tables(connection)
self._ensure_column_exists(connection, "turns", "my_thinking_json", "TEXT")
self._ensure_column_exists(connection, "games", "your_snake_type", "TEXT")
self._ensure_column_exists(connection, "games", "your_snake_version", "TEXT")
self._ensure_column_exists(connection, "games", "game_type", "TEXT")
self._ensure_column_exists(connection, "snake_turns", "latency", "TEXT")
if self._zstd_available:
self._enable_zstd_compression(connection)
connection.execute("PRAGMA optimize")
def _create_indexes_if_tables(self, connection: sqlite3.Connection) -> None:
real_tables = {
row[0] for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
indexes = [
("idx_turns_game_turn", "turns", "game_id, turn"),
("idx_games_status", "games", "status"),
("idx_snake_turns_game_turn", "snake_turns", "game_id, turn"),
]
for idx_name, table, cols in indexes:
if table in real_tables:
connection.execute(f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table}({cols})")
def _ensure_column_exists(self, connection:sqlite3.Connection, table_name:str, column_name:str, column_type:str) -> None:
obj = connection.execute(
"SELECT type FROM sqlite_master WHERE name = ?", (table_name,)
).fetchone()
if obj and obj["type"] == "view":
# zstd replaced this table with a view — operate on the underlying compressed table
underlying = f"_{table_name}_zstd"
exists = connection.execute(
"SELECT 1 FROM sqlite_master WHERE name = ? AND type = 'table'", (underlying,)
).fetchone()
if not exists:
return # nothing we can do without the extension
actual_table = underlying
else:
actual_table = table_name
existing = connection.execute(f"PRAGMA table_info({actual_table})").fetchall()
if any(row["name"] == column_name for row in existing):
return
connection.execute(f"ALTER TABLE {actual_table} ADD COLUMN {column_name} {column_type}")
def _enable_zstd_compression(self, connection: sqlite3.Connection) -> None:
compressed_columns = [
("turns", "board_state_json"),
("turns", "snakes_json"),
("turns", "you_json"),
("turns", "food_json"),
("turns", "hazards_json"),
("snake_turns", "body_json"),
]
for table, column in compressed_columns:
try:
connection.execute(
"SELECT zstd_enable_transparent(?)",
[json.dumps({"table": table, "column": column, "compression_level": 6, "dict_chooser": "'a'"})],
)
except sqlite3.OperationalError:
pass # already enabled
connection.execute("SELECT zstd_incremental_maintenance(null, 1)")
def _utc_now(self) -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_utc_timestamp(self, value:str|None) -> datetime|None:
if not value:
return None
normalized = value.strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _to_json(self, payload:object) -> 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 _derive_game_type(self, board:dict, ruleset:dict) -> str:
initial_snake_count = len(board.get("snakes", []))
if initial_snake_count == 2:
return "duel"
return ruleset.get("name") or "standard"
def _record_game_start_sync(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
game = game_state.get("game", {})
board = game_state.get("board", {})
you = self._extract_you(game_state)
ruleset = game.get("ruleset", {})
game_type = self._derive_game_type(board, 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,
your_snake_type, your_snake_version, game_type, 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,
your_snake_type = excluded.your_snake_type,
your_snake_version = excluded.your_snake_version,
game_type = excluded.game_type,
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"),
snake_type,
snake_version,
game_type,
),
)
connection.execute("PRAGMA wal_checkpoint(PASSIVE)")
connection.execute("PRAGMA optimize")
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, latency
) 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,
latency = excluded.latency
""",
(
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,
snake.get("latency"),
),
)
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 _finalize_stale_running_games_sync(self, stale_after_seconds:int=600) -> int:
threshold = max(60, int(stale_after_seconds))
now_utc = datetime.now(timezone.utc)
finalized = 0
with self._connect() as connection:
rows = connection.execute("""
SELECT game_id, started_at, final_turn, your_snake_id
FROM games
WHERE status = 'running'
ORDER BY started_at ASC
""").fetchall()
for row in rows:
started_at = self._parse_utc_timestamp(row["started_at"])
if started_at is None:
continue
age_seconds = (now_utc - started_at).total_seconds()
if age_seconds < threshold:
continue
game_id = row["game_id"]
your_snake_id = row["your_snake_id"]
final_turn = int(row["final_turn"] or 0)
snake_rows = connection.execute("""
SELECT snake_id, snake_name
FROM snake_turns
WHERE game_id = ? AND turn = ?
ORDER BY is_you DESC, snake_name ASC
""",
(game_id, final_turn),
).fetchall()
if len(snake_rows) == 0:
latest_turn_row = connection.execute("""
SELECT MAX(turn) AS latest_turn
FROM snake_turns
WHERE game_id = ?
""",
(game_id,),
).fetchone()
latest_turn = (
latest_turn_row["latest_turn"]
if latest_turn_row is not None
else None
)
if latest_turn is not None:
final_turn = int(latest_turn)
snake_rows = connection.execute("""
SELECT snake_id, snake_name
FROM snake_turns
WHERE game_id = ? AND turn = ?
ORDER BY is_you DESC, snake_name ASC
""",
(game_id, final_turn),
).fetchall()
survivor_ids = [snake["snake_id"] for snake in snake_rows if snake["snake_id"]]
survivor_names = [snake["snake_name"] for snake in snake_rows if snake["snake_name"]]
winner_you = bool(
your_snake_id
and your_snake_id in survivor_ids
and len(survivor_ids) == 1
)
update_result = 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 = ? AND status = 'running'
""",
(
self._utc_now(),
self._to_json(survivor_names),
1 if winner_you else 0,
final_turn,
final_turn,
game_id,
),
)
if update_result.rowcount > 0:
finalized += 1
return finalized
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()
by_type = connection.execute("""
SELECT
COALESCE(game_type, ruleset_name, 'unknown') AS type_label,
COUNT(*) AS total,
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
FROM games
WHERE status = 'finished'
GROUP BY type_label
ORDER BY total DESC
"""
).fetchall()
recent = connection.execute("""
SELECT game_id, started_at, ended_at, map_name, ruleset_name, game_type,
your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status
FROM games
ORDER BY started_at DESC
LIMIT ?
""",
(max(1, int(recent_limit)),),
).fetchall()
return {
"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),
"by_game_type": [{
"game_type": row["type_label"],
"total": int(row["total"]),
"wins": int(row["wins"]),
"losses": int(row["losses"]),
} for row in by_type],
"recent_games": [{
"game_id": row["game_id"],
"started_at": row["started_at"],
"ended_at": row["ended_at"],
"map": row["map_name"],
"ruleset": row["ruleset_name"],
"game_type": row["game_type"],
"snake": row["your_snake_name"],
"snake_type": row["your_snake_type"],
"snake_version": row["your_snake_version"],
"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, game_type,
your_snake_name, your_snake_type, your_snake_version,
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"],
"game_type": row["game_type"],
"snake": row["your_snake_name"],
"snake_type": row["your_snake_type"],
"snake_version": row["your_snake_version"],
"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, game_type, your_snake_id, your_snake_name,
your_snake_type, your_snake_version,
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, latency
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"],
"latency": row["latency"],
})
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"],
"game_type": game_row["game_type"],
"your_snake_id": game_row["your_snake_id"],
"your_snake_name": game_row["your_snake_name"],
"your_snake_type": game_row["your_snake_type"],
"your_snake_version": game_row["your_snake_version"],
"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 initialize(self) -> None:
await self._backend.initialize()
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
await asyncio.to_thread(self._record_game_start_sync, game_state, snake_type, snake_version)
await self._backend.record_game_start(game_state, snake_type, snake_version)
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)
await self._backend.record_turn(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)
await self._backend.record_game_end(game_state)
async def get_summary(self, recent_limit:int=15) -> dict:
return await asyncio.to_thread(self._get_summary_sync, recent_limit)
return await self._backend.get_summary(recent_limit)
async def list_games(self, limit:int=50) -> list[dict]:
return await asyncio.to_thread(self._list_games_sync, limit)
return await self._backend.list_games(limit)
async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
return await asyncio.to_thread(self._finalize_stale_running_games_sync, stale_after_seconds)
return await self._backend.finalize_stale_running_games(stale_after_seconds)
async def get_game_replay(self, game_id:str) -> dict|None:
return await asyncio.to_thread(self._get_game_replay_sync, game_id)
return await self._backend.get_game_replay(game_id)
async def close(self) -> None:
return None
await self._backend.close()
+2
View File
@@ -1,4 +1,6 @@
from .GameplayDatabase import GameplayDatabase
from .backend import GameplayBackendBuilder
from .LocalStorage import LocalStorage
from .EdgeDB import EdgeDB
@@ -0,0 +1,836 @@
"""PostgreSQL gameplay backend using asyncpg.
JSON columns use the JSONB type so PostgreSQL stores them in a binary,
decomposed format and automatically compresses large values via TOAST
(Oversized-Attribute Storage Technique). No application-level
serialisation/deserialisation round-trip is needed for reads asyncpg
decodes JSONB rows directly into Python dicts/lists.
Connection: pass a DSN via the `dsn` constructor argument, e.g.
postgresql://user:password@host:5432/dbname
or set GAMEPLAY_DB_PG_DSN in the environment.
"""
import asyncio, json, logging, sqlite3, sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse, urlunparse
from .Template import GameplayBackendTemplate
logger = logging.getLogger(__name__)
if not logger.handlers:
_handler = logging.StreamHandler(stream=sys.stdout)
_handler.setFormatter(logging.Formatter(fmt="%(levelname)s %(module)s: %(message)s"))
logger.addHandler(_handler)
logger.propagate = False
# DDL --------------------------------------------------------------------- #
_DDL = """
CREATE TABLE IF NOT EXISTS games (
game_id TEXT PRIMARY KEY,
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
width INTEGER,
height INTEGER,
source TEXT,
map_name TEXT,
ruleset_name TEXT,
ruleset_version TEXT,
your_snake_id TEXT,
your_snake_name TEXT,
your_snake_type TEXT,
your_snake_version TEXT,
game_type TEXT,
winner_name TEXT,
winner_you BOOLEAN NOT NULL DEFAULT FALSE,
final_turn INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running'
);
CREATE TABLE IF NOT EXISTS turns (
id BIGSERIAL PRIMARY KEY,
game_id TEXT NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
turn INTEGER NOT NULL,
observed_at TIMESTAMPTZ NOT NULL,
my_move TEXT,
my_thinking JSONB,
board_state JSONB NOT NULL,
snakes JSONB NOT NULL,
you JSONB NOT NULL,
food JSONB NOT NULL,
hazards JSONB NOT NULL,
UNIQUE (game_id, turn)
);
CREATE TABLE IF NOT EXISTS snake_turns (
id BIGSERIAL PRIMARY KEY,
game_id TEXT NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
turn INTEGER NOT NULL,
snake_id TEXT NOT NULL,
snake_name TEXT,
health INTEGER,
length INTEGER,
head_x INTEGER,
head_y INTEGER,
body JSONB NOT NULL,
is_you BOOLEAN NOT NULL DEFAULT FALSE,
inferred_move TEXT,
latency TEXT,
UNIQUE (game_id, turn, snake_id)
);
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);
"""
# Schema evolution: add new columns to existing tables (idempotent).
_ALTER_DDL = """
ALTER TABLE games ADD COLUMN IF NOT EXISTS game_type TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS your_snake_type TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS your_snake_version TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS winner_name TEXT;
ALTER TABLE turns ADD COLUMN IF NOT EXISTS my_thinking JSONB;
ALTER TABLE snake_turns ADD COLUMN IF NOT EXISTS latency TEXT;
"""
# Force TOAST compression on the large JSONB columns so that even
# moderately-sized payloads get compressed on-disk.
_TOAST_DDL = """
ALTER TABLE turns ALTER COLUMN board_state SET STORAGE EXTENDED;
ALTER TABLE turns ALTER COLUMN snakes SET STORAGE EXTENDED;
ALTER TABLE turns ALTER COLUMN you SET STORAGE EXTENDED;
ALTER TABLE turns ALTER COLUMN food SET STORAGE EXTENDED;
ALTER TABLE turns ALTER COLUMN hazards SET STORAGE EXTENDED;
ALTER TABLE snake_turns ALTER COLUMN body SET STORAGE EXTENDED;
"""
class PostgresqlGameplayBackend(GameplayBackendTemplate):
"""Async PostgreSQL backend. A connection pool is created lazily on the
first method call and reused for the lifetime of the object.
Requires: pip install asyncpg
"""
def __init__(self, dsn:str, min_size:int=1, max_size:int=5, sqlite_migration_path:str|None=None):
self._dsn = dsn
self._min_size = min_size
self._max_size = max_size
self._sqlite_migration_path = sqlite_migration_path
self._pool = None # asyncpg.Pool, typed at runtime
# ── DSN normalisation ──────────────────────────────────────────────────────
_DEFAULT_DB_NAME = "battlesnake"
@classmethod
def _ensure_db_name(cls, dsn:str) -> str:
"""Return *dsn* with a database name appended when none is present.
A DSN has no database name when its path component is empty or ``/``.
In that case ``battlesnake`` is appended so asyncpg gets a complete
connection string without the caller having to remember to add one.
"""
parsed = urlparse(dsn)
db = parsed.path.lstrip("/")
if db:
return dsn
new_path = f"/{cls._DEFAULT_DB_NAME}"
return urlunparse(parsed._replace(path=new_path))
# ── pool / schema ──────────────────────────────────────────────────────────
async def initialize(self) -> None:
"""Eagerly create the connection pool on startup so schema init and
SQLite migration run immediately rather than on the first game request."""
await self._get_pool()
async def _get_pool(self):
if self._pool is None:
try:
import asyncpg # noqa: PLC0415
except ImportError as exc:
raise ImportError(
"asyncpg is required for the PostgreSQL gameplay backend. "
"Install it with: pip install asyncpg"
) from exc
target_dsn = self._ensure_db_name(self._dsn)
await self._ensure_database_exists(asyncpg, target_dsn)
async def _init_conn(conn) -> None:
await conn.set_type_codec('jsonb', encoder=json.dumps, decoder=json.loads, schema='pg_catalog')
await conn.set_type_codec('json', encoder=json.dumps, decoder=json.loads, schema='pg_catalog')
self._pool = await asyncpg.create_pool(
dsn=target_dsn,
min_size=self._min_size,
max_size=self._max_size,
init=_init_conn,
)
await self._initialize_schema()
await self._maybe_migrate_from_sqlite()
return self._pool
async def _ensure_database_exists(self, asyncpg, target_dsn:str) -> None:
"""Connect to the postgres maintenance DB and CREATE the target database
if it does not already exist. Uses a plain connection (not a pool) so
the CREATE DATABASE statement can run outside any transaction."""
parsed = urlparse(target_dsn)
db_name = parsed.path.lstrip("/")
maintenance_dsn = urlunparse(parsed._replace(path="/postgres"))
try:
conn = await asyncpg.connect(dsn=maintenance_dsn)
except Exception:
# Fall back to connecting without specifying a database — some setups
# (e.g. Cloud SQL, managed PG) disallow direct access to 'postgres'.
maintenance_dsn = urlunparse(parsed._replace(path=""))
conn = await asyncpg.connect(dsn=maintenance_dsn)
try:
exists = await conn.fetchval(
"SELECT 1 FROM pg_database WHERE datname = $1", db_name
)
if not exists:
await conn.execute(f'CREATE DATABASE "{db_name}"')
logger.info(f"PostgreSQL: created database '{db_name}'")
finally:
await conn.close()
async def _initialize_schema(self) -> None:
assert self._pool is not None
async with self._pool.acquire() as conn:
await conn.execute(_DDL)
await conn.execute(_ALTER_DDL)
# TOAST storage hints are idempotent; ignore errors on repeated runs.
try:
await conn.execute(_TOAST_DDL)
except Exception as exc:
logger.debug(f"TOAST DDL skipped (likely already set): {exc}")
# ── sqlite migration ───────────────────────────────────────────────────────
async def _maybe_migrate_from_sqlite(self) -> None:
if not self._sqlite_migration_path:
return
src = Path(self._sqlite_migration_path)
if not src.exists():
return
logger.info(f"SQLite migration: found {src}, starting migration to PostgreSQL …")
try:
games, turns, snake_turns = await asyncio.to_thread(self._read_sqlite_data_sync, str(src))
await self._insert_migrated_data(games, turns, snake_turns)
done_path = src.with_suffix(".migrated")
src.rename(done_path)
logger.info(
f"SQLite migration complete: {len(games)} games, {len(turns)} turns, "
f"{len(snake_turns)} snake_turns migrated. "
f"Source file renamed to {done_path.name}"
)
except Exception:
logger.exception("SQLite migration failed — PostgreSQL data is untouched, original SQLite file kept")
def _read_sqlite_data_sync(self, db_path:str) -> tuple[list[sqlite3.Row], list[sqlite3.Row], list[sqlite3.Row]]:
conn = sqlite3.connect(db_path, timeout=30, isolation_level=None)
conn.row_factory = sqlite3.Row
try:
games = conn.execute("""
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
FROM games
ORDER BY started_at ASC
""").fetchall()
turns = conn.execute("""
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 game_id ASC, turn ASC
""").fetchall()
snake_turns = conn.execute("""
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 game_id ASC, turn ASC, snake_id ASC
""").fetchall()
finally:
conn.close()
return games, turns, snake_turns
def _parse_ts(self, value:str|None) -> datetime|None:
"""Parse an ISO-8601 TEXT timestamp from SQLite into a timezone-aware datetime."""
ts = self._parse_utc_timestamp(value)
return ts # already UTC-aware from base class helper
def _parse_json(self, value: str|None) -> object:
if not value:
return None
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return None
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:
async with conn.transaction():
# games ─────────────────────────────────────────────────────────────
# winner_name is TEXT — no cast needed.
await conn.executemany("""
INSERT INTO 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
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (game_id) DO NOTHING
""",
[
(
row["game_id"],
self._parse_ts(row["started_at"]),
self._parse_ts(row["ended_at"]),
row["width"],
row["height"],
row["source"],
row["map_name"],
row["ruleset_name"],
row["ruleset_version"],
row["your_snake_id"],
row["your_snake_name"],
row["your_snake_type"],
row["your_snake_version"],
row["game_type"],
(self._parse_json(row["winner_names_json"]) or [None])[0],
bool(row["winner_you"]),
row["final_turn"],
row["status"],
)
for row in games
],
)
await conn.executemany("""
INSERT INTO turns (
game_id, turn, observed_at, my_move, my_thinking,
board_state, snakes, you, food, hazards
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (game_id, turn) DO NOTHING
""",
[
(
row["game_id"],
row["turn"],
self._parse_ts(row["observed_at"]),
row["my_move"],
self._parse_json(row["my_thinking_json"]),
self._parse_json(row["board_state_json"]),
self._parse_json(row["snakes_json"]),
self._parse_json(row["you_json"]),
self._parse_json(row["food_json"]),
self._parse_json(row["hazards_json"]),
)
for row in turns
],
)
# snake_turns
await conn.executemany("""
INSERT INTO snake_turns (
game_id, turn, snake_id, snake_name, health, length,
head_x, head_y, body, is_you, inferred_move, latency
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT (game_id, turn, snake_id) DO NOTHING
""",
[
(
row["game_id"],
row["turn"],
row["snake_id"],
row["snake_name"],
row["health"],
row["length"],
row["head_x"],
row["head_y"],
self._parse_json(row["body_json"]),
bool(row["is_you"]),
row["inferred_move"],
row["latency"],
)
for row in snake_turns
],
)
# ── helpers ────────────────────────────────────────────────────────────────
def _utc_now_ts(self) -> datetime:
return datetime.now(timezone.utc)
# The pool init callback registers JSON/JSONB codecs so asyncpg automatically
# encodes Python dicts/lists on write and decodes them on read.
# ── write methods ──────────────────────────────────────────────────────────
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
game = game_state.get("game", {})
board = game_state.get("board", {})
you = self._extract_you(game_state)
ruleset = game.get("ruleset", {})
game_type = self._derive_game_type(board, ruleset)
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("""
INSERT INTO games (
game_id, started_at, width, height, source, map_name,
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
your_snake_type, your_snake_version, game_type, status
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'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,
your_snake_type = EXCLUDED.your_snake_type,
your_snake_version = EXCLUDED.your_snake_version,
game_type = EXCLUDED.game_type,
status = 'running'
""",
game.get("id"),
self._utc_now_ts(),
board.get("width"),
board.get("height"),
game.get("source"),
game.get("map"),
ruleset.get("name"),
ruleset.get("version"),
you.get("id"),
you.get("name"),
snake_type,
snake_version,
game_type,
)
async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=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))
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute("""
INSERT INTO turns (
game_id, turn, observed_at, my_move, my_thinking,
board_state, snakes, you, food, hazards
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (game_id, turn) DO UPDATE SET
observed_at = EXCLUDED.observed_at,
my_move = EXCLUDED.my_move,
my_thinking = EXCLUDED.my_thinking,
board_state = EXCLUDED.board_state,
snakes = EXCLUDED.snakes,
you = EXCLUDED.you,
food = EXCLUDED.food,
hazards = EXCLUDED.hazards
""",
game_id,
turn,
self._utc_now_ts(),
my_move,
my_thinking,
board,
snakes,
you,
board.get("food", []),
board.get("hazards", []),
)
previous_positions:dict[str, tuple[int, int]] = {}
if turn > 0:
prev_rows = await conn.fetch("""
SELECT snake_id, head_x, head_y
FROM snake_turns
WHERE game_id = $1 AND turn = $2
""",
game_id, turn - 1,
)
previous_positions = {
row["snake_id"]: (int(row["head_x"]), int(row["head_y"]))
for row in prev_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)
await conn.execute("""
INSERT INTO snake_turns (
game_id, turn, snake_id, snake_name, health, length,
head_x, head_y, body, is_you, inferred_move, latency
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
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 = EXCLUDED.body,
is_you = EXCLUDED.is_you,
inferred_move = EXCLUDED.inferred_move,
latency = EXCLUDED.latency
""",
game_id,
turn,
snake_id,
snake.get("name"),
snake.get("health"),
snake.get("length"),
head_x,
head_y,
snake.get("body", []),
snake_id == you_id,
inferred,
snake.get("latency"),
)
await conn.execute("""
UPDATE games
SET final_turn = GREATEST(final_turn, $1)
WHERE game_id = $2
""",
turn, game_id,
)
async def record_game_end(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_name = next((s.get("name") for s in snakes if s.get("name")), None)
you_id = you.get("id")
winner_you = any(s.get("id") == you_id for s in snakes)
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("""
UPDATE games
SET ended_at = $1,
winner_name = $2,
winner_you = $3,
final_turn = GREATEST(final_turn, $4),
status = 'finished'
WHERE game_id = $5
""",
self._utc_now_ts(),
winner_name,
winner_you,
int(game_state.get("turn", 0)),
game_id,
)
# ── stale game finalization ────────────────────────────────────────────────
async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
threshold = max(60, int(stale_after_seconds))
now_utc = datetime.now(timezone.utc)
finalized = 0
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("""
SELECT game_id, started_at, final_turn, your_snake_id
FROM games
WHERE status = 'running'
ORDER BY started_at ASC
""")
for row in rows:
started_at = row["started_at"]
if started_at is None:
continue
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=timezone.utc)
if (now_utc - started_at).total_seconds() < threshold:
continue
game_id = row["game_id"]
your_snake_id = row["your_snake_id"]
final_turn = int(row["final_turn"] or 0)
snake_rows = await conn.fetch("""
SELECT snake_id, snake_name
FROM snake_turns
WHERE game_id = $1 AND turn = $2
ORDER BY is_you DESC, snake_name ASC
""",
game_id, final_turn,
)
if len(snake_rows) == 0:
latest_row = await conn.fetchrow(
"SELECT MAX(turn) AS latest_turn FROM snake_turns WHERE game_id = $1",
game_id,
)
if latest_row is not None and latest_row["latest_turn"] is not None:
final_turn = int(latest_row["latest_turn"])
snake_rows = await conn.fetch("""
SELECT snake_id, snake_name
FROM snake_turns
WHERE game_id = $1 AND turn = $2
ORDER BY is_you DESC, snake_name ASC
""",
game_id, final_turn,
)
survivor_ids = [s["snake_id"] for s in snake_rows if s["snake_id"]]
winner_you = bool(
your_snake_id
and your_snake_id in survivor_ids
and len(survivor_ids) == 1
)
survivor_name = next((s["snake_name"] for s in snake_rows if s["snake_name"]), None)
tag = await conn.execute("""
UPDATE games
SET ended_at = $1,
winner_name = $2,
winner_you = $3,
final_turn = GREATEST(final_turn, $4),
status = 'finished'
WHERE game_id = $5 AND status = 'running'
""",
self._utc_now_ts(),
survivor_name,
winner_you,
final_turn,
game_id,
)
if tag and tag.endswith("1"):
finalized += 1
return finalized
# ── read methods ───────────────────────────────────────────────────────────
async def get_summary(self, recent_limit:int=15) -> dict:
pool = await self._get_pool()
async with pool.acquire() as conn:
totals = await conn.fetchrow("""
SELECT
COUNT(*) AS total_games,
COUNT(*) FILTER (WHERE status = 'running') AS running_games,
COUNT(*) FILTER (WHERE status = 'finished') AS finished_games,
COUNT(*) FILTER (WHERE status = 'finished' AND winner_you) AS wins,
COUNT(*) FILTER (WHERE status = 'finished' AND NOT winner_you) AS losses,
AVG(final_turn) FILTER (WHERE status = 'finished') AS avg_turns
FROM games
""")
by_type = await conn.fetch("""
SELECT
COALESCE(game_type, ruleset_name, 'unknown') AS type_label,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE winner_you) AS wins,
COUNT(*) FILTER (WHERE NOT winner_you) AS losses
FROM games
WHERE status = 'finished'
GROUP BY type_label
ORDER BY total DESC
""")
recent = await conn.fetch("""
SELECT game_id, started_at, ended_at, map_name, ruleset_name, game_type,
your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status
FROM games
ORDER BY started_at DESC
LIMIT $1
""",
max(1, int(recent_limit)),
)
return {
"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),
"by_game_type": [{
"game_type": row["type_label"],
"total": int(row["total"]),
"wins": int(row["wins"]),
"losses": int(row["losses"]),
} for row in by_type],
"recent_games": [{
"game_id": row["game_id"],
"started_at": row["started_at"].isoformat() if row["started_at"] else None,
"ended_at": row["ended_at"].isoformat() if row["ended_at"] else None,
"map": row["map_name"],
"ruleset": row["ruleset_name"],
"game_type": row["game_type"],
"snake": row["your_snake_name"],
"snake_type": row["your_snake_type"],
"snake_version": row["your_snake_version"],
"winner_you": bool(row["winner_you"]),
"final_turn": int(row["final_turn"] or 0),
"status": row["status"],
} for row in recent],
}
async def list_games(self, limit:int=50) -> list[dict]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("""
SELECT game_id, started_at, ended_at, map_name, source, ruleset_name, game_type,
your_snake_name, your_snake_type, your_snake_version,
winner_you, winner_name, final_turn, status
FROM games
ORDER BY started_at DESC
LIMIT $1
""",
max(1, int(limit)),
)
return [{
"game_id": row["game_id"],
"started_at": row["started_at"].isoformat() if row["started_at"] else None,
"ended_at": row["ended_at"].isoformat() if row["ended_at"] else None,
"map": row["map_name"],
"source": row["source"],
"ruleset": row["ruleset_name"],
"game_type": row["game_type"],
"snake": row["your_snake_name"],
"snake_type": row["your_snake_type"],
"snake_version": row["your_snake_version"],
"winner_you": bool(row["winner_you"]),
"winner_name": row["winner_name"],
"final_turn": int(row["final_turn"] or 0),
"status": row["status"],
} for row in rows]
async def get_game_replay(self, game_id:str) -> dict|None:
pool = await self._get_pool()
async with pool.acquire() as conn:
game_row = await conn.fetchrow("""
SELECT game_id, started_at, ended_at, width, height, source, map_name,
ruleset_name, ruleset_version, game_type, your_snake_id, your_snake_name,
your_snake_type, your_snake_version,
winner_name, winner_you, final_turn, status
FROM games
WHERE game_id = $1
""",
game_id,
)
if game_row is None:
return None
turn_rows = await conn.fetch("""
SELECT turn, observed_at, my_move, my_thinking,
board_state, food, hazards, you
FROM turns
WHERE game_id = $1
ORDER BY turn ASC
""",
game_id,
)
snake_rows = await conn.fetch("""
SELECT turn, snake_id, snake_name, health, length, head_x, head_y,
body, is_you, inferred_move, latency
FROM snake_turns
WHERE game_id = $1
ORDER BY turn ASC, is_you DESC, snake_name ASC
""",
game_id,
)
snakes_by_turn:dict[int, list[dict]] = {}
for row in snake_rows:
snakes_by_turn.setdefault(int(row["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": row["body"] or [],
"is_you": bool(row["is_you"]),
"inferred_move": row["inferred_move"],
"latency": row["latency"],
})
return {
"game": {
"game_id": game_row["game_id"],
"started_at": game_row["started_at"].isoformat() if game_row["started_at"] else None,
"ended_at": game_row["ended_at"].isoformat() if game_row["ended_at"] else None,
"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"],
"game_type": game_row["game_type"],
"your_snake_id": game_row["your_snake_id"],
"your_snake_name": game_row["your_snake_name"],
"your_snake_type": game_row["your_snake_type"],
"your_snake_version": game_row["your_snake_version"],
"winner_name": game_row["winner_name"],
"winner_you": bool(game_row["winner_you"]),
"final_turn": int(game_row["final_turn"] or 0),
"status": game_row["status"],
},
"turns": [
{
"turn": int(row["turn"]),
"observed_at": row["observed_at"].isoformat() if row["observed_at"] else None,
"my_move": row["my_move"],
"my_thinking": row["my_thinking"],
"board": row["board_state"],
"food": row["food"] or [],
"hazards": row["hazards"] or [],
"you": row["you"] or {},
"snakes": snakes_by_turn.get(int(row["turn"]), []),
}
for row in turn_rows
],
}
# ── lifecycle ──────────────────────────────────────────────────────────────
async def close(self) -> None:
if self._pool is not None:
await self._pool.close()
self._pool = None
@@ -0,0 +1,658 @@
from quart_common.web.env import env_bool
import asyncio, sqlite3, json, os, logging, sys
from datetime import datetime, timezone
from pathlib import Path
from server.database.backend.Template import GameplayBackendTemplate
logger = logging.getLogger(__name__)
if not logger.handlers:
_handler = logging.StreamHandler(stream=sys.stdout)
_handler.setFormatter(logging.Formatter(fmt="%(levelname)s %(module)s: %(message)s"))
logger.addHandler(_handler)
logger.propagate = False
_ZSTD_EXT = Path(os.environ.get("SQLITE_ZSTD_EXT", "/usr/local/lib/libsqlite_zstd.so")).expanduser().resolve()
class SqliteGameplayBackend(GameplayBackendTemplate):
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._zstd_available = False
self._initialize_database()
# ── connection ─────────────────────────────────────────────────────────────
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
if _ZSTD_EXT.exists() and not env_bool('DISABLE_GAMEPLAY_DB_COMPRESSION', True):
try:
connection.enable_load_extension(True)
connection.load_extension(str(_ZSTD_EXT))
self._zstd_available = True
except sqlite3.OperationalError as e:
logger.warning(f"sqlite-zstd extension skipped: {e}")
finally:
connection.enable_load_extension(False)
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA journal_mode = WAL")
connection.execute("PRAGMA synchronous = NORMAL")
connection.execute("PRAGMA temp_store = MEMORY")
connection.execute("PRAGMA journal_size_limit = 1048576")
connection.execute(f"PRAGMA busy_timeout = {self.busy_timeout_ms}")
return connection
def _ensure_auto_vacuum_full(self, connection:sqlite3.Connection) -> None:
current = connection.execute("PRAGMA auto_vacuum").fetchone()[0]
if current != 1:
connection.execute("PRAGMA auto_vacuum = FULL")
connection.execute("VACUUM")
# ── schema setup ───────────────────────────────────────────────────────────
def _initialize_database(self) -> None:
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
with self._connect() as connection:
self._ensure_auto_vacuum_full(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,
your_snake_type TEXT,
your_snake_version TEXT,
winner_name 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
);
""")
self._create_indexes_if_tables(connection)
self._ensure_column_exists(connection, "turns", "my_thinking_json", "TEXT")
self._ensure_column_exists(connection, "games", "your_snake_type", "TEXT")
self._ensure_column_exists(connection, "games", "your_snake_version", "TEXT")
self._ensure_column_exists(connection, "games", "game_type", "TEXT")
self._ensure_column_exists(connection, "snake_turns", "latency", "TEXT")
self._ensure_column_exists(connection, "games", "winner_name", "TEXT")
if self._zstd_available:
self._enable_zstd_compression(connection)
connection.execute("PRAGMA optimize")
def _create_indexes_if_tables(self, connection:sqlite3.Connection) -> None:
real_tables = {
row[0] for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
indexes = [
("idx_turns_game_turn", "turns", "game_id, turn"),
("idx_games_status", "games", "status"),
("idx_snake_turns_game_turn", "snake_turns", "game_id, turn"),
]
for idx_name, table, cols in indexes:
if table in real_tables:
connection.execute(f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table}({cols})")
def _ensure_column_exists(self, connection:sqlite3.Connection, table_name:str, column_name:str, column_type:str) -> None:
obj = connection.execute(
"SELECT type FROM sqlite_master WHERE name = ?", (table_name,)
).fetchone()
if obj and obj["type"] == "view":
underlying = f"_{table_name}_zstd"
exists = connection.execute(
"SELECT 1 FROM sqlite_master WHERE name = ? AND type = 'table'", (underlying,)
).fetchone()
if not exists:
return
actual_table = underlying
else:
actual_table = table_name
existing = connection.execute(f"PRAGMA table_info({actual_table})").fetchall()
if any(row["name"] == column_name for row in existing):
return
connection.execute(f"ALTER TABLE {actual_table} ADD COLUMN {column_name} {column_type}")
def _enable_zstd_compression(self, connection:sqlite3.Connection) -> None:
compressed_columns = [
("turns", "board_state_json"),
("turns", "snakes_json"),
("turns", "you_json"),
("turns", "food_json"),
("turns", "hazards_json"),
("snake_turns", "body_json"),
]
for table, column in compressed_columns:
try:
connection.execute(
"SELECT zstd_enable_transparent(?)",
[json.dumps({"table": table, "column": column, "compression_level": 6, "dict_chooser": "'a'"})],
)
except sqlite3.OperationalError:
pass
connection.execute("SELECT zstd_incremental_maintenance(null, 1)")
# ── sync write methods ─────────────────────────────────────────────────────
def _record_game_start_sync(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
game = game_state.get("game", {})
board = game_state.get("board", {})
you = self._extract_you(game_state)
ruleset = game.get("ruleset", {})
game_type = self._derive_game_type(board, 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,
your_snake_type, your_snake_version, game_type, 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,
your_snake_type = excluded.your_snake_type,
your_snake_version = excluded.your_snake_version,
game_type = excluded.game_type,
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"),
snake_type,
snake_version,
game_type,
),
)
connection.execute("PRAGMA wal_checkpoint(PASSIVE)")
connection.execute("PRAGMA optimize")
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, latency
) 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,
latency = excluded.latency
""",
(
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,
snake.get("latency"),
),
)
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_name = next((snake.get("name") for snake in snakes if snake.get("name")), None)
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_name = ?,
winner_you = ?,
final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END,
status = 'finished'
WHERE game_id = ?
""",
(
self._utc_now(),
winner_name,
1 if winner_you else 0,
int(game_state.get("turn", 0)),
int(game_state.get("turn", 0)),
game_id,
),
)
def _finalize_stale_running_games_sync(self, stale_after_seconds:int=600) -> int:
threshold = max(60, int(stale_after_seconds))
now_utc = datetime.now(timezone.utc)
finalized = 0
with self._connect() as connection:
rows = connection.execute("""
SELECT game_id, started_at, final_turn, your_snake_id
FROM games
WHERE status = 'running'
ORDER BY started_at ASC
""").fetchall()
for row in rows:
started_at = self._parse_utc_timestamp(row["started_at"])
if started_at is None:
continue
if (now_utc - started_at).total_seconds() < threshold:
continue
game_id = row["game_id"]
your_snake_id = row["your_snake_id"]
final_turn = int(row["final_turn"] or 0)
snake_rows = connection.execute("""
SELECT snake_id, snake_name
FROM snake_turns
WHERE game_id = ? AND turn = ?
ORDER BY is_you DESC, snake_name ASC
""",
(game_id, final_turn),
).fetchall()
if len(snake_rows) == 0:
latest_row = connection.execute(
"SELECT MAX(turn) AS latest_turn FROM snake_turns WHERE game_id = ?",
(game_id,),
).fetchone()
if latest_row is not None and latest_row["latest_turn"] is not None:
final_turn = int(latest_row["latest_turn"])
snake_rows = connection.execute("""
SELECT snake_id, snake_name
FROM snake_turns
WHERE game_id = ? AND turn = ?
ORDER BY is_you DESC, snake_name ASC
""",
(game_id, final_turn),
).fetchall()
survivor_ids = [s["snake_id"] for s in snake_rows if s["snake_id"]]
winner_you = bool(
your_snake_id
and your_snake_id in survivor_ids
and len(survivor_ids) == 1
)
survivor_name = next((s["snake_name"] for s in snake_rows if s["snake_name"]), None)
result = connection.execute("""
UPDATE games
SET ended_at = ?,
winner_name = ?,
winner_you = ?,
final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END,
status = 'finished'
WHERE game_id = ? AND status = 'running'
""",
(
self._utc_now(),
survivor_name,
1 if winner_you else 0,
final_turn,
final_turn,
game_id,
),
)
if result.rowcount > 0:
finalized += 1
return finalized
# ── sync read methods ──────────────────────────────────────────────────────
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()
by_type = connection.execute("""
SELECT
COALESCE(game_type, ruleset_name, 'unknown') AS type_label,
COUNT(*) AS total,
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
FROM games
WHERE status = 'finished'
GROUP BY type_label
ORDER BY total DESC
""").fetchall()
recent = connection.execute("""
SELECT game_id, started_at, ended_at, map_name, ruleset_name, game_type,
your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status
FROM games
ORDER BY started_at DESC
LIMIT ?
""",
(max(1, int(recent_limit)),),
).fetchall()
return {
"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),
"by_game_type": [{
"game_type": row["type_label"],
"total": int(row["total"]),
"wins": int(row["wins"]),
"losses": int(row["losses"]),
} for row in by_type],
"recent_games": [{
"game_id": row["game_id"],
"started_at": row["started_at"],
"ended_at": row["ended_at"],
"map": row["map_name"],
"ruleset": row["ruleset_name"],
"game_type": row["game_type"],
"snake": row["your_snake_name"],
"snake_type": row["your_snake_type"],
"snake_version": row["your_snake_version"],
"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, game_type,
your_snake_name, your_snake_type, your_snake_version,
winner_you, winner_name, 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"],
"game_type": row["game_type"],
"snake": row["your_snake_name"],
"snake_type": row["your_snake_type"],
"snake_version": row["your_snake_version"],
"winner_you": bool(row["winner_you"]),
"winner_name": row["winner_name"],
"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, game_type, your_snake_id, your_snake_name,
your_snake_type, your_snake_version,
winner_name, 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, latency
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:
snakes_by_turn.setdefault(int(row["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"],
"latency": row["latency"],
})
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"],
"game_type": game_row["game_type"],
"your_snake_id": game_row["your_snake_id"],
"your_snake_name": game_row["your_snake_name"],
"your_snake_type": game_row["your_snake_type"],
"your_snake_version": game_row["your_snake_version"],
"winner_name": game_row["winner_name"],
"winner_you": bool(game_row["winner_you"]),
"final_turn": int(game_row["final_turn"] or 0),
"status": game_row["status"],
},
"turns": [
{
"turn": int(row["turn"]),
"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(int(row["turn"]), []),
}
for row in turn_rows
],
}
# ── public async interface ─────────────────────────────────────────────────
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
await asyncio.to_thread(self._record_game_start_sync, game_state, snake_type, snake_version)
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 finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
return await asyncio.to_thread(self._finalize_stale_running_games_sync, stale_after_seconds)
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
+100
View File
@@ -0,0 +1,100 @@
import json
from datetime import datetime, timezone
from typing import Any
class GameplayBackendTemplate:
"""Abstract base for gameplay database backends.
Subclasses must override every method that raises NotImplementedError.
Shared pure-Python helpers (_utc_now, _to_json, etc.) live here so they
are available to both SQLite and PostgreSQL implementations.
"""
# ── public async interface ─────────────────────────────────────────────────
async def initialize(self) -> None:
"""Called once on server startup. Backends that need eager connection
(pool creation, schema init, migration) should override this."""
return None
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
raise NotImplementedError
async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None:
raise NotImplementedError
async def record_game_end(self, game_state:dict) -> None:
raise NotImplementedError
async def get_summary(self, recent_limit:int=15) -> dict:
raise NotImplementedError
async def list_games(self, limit:int=50) -> list[dict]:
raise NotImplementedError
async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
raise NotImplementedError
async def get_game_replay(self, game_id:str) -> dict|None:
raise NotImplementedError
async def close(self) -> None:
return None
# ── shared pure-python helpers ─────────────────────────────────────────────
def _utc_now(self) -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_utc_timestamp(self, value:str|None) -> datetime|None:
if not value:
return None
normalized = value.strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _to_json(self, payload:object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def _from_json(self, payload:str|None) -> Any:
if payload is None or payload == "":
return None
try:
return json.loads(payload)
except (json.JSONDecodeError, TypeError):
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
dx = new_head[0] - old_head[0]
dy = new_head[1] - old_head[1]
if dx == 1 and dy == 0:
return "right"
if dx == -1 and dy == 0:
return "left"
if dx == 0 and dy == 1:
return "up"
if dx == 0 and dy == -1:
return "down"
return None
def _derive_game_type(self, board:dict, ruleset:dict) -> str:
if len(board.get("snakes", [])) == 2:
return "duel"
return ruleset.get("name") or "standard"
+25
View File
@@ -0,0 +1,25 @@
from .Template import GameplayBackendTemplate
class GameplayBackendBuilder:
@staticmethod
def build(backend:str="sqlite", db_path:str|None=None, busy_timeout_ms:int=5000, pg_dsn:str|None=None, pg_min_size:int=1, pg_max_size:int=5) -> GameplayBackendTemplate:
normalized = (backend or "sqlite").strip().lower()
if normalized == "postgresql" or normalized == "postgres":
from .PostgresqlGameplayBackend import PostgresqlGameplayBackend
if not pg_dsn:
raise ValueError("pg_dsn is required for the postgresql backend")
return PostgresqlGameplayBackend(
dsn=pg_dsn,
min_size=pg_min_size,
max_size=pg_max_size,
sqlite_migration_path=db_path,
)
if normalized == "sqlite":
from .SqliteGameplayBackend import SqliteGameplayBackend
if not db_path:
raise ValueError("db_path is required for the sqlite backend")
return SqliteGameplayBackend(db_path=db_path, busy_timeout_ms=busy_timeout_ms)
raise ValueError(f"Unknown gameplay backend: {backend!r}. Choose 'sqlite' or 'postgresql'.")
@@ -1,20 +0,0 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from server.GameBoard import GameBoard
class MemoryGameBoardStore:
def __init__(self, **kwargs):
self._state:dict[str, object] = {}
async def save(self, game_id:str, game_board:'GameBoard') -> None:
self._state[game_id] = game_board
async def load(self, game_id:str):
return self._state.get(game_id)
async def delete(self, game_id:str) -> None:
self._state.pop(game_id, None)
async def close(self) -> None:
return None
@@ -1,61 +0,0 @@
from typing import TYPE_CHECKING
import inspect, pickle
if TYPE_CHECKING:
from server.GameBoard import GameBoard
class RedisGameBoardStore:
def __init__(self, redis_url:str="redis://localhost:6379/0", key_prefix:str="snake:gameboard", ttl_seconds:int=900, **kwargs):
self.redis_url = redis_url
self.key_prefix = key_prefix
self.ttl_seconds = max(60, int(ttl_seconds))
self._redis = None
async def _get_redis(self):
if self._redis is not None:
return self._redis
try:
import redis.asyncio as aioredis # type: ignore[import-not-found]
except ImportError as error: # pragma: no cover
raise RuntimeError("Redis backend selected but 'redis' package with asyncio support is not installed") from error
self._redis = aioredis.from_url(self.redis_url)
return self._redis
def _key(self, game_id:str) -> str:
return f"{self.key_prefix}:{game_id}"
async def save(self, game_id:str, game_board:'GameBoard') -> None:
redis = await self._get_redis()
payload = pickle.dumps(game_board, protocol=pickle.HIGHEST_PROTOCOL)
await redis.set(self._key(game_id), payload, ex=self.ttl_seconds)
async def load(self, game_id:str):
redis = await self._get_redis()
payload = await redis.get(self._key(game_id))
if payload is None:
return None
return pickle.loads(payload)
async def delete(self, game_id:str) -> None:
redis = await self._get_redis()
await redis.delete(self._key(game_id))
async def close(self) -> None:
if self._redis is None:
return
aclose_method = getattr(self._redis, "aclose", None)
if callable(aclose_method):
maybe_result = aclose_method()
if inspect.isawaitable(maybe_result):
await maybe_result
else:
close_method = getattr(self._redis, "close", None)
if callable(close_method):
close_result = close_method()
if inspect.isawaitable(close_result):
await close_result
self._redis = None
-10
View File
@@ -1,10 +0,0 @@
from .MemoryGameBoardStore import MemoryGameBoardStore
from .RedisGameBoardStore import RedisGameBoardStore
class GameStateStoreBuilder:
@classmethod
def build(self, backend:str="memory", **kwargs) -> MemoryGameBoardStore|RedisGameBoardStore:
selected = (backend or "memory").strip().lower()
if selected == "redis":
return RedisGameBoardStore(**kwargs)
return MemoryGameBoardStore(**kwargs)
+4 -22
View File
@@ -3,12 +3,11 @@ from server.metrics.backends.Template import StoreTemplate
import time
class MetricsCollector:
def __init__(self, metrics_manager:StoreTemplate, game_state_local_cache:bool, metrics_backend:str, game_state_backend:str, stale_game_timeout_sec:int, game_last_seen_unix:dict, game_move_counts:dict):
def __init__(self, metrics_manager:StoreTemplate, metrics_backend:str, stale_game_timeout_sec:int, game_last_seen_unix:dict, game_move_counts:dict):
self._manager = metrics_manager
self._stale_game_timeout_sec = stale_game_timeout_sec
self._game_last_seen_unix = game_last_seen_unix
self._game_move_counts = game_move_counts
self._game_state_backend_is_redis = game_state_backend.strip().lower() == 'redis'
self._metrics = {
'games_started': 0,
'games_ended': 0,
@@ -39,7 +38,6 @@ class MetricsCollector:
'last_game_end_unix': 0,
'last_move_unix': 0,
'games_stuck_removed': 0,
'game_state_local_cache_enabled': bool(game_state_local_cache),
'metrics_backend': metrics_backend,
}
@@ -101,8 +99,6 @@ class MetricsCollector:
await self._auto_publish()
async def record_stuck_removed(self) -> None:
if self._game_state_backend_is_redis:
return
self._metrics['games_stuck_removed'] += 1
await self._auto_publish()
@@ -117,23 +113,9 @@ class MetricsCollector:
if now - last_seen >= self._stale_game_timeout_sec
)
if self._game_state_backend_is_redis:
# Redis auto-expires stale keys via TTL, so stale games are already gone from the
# server's perspective. We exclude them from all metrics so we only report games
# that are actually still alive in Redis.
report_active_games = len(game_last_seen_unix) - stale_candidates
report_stale_candidates = 0
# Only include non-stale timestamps when calculating the oldest active game age,
# so a game that Redis already deleted doesn't inflate the age metric.
active_last_seen = [
last_seen
for last_seen in game_last_seen_unix.values()
if now - last_seen < self._stale_game_timeout_sec
]
else:
report_active_games = len(game_last_seen_unix)
report_stale_candidates = stale_candidates
active_last_seen = list(game_last_seen_unix.values())
report_active_games = len(game_last_seen_unix)
report_stale_candidates = stale_candidates
active_last_seen = list(game_last_seen_unix.values())
oldest_active_age = max(0, now - min(active_last_seen)) if active_last_seen else 0
return report_active_games, report_stale_candidates, oldest_active_age
-2
View File
@@ -79,7 +79,6 @@ class StoreTemplate:
"last_game_end_unix": 0,
"last_move_unix": 0,
"games_stuck_removed": 0,
"game_state_local_cache_enabled": False,
"metrics_backend": "redis",
"active_games": 0,
"tracked_games": 0,
@@ -122,7 +121,6 @@ class StoreTemplate:
merged["last_move_unix"] = max(merged["last_move_unix"], int(worker.get("last_move_unix", 0)))
merged["oldest_active_game_age_sec"] = max(merged["oldest_active_game_age_sec"], int(worker.get("oldest_active_game_age_sec", 0)))
merged["stale_game_timeout_sec"] = max(merged["stale_game_timeout_sec"], int(worker.get("stale_game_timeout_sec", 0)))
merged["game_state_local_cache_enabled"] = merged["game_state_local_cache_enabled"] or bool(worker.get("game_state_local_cache_enabled", False))
for endpoint in merged["http_requests_by_endpoint"]:
merged["http_requests_by_endpoint"][endpoint] += int(worker.get("http_requests_by_endpoint", {}).get(endpoint, 0))
+6 -33
View File
@@ -1,4 +1,3 @@
from typing import Protocol, cast
import time
from server.metrics import MetricsCollector
@@ -6,18 +5,9 @@ from server.GameBoard import GameBoard
from snakes import SnakeBuilder
class GameStateStoreLike(Protocol):
async def save(self, game_id: str, game_board: GameBoard) -> None: ...
async def load(self, game_id: str) -> object | None: ...
async def delete(self, game_id: str) -> None: ...
class GameRuntimeService:
def __init__(self, game_state_store:GameStateStoreLike, snake_type:str, game_state_local_cache:bool, stale_game_timeout_sec:int):
self.game_state_store = game_state_store
def __init__(self, snake_type:str, stale_game_timeout_sec:int):
self.snake_type = snake_type
self.game_state_local_cache = game_state_local_cache
self.stale_game_timeout_sec = stale_game_timeout_sec
self.metrics_collector = None
@@ -41,43 +31,27 @@ class GameRuntimeService:
)
await new_game_board.start_game(game_state)
if self.game_state_local_cache:
self.running_games[game_id] = new_game_board
await self.game_state_store.save(game_id, new_game_board)
self.running_games[game_id] = new_game_board
self.game_move_counts[game_id] = 0
self.game_last_seen_unix[game_id] = int(time.time())
if self.metrics_collector is not None:
await self.metrics_collector.record_game_started(len(self.game_last_seen_unix))
return new_game_board
async def persist_game_board(self, game_id:str, game_board:GameBoard) -> None:
if self.game_state_local_cache:
self.running_games[game_id] = game_board
await self.game_state_store.save(game_id, game_board)
async def delete_game_board(self, game_state:dict) -> None:
game_id = game_state['game']['id']
self.running_games.pop(game_id, None)
self.game_move_counts.pop(game_id, None)
self.game_last_seen_unix.pop(game_id, None)
await self.game_state_store.delete(game_id)
async def get_game_board(self, game_state:dict, end:bool=False) -> GameBoard:
game_id = game_state['game']['id']
game_board:GameBoard
if self.game_state_local_cache and game_id in self.running_games:
if game_id in self.running_games:
game_board = self.running_games[game_id]
else:
persisted_board = await self.game_state_store.load(game_id)
if persisted_board is not None:
game_board = cast(GameBoard, persisted_board)
if self.game_state_local_cache:
self.running_games[game_id] = game_board
else:
game_board = await self.create_game_board(game_state)
if self.metrics_collector is not None:
await self.metrics_collector.record_game_autocreated()
game_board = await self.create_game_board(game_state)
if self.metrics_collector is not None:
await self.metrics_collector.record_game_autocreated()
if not end:
self.game_move_counts[game_id] = self.game_move_counts.get(game_id, 0) + 1
@@ -86,7 +60,6 @@ class GameRuntimeService:
game_board.read_game_data(game_state)
if end:
game_board.end_game(game_state)
await self.persist_game_board(game_id, game_board)
return game_board
File diff suppressed because it is too large Load Diff
+9
View File
@@ -202,3 +202,12 @@ class TemplateSnake:
def set_target_food(self, target_food:dict):
self.target_food = target_food
return True
def __getstate__(self):
state = self.__dict__.copy()
state['history'] = [] # strip history — grows per turn, not needed for moves
state.pop('game_board', None) # re-set at top of every choose_move; circular ref
state.pop('calculations', None) # re-initialised at top of every choose_move
state.pop('eat_the_snake_overwrite', None) # re-initialised at top of every choose_move
state.pop('kill_the_snake', None) # per-call transient
return state
+11
View File
@@ -100,6 +100,17 @@ class UltimateBattleSnake(TemplateSnake):
# RL bootstrap dataset recorder
self.rl_bootstrap = RLBootstrapDataset()
def __getstate__(self):
state = super().__getstate__()
# strip per-turn precomputed state — all re-assigned at the top of choose_move
state['_enemy_dmaps'] = []
state['_enemy_heads'] = []
state['_base_blocked'] = set()
state['_is_snail'] = False
state['_bfs_cache'] = {}
state['_bfs_cache_turn'] = -1
return state
# ── Env helpers ──────────────────────────────────────────────────────────────
def _get_timeout_buffer_ms(self) -> int:
+1
View File
@@ -9,6 +9,7 @@ SNAKE_REGISTRY = {
"BestBattleSnake": "2.6.0",
"TrainedBattleSnake": "0.1.0",
"UltimateBattleSnake": "4.5.0",
"ApexBattleSnake": "1.0.0",
}
DEFAULT_SNAKE_CONFIG = {
+170
View File
@@ -0,0 +1,170 @@
class DashboardWebSocket {
constructor({ onGamesUpdate, onShutdown } = {}) {
this._socket = null;
this._reconnectTimer = null;
this._shuttingDown = false;
this._pendingRequests = new Map();
this._requestSeq = 0;
this._onGamesUpdate = onGamesUpdate || (() => {});
this._onShutdown = onShutdown || (() => {});
}
get isShuttingDown() { return this._shuttingDown; }
connect() {
if (this._shuttingDown) return;
if (this._socket && (
this._socket.readyState === WebSocket.OPEN ||
this._socket.readyState === WebSocket.CONNECTING
)) return;
const wsUrl = this._buildUrl();
try {
this._socket = new WebSocket(wsUrl);
} catch {
this._scheduleReconnect();
return;
}
this._socket.addEventListener("message", (event) => {
let payload = null;
try { payload = JSON.parse(event.data); } catch { return; }
if (!payload || !payload.type) return;
if (payload.type === "dashboard_ws_shutdown") {
this._shuttingDown = true;
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
this._rejectAll("Server shutting down");
if (this._socket) this._socket.close();
this._onShutdown();
return;
}
if (payload.type === "dashboard_game_replay") {
const requestId = String(payload.request_id || "");
if (!requestId) return;
const pending = this._pendingRequests.get(requestId);
if (!pending) return;
this._pendingRequests.delete(requestId);
window.clearTimeout(pending.timeoutId);
if (payload.error) {
pending.reject(new Error(String(payload.error)));
return;
}
pending.resolve(payload.replay || null);
return;
}
if (payload.type === "dashboard_games_update") {
this._onGamesUpdate(payload);
}
});
this._socket.addEventListener("close", () => {
this._socket = null;
this._rejectAll("Dashboard websocket disconnected");
if (!this._shuttingDown) this._scheduleReconnect();
});
this._socket.addEventListener("error", () => {
if (this._socket) this._socket.close();
});
}
waitForOpen(timeoutMs = 4000) {
if (this._shuttingDown) return Promise.resolve(false);
if (!this._socket || this._socket.readyState === WebSocket.CLOSED) this.connect();
if (this._socket && this._socket.readyState === WebSocket.OPEN) return Promise.resolve(true);
return new Promise((resolve) => {
if (!this._socket) { resolve(false); return; }
const socketRef = this._socket;
let settled = false;
const cleanup = () => {
socketRef.removeEventListener("open", onOpen);
socketRef.removeEventListener("close", onClose);
socketRef.removeEventListener("error", onError);
window.clearTimeout(timeoutId);
};
const finish = (value) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
};
const onOpen = () => finish(true);
const onClose = () => finish(false);
const onError = () => finish(false);
const timeoutId = window.setTimeout(() => finish(false), timeoutMs);
socketRef.addEventListener("open", onOpen);
socketRef.addEventListener("close", onClose);
socketRef.addEventListener("error", onError);
});
}
async requestReplay(gameId) {
const isOpen = await this.waitForOpen();
if (!isOpen || !this._socket || this._socket.readyState !== WebSocket.OPEN) {
throw new Error("Dashboard websocket unavailable");
}
const requestId = `replay-${Date.now()}-${this._requestSeq++}`;
return await new Promise((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
this._pendingRequests.delete(requestId);
reject(new Error(`Replay websocket timeout for ${gameId}`));
}, 4000);
this._pendingRequests.set(requestId, { resolve, reject, timeoutId });
try {
this._socket.send(JSON.stringify({
type: "dashboard_game_replay_request",
request_id: requestId,
game_id: gameId,
}));
} catch (error) {
window.clearTimeout(timeoutId);
this._pendingRequests.delete(requestId);
reject(error);
}
});
}
shutdown() {
this._shuttingDown = true;
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
this._rejectAll("Dashboard unloading");
if (this._socket) {
this._socket.close();
this._socket = null;
}
}
_buildUrl() {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
return `${protocol}://${window.location.host}/dashboard/ws/games`;
}
_scheduleReconnect() {
if (this._shuttingDown) return;
if (this._reconnectTimer) return;
this._reconnectTimer = window.setTimeout(() => {
this._reconnectTimer = null;
this.connect();
}, 1500);
}
_rejectAll(message) {
for (const pending of this._pendingRequests.values()) {
window.clearTimeout(pending.timeoutId);
pending.reject(new Error(message));
}
this._pendingRequests.clear();
}
}
+309
View File
@@ -0,0 +1,309 @@
class GameBoard {
constructor(boardEl) {
this._boardEl = boardEl;
this._svgCache = new Map();
}
clearBoard() {
this._boardEl.innerHTML = "";
this._boardEl.style.gridTemplateColumns = "none";
}
async preloadSvgs(replay) {
if (!replay || !Array.isArray(replay.turns)) return;
const urls = new Set();
for (const turn of replay.turns) {
const snakes = turn && turn.board && Array.isArray(turn.board.snakes) ? turn.board.snakes : [];
for (const snake of snakes) {
const custom = snake && (snake.customizations || {});
const headUrl = SnakeUtils.buildCustomizationIconUrl("heads", custom.head);
const tailUrl = SnakeUtils.buildCustomizationIconUrl("tails", custom.tail);
if (headUrl) urls.add(headUrl);
if (tailUrl) urls.add(tailUrl);
}
}
await Promise.all([...urls].map((url) => this._loadSvg(url)));
}
async _loadSvg(url) {
if (this._svgCache.has(url)) return this._svgCache.get(url);
try {
const res = await fetch(url);
const text = res.ok ? await res.text() : null;
this._svgCache.set(url, text);
return text;
} catch {
this._svgCache.set(url, null);
return null;
}
}
_parseViewBox(svgEl) {
const raw = String(svgEl.getAttribute("viewBox") || "").trim();
const parts = raw.split(/\s+/).map((item) => Number(item));
if (parts.length !== 4 || parts.some((v) => Number.isNaN(v))) {
return { minX: 0, minY: 0, width: 100, height: 100 };
}
return { minX: parts[0], minY: parts[1], width: parts[2], height: parts[3] };
}
_groupLooksOffCanvas(groupEl, viewBox) {
const attrNames = new Set(["x", "y", "cx", "cy", "x1", "y1", "x2", "y2", "d", "points"]);
const allElements = [groupEl, ...groupEl.querySelectorAll("*")];
let farOutsideCount = 0;
let numericCount = 0;
const minAllowedX = viewBox.minX - Math.max(40, viewBox.width * 0.8);
const minAllowedY = viewBox.minY - Math.max(40, viewBox.height * 0.8);
const maxAllowedX = viewBox.minX + viewBox.width + Math.max(40, viewBox.width * 0.8);
const maxAllowedY = viewBox.minY + viewBox.height + Math.max(40, viewBox.height * 0.8);
for (const node of allElements) {
for (const attr of node.getAttributeNames()) {
if (!attrNames.has(attr)) continue;
const value = node.getAttribute(attr);
if (!value) continue;
const matches = value.match(/-?\d*\.?\d+/g);
if (!matches) continue;
for (let idx = 0; idx < matches.length; idx += 1) {
const num = Number(matches[idx]);
if (Number.isNaN(num)) continue;
numericCount += 1;
const isXCoord = idx % 2 === 0;
if (isXCoord) {
if (num < minAllowedX || num > maxAllowedX) farOutsideCount += 1;
} else {
if (num < minAllowedY || num > maxAllowedY) farOutsideCount += 1;
}
}
}
}
if (numericCount < 10) return false;
return farOutsideCount / numericCount > 0.55;
}
_maxNestedGroupDepth(groupEl) {
let maxDepth = 1;
const stack = [{ node: groupEl, depth: 1 }];
while (stack.length > 0) {
const entry = stack.pop();
if (!entry) continue;
maxDepth = Math.max(maxDepth, entry.depth);
for (const child of Array.from(entry.node.children)) {
if (!child.tagName || child.tagName.toLowerCase() !== "g") continue;
stack.push({ node: child, depth: entry.depth + 1 });
}
}
return maxDepth;
}
_normalizeHeadSvgMarkup(svgMarkup) {
if (!svgMarkup) return null;
try {
const parser = new DOMParser();
const parsed = parser.parseFromString(svgMarkup, "image/svg+xml");
const svgEl = parsed.querySelector("svg");
if (!svgEl) return svgMarkup;
const topLevelGroups = Array.from(svgEl.children).filter(
(el) => el.tagName && el.tagName.toLowerCase() === "g"
);
if (topLevelGroups.length > 1) {
const viewBox = this._parseViewBox(svgEl);
const firstGroup = topLevelGroups[0];
if (this._groupLooksOffCanvas(firstGroup, viewBox) || this._maxNestedGroupDepth(firstGroup) >= 3) {
firstGroup.remove();
}
}
return new XMLSerializer().serializeToString(svgEl);
} catch {
return svgMarkup;
}
}
_createIconLayer(iconUrl, color, transformValue, type) {
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");
}
}
} else {
layer.style.setProperty("--icon-url", `url(${iconUrl})`);
layer.style.setProperty("--icon-color", color || "var(--you)");
}
return layer;
}
_cellKey(x, y) {
return `${x}:${y}`;
}
paintBoard(turnData, width, height, selectedSnakeId, replay) {
this.clearBoard();
if (!turnData || !width || !height) return;
const colorById = SnakeUtils.buildSnakeColorById(turnData, replay);
const customById = SnakeUtils.buildSnakeCustomizationById(turnData, replay);
this._boardEl.style.gridTemplateColumns = `repeat(${width}, 1fr)`;
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 snakeHead = new Set();
const snakeTail = new Map();
const headVariantByCell = new Map();
const tailVariantByCell = new Map();
const headIconByCell = new Map();
const tailIconByCell = new Map();
const headTransformByCell = new Map();
const tailTransformByCell = new Map();
const snakeColorByCell = new Map();
const snakeIdByCell = new Map();
(turnData.snakes || []).forEach((snake, idx) => {
if (!snake) return;
const snakeId = snake.snake_id || snake.id || `${Utils.safeString(snake.snake_name)}-${idx}`;
const bodyColor = SnakeUtils.resolveSnakeColor(snakeId, snake.is_you, colorById);
const custom = customById.get(snakeId) || {};
const headVariant = SnakeUtils.stableVariantFromString(custom.head);
const tailVariant = SnakeUtils.stableVariantFromString(custom.tail);
const headIcon = SnakeUtils.buildCustomizationIconUrl("heads", custom.head);
const tailIcon = SnakeUtils.buildCustomizationIconUrl("tails", custom.tail);
const headTransform = SnakeUtils.directionToHeadTransform(SnakeUtils.inferHeadDirection(snake));
const tailTransform = SnakeUtils.directionToTailTransform(SnakeUtils.inferTailDirection(snake));
for (const part of (snake.body || [])) {
snakeBody.set(this._cellKey(part.x, part.y), bodyColor);
snakeIdByCell.set(this._cellKey(part.x, part.y), snakeId);
}
if (snake.head) {
const headKey = this._cellKey(snake.head.x, snake.head.y);
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];
const tailKey = this._cellKey(tail.x, tail.y);
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);
}
});
for (let y = height - 1; y >= 0; y--) {
for (let x = 0; x < width; x++) {
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(", ");
}
}
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)) {
cell.classList.add("has-tail-icon", "icon-tail");
cell.appendChild(this._createIconLayer(
tailIcon,
snakeColorByCell.get(key) || "var(--you)",
tailTransformByCell.get(key) || "scaleX(-1)",
"tail",
));
}
}
if (snakeHead.has(key)) {
cell.classList.add("snake-head");
cell.classList.add(`head-style-${headVariantByCell.get(key) || 1}`);
const headIcon = headIconByCell.get(key);
if (headIcon) {
cell.classList.add("has-head-icon", "icon-head");
cell.appendChild(this._createIconLayer(
headIcon,
snakeColorByCell.get(key) || "var(--you)",
headTransformByCell.get(key) || "rotate(0deg)",
"head",
));
}
}
this._boardEl.appendChild(cell);
}
}
}
}
+140
View File
@@ -0,0 +1,140 @@
class GameState {
constructor({ gameBoard, thinkingPanel, gamesTable, sliderEl, turnLabelEl }) {
this._gameBoard = gameBoard;
this._thinkingPanel = thinkingPanel;
this._gamesTable = gamesTable;
this._sliderEl = sliderEl;
this._turnLabelEl = turnLabelEl;
this._webSocket = null;
this.replay = null;
this.turnIndex = 0;
this.activeGameId = "";
this.selectedSnakeId = null;
this._timer = null;
this._hasLoadedReplayOnce = false;
}
setWebSocket(webSocket) {
this._webSocket = webSocket;
}
get isPlaying() { return Boolean(this._timer); }
async loadReplay(gameId) {
let nextReplay = null;
try {
nextReplay = await this._webSocket.requestReplay(gameId);
} catch {
if (!this._hasLoadedReplayOnce) {
this._thinkingPanel.render(
{ my_move: "-", my_thinking: { error: `Replay websocket unavailable for ${gameId}` } },
null,
);
return;
}
const response = await fetch(`/dashboard/game/${gameId}`);
if (!response.ok) {
this._thinkingPanel.render(
{ my_move: "-", my_thinking: { error: `Replay load failed for ${gameId}` } },
null,
);
return;
}
nextReplay = await response.json();
}
this.replay = nextReplay;
this._hasLoadedReplayOnce = true;
this.activeGameId = String(gameId || "");
await this._gameBoard.preloadSvgs(this.replay);
this.turnIndex = 0;
const count = Array.isArray(this.replay.turns) ? this.replay.turns.length : 0;
this._sliderEl.max = String(Math.max(0, count - 1));
this._sliderEl.value = "0";
this._gamesTable.setActive(gameId);
this.renderTurn();
}
renderTurn() {
if (!this.replay || !Array.isArray(this.replay.turns) || this.replay.turns.length === 0) {
this._turnLabelEl.textContent = "Turn -";
this._gameBoard.clearBoard();
this._thinkingPanel.render(null, null);
return;
}
const game = this.replay.game || {};
const turns = this.replay.turns;
const turn = turns[this.turnIndex];
this._turnLabelEl.textContent = `Turn ${turn.turn} / ${turns[turns.length - 1].turn}`;
this._sliderEl.value = String(this.turnIndex);
this._gameBoard.paintBoard(turn, game.width, game.height, this.selectedSnakeId, this.replay);
this._thinkingPanel.render(turn, this.replay);
if (this.selectedSnakeId) {
this._thinkingPanel.highlightSnake(this.selectedSnakeId);
}
}
stopPlayback() {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
const playBtn = document.getElementById("play-btn");
playBtn.textContent = "▶";
playBtn.setAttribute("title", "Play");
playBtn.setAttribute("aria-label", "Play");
}
startPlayback() {
if (!this.replay || !Array.isArray(this.replay.turns) || 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) {
this.stopPlayback();
return;
}
this.turnIndex += 1;
this.renderTurn();
}, interval);
const playBtn = document.getElementById("play-btn");
playBtn.textContent = "❚❚";
playBtn.setAttribute("title", "Pause");
playBtn.setAttribute("aria-label", "Pause");
}
stepBackward() {
this.stopPlayback();
if (!this.replay || this.turnIndex <= 0) return;
this.turnIndex -= 1;
this.renderTurn();
}
stepForward() {
this.stopPlayback();
if (!this.replay || !Array.isArray(this.replay.turns) || this.turnIndex >= this.replay.turns.length - 1) return;
this.turnIndex += 1;
this.renderTurn();
}
adjustSpeed(direction) {
const speedEl = document.getElementById("speed");
const optionCount = speedEl.options.length;
if (optionCount <= 1) return;
const currentIndex = speedEl.selectedIndex;
const nextIndex = Math.max(0, Math.min(optionCount - 1, currentIndex + direction));
if (nextIndex === currentIndex) return;
speedEl.selectedIndex = nextIndex;
speedEl.dispatchEvent(new Event("change"));
}
setSelectedSnakeId(id) {
this.selectedSnakeId = id;
}
}
+52
View File
@@ -0,0 +1,52 @@
class GamesTable {
constructor(gamesBodyEl, battlesnakeUrl, onGameClick) {
this._el = gamesBodyEl;
this._battlesnakeUrl = battlesnakeUrl;
this._onGameClick = onGameClick;
}
render(games, activeGameId) {
this._el.innerHTML = games.map((g) => `
<tr data-game-id="${g.game_id}">
<td><a href="${this._battlesnakeUrl}/${g.game_id}"><code>${GamesTable._shortId(g.game_id)}</code></a><br><small>${Utils.safeString(GamesTable._displayGameTypeOrMap(g))}</small></td>
<td>${Utils.toTitle(g.status)}</td>
<td>${g.status === "running" ? "-" : g.winner_you ? "Win" : "Loss"}</td>
<td>${Utils.safeString(g.final_turn)}</td>
</tr>
`).join("");
for (const row of this._el.querySelectorAll("tr")) {
row.addEventListener("click", (event) => {
event.preventDefault();
const gameId = row.getAttribute("data-game-id");
if (gameId) this._onGameClick(gameId);
});
}
if (activeGameId) this.setActive(activeGameId);
}
setActive(gameId) {
this._clearActive();
const active = this._el.querySelector(`tr[data-game-id="${gameId}"]`);
if (active) active.classList.add("active");
}
_clearActive() {
for (const row of this._el.querySelectorAll("tr")) {
row.classList.remove("active");
}
}
static _shortId(gameId) {
return String(gameId || "-").slice(0, 8);
}
static _displayGameTypeOrMap(game) {
const mapName = String((game && game.map) || "").trim();
const gameType = String((game && game.game_type) || "").trim();
if (gameType.toLowerCase() === "duel" && mapName.toLowerCase() === "standard") return "duel";
if (mapName && mapName.toLowerCase() !== "empty") return mapName;
return gameType || "-";
}
}
+14
View File
@@ -0,0 +1,14 @@
class MoveTable {
static buildScoresRows(reasoning) {
const scores = reasoning && typeof reasoning === "object" ? reasoning.scores : null;
const moveOrder = ["up", "down", "left", "right"];
return moveOrder.map((move) => {
const hasScore = scores
&& typeof scores === "object"
&& !Array.isArray(scores)
&& Object.prototype.hasOwnProperty.call(scores, move);
const value = hasScore ? scores[move] : "-";
return `<tr><td>${move}</td><td>${Utils.safeString(value)}</td></tr>`;
}).join("");
}
}
+22
View File
@@ -0,0 +1,22 @@
class OverallStats {
constructor(statsEl) {
this._el = statsEl;
}
render(summary) {
const finished = summary.finished_games || 0;
const wins = summary.wins || 0;
const winRate = finished > 0 ? ((wins / finished) * 100).toFixed(1) + "%" : "-";
const items = [
["Games", summary.total_games || 0],
["Finished", finished],
["Wins", wins],
["Losses", summary.losses || 0],
["Win Rate", winRate],
["Avg Turns", summary.avg_turns_finished || 0],
];
this._el.innerHTML = items.map(([k, v]) => (
`<div class="stat"><span class="k">${k}</span><span class="v">${v}</span></div>`
)).join("");
}
}
+221
View File
@@ -0,0 +1,221 @@
class SnakeUtils {
static snakeColor(index) {
return `var(--snake-${((index % 10) + 1)})`;
}
static stableColorIndexFromId(snakeId) {
const raw = String(snakeId || "");
let hash = 0;
for (let i = 0; i < raw.length; i += 1) {
hash = ((hash * 31) + raw.charCodeAt(i)) >>> 0;
}
return hash % 10;
}
static resolveSnakeColor(snakeId, isYou, colorById) {
if (snakeId && colorById && colorById.has(snakeId)) {
return colorById.get(snakeId);
}
if (isYou) return "var(--you)";
return SnakeUtils.snakeColor(SnakeUtils.stableColorIndexFromId(snakeId));
}
static extractSnakeColor(rawSnake) {
if (!rawSnake || typeof rawSnake !== "object") return null;
const direct = rawSnake.color;
const custom = rawSnake.customizations && rawSnake.customizations.color;
const appearance = rawSnake.appearance && rawSnake.appearance.color;
const color = direct || custom || appearance;
if (!color) return null;
return String(color).trim();
}
static buildSnakeColorById(turnData, replay) {
const colorById = new Map();
const replayTurns = replay && Array.isArray(replay.turns) ? replay.turns : [];
const boardSnakes = turnData && turnData.board && Array.isArray(turnData.board.snakes)
? turnData.board.snakes
: [];
const replaySnakes = Array.isArray(turnData && turnData.snakes) ? turnData.snakes : [];
const historicalSnakes = [];
for (const replayTurn of replayTurns) {
if (replayTurn && Array.isArray(replayTurn.snakes)) {
historicalSnakes.push(...replayTurn.snakes);
}
if (replayTurn && replayTurn.board && Array.isArray(replayTurn.board.snakes)) {
historicalSnakes.push(...replayTurn.board.snakes);
}
}
for (const snake of [...historicalSnakes, ...boardSnakes, ...replaySnakes]) {
if (!snake) continue;
const snakeId = snake.id || snake.snake_id;
if (!snakeId) continue;
const color = SnakeUtils.extractSnakeColor(snake);
if (color) colorById.set(snakeId, color);
}
return colorById;
}
static buildSnakeCustomizationById(turnData, replay) {
const customById = new Map();
const replayTurns = replay && Array.isArray(replay.turns) ? replay.turns : [];
const sources = [];
for (const replayTurn of replayTurns) {
if (replayTurn && replayTurn.board && Array.isArray(replayTurn.board.snakes)) {
sources.push(...replayTurn.board.snakes);
}
}
if (turnData && turnData.board && Array.isArray(turnData.board.snakes)) {
sources.push(...turnData.board.snakes);
}
for (const snake of sources) {
if (!snake) continue;
const snakeId = snake.id || snake.snake_id;
if (!snakeId) continue;
const custom = snake.customizations || {};
const head = custom.head || null;
const tail = custom.tail || null;
if (head || tail) {
customById.set(snakeId, { head, tail });
}
}
return customById;
}
static buildCustomizationIconUrl(kind, value) {
const raw = String(value || "").trim().toLowerCase();
if (!raw) return null;
if (!/^[a-z0-9-]+$/.test(raw)) return null;
return `/dashboard/customizations/${kind}/${raw}.svg`;
}
static parseSnakeColor(color) {
if (!color) return null;
const value = String(color).trim();
if (value.startsWith("#")) {
const hex = value.slice(1);
if (hex.length === 3) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b };
}
if (hex.length === 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b };
}
}
const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (rgb) {
return {
r: Math.max(0, Math.min(255, Number(rgb[1]))),
g: Math.max(0, Math.min(255, Number(rgb[2]))),
b: Math.max(0, Math.min(255, Number(rgb[3]))),
};
}
return null;
}
static snakeRowBackground(color) {
const parsed = SnakeUtils.parseSnakeColor(color);
if (!parsed) return "transparent";
const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
const alpha = isDark ? 0.26 : 0.16;
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);
if (dx > 0) return "right";
if (dx < 0) return "left";
if (dy > 0) return "up";
if (dy < 0) return "down";
return "right";
}
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";
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;
}
}
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";
}
static directionToHeadTransform(direction) {
if (direction === "left") return "scaleX(-1)";
if (direction === "up") return "rotate(270deg)";
if (direction === "down") return "rotate(90deg)";
return "rotate(0deg)";
}
static directionToTailTransform(direction) {
if (direction === "right") return "scaleX(-1)";
if (direction === "left") return "rotate(0deg)";
if (direction === "up") return "rotate(270deg) scaleX(-1)";
if (direction === "down") return "rotate(90deg) scaleX(-1)";
return "scaleX(-1)";
}
static stableVariantFromString(value) {
const raw = String(value || "");
if (!raw) return 1;
let hash = 0;
for (let i = 0; i < raw.length; i += 1) {
hash = ((hash * 33) + raw.charCodeAt(i)) >>> 0;
}
return (hash % 5) + 1;
}
}
+122
View File
@@ -0,0 +1,122 @@
class SnakeTable {
static buildSnakesRows(turn, replay) {
console.log(turn);
const currentTurn = Number(turn && turn.turn !== undefined ? turn.turn : 0);
const turns = replay && Array.isArray(replay.turns) ? replay.turns : [];
const lastSeenById = new Map();
const lastSeenTurnById = new Map();
const aliveById = new Map();
for (const historyTurn of turns) {
const historyTurnNumber = Number(historyTurn && historyTurn.turn !== undefined ? historyTurn.turn : 0);
if (historyTurnNumber > currentTurn) continue;
for (const snake of (historyTurn.snakes || [])) {
if (!snake) continue;
const snakeId = snake.snake_id || snake.id || `${Utils.safeString(snake.snake_name)}-${historyTurnNumber}`;
lastSeenById.set(snakeId, snake);
lastSeenTurnById.set(snakeId, historyTurnNumber);
if (historyTurnNumber === currentTurn) {
aliveById.set(snakeId, snake);
}
}
}
const snakes = [];
for (const [snakeId, snake] of lastSeenById.entries()) {
const aliveSnake = aliveById.get(snakeId);
snakes.push({
...(aliveSnake || snake),
_snake_id: snakeId,
_is_dead: !aliveSnake,
_last_seen_turn: Number(lastSeenTurnById.get(snakeId) ?? currentTurn),
});
}
snakes.sort((a, b) => {
const youDelta = Number(Boolean(b.is_you)) - Number(Boolean(a.is_you));
if (youDelta !== 0) return youDelta;
const deadDelta = Number(Boolean(a._is_dead)) - Number(Boolean(b._is_dead));
if (deadDelta !== 0) return deadDelta;
return Utils.safeString(a.snake_name).localeCompare(Utils.safeString(b.snake_name));
});
const colorById = SnakeUtils.buildSnakeColorById(turn, replay);
if (snakes.length === 0) {
return "<tr><td colspan=\"7\">No snake data available</td></tr>";
}
return snakes.map((snake, idx) => {
const healthValue = Number(snake.health ?? 0);
const healthClamped = snake._is_dead ? 0 : Math.max(0, Math.min(100, healthValue));
const healthColor = healthClamped > 60 ? "#28a264" : (healthClamped > 30 ? "#d39a1c" : "#c34939");
const healthText = snake._is_dead ? "dead" : Utils.safeString(snake.health);
const healthCell = `<span class="health-wrap"><span class="health-fill" style="width:${healthClamped}%;background:${healthColor};"></span></span><span class="health-text">${healthText}</span>`;
const snakeId = snake._snake_id || snake.snake_id || snake.id || `${Utils.safeString(snake.snake_name)}-${idx}`;
const rowColor = SnakeUtils.resolveSnakeColor(snakeId, snake.is_you, colorById);
const rowBg = SnakeUtils.snakeRowBackground(rowColor);
const rowStyle = `style="--snake-row-color:${rowColor};--snake-row-bg:${rowBg};"`;
const diedTurn = Number(snake._last_seen_turn ?? currentTurn) + 1;
const moveText = snake._is_dead ? `dead @ ${diedTurn}` : Utils.safeString(snake.inferred_move);
const deadClass = snake._is_dead ? " dead-row" : "";
const causeLabel = SnakeTable._getCauseLabel(snake, turns, replay);
const deadLabel = snake._is_dead ? ` (dead${causeLabel})` : "";
return `
<tr class="snake-row${deadClass}" data-snake-id="${snakeId}" ${rowStyle}>
<td class="name-cell">${Utils.safeString(snake.snake_name)}${snake.is_you ? " (you)" : ""}${deadLabel}</td>
<td class="num-cell">${Utils.safeString(snake.latency)}</td>
<td class="num-cell">${moveText}</td>
<td class="num-cell">${healthCell}</td>
<td class="num-cell">${Utils.safeString(snake.length)}</td>
</tr>`;
}).join("");
}
static _getCauseLabel(snake, turns, replay) {
if (!snake._is_dead) return "";
const lastTurn = turns.find((t) => t && t.turn === snake._last_seen_turn);
if (!lastTurn) return "";
const lastSelf = (lastTurn.snakes || []).find((s) => (s.snake_id || s.id) === snake._snake_id);
if (!lastSelf) return "";
// Starvation: health ≤ 1 at last seen turn
if (Number(lastSelf.health) <= 1) return " · starved";
// Project head to next position based on body direction
const body = Array.isArray(lastSelf.body) ? lastSelf.body : [];
const h = lastSelf.head || (body[0] || null);
const neck = body[1] || null;
const projX = h && neck ? h.x + (h.x - neck.x) : null;
const projY = h && neck ? h.y + (h.y - neck.y) : null;
const nextTurn = turns.find((t) => t && t.turn === snake._last_seen_turn + 1);
if (projX !== null) {
const game = replay && replay.game ? replay.game : {};
const bw = Number(game.width || 0);
const bh = Number(game.height || 0);
// Wall collision: projected head out of bounds
if (bw > 0 && bh > 0 && (projX < 0 || projX >= bw || projY < 0 || projY >= bh)) {
return " · wall";
}
if (nextTurn) {
// Head-to-head: another alive snake's head at projected position
for (const other of (nextTurn.snakes || [])) {
const otherId = other.snake_id || other.id;
if (otherId === snake._snake_id) continue;
if (other.head && other.head.x === projX && other.head.y === projY) {
return ` · head-to-head ${Utils.safeString(other.snake_name)}`;
}
}
// Body collision: projected head inside another snake's body
for (const other of (nextTurn.snakes || [])) {
const otherId = other.snake_id || other.id;
if (otherId === snake._snake_id) continue;
const otherBody = Array.isArray(other.body) ? other.body : [];
const hitBody = otherBody.some((seg, i) => i > 0 && seg.x === projX && seg.y === projY);
if (hitBody) return ` · hit ${Utils.safeString(other.snake_name)}`;
}
// Hazard: projected position is in hazard list
const hazards = nextTurn.hazards || [];
if (hazards.some((hz) => hz.x === projX && hz.y === projY)) return " · Hazard";
}
}
return "";
}
}
+108
View File
@@ -0,0 +1,108 @@
class ThinkingPanel {
constructor(thinkingEl) {
this._el = thinkingEl;
}
get element() { return this._el; }
render(turn, replay) {
if (!turn) {
this._el.innerHTML = "<p class=\"section-title\">Select a game to inspect reasoning.</p>";
this._syncMonoOffset();
return;
}
const reasoning = turn.my_thinking;
const reasons = this._extractReasoningList(reasoning);
const reasonList = reasons.map((item) => `<li>${item}</li>`).join("");
const gameMeta = replay && replay.game ? replay.game : {};
const snakeType = Utils.safeString(gameMeta.your_snake_type);
const snakeVersion = Utils.safeString(gameMeta.your_snake_version);
this._el.innerHTML = `
<div class="think-grid">
<div class="chip"><span class="k">Chosen Move</span><span class="v">${Utils.safeString(turn.my_move)}</span></div>
<div class="chip"><span class="k">Snake Type</span><span class="v">${snakeType}</span></div>
<div class="chip"><span class="k">Snake Version</span><span class="v">${snakeVersion}</span></div>
<div class="chip"><span class="k">Observed At</span><span class="v">${Utils.formatObservedAtLocal(turn.observed_at)}</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 class="snakes-section">
<table class="score-table">
<colgroup>
<col style="width:32%">
<col style="width:10%">
<col style="width:10%">
<col style="width:20%">
<col style="width:6%">
</colgroup>
<thead><tr><th>Snake</th><th>Latency</th><th>Move</th><th>Health</th><th>Length</th></tr></thead>
<tbody>${SnakeTable.buildSnakesRows(turn, replay)}</tbody>
</table>
</section>
<section class="scores-section">
<p class="section-title">Move Scores</p>
<table class="score-table">
<colgroup><col style="width:50%"><col style="width:50%"></colgroup>
<thead><tr><th>Move</th><th>Score</th></tr></thead>
<tbody>${MoveTable.buildScoresRows(reasoning)}</tbody>
</table>
</section>
<section>
<p class="section-title">Decision Summary</p>
<ul class="reason-list">${reasonList}</ul>
</section>
<section class="raw-block">
<p class="section-title">Raw Reasoning Payload</p>
<pre class="mono">${JSON.stringify(reasoning, null, 2)}</pre>
</section>
`;
this._syncMonoOffset();
}
highlightSnake(snakeId) {
const section = this._el.querySelector(".snakes-section");
if (!section) return;
section.querySelectorAll(".snake-row.highlighted").forEach((r) => r.classList.remove("highlighted"));
section.classList.remove("has-highlight");
if (!snakeId) return;
const row = section.querySelector(`[data-snake-id="${CSS.escape(snakeId)}"]`);
if (row) {
row.classList.add("highlighted");
section.classList.add("has-highlight");
}
}
syncMonoOffset() {
this._syncMonoOffset();
}
_syncMonoOffset() {
const mono = this._el.querySelector(".mono");
if (!mono) return;
const rect = mono.getBoundingClientRect();
const bottomPaddingPx = 36;
const offset = Math.max(120, Math.round(rect.top + bottomPaddingPx));
document.documentElement.style.setProperty("--mono-vh-offset", `${offset}px`);
}
_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;
}
}
+25
View File
@@ -0,0 +1,25 @@
class Utils {
static safeString(value) {
if (value === null || value === undefined || value === "") return "-";
return String(value);
}
static toTitle(value) {
if (String(value || "").toLowerCase() === "finished") return "Done";
return String(value || "").replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
}
static formatObservedAtLocal(value) {
if (value === null || value === undefined || value === "") return "-";
const raw = String(value).trim();
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) {
return Utils.safeString(raw).slice(11, 19);
}
return parsed.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
}
File diff suppressed because it is too large Load Diff
-108
View File
@@ -1,108 +0,0 @@
import unittest
from typing import Any, cast
from server.GameBoard import GameBoard
from server.game_state_store import GameStateStoreBuilder, MemoryGameBoardStore, RedisGameBoardStore
from snakes.TemplateSnake import TemplateSnake
class _FakeRedis:
def __init__(self):
self.data = {}
async def set(self, key, value, ex=None):
self.data[key] = value
async def get(self, key):
return self.data.get(key)
async def delete(self, key):
self.data.pop(key, None)
async def aclose(self):
return None
class TestGameStateStore(unittest.IsolatedAsyncioTestCase):
def _build_board(self) -> GameBoard:
board = GameBoard(
game_id="game-1",
width=11,
height=11,
ruleset={"name": "standard", "version": "v1.0.0"},
source="custom",
map="standard",
snake_class=TemplateSnake(),
)
board.read_game_data(
{
"turn": 3,
"board": {
"food": [{"x": 1, "y": 1}],
"hazards": [],
"snakes": [
{
"id": "me",
"name": "me",
"health": 99,
"length": 3,
"head": {"x": 2, "y": 2},
"body": [
{"x": 2, "y": 2},
{"x": 2, "y": 1},
{"x": 2, "y": 0},
],
}
],
},
"you": {
"id": "me",
"name": "me",
"health": 99,
"length": 3,
"head": {"x": 2, "y": 2},
"body": [
{"x": 2, "y": 2},
{"x": 2, "y": 1},
{"x": 2, "y": 0},
],
},
"game": {"timeout": 500},
}
)
return board
def test_builder_selects_store_backend(self):
memory_store = GameStateStoreBuilder.build(backend="memory")
redis_store = GameStateStoreBuilder.build(backend="redis")
default_store = GameStateStoreBuilder.build(backend="unknown")
self.assertIsInstance(memory_store, MemoryGameBoardStore)
self.assertIsInstance(redis_store, RedisGameBoardStore)
self.assertIsInstance(default_store, MemoryGameBoardStore)
async def test_memory_backend_roundtrip(self):
store = MemoryGameBoardStore()
board = self._build_board()
await store.save("game-1", board)
loaded = cast(GameBoard, await store.load("game-1"))
self.assertIsNotNone(loaded)
self.assertEqual(loaded.id, "game-1")
await store.delete("game-1")
self.assertIsNone(await store.load("game-1"))
async def test_redis_backend_roundtrip(self):
store = RedisGameBoardStore()
store._redis = cast(Any, _FakeRedis())
board = self._build_board()
await store.save("game-1", board)
loaded = cast(GameBoard, await store.load("game-1"))
self.assertIsNotNone(loaded)
self.assertEqual(loaded.id, "game-1")
self.assertEqual(loaded.get_turn(), 3)
await store.delete("game-1")
self.assertIsNone(await store.load("game-1"))
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -3,7 +3,7 @@ import unittest
from pathlib import Path
import tempfile, sqlite3
from server.database import GameplayDatabase
from server.database import GameplayDatabase, GameplayBackendBuilder
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:
@@ -57,7 +57,7 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase):
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)
database = GameplayDatabase(GameplayBackendBuilder.build(db_path=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(
-3
View File
@@ -27,7 +27,6 @@ class TestMetricsStoreTemplate(unittest.IsolatedAsyncioTestCase):
"last_game_end_unix": 2,
"last_move_unix": 3,
"games_stuck_removed": 0,
"game_state_local_cache_enabled": False,
"metrics_backend": "memory",
"active_games": 1,
"tracked_games": 1,
@@ -78,7 +77,6 @@ class TestMetricsStoreTemplate(unittest.IsolatedAsyncioTestCase):
"oldest_active_game_age_sec": 5,
"stale_game_timeout_sec": 180,
"active_games_stale": 0,
"game_state_local_cache_enabled": True,
"http_requests_by_endpoint": {
"info": 1,
"start": 1,
@@ -115,7 +113,6 @@ class TestMetricsStoreTemplate(unittest.IsolatedAsyncioTestCase):
"oldest_active_game_age_sec": 7,
"stale_game_timeout_sec": 180,
"active_games_stale": 1,
"game_state_local_cache_enabled": False,
"http_requests_by_endpoint": {
"info": 1,
"start": 1,
Generated
+36
View File
@@ -17,6 +17,38 @@ version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/a8/ca4c00b319b877d29aa792cdd4ae3fb2a9f57268d94708a637abe9ae58c5/aiologger-0.7.0.tar.gz", hash = "sha256:7a4d5c91b836b61e842a791071786a3d80d6b6fa46fb8fd8e73391253ecb72ac", size = 20485, upload-time = "2022-10-05T01:03:22.199Z" }
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
@@ -281,8 +313,10 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiologger" },
{ name = "asyncpg" },
{ name = "dotenv" },
{ name = "gel" },
{ name = "python-dotenv" },
{ name = "quart" },
{ name = "redis" },
]
@@ -290,8 +324,10 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "aiologger", specifier = ">=0.7.0" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "gel", specifier = ">=3.1.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "quart", specifier = ">=0.20.0" },
{ name = "redis", specifier = ">=5.2.1" },
]