Compare commits
11 Commits
f4c0ad193e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 60fe19c61c | |||
|
79c6b00ace
|
|||
|
3614625c56
|
|||
|
341bb27278
|
|||
|
a62501cf22
|
|||
|
f6e19e18e6
|
|||
|
f479541c04
|
|||
|
f0d62a6049
|
|||
|
d2e1f2560e
|
|||
|
739c0520f9
|
|||
|
03968fecdf
|
@@ -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:
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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'):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 || "-";
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
+74
-1288
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user