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
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
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
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
# To get you started we've included code to prevent your Battlesnake from moving backwards.
|
# To get you started we've included code to prevent your Battlesnake from moving backwards.
|
||||||
# For more info see docs.battlesnake.com
|
# For more info see docs.battlesnake.com
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from server.CreateEnvironmentFile import CreateEnvironmentFile
|
from server.CreateEnvironmentFile import CreateEnvironmentFile
|
||||||
from server.bootstrap import build_run_config, build_server_from_env
|
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
|
# Start server when `python main.py` is run
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
if os.environ.get("CREATE_ENV_FILE", None):
|
if os.environ.get("CREATE_ENV_FILE", None):
|
||||||
CreateEnvironmentFile.load_dotenv({
|
CreateEnvironmentFile.load_dotenv({
|
||||||
"STORE_GAME_HISTORY": True,
|
"STORE_GAME_HISTORY": True,
|
||||||
"DEBUG": True,
|
"DEBUG": True,
|
||||||
"SNAKE": "TemplateSnake",
|
"SNAKE": "TemplateSnake",
|
||||||
})
|
})
|
||||||
|
else:
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
server = build_server_from_env(default_snake_type="TemplateSnake")
|
server = build_server_from_env(default_snake_type="TemplateSnake")
|
||||||
asyncio.run(server.run(**build_run_config()))
|
asyncio.run(server.run(**build_run_config()))
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ dependencies = [
|
|||||||
"gel>=3.1.0",
|
"gel>=3.1.0",
|
||||||
"redis>=5.2.1",
|
"redis>=5.2.1",
|
||||||
"quart>=0.20.0",
|
"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}
|
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):
|
async def save(self, store_class, **kwargs):
|
||||||
store = store_class(**kwargs)
|
store = store_class(**kwargs)
|
||||||
await store.save(self)
|
await store.save(self)
|
||||||
|
|||||||
+12
-16
@@ -1,11 +1,11 @@
|
|||||||
from quart_common.web.logger import build_logger, await_log
|
from quart_common.web.logger import build_logger, await_log
|
||||||
from quart_common.web.env import env_bool, env_int
|
from quart_common.web.env import env_bool, env_int
|
||||||
|
|
||||||
from server.game_state_store import GameStateStoreBuilder
|
|
||||||
from snakes import SnakeBuilder
|
from snakes import SnakeBuilder
|
||||||
|
|
||||||
from server.database import (
|
from server.database import (
|
||||||
GameplayDatabase,
|
GameplayDatabase,
|
||||||
|
GameplayBackendBuilder,
|
||||||
StorageLoader,
|
StorageLoader,
|
||||||
)
|
)
|
||||||
from server.metrics import (
|
from server.metrics import (
|
||||||
@@ -30,7 +30,7 @@ from server.services import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Server:
|
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.debug = debug
|
||||||
self.data_path = data_path
|
self.data_path = data_path
|
||||||
|
|
||||||
@@ -41,22 +41,13 @@ class Server:
|
|||||||
self.check_tls_security = check_tls_security
|
self.check_tls_security = check_tls_security
|
||||||
|
|
||||||
self.store_game_state = False
|
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()
|
metrics_backend_normalized = (metrics_backend or 'memory').strip().lower()
|
||||||
self.metrics_backend_normalized = metrics_backend_normalized
|
self.metrics_backend_normalized = metrics_backend_normalized
|
||||||
self.metrics_redis_url = metrics_redis_url
|
self.metrics_redis_url = metrics_redis_url
|
||||||
self.stale_game_timeout_sec = self._get_stale_game_timeout_sec()
|
self.stale_game_timeout_sec = self._get_stale_game_timeout_sec()
|
||||||
|
|
||||||
self.game_runtime = GameRuntimeService(
|
self.game_runtime = GameRuntimeService(
|
||||||
game_state_store=self.game_state_store,
|
|
||||||
snake_type=self.snake_type,
|
snake_type=self.snake_type,
|
||||||
game_state_local_cache=self.game_state_local_cache,
|
|
||||||
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
||||||
)
|
)
|
||||||
self.dashboard_ws_hub = DashboardWebSocketHub()
|
self.dashboard_ws_hub = DashboardWebSocketHub()
|
||||||
@@ -68,9 +59,7 @@ class Server:
|
|||||||
ttl_seconds=metrics_ttl_sec,
|
ttl_seconds=metrics_ttl_sec,
|
||||||
key_prefix=os.environ.get('METRICS_REDIS_KEY_PREFIX', 'snake:metrics:worker'),
|
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,
|
metrics_backend=metrics_backend_normalized,
|
||||||
game_state_backend=game_state_backend,
|
|
||||||
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
||||||
game_last_seen_unix=self.game_runtime.game_last_seen_unix,
|
game_last_seen_unix=self.game_runtime.game_last_seen_unix,
|
||||||
game_move_counts=self.game_runtime.game_move_counts,
|
game_move_counts=self.game_runtime.game_move_counts,
|
||||||
@@ -86,8 +75,12 @@ class Server:
|
|||||||
if gameplay_db_enabled:
|
if gameplay_db_enabled:
|
||||||
db_path = gameplay_db_path or os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3')
|
db_path = gameplay_db_path or os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3')
|
||||||
self.gameplay_database = GameplayDatabase(
|
self.gameplay_database = GameplayDatabase(
|
||||||
db_path=db_path,
|
backend=GameplayBackendBuilder.build(
|
||||||
busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
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(
|
self.gameplay_tracking = GameplayTrackingService(
|
||||||
@@ -127,16 +120,19 @@ class Server:
|
|||||||
if self._startup_worker_metrics_cleared:
|
if self._startup_worker_metrics_cleared:
|
||||||
return
|
return
|
||||||
self._startup_worker_metrics_cleared = True
|
self._startup_worker_metrics_cleared = True
|
||||||
|
|
||||||
if env_bool('METRICS_CLEAR_WORKERS_ON_STARTUP', 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))
|
should_clear = await self.metrics_collector.should_clear_worker_metrics_on_startup(env_int('METRICS_STARTUP_CLEANUP_LOCK_TTL_SEC', 300))
|
||||||
if should_clear:
|
if should_clear:
|
||||||
await self.metrics_collector.clear_worker_metrics()
|
await self.metrics_collector.clear_worker_metrics()
|
||||||
await self.dashboard_events_service.start_listener()
|
await self.dashboard_events_service.start_listener()
|
||||||
|
|
||||||
|
if self.gameplay_database is not None:
|
||||||
|
await self.gameplay_database.initialize()
|
||||||
|
|
||||||
@self.app.after_serving
|
@self.app.after_serving
|
||||||
async def shutdown_state_storage():
|
async def shutdown_state_storage():
|
||||||
await self.dashboard_events_service.stop_listener()
|
await self.dashboard_events_service.stop_listener()
|
||||||
await self.game_state_store.close()
|
|
||||||
await self.metrics_collector.close()
|
await self.metrics_collector.close()
|
||||||
if self.gameplay_database is not None:
|
if self.gameplay_database is not None:
|
||||||
await self.gameplay_database.close()
|
await self.gameplay_database.close()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
import json, time, os
|
import asyncio, json, time, os
|
||||||
|
|
||||||
from quart import Blueprint, request, jsonify
|
from quart import Blueprint, request, jsonify
|
||||||
|
|
||||||
@@ -61,9 +61,22 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
|
|||||||
server.metrics_collector.record_http_request('move')
|
server.metrics_collector.record_http_request('move')
|
||||||
game_state = await request.get_json()
|
game_state = await request.get_json()
|
||||||
move_started = time.perf_counter()
|
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()
|
game_id = game_state['game']['id']
|
||||||
await server.game_runtime.persist_game_board(game_state['game']['id'], game_board)
|
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)
|
await server.gameplay_tracking.record_gameplay_turn(game_state, next_move, game_board)
|
||||||
elapsed_ms = (time.perf_counter() - move_started) * 1000.0
|
elapsed_ms = (time.perf_counter() - move_started) * 1000.0
|
||||||
await server.metrics_collector.record_move(next_move, elapsed_ms)
|
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, request
|
||||||
|
|
||||||
from quart import Blueprint, jsonify
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -10,26 +8,18 @@ def create_metrics_blueprint(server:'Server') -> Blueprint:
|
|||||||
blueprint = Blueprint('metrics', __name__)
|
blueprint = Blueprint('metrics', __name__)
|
||||||
|
|
||||||
@blueprint.get('/metrics')
|
@blueprint.get('/metrics')
|
||||||
@restrict_ip_addresses(allow=['127.0.0.1', '192.168.200.0/24'], abort_code=404)
|
|
||||||
async def metrics():
|
async def metrics():
|
||||||
snapshot = await server.metrics_collector.build_snapshot(
|
snapshot = await server.metrics_collector.build_snapshot(
|
||||||
server.game_runtime.game_last_seen_unix,
|
server.game_runtime.game_last_seen_unix,
|
||||||
server.game_runtime.game_move_counts,
|
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)
|
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
|
return blueprint
|
||||||
|
|||||||
+6
-15
@@ -12,31 +12,24 @@ class RunConfig(TypedDict):
|
|||||||
|
|
||||||
def build_server_from_env(default_snake_type:str) -> Server:
|
def build_server_from_env(default_snake_type:str) -> Server:
|
||||||
data_path = str(Path(__file__).resolve().parent.parent)
|
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')
|
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)
|
metrics_backend = os.environ.get('METRICS_BACKEND', None)
|
||||||
if metrics_backend is None:
|
if metrics_backend is None:
|
||||||
metrics_backend = os.environ.get('BACKEND', None)
|
metrics_backend = os.environ.get('BACKEND', 'memory')
|
||||||
if metrics_backend is None:
|
|
||||||
metrics_backend = ('redis' if game_state_backend.strip().lower() == 'redis' else 'memory')
|
|
||||||
|
|
||||||
metrics_redis_url = os.environ.get('METRICS_REDIS_URL', redis_url)
|
metrics_redis_url = os.environ.get('METRICS_REDIS_URL', redis_url)
|
||||||
metrics_ttl_sec_raw = os.environ.get('METRICS_TTL_SEC', None)
|
metrics_ttl_sec_raw = os.environ.get('METRICS_TTL_SEC', None)
|
||||||
if metrics_ttl_sec_raw is None:
|
metrics_ttl_sec = env_int('METRICS_TTL_SEC', 900) if metrics_ttl_sec_raw is not None else 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)
|
|
||||||
|
|
||||||
gameplay_db_enabled = env_bool('GAMEPLAY_DB_ENABLED', True)
|
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.environ.get(
|
||||||
'GAMEPLAY_DB_PATH',
|
'GAMEPLAY_DB_PATH',
|
||||||
os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3'),
|
os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3'),
|
||||||
)
|
)
|
||||||
gameplay_db_busy_timeout_ms = env_int('GAMEPLAY_DB_BUSY_TIMEOUT_MS', 5000)
|
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(
|
server = Server(
|
||||||
data_path=data_path,
|
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'),
|
storage_type=os.environ.get('STORAGE', 'LocalStorage'),
|
||||||
debug=env_bool('DEBUG_SERVER'),
|
debug=env_bool('DEBUG_SERVER'),
|
||||||
check_tls_security=False,
|
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_backend=metrics_backend,
|
||||||
metrics_redis_url=metrics_redis_url,
|
metrics_redis_url=metrics_redis_url,
|
||||||
metrics_ttl_sec=metrics_ttl_sec,
|
metrics_ttl_sec=metrics_ttl_sec,
|
||||||
gameplay_db_enabled=gameplay_db_enabled,
|
gameplay_db_enabled=gameplay_db_enabled,
|
||||||
|
gameplay_db_backend=gameplay_db_backend,
|
||||||
gameplay_db_path=gameplay_db_path,
|
gameplay_db_path=gameplay_db_path,
|
||||||
gameplay_db_busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
gameplay_db_busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
||||||
|
gameplay_db_pg_dsn=gameplay_db_pg_dsn,
|
||||||
)
|
)
|
||||||
|
|
||||||
if env_bool('STORE_GAME_HISTORY'):
|
if env_bool('STORE_GAME_HISTORY'):
|
||||||
|
|||||||
@@ -1,724 +1,37 @@
|
|||||||
from quart_common.web.env import env_bool
|
from .backend.Template import GameplayBackendTemplate
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
class GameplayDatabase:
|
class GameplayDatabase:
|
||||||
def __init__(self, db_path:str, busy_timeout_ms:int=5000):
|
"""Thin facade that delegates all operations to a GameplayBackendTemplate.
|
||||||
self.db_path = db_path
|
|
||||||
self.busy_timeout_ms = max(1000, int(busy_timeout_ms))
|
|
||||||
self._zstd_available = False
|
|
||||||
self._initialize_database()
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
Construct via GameplayBackendBuilder.build() or pass a backend directly.
|
||||||
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):
|
def __init__(self, backend:GameplayBackendTemplate):
|
||||||
try:
|
self._backend = backend
|
||||||
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")
|
async def initialize(self) -> None:
|
||||||
connection.execute("PRAGMA journal_mode = WAL")
|
await self._backend.initialize()
|
||||||
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 record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
|
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:
|
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:
|
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:
|
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]:
|
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:
|
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:
|
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:
|
async def close(self) -> None:
|
||||||
return None
|
await self._backend.close()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from .GameplayDatabase import GameplayDatabase
|
from .GameplayDatabase import GameplayDatabase
|
||||||
|
from .backend import GameplayBackendBuilder
|
||||||
|
|
||||||
from .LocalStorage import LocalStorage
|
from .LocalStorage import LocalStorage
|
||||||
from .EdgeDB import EdgeDB
|
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
|
import time
|
||||||
|
|
||||||
class MetricsCollector:
|
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._manager = metrics_manager
|
||||||
self._stale_game_timeout_sec = stale_game_timeout_sec
|
self._stale_game_timeout_sec = stale_game_timeout_sec
|
||||||
self._game_last_seen_unix = game_last_seen_unix
|
self._game_last_seen_unix = game_last_seen_unix
|
||||||
self._game_move_counts = game_move_counts
|
self._game_move_counts = game_move_counts
|
||||||
self._game_state_backend_is_redis = game_state_backend.strip().lower() == 'redis'
|
|
||||||
self._metrics = {
|
self._metrics = {
|
||||||
'games_started': 0,
|
'games_started': 0,
|
||||||
'games_ended': 0,
|
'games_ended': 0,
|
||||||
@@ -39,7 +38,6 @@ class MetricsCollector:
|
|||||||
'last_game_end_unix': 0,
|
'last_game_end_unix': 0,
|
||||||
'last_move_unix': 0,
|
'last_move_unix': 0,
|
||||||
'games_stuck_removed': 0,
|
'games_stuck_removed': 0,
|
||||||
'game_state_local_cache_enabled': bool(game_state_local_cache),
|
|
||||||
'metrics_backend': metrics_backend,
|
'metrics_backend': metrics_backend,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,8 +99,6 @@ class MetricsCollector:
|
|||||||
await self._auto_publish()
|
await self._auto_publish()
|
||||||
|
|
||||||
async def record_stuck_removed(self) -> None:
|
async def record_stuck_removed(self) -> None:
|
||||||
if self._game_state_backend_is_redis:
|
|
||||||
return
|
|
||||||
self._metrics['games_stuck_removed'] += 1
|
self._metrics['games_stuck_removed'] += 1
|
||||||
await self._auto_publish()
|
await self._auto_publish()
|
||||||
|
|
||||||
@@ -117,23 +113,9 @@ class MetricsCollector:
|
|||||||
if now - last_seen >= self._stale_game_timeout_sec
|
if now - last_seen >= self._stale_game_timeout_sec
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._game_state_backend_is_redis:
|
report_active_games = len(game_last_seen_unix)
|
||||||
# Redis auto-expires stale keys via TTL, so stale games are already gone from the
|
report_stale_candidates = stale_candidates
|
||||||
# server's perspective. We exclude them from all metrics so we only report games
|
active_last_seen = list(game_last_seen_unix.values())
|
||||||
# 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())
|
|
||||||
|
|
||||||
oldest_active_age = max(0, now - min(active_last_seen)) if active_last_seen else 0
|
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
|
return report_active_games, report_stale_candidates, oldest_active_age
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ class StoreTemplate:
|
|||||||
"last_game_end_unix": 0,
|
"last_game_end_unix": 0,
|
||||||
"last_move_unix": 0,
|
"last_move_unix": 0,
|
||||||
"games_stuck_removed": 0,
|
"games_stuck_removed": 0,
|
||||||
"game_state_local_cache_enabled": False,
|
|
||||||
"metrics_backend": "redis",
|
"metrics_backend": "redis",
|
||||||
"active_games": 0,
|
"active_games": 0,
|
||||||
"tracked_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["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["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["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"]:
|
for endpoint in merged["http_requests_by_endpoint"]:
|
||||||
merged["http_requests_by_endpoint"][endpoint] += int(worker.get("http_requests_by_endpoint", {}).get(endpoint, 0))
|
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
|
import time
|
||||||
|
|
||||||
from server.metrics import MetricsCollector
|
from server.metrics import MetricsCollector
|
||||||
@@ -6,18 +5,9 @@ from server.GameBoard import GameBoard
|
|||||||
|
|
||||||
from snakes import SnakeBuilder
|
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:
|
class GameRuntimeService:
|
||||||
def __init__(self, game_state_store:GameStateStoreLike, snake_type:str, game_state_local_cache:bool, stale_game_timeout_sec:int):
|
def __init__(self, snake_type:str, stale_game_timeout_sec:int):
|
||||||
self.game_state_store = game_state_store
|
|
||||||
self.snake_type = snake_type
|
self.snake_type = snake_type
|
||||||
self.game_state_local_cache = game_state_local_cache
|
|
||||||
self.stale_game_timeout_sec = stale_game_timeout_sec
|
self.stale_game_timeout_sec = stale_game_timeout_sec
|
||||||
self.metrics_collector = None
|
self.metrics_collector = None
|
||||||
|
|
||||||
@@ -41,43 +31,27 @@ class GameRuntimeService:
|
|||||||
)
|
)
|
||||||
await new_game_board.start_game(game_state)
|
await new_game_board.start_game(game_state)
|
||||||
|
|
||||||
if self.game_state_local_cache:
|
self.running_games[game_id] = new_game_board
|
||||||
self.running_games[game_id] = new_game_board
|
|
||||||
|
|
||||||
await self.game_state_store.save(game_id, new_game_board)
|
|
||||||
self.game_move_counts[game_id] = 0
|
self.game_move_counts[game_id] = 0
|
||||||
self.game_last_seen_unix[game_id] = int(time.time())
|
self.game_last_seen_unix[game_id] = int(time.time())
|
||||||
if self.metrics_collector is not None:
|
if self.metrics_collector is not None:
|
||||||
await self.metrics_collector.record_game_started(len(self.game_last_seen_unix))
|
await self.metrics_collector.record_game_started(len(self.game_last_seen_unix))
|
||||||
return new_game_board
|
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:
|
async def delete_game_board(self, game_state:dict) -> None:
|
||||||
game_id = game_state['game']['id']
|
game_id = game_state['game']['id']
|
||||||
self.running_games.pop(game_id, None)
|
self.running_games.pop(game_id, None)
|
||||||
self.game_move_counts.pop(game_id, None)
|
self.game_move_counts.pop(game_id, None)
|
||||||
self.game_last_seen_unix.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:
|
async def get_game_board(self, game_state:dict, end:bool=False) -> GameBoard:
|
||||||
game_id = game_state['game']['id']
|
game_id = game_state['game']['id']
|
||||||
game_board:GameBoard
|
if game_id in self.running_games:
|
||||||
if self.game_state_local_cache and game_id in self.running_games:
|
|
||||||
game_board = self.running_games[game_id]
|
game_board = self.running_games[game_id]
|
||||||
else:
|
else:
|
||||||
persisted_board = await self.game_state_store.load(game_id)
|
game_board = await self.create_game_board(game_state)
|
||||||
if persisted_board is not None:
|
if self.metrics_collector is not None:
|
||||||
game_board = cast(GameBoard, persisted_board)
|
await self.metrics_collector.record_game_autocreated()
|
||||||
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()
|
|
||||||
|
|
||||||
if not end:
|
if not end:
|
||||||
self.game_move_counts[game_id] = self.game_move_counts.get(game_id, 0) + 1
|
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)
|
game_board.read_game_data(game_state)
|
||||||
if end:
|
if end:
|
||||||
game_board.end_game(game_state)
|
game_board.end_game(game_state)
|
||||||
await self.persist_game_board(game_id, game_board)
|
|
||||||
|
|
||||||
return 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):
|
def set_target_food(self, target_food:dict):
|
||||||
self.target_food = target_food
|
self.target_food = target_food
|
||||||
return True
|
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
|
# RL bootstrap dataset recorder
|
||||||
self.rl_bootstrap = RLBootstrapDataset()
|
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 ──────────────────────────────────────────────────────────────
|
# ── Env helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_timeout_buffer_ms(self) -> int:
|
def _get_timeout_buffer_ms(self) -> int:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ SNAKE_REGISTRY = {
|
|||||||
"BestBattleSnake": "2.6.0",
|
"BestBattleSnake": "2.6.0",
|
||||||
"TrainedBattleSnake": "0.1.0",
|
"TrainedBattleSnake": "0.1.0",
|
||||||
"UltimateBattleSnake": "4.5.0",
|
"UltimateBattleSnake": "4.5.0",
|
||||||
|
"ApexBattleSnake": "1.0.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_SNAKE_CONFIG = {
|
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
|
from pathlib import Path
|
||||||
import tempfile, sqlite3
|
import tempfile, sqlite3
|
||||||
|
|
||||||
from server.database import GameplayDatabase
|
from server.database import GameplayDatabase, GameplayBackendBuilder
|
||||||
|
|
||||||
class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase):
|
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:
|
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):
|
async def test_records_gameplay_with_wal_and_inferred_moves(self):
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
db_path = Path(temp_dir) / "gameplay.sqlite3"
|
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_game_start(self._build_state(turn=0, me_head=(1, 1), enemy_head=(5, 5)))
|
||||||
await database.record_turn(
|
await database.record_turn(
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ class TestMetricsStoreTemplate(unittest.IsolatedAsyncioTestCase):
|
|||||||
"last_game_end_unix": 2,
|
"last_game_end_unix": 2,
|
||||||
"last_move_unix": 3,
|
"last_move_unix": 3,
|
||||||
"games_stuck_removed": 0,
|
"games_stuck_removed": 0,
|
||||||
"game_state_local_cache_enabled": False,
|
|
||||||
"metrics_backend": "memory",
|
"metrics_backend": "memory",
|
||||||
"active_games": 1,
|
"active_games": 1,
|
||||||
"tracked_games": 1,
|
"tracked_games": 1,
|
||||||
@@ -78,7 +77,6 @@ class TestMetricsStoreTemplate(unittest.IsolatedAsyncioTestCase):
|
|||||||
"oldest_active_game_age_sec": 5,
|
"oldest_active_game_age_sec": 5,
|
||||||
"stale_game_timeout_sec": 180,
|
"stale_game_timeout_sec": 180,
|
||||||
"active_games_stale": 0,
|
"active_games_stale": 0,
|
||||||
"game_state_local_cache_enabled": True,
|
|
||||||
"http_requests_by_endpoint": {
|
"http_requests_by_endpoint": {
|
||||||
"info": 1,
|
"info": 1,
|
||||||
"start": 1,
|
"start": 1,
|
||||||
@@ -115,7 +113,6 @@ class TestMetricsStoreTemplate(unittest.IsolatedAsyncioTestCase):
|
|||||||
"oldest_active_game_age_sec": 7,
|
"oldest_active_game_age_sec": 7,
|
||||||
"stale_game_timeout_sec": 180,
|
"stale_game_timeout_sec": 180,
|
||||||
"active_games_stale": 1,
|
"active_games_stale": 1,
|
||||||
"game_state_local_cache_enabled": False,
|
|
||||||
"http_requests_by_endpoint": {
|
"http_requests_by_endpoint": {
|
||||||
"info": 1,
|
"info": 1,
|
||||||
"start": 1,
|
"start": 1,
|
||||||
|
|||||||
@@ -17,6 +17,38 @@ version = "0.7.0"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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]]
|
[[package]]
|
||||||
name = "blinker"
|
name = "blinker"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -281,8 +313,10 @@ version = "0.1.0"
|
|||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiologger" },
|
{ name = "aiologger" },
|
||||||
|
{ name = "asyncpg" },
|
||||||
{ name = "dotenv" },
|
{ name = "dotenv" },
|
||||||
{ name = "gel" },
|
{ name = "gel" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
{ name = "quart" },
|
{ name = "quart" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
]
|
]
|
||||||
@@ -290,8 +324,10 @@ dependencies = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiologger", specifier = ">=0.7.0" },
|
{ name = "aiologger", specifier = ">=0.7.0" },
|
||||||
|
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||||
{ name = "dotenv", specifier = ">=0.9.9" },
|
{ name = "dotenv", specifier = ">=0.9.9" },
|
||||||
{ name = "gel", specifier = ">=3.1.0" },
|
{ name = "gel", specifier = ">=3.1.0" },
|
||||||
|
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||||
{ name = "quart", specifier = ">=0.20.0" },
|
{ name = "quart", specifier = ">=0.20.0" },
|
||||||
{ name = "redis", specifier = ">=5.2.1" },
|
{ name = "redis", specifier = ">=5.2.1" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user