Compare commits
48 Commits
050dd1083c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 60fe19c61c | |||
|
79c6b00ace
|
|||
|
3614625c56
|
|||
|
341bb27278
|
|||
|
a62501cf22
|
|||
|
f6e19e18e6
|
|||
|
f479541c04
|
|||
|
f0d62a6049
|
|||
|
d2e1f2560e
|
|||
|
739c0520f9
|
|||
|
03968fecdf
|
|||
|
f4c0ad193e
|
|||
|
898f8106ed
|
|||
|
dfa658e4ce
|
|||
|
abed259129
|
|||
|
8c4f83fb4b
|
|||
|
1b8fa67059
|
|||
|
a2a79b7efb
|
|||
|
59d01428a9
|
|||
|
3da10189b7
|
|||
|
a8eb6a4447
|
|||
|
5c1875be60
|
|||
|
7c990f98fc
|
|||
|
dd3547678c
|
|||
|
0af5f58688
|
|||
|
fdc22af4cf
|
|||
|
ed41c32ad8
|
|||
|
6c34df103e
|
|||
|
af7df92f4d
|
|||
|
fbc7a50f34
|
|||
|
1a1bcd8ec3
|
|||
|
fe999c11f4
|
|||
|
01343472df
|
|||
|
c4238d19e8
|
|||
|
afaf6c7c63
|
|||
|
1cd0f1ed1d
|
|||
|
8f6a2ef674
|
|||
|
5328252cf1
|
|||
|
41f117e3a8
|
|||
|
43c7720480
|
|||
|
98be2fe6fe
|
|||
|
30eb17bb83
|
|||
|
d89986dba9
|
|||
|
0ebb04f0a2
|
|||
|
097a7f295a
|
|||
|
4626a491f5
|
|||
|
e7d0227cf9
|
|||
|
b7c6a0e345
|
@@ -9,6 +9,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Rewrite SSH submodule URLs to HTTPS for CI
|
||||
run: |
|
||||
git config --global url."https://x-token:${{ secrets.ACTION_ACCESS_TOKEN }}@git.yiprawr.dev/".insteadOf "git@git.yiprawr.dev:"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
|
||||
@@ -5,12 +5,10 @@
|
||||
.vscode
|
||||
.venv/
|
||||
__pycache__/
|
||||
data/
|
||||
.env
|
||||
.tools/
|
||||
.testing/
|
||||
|
||||
dbschema/migrations/
|
||||
|
||||
*.jsonl
|
||||
/dataset/
|
||||
models/
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
# Stage 1 — compile sqlite-zstd + SQLite 3.49.1
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim AS sqlite-zstd-builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl unzip git libzstd-dev pkg-config build-essential ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Compile SQLite 3.49.1 as a shared library so the runtime image can use it
|
||||
RUN curl -fsSL https://www.sqlite.org/2025/sqlite-amalgamation-3490100.zip -o /tmp/sqlite.zip && \
|
||||
unzip /tmp/sqlite.zip -d /tmp/ && \
|
||||
cd /tmp/sqlite-amalgamation-3490100 && \
|
||||
gcc -O2 -shared -fPIC -o /usr/local/lib/libsqlite3.so.0 sqlite3.c -ldl -lpthread
|
||||
|
||||
# Install Rust and compile sqlite-zstd (uses bundled SQLite 3.49.1 — no patching needed)
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN git clone --depth=1 --branch v0.3.5 https://github.com/phiresky/sqlite-zstd.git /tmp/sqlite-zstd && \
|
||||
cd /tmp/sqlite-zstd && \
|
||||
cargo build --release --features build_extension && \
|
||||
cp target/release/libsqlite_zstd.so /usr/local/lib/libsqlite_zstd.so
|
||||
|
||||
# Stage 2 — runtime image
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libzstd1 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Replace system SQLite 3.46.1 with 3.49.1 so it matches the extension
|
||||
COPY --from=sqlite-zstd-builder /usr/local/lib/libsqlite3.so.0 /usr/local/lib/libsqlite3.so.0
|
||||
RUN echo "/usr/local/lib" > /etc/ld.so.conf.d/local.conf && ldconfig
|
||||
|
||||
COPY --from=sqlite-zstd-builder /usr/local/lib/libsqlite_zstd.so /usr/local/lib/libsqlite_zstd.so
|
||||
|
||||
# Install app
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
RUN uv sync --no-config --frozen --compile-bytecode
|
||||
|
||||
# Starten Sie Ihre Anwendung
|
||||
EXPOSE 8000
|
||||
|
||||
CMD [".venv/bin/hypercorn", "asgi:app", "--bind", "0.0.0.0:8000", "--workers", "1", "--access-logfile", "-"]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.3'
|
||||
|
||||
services:
|
||||
battlesnake:
|
||||
image: daniel156161/battlesnake
|
||||
@@ -7,7 +5,7 @@ services:
|
||||
ports:
|
||||
- 8000:8000
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ${DOCKER_DATA_PATH}:/app/data
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -12,8 +12,8 @@ set dotenv-required := true
|
||||
# Use zsh
|
||||
set shell := ["bash", "-cu"]
|
||||
|
||||
BATTLESNAKE_CLI_DIR := ".tools/battlesnake-cli"
|
||||
BATTLESNAKE_CLI_BIN := ".tools/battlesnake-cli/battlesnake"
|
||||
BATTLESNAKE_CLI_DIR := ".testing/tools/battlesnake-cli"
|
||||
BATTLESNAKE_CLI_BIN := ".testing/tools/battlesnake-cli/battlesnake"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Default
|
||||
@@ -112,7 +112,7 @@ test-local-4 mode="standard" map="standard" base_port="9101" snake="BestBattleSn
|
||||
set -euo pipefail
|
||||
|
||||
BATTLESNAKE_CLI="{{justfile_directory()}}/{{BATTLESNAKE_CLI_BIN}}"
|
||||
LOG_DIR="{{justfile_directory()}}/.tools/snake-logs"
|
||||
LOG_DIR="{{justfile_directory()}}/.testing/tools/snake-logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
pids=()
|
||||
@@ -162,17 +162,17 @@ test-local-4 mode="standard" map="standard" base_port="9101" snake="BestBattleSn
|
||||
# Dataset helpers
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
export-dataset input="data" output="data/dataset/good_moves.jsonl":
|
||||
export-dataset input=".testing/data" output=".testing/data/dataset/good_moves.jsonl":
|
||||
python -m server.DatasetExporter --input "{{input}}" --output "{{output}}"
|
||||
|
||||
curate-dataset input="good_moves-*.jsonl" output="data/dataset/best_moves.jsonl" min_turn="6" late_turn="20" max_safe_options="2" min_score="3" append="false" archive="false" archive_dir="":
|
||||
curate-dataset input="good_moves-*.jsonl" output=".testing/data/dataset/best_moves.jsonl" min_turn="6" late_turn="20" max_safe_options="2" min_score="3" append="false" archive="false" archive_dir="":
|
||||
FLAGS=""; if [ "{{append}}" = "true" ]; then FLAGS="$FLAGS --append"; fi; if [ "{{archive}}" = "true" ]; then FLAGS="$FLAGS --archive-input"; fi; if [ -n "{{archive_dir}}" ]; then FLAGS="$FLAGS --archive-dir {{archive_dir}}"; fi; python -m server.DatasetCurator --input "{{input}}" --output "{{output}}" --min-turn "{{min_turn}}" --late-turn "{{late_turn}}" --max-safe-options "{{max_safe_options}}" --min-score "{{min_score}}" $FLAGS
|
||||
|
||||
analyze-dataset input="good_moves-*.jsonl" output="":
|
||||
if [ -n "{{output}}" ]; then python -m server.DatasetStats --input "{{input}}" --output "{{output}}"; else python -m server.DatasetStats --input "{{input}}"; fi
|
||||
|
||||
train-ai input="dataset/best_moves.jsonl" rl_input="dataset/rl_bootstrap.jsonl" output="models/battlesnake_softmax_v2.json" eval_split="0.2" seed="42" epochs="14" lr="0.08":
|
||||
train-ai input=".testing/data/dataset/best_moves.jsonl" rl_input=".testing/data/dataset/rl_bootstrap.jsonl" output=".testing/models/battlesnake_softmax_v2.json" eval_split="0.2" seed="42" epochs="14" lr="0.08":
|
||||
if [ -f "{{rl_input}}" ]; then python -m server.TrainBattleSnakeAI --input "{{input}}" --input "{{rl_input}}" --output "{{output}}" --eval-split "{{eval_split}}" --seed "{{seed}}" --epochs "{{epochs}}" --lr "{{lr}}"; else python -m server.TrainBattleSnakeAI --input "{{input}}" --output "{{output}}" --eval-split "{{eval_split}}" --seed "{{seed}}" --epochs "{{epochs}}" --lr "{{lr}}"; fi
|
||||
|
||||
run-trained model="models/battlesnake_softmax_v2.json" port="8000":
|
||||
run-trained model=".testing/models/battlesnake_softmax_v2.json" port="8000":
|
||||
TRAINED_SNAKE_MODEL="{{model}}" SNAKE="TrainedBattleSnake" PORT="{{port}}" "{{justfile_directory()}}/main.py"
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
# To get you started we've included code to prevent your Battlesnake from moving backwards.
|
||||
# For more info see docs.battlesnake.com
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from server.CreateEnvironmentFile import CreateEnvironmentFile
|
||||
from server.bootstrap import build_run_config, build_server_from_env
|
||||
|
||||
@@ -20,12 +22,15 @@ import os
|
||||
|
||||
# Start server when `python main.py` is run
|
||||
if __name__ == "__main__":
|
||||
|
||||
if os.environ.get("CREATE_ENV_FILE", None):
|
||||
CreateEnvironmentFile.load_dotenv({
|
||||
"STORE_GAME_HISTORY": True,
|
||||
"DEBUG": True,
|
||||
"SNAKE": "TemplateSnake",
|
||||
})
|
||||
else:
|
||||
load_dotenv()
|
||||
|
||||
server = build_server_from_env(default_snake_type="TemplateSnake")
|
||||
asyncio.run(server.run(**build_run_config()))
|
||||
|
||||
@@ -10,4 +10,6 @@ dependencies = [
|
||||
"gel>=3.1.0",
|
||||
"redis>=5.2.1",
|
||||
"quart>=0.20.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
"asyncpg>=0.31.0",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from snakes.TemplateSnake import TemplateSnake
|
||||
from datetime import datetime
|
||||
|
||||
class GameBoard:
|
||||
def __init__(self, game_id:str, width:int, height:int, ruleset:dict, source:str, map:str, snake_class):
|
||||
def __init__(self, game_id:str, width:int, height:int, ruleset:dict, source:str, map:str, snake_class:TemplateSnake):
|
||||
self.id = game_id
|
||||
self.width = width
|
||||
self.height = height
|
||||
@@ -19,6 +20,10 @@ class GameBoard:
|
||||
self.url = self._get_game_url(True if ruleset["version"] == "cli" else False)
|
||||
self.timeout = 500
|
||||
|
||||
# Snake Helper Functions
|
||||
def get_snake_name_and_version(self) -> tuple[str, str]:
|
||||
return self.snake_class.name, self.snake_class.version
|
||||
|
||||
# Setter Functions
|
||||
def _set_snakes(self, snakes:list[dict]):
|
||||
self.other_snakes = [ x for x in snakes if x["id"] != self.my_snake["id"] ]
|
||||
@@ -146,6 +151,11 @@ class GameBoard:
|
||||
|
||||
return {"name": self.type, "is_ladder": self.is_ladder}
|
||||
|
||||
def __getstate__(self):
|
||||
state = self.__dict__.copy()
|
||||
state['turns'] = [] # strip turn history — grows linearly, not needed for move computation
|
||||
return state
|
||||
|
||||
async def save(self, store_class, **kwargs):
|
||||
store = store_class(**kwargs)
|
||||
await store.save(self)
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
from quart_common.web.logger import build_logger, await_log
|
||||
from server.Files import read_file
|
||||
|
||||
from server.game_state_store import GameStateStoreBuilder
|
||||
from server.GameBoard import GameBoard
|
||||
from quart_common.web.env import env_bool, env_int
|
||||
|
||||
from snakes import SnakeBuilder
|
||||
|
||||
from server.storage import StorageLoader
|
||||
from server.database import GameplayDatabase
|
||||
|
||||
from server.database import (
|
||||
GameplayDatabase,
|
||||
GameplayBackendBuilder,
|
||||
StorageLoader,
|
||||
)
|
||||
from server.metrics import (
|
||||
MetricsStoreBuilder,
|
||||
MetricsCollector,
|
||||
)
|
||||
|
||||
from quart import Quart, request, jsonify, render_template, send_from_directory
|
||||
import logging, json, os, re, time
|
||||
from typing import cast
|
||||
import asyncio, signal, logging, os, re, time
|
||||
from quart import Quart
|
||||
|
||||
from server.blueprints import (
|
||||
create_battlesnake_blueprint,
|
||||
create_metrics_blueprint,
|
||||
create_dashboard_blueprint,
|
||||
)
|
||||
from server.services import (
|
||||
DashboardEventsService,
|
||||
DashboardWebSocketHub,
|
||||
GameRuntimeService,
|
||||
GameplayTrackingService,
|
||||
DashboardQueryService,
|
||||
)
|
||||
|
||||
class Server:
|
||||
default_snake_config = {
|
||||
'apiversion': '1',
|
||||
'author': '',
|
||||
'color': '#888888',
|
||||
'head': 'default',
|
||||
'tail': 'default',
|
||||
'version': '1.0.0',
|
||||
}
|
||||
|
||||
def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, check_tls_security:bool=False, game_state_backend:str='memory', game_state_redis_url:str='redis://localhost:6379/0', game_state_ttl_sec:int=900, game_state_local_cache:bool=True, metrics_backend:str='memory', metrics_redis_url:str='redis://localhost:6379/0', metrics_ttl_sec:int|None=None, gameplay_db_enabled:bool=True, gameplay_db_path:str|None=None, gameplay_db_busy_timeout_ms:int=5000):
|
||||
def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, check_tls_security:bool=False, metrics_backend:str='memory', metrics_redis_url:str='redis://localhost:6379/0', metrics_ttl_sec:int|None=None, gameplay_db_enabled:bool=True, gameplay_db_backend:str='sqlite', gameplay_db_path:str|None=None, gameplay_db_busy_timeout_ms:int=5000, gameplay_db_pg_dsn:str|None=None):
|
||||
self.debug = debug
|
||||
self.data_path = data_path
|
||||
|
||||
self.snake_type = snake_type
|
||||
self.storage_type = storage_type
|
||||
|
||||
self.config_file = os.path.join(data_path, 'data', 'snake-config.json')
|
||||
self.data_path = data_path
|
||||
self.check_tls_security = check_tls_security
|
||||
|
||||
self.store_game_state = False
|
||||
normalized_backend = (game_state_backend or 'memory').strip().lower()
|
||||
self.game_state_local_cache = (game_state_local_cache and normalized_backend != 'memory')
|
||||
self.game_state_store = GameStateStoreBuilder.build(
|
||||
backend=game_state_backend,
|
||||
redis_url=game_state_redis_url,
|
||||
ttl_seconds=game_state_ttl_sec,
|
||||
)
|
||||
metrics_backend_normalized = (metrics_backend or 'memory').strip().lower()
|
||||
self.metrics_backend_normalized = metrics_backend_normalized
|
||||
self.metrics_redis_url = metrics_redis_url
|
||||
self.stale_game_timeout_sec = self._get_stale_game_timeout_sec()
|
||||
|
||||
self.running_games:dict[str, GameBoard] = {}
|
||||
self.game_move_counts:dict[str, int] = {}
|
||||
self.game_last_seen_unix:dict[str, int] = {}
|
||||
self.game_runtime = GameRuntimeService(
|
||||
snake_type=self.snake_type,
|
||||
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
||||
)
|
||||
self.dashboard_ws_hub = DashboardWebSocketHub()
|
||||
|
||||
self.metrics_collector = MetricsCollector(
|
||||
metrics_manager=MetricsStoreBuilder.build(
|
||||
@@ -59,96 +59,56 @@ class Server:
|
||||
ttl_seconds=metrics_ttl_sec,
|
||||
key_prefix=os.environ.get('METRICS_REDIS_KEY_PREFIX', 'snake:metrics:worker'),
|
||||
),
|
||||
game_state_local_cache=self.game_state_local_cache,
|
||||
metrics_backend=metrics_backend_normalized,
|
||||
game_state_backend=game_state_backend,
|
||||
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
||||
game_last_seen_unix=self.game_last_seen_unix,
|
||||
game_move_counts=self.game_move_counts,
|
||||
game_last_seen_unix=self.game_runtime.game_last_seen_unix,
|
||||
game_move_counts=self.game_runtime.game_move_counts,
|
||||
)
|
||||
self.clear_worker_metrics_on_startup = self._env_bool('METRICS_CLEAR_WORKERS_ON_STARTUP', True)
|
||||
self.worker_metrics_startup_lock_ttl_sec = self._env_int('METRICS_STARTUP_CLEANUP_LOCK_TTL_SEC', 300)
|
||||
|
||||
self.game_runtime.attach_metrics_collector(self.metrics_collector)
|
||||
self._startup_worker_metrics_cleared = False
|
||||
|
||||
self.logger = build_logger('Battlesnake', debug_env_var='DEBUG_SERVER')
|
||||
self.snake_version = self._get_snake_version()
|
||||
|
||||
self.gameplay_database = None
|
||||
if gameplay_db_enabled:
|
||||
db_path = gameplay_db_path or os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3')
|
||||
self.gameplay_database = GameplayDatabase(
|
||||
backend=GameplayBackendBuilder.build(
|
||||
backend=gameplay_db_backend,
|
||||
db_path=db_path,
|
||||
busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
||||
pg_dsn=gameplay_db_pg_dsn,
|
||||
)
|
||||
)
|
||||
|
||||
self.app = Quart('Battlesnake', template_folder=os.path.join(data_path, 'server', 'templates'))
|
||||
|
||||
# info is called when you create your Battlesnake on play.battlesnake.com
|
||||
# and controls your Battlesnake's appearance
|
||||
# TIP: If you open your Battlesnake URL in a browser you should see this data
|
||||
@self.app.get('/')
|
||||
async def on_info():
|
||||
self.metrics_collector.record_http_request('info')
|
||||
snake_config = await self._read_json_config_or_create()
|
||||
|
||||
await await_log(self.logger.info(f'INFO Snake: {snake_config}'))
|
||||
return snake_config
|
||||
|
||||
# start is called when your Battlesnake begins a game
|
||||
@self.app.post('/start')
|
||||
async def on_start():
|
||||
self.metrics_collector.record_http_request('start')
|
||||
await self._prune_stale_games()
|
||||
game_state = await request.get_json()
|
||||
await self._create_game_board(game_state)
|
||||
await self._record_gameplay_start(game_state)
|
||||
await await_log(self.logger.info(f'GAME START: {game_state['game']}'))
|
||||
return 'ok'
|
||||
|
||||
# move is called when your Battlesnake game is running game
|
||||
@self.app.post('/move')
|
||||
async def on_move():
|
||||
self.metrics_collector.record_http_request('move')
|
||||
game_state = await request.get_json()
|
||||
move_started = time.perf_counter()
|
||||
game_board = cast(GameBoard, await self._get_game_board(game_state))
|
||||
next_move = game_board.snake_neat_make_a_move()
|
||||
await self._persist_game_board(game_state['game']['id'], game_board)
|
||||
await self._record_gameplay_turn(game_state, next_move, game_board)
|
||||
elapsed_ms = (time.perf_counter() - move_started) * 1000.0
|
||||
await self.metrics_collector.record_move(next_move, elapsed_ms)
|
||||
|
||||
if self.debug:
|
||||
await await_log(self.logger.debug(f'TURN: {game_state['turn']:3}, MOVE: {next_move:5}'))
|
||||
|
||||
return {'move': next_move}
|
||||
|
||||
# end is called when your Battlesnake finishes a game
|
||||
@self.app.post('/end')
|
||||
async def on_end():
|
||||
self.metrics_collector.record_http_request('end')
|
||||
await self._prune_stale_games()
|
||||
game_state = await request.get_json()
|
||||
if self.store_game_state:
|
||||
game_board = cast(GameBoard, await self._get_game_board(game_state, end=True))
|
||||
if self.check_tls_security:
|
||||
await game_board.save(
|
||||
StorageLoader.build(self.storage_type),
|
||||
file_path=os.path.join(self.data_path, 'data'),
|
||||
database=os.getenv('EDGEDB_DATABASE', None),
|
||||
tls_security=None,
|
||||
self.gameplay_tracking = GameplayTrackingService(
|
||||
gameplay_database=self.gameplay_database,
|
||||
logger=self.logger,
|
||||
)
|
||||
else:
|
||||
await game_board.save(
|
||||
StorageLoader.build(self.storage_type),
|
||||
file_path=os.path.join(self.data_path, 'data'),
|
||||
database=os.getenv('EDGEDB_DATABASE', None),
|
||||
self.dashboard_query = DashboardQueryService(
|
||||
gameplay_database=self.gameplay_database,
|
||||
ws_hub=self.dashboard_ws_hub,
|
||||
logger=self.logger,
|
||||
dashboard_running_game_stale_sec=600,
|
||||
)
|
||||
self.dashboard_events_service = DashboardEventsService(
|
||||
enabled=(self.metrics_backend_normalized == 'redis' and env_bool('DASHBOARD_EVENTS_ENABLED', True)),
|
||||
redis_url=self.metrics_redis_url,
|
||||
channel= os.getenv('DASHBOARD_EVENTS_CHANNEL', 'snake:dashboard:events'),
|
||||
event_origin=f'worker-{os.getpid()}-{int(time.time() * 1000)}',
|
||||
shutdown_event=self.dashboard_ws_hub.shutdown_event,
|
||||
on_notice=self._on_dashboard_games_update_notice,
|
||||
logger=self.logger,
|
||||
)
|
||||
self.dashboard_query.set_publish_notice(self.dashboard_events_service.publish_notice)
|
||||
|
||||
await self._record_gameplay_end(game_state)
|
||||
await await_log(self.logger.info(f'GAME ENDED: Winner is {[x['name'] for x in game_state['board']['snakes']]}'))
|
||||
await self._delete_game_board(game_state)
|
||||
await self.metrics_collector.record_game_end(game_state)
|
||||
return 'ok'
|
||||
self.app = Quart('Battlesnake', template_folder=os.path.join(data_path, 'templates', 'side'), static_folder=os.path.join(data_path, 'templates', 'files'))
|
||||
|
||||
self.app.register_blueprint(create_battlesnake_blueprint(self))
|
||||
self.app.register_blueprint(create_metrics_blueprint(self))
|
||||
self.app.register_blueprint(create_dashboard_blueprint(self))
|
||||
|
||||
@self.app.after_request
|
||||
async def identify_server(response):
|
||||
@@ -160,278 +120,70 @@ class Server:
|
||||
if self._startup_worker_metrics_cleared:
|
||||
return
|
||||
self._startup_worker_metrics_cleared = True
|
||||
if self.clear_worker_metrics_on_startup:
|
||||
should_clear = await self.metrics_collector.should_clear_worker_metrics_on_startup(self.worker_metrics_startup_lock_ttl_sec)
|
||||
|
||||
if env_bool('METRICS_CLEAR_WORKERS_ON_STARTUP', True):
|
||||
should_clear = await self.metrics_collector.should_clear_worker_metrics_on_startup(env_int('METRICS_STARTUP_CLEANUP_LOCK_TTL_SEC', 300))
|
||||
if should_clear:
|
||||
await self.metrics_collector.clear_worker_metrics()
|
||||
await self.dashboard_events_service.start_listener()
|
||||
|
||||
if self.gameplay_database is not None:
|
||||
await self.gameplay_database.initialize()
|
||||
|
||||
@self.app.after_serving
|
||||
async def shutdown_state_storage():
|
||||
await self.game_state_store.close()
|
||||
await self.dashboard_events_service.stop_listener()
|
||||
await self.metrics_collector.close()
|
||||
if self.gameplay_database is not None:
|
||||
await self.gameplay_database.close()
|
||||
|
||||
@self.app.get('/cleanup')
|
||||
async def cleanup():
|
||||
results = self._cleanup_database()
|
||||
return jsonify(data=json.loads(results), status=200)
|
||||
|
||||
@self.app.get('/metrics')
|
||||
async def metrics():
|
||||
snapshot = await self.metrics_collector.build_snapshot(self.game_last_seen_unix, self.game_move_counts)
|
||||
return jsonify(snapshot)
|
||||
|
||||
@self.app.get('/metrics/prometheus')
|
||||
async def metrics_prometheus():
|
||||
snapshot = await self.metrics_collector.build_snapshot(self.game_last_seen_unix, self.game_move_counts)
|
||||
return (
|
||||
self.metrics_collector.build_prometheus_metrics(snapshot),
|
||||
200,
|
||||
{'Content-Type': 'text/plain; version=0.0.4; charset=utf-8'},
|
||||
)
|
||||
|
||||
@self.app.get('/dashboard')
|
||||
async def dashboard_view():
|
||||
initial_game_id = request.args.get('game_id', '')
|
||||
initial_summary = await self._get_dashboard_summary()
|
||||
initial_games = await self._get_dashboard_games(limit=100)
|
||||
return await render_template(
|
||||
'dashboard.html',
|
||||
initial_game_id=initial_game_id,
|
||||
initial_summary=initial_summary,
|
||||
initial_games=initial_games,
|
||||
)
|
||||
|
||||
@self.app.get('/dashboard/game/<string:game_id>')
|
||||
async def dashboard_game_replay(game_id:str):
|
||||
replay = await self._get_dashboard_game_replay(game_id)
|
||||
if replay is None:
|
||||
return jsonify({'error': 'game_not_found', 'game_id': game_id}), 404
|
||||
return jsonify(replay)
|
||||
|
||||
@self.app.get('/dashboard/customizations/<path:asset_path>')
|
||||
async def dashboard_customizations_asset(asset_path:str):
|
||||
customization_root = os.path.join(self.data_path, 'server', 'static', 'customizations')
|
||||
return await send_from_directory(customization_root, asset_path)
|
||||
|
||||
async def run(self, host:str='0.0.0.0', port:int=8000, debug:bool=False):
|
||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
await await_log(self.logger.info(f'Running Battlesnake at http://{host}:{port} with the {" ".join(re.findall("[A-Z][^A-Z]*", self.snake_type))}'))
|
||||
await self.app.run_task(host=host, port=port, debug=debug)
|
||||
installed_signal_handlers:list[signal.Signals] = []
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
async def _read_json_config_or_create(self) -> dict[str, str]:
|
||||
snake_config = cast(dict[str, str]|None, await read_file(self.config_file, json.load))
|
||||
if not snake_config:
|
||||
return await self._override_snake_config_with_environment_variables(self.default_snake_config)
|
||||
return await self._override_snake_config_with_environment_variables(snake_config)
|
||||
def on_shutdown_signal() -> None:
|
||||
self.dashboard_ws_hub.request_shutdown()
|
||||
shutdown_event.set()
|
||||
|
||||
async def _override_snake_config_with_environment_variables(self, config:dict[str, str]) -> dict[str, str]:
|
||||
config['version'] = self.snake_version
|
||||
async def shutdown_trigger() -> None:
|
||||
await shutdown_event.wait()
|
||||
|
||||
for key in ('author', 'color', 'head', 'tail'):
|
||||
value = os.environ.get(f'SNAKE_{key.upper()}')
|
||||
if value is not None:
|
||||
config[key] = value
|
||||
for shutdown_signal in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(shutdown_signal, on_shutdown_signal)
|
||||
installed_signal_handlers.append(shutdown_signal)
|
||||
except (NotImplementedError, RuntimeError):
|
||||
continue
|
||||
|
||||
version_override = os.environ.get('SNAKE_VERSION')
|
||||
if version_override is not None:
|
||||
config['version'] = version_override
|
||||
|
||||
return config
|
||||
await await_log(self.logger.info(f'Running Battlesnake at http://{host}:{port} with the {' '.join(re.findall('[A-Z][^A-Z]*', self.snake_type))}'))
|
||||
try:
|
||||
await self.app.run_task(host=host, port=port, debug=debug, shutdown_trigger=shutdown_trigger)
|
||||
finally:
|
||||
self.dashboard_ws_hub.request_shutdown()
|
||||
for shutdown_signal in installed_signal_handlers:
|
||||
try:
|
||||
loop.remove_signal_handler(shutdown_signal)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def _get_snake_version(self) -> str:
|
||||
configured_version = SnakeBuilder.get_version(self.snake_type)
|
||||
if configured_version:
|
||||
return configured_version
|
||||
|
||||
try:
|
||||
snake = SnakeBuilder.build(self.snake_type)
|
||||
except Exception:
|
||||
return self.default_snake_config['version']
|
||||
|
||||
version = getattr(snake, 'version', None)
|
||||
if version is None:
|
||||
version = getattr(snake, 'VERSION', None)
|
||||
if not version:
|
||||
return self.default_snake_config['version']
|
||||
return str(version)
|
||||
if configured_version is None:
|
||||
return str(SnakeBuilder.get_version('TemplateSnake'))
|
||||
return str(configured_version)
|
||||
|
||||
def _get_stale_game_timeout_sec(self) -> int:
|
||||
value = os.getenv('SNAKE_STUCK_GAME_TIMEOUT_SEC', '180')
|
||||
try:
|
||||
return max(30, int(value))
|
||||
except ValueError:
|
||||
return 180
|
||||
|
||||
def _env_bool(self, name:str, default:bool=False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||
|
||||
def _env_int(self, name: str, default: int) -> int:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
async def _create_game_board(self, game_state:dict) -> GameBoard:
|
||||
game_id = game_state['game']['id']
|
||||
new_game_board = GameBoard(
|
||||
game_id=game_id,
|
||||
width=game_state['board']['width'],
|
||||
height=game_state['board']['height'],
|
||||
ruleset=game_state['game']['ruleset'],
|
||||
source=game_state['game']['source'],
|
||||
map=game_state['game']['map'],
|
||||
snake_class=SnakeBuilder.build(self.snake_type),
|
||||
)
|
||||
await new_game_board.start_game(game_state)
|
||||
|
||||
if self.game_state_local_cache:
|
||||
self.running_games[game_id] = new_game_board
|
||||
await self.game_state_store.save(game_id, new_game_board)
|
||||
self.game_move_counts[game_id] = 0
|
||||
self.game_last_seen_unix[game_id] = int(time.time())
|
||||
await self.metrics_collector.record_game_started(len(self.game_last_seen_unix))
|
||||
return new_game_board
|
||||
|
||||
async def _persist_game_board(self, game_id:str, game_board:GameBoard):
|
||||
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):
|
||||
game_id = game_state['game']['id']
|
||||
self.running_games.pop(game_id, None)
|
||||
self.game_move_counts.pop(game_id, None)
|
||||
self.game_last_seen_unix.pop(game_id, None)
|
||||
await self.game_state_store.delete(game_id)
|
||||
|
||||
async def _get_game_board(self, game_state:dict, end:bool=False) -> GameBoard:
|
||||
game_id = game_state['game']['id']
|
||||
game_board:GameBoard
|
||||
if self.game_state_local_cache and game_id in self.running_games:
|
||||
game_board = self.running_games[game_id]
|
||||
else:
|
||||
persisted_board = await self.game_state_store.load(game_id)
|
||||
if persisted_board is not None:
|
||||
game_board = cast(GameBoard, persisted_board)
|
||||
if self.game_state_local_cache:
|
||||
self.running_games[game_id] = game_board
|
||||
else:
|
||||
game_board = await self._create_game_board(game_state)
|
||||
await self.metrics_collector.record_game_autocreated()
|
||||
|
||||
if not end:
|
||||
self.game_move_counts[game_id] = self.game_move_counts.get(game_id, 0) + 1
|
||||
|
||||
self.game_last_seen_unix[game_id] = int(time.time())
|
||||
|
||||
game_board.read_game_data(game_state)
|
||||
if end:
|
||||
game_board.end_game(game_state)
|
||||
await self._persist_game_board(game_id, game_board)
|
||||
|
||||
return game_board
|
||||
return max(30, env_int('SNAKE_STUCK_GAME_TIMEOUT_SEC', 180))
|
||||
|
||||
def enable_store_game_state(self):
|
||||
self.store_game_state = True
|
||||
|
||||
def _cleanup_database(self):
|
||||
storage = StorageLoader.build(self.storage_type)()
|
||||
storage = StorageLoader.build(self.storage_type)
|
||||
return storage.cleanup()
|
||||
|
||||
async def _prune_stale_games(self):
|
||||
if not self.game_last_seen_unix:
|
||||
return
|
||||
|
||||
now = int(time.time())
|
||||
stale_ids = [
|
||||
game_id
|
||||
for game_id, last_seen in self.game_last_seen_unix.items()
|
||||
if now - last_seen >= self.stale_game_timeout_sec
|
||||
]
|
||||
for game_id in stale_ids:
|
||||
self.running_games.pop(game_id, None)
|
||||
self.game_move_counts.pop(game_id, None)
|
||||
self.game_last_seen_unix.pop(game_id, None)
|
||||
await self.metrics_collector.record_stuck_removed()
|
||||
|
||||
async def _record_gameplay_start(self, game_state:dict) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
try:
|
||||
await self.gameplay_database.record_game_start(
|
||||
game_state,
|
||||
snake_type=self.snake_type,
|
||||
snake_version=self.snake_version,
|
||||
)
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB start record failed:{error}'))
|
||||
|
||||
def _extract_latest_snake_thinking(self, game_board:GameBoard) -> dict | None:
|
||||
try:
|
||||
history = game_board.snake_class.get_history()
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(history, list) or len(history) == 0:
|
||||
return None
|
||||
latest = history[-1]
|
||||
return latest if isinstance(latest, dict) else None
|
||||
|
||||
async def _record_gameplay_turn(self, game_state:dict, my_move:str, game_board:GameBoard) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
try:
|
||||
thinking = self._extract_latest_snake_thinking(game_board)
|
||||
await self.gameplay_database.record_turn(game_state, my_move, thinking)
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB turn record failed:{error}'))
|
||||
|
||||
async def _record_gameplay_end(self, game_state:dict) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
try:
|
||||
await self.gameplay_database.record_game_end(game_state)
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB end record failed:{error}'))
|
||||
|
||||
async def _get_dashboard_summary(self) -> dict:
|
||||
if self.gameplay_database is None:
|
||||
return {'enabled': False}
|
||||
try:
|
||||
summary = await self.gameplay_database.get_summary()
|
||||
summary['enabled'] = True
|
||||
return summary
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB summary failed:{error}'))
|
||||
return {'enabled': True, 'error':' summary_unavailable'}
|
||||
|
||||
async def _get_dashboard_games(self, limit:int=50) -> dict:
|
||||
if self.gameplay_database is None:
|
||||
return {'enabled': False, 'games': []}
|
||||
try:
|
||||
games = await self.gameplay_database.list_games(limit=limit)
|
||||
return {'enabled': True, 'games': games}
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB game list failed:{error}'))
|
||||
return {'enabled': True, 'error': 'games_unavailable', 'games': []}
|
||||
|
||||
async def _get_dashboard_game_replay(self, game_id:str) -> dict | None:
|
||||
if self.gameplay_database is None:
|
||||
return {'enabled': False, 'error': 'database_disabled', 'game_id': game_id}
|
||||
try:
|
||||
replay = await self.gameplay_database.get_game_replay(game_id)
|
||||
if replay is None:
|
||||
return None
|
||||
replay['enabled'] = True
|
||||
return replay
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB replay failed:{error}'))
|
||||
return {'enabled': True, 'error': 'replay_unavailable', 'game_id': game_id}
|
||||
async def _on_dashboard_games_update_notice(self, trigger:str) -> None:
|
||||
await self.dashboard_query.on_dashboard_games_update_notice(trigger)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .battlesnake import create_battlesnake_blueprint
|
||||
from .metrics import create_metrics_blueprint
|
||||
from .dashboard import create_dashboard_blueprint
|
||||
@@ -0,0 +1,123 @@
|
||||
from typing import TYPE_CHECKING, cast
|
||||
import asyncio, json, time, os
|
||||
|
||||
from quart import Blueprint, request, jsonify
|
||||
|
||||
from quart_common.web.decorators import require_user_agent
|
||||
from quart_common.web.logger import await_log
|
||||
from server.database import StorageLoader
|
||||
from snakes import DEFAULT_SNAKE_CONFIG
|
||||
from server.GameBoard import GameBoard
|
||||
from server.Files import read_file
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from server.Server import Server
|
||||
|
||||
def create_battlesnake_blueprint(server:'Server') -> Blueprint:
|
||||
blueprint = Blueprint('battlesnake', __name__)
|
||||
|
||||
async def _override_snake_config_with_environment_variables(config:dict[str, str]) -> dict[str, str]:
|
||||
print(config)
|
||||
config['version'] = server.snake_version
|
||||
|
||||
for key in ('author', 'color', 'head', 'tail'):
|
||||
value = os.environ.get(f'SNAKE_{key.upper()}')
|
||||
if value is not None:
|
||||
config[key] = value
|
||||
|
||||
version_override = os.environ.get('SNAKE_VERSION')
|
||||
if version_override is not None:
|
||||
config['version'] = version_override
|
||||
|
||||
return config
|
||||
|
||||
@blueprint.get('/')
|
||||
async def on_info():
|
||||
server.metrics_collector.record_http_request('info')
|
||||
|
||||
snake_config = cast(dict[str, str]|None, await read_file(server.config_file, json.load))
|
||||
if not snake_config:
|
||||
snake_json = await _override_snake_config_with_environment_variables(DEFAULT_SNAKE_CONFIG)
|
||||
else:
|
||||
snake_json = await _override_snake_config_with_environment_variables(snake_config)
|
||||
|
||||
await await_log(server.logger.info(f'INFO Snake: {snake_json}'))
|
||||
return snake_json
|
||||
|
||||
@blueprint.post('/start')
|
||||
@require_user_agent("BattlesnakeEngine", abort_code=404)
|
||||
async def on_start():
|
||||
server.metrics_collector.record_http_request('start')
|
||||
await server.game_runtime.prune_stale_games()
|
||||
game_state = await request.get_json()
|
||||
game_board = await server.game_runtime.create_game_board(game_state)
|
||||
await server.gameplay_tracking.record_gameplay_start(game_state, game_board)
|
||||
await await_log(server.logger.info(f'GAME START: {game_state['game']}'))
|
||||
return 'ok'
|
||||
|
||||
@blueprint.post('/move')
|
||||
@require_user_agent("BattlesnakeEngine", abort_code=404)
|
||||
async def on_move():
|
||||
server.metrics_collector.record_http_request('move')
|
||||
game_state = await request.get_json()
|
||||
move_started = time.perf_counter()
|
||||
|
||||
game_id = game_state['game']['id']
|
||||
timeout_ms = int(game_state.get('game', {}).get('timeout', 500))
|
||||
budget_sec = max(0.05, (timeout_ms - 50) / 1000.0)
|
||||
|
||||
next_move = None
|
||||
game_board = None
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(budget_sec):
|
||||
game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state))
|
||||
loop = asyncio.get_running_loop()
|
||||
next_move = await loop.run_in_executor(None, game_board.snake_neat_make_a_move)
|
||||
except TimeoutError:
|
||||
await await_log(server.logger.warning(f'MOVE TIMEOUT: turn={game_state.get("turn")}, game={game_id}, returning fallback {next_move!r}'))
|
||||
|
||||
await server.gameplay_tracking.record_gameplay_turn(game_state, next_move, game_board)
|
||||
elapsed_ms = (time.perf_counter() - move_started) * 1000.0
|
||||
await server.metrics_collector.record_move(next_move, elapsed_ms)
|
||||
|
||||
if server.debug:
|
||||
await await_log(server.logger.debug(f'TURN: {game_state['turn']:3}, MOVE: {next_move:5}'))
|
||||
|
||||
return {'move': next_move}
|
||||
|
||||
@blueprint.post('/end')
|
||||
@require_user_agent("BattlesnakeEngine", abort_code=404)
|
||||
async def on_end():
|
||||
server.metrics_collector.record_http_request('end')
|
||||
await server.game_runtime.prune_stale_games()
|
||||
game_state = await request.get_json()
|
||||
if server.store_game_state:
|
||||
game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state, end=True))
|
||||
if server.check_tls_security:
|
||||
await game_board.save(
|
||||
StorageLoader.build(server.storage_type),
|
||||
file_path=os.path.join(server.data_path, 'data'),
|
||||
database=os.getenv('EDGEDB_DATABASE', None),
|
||||
tls_security=None,
|
||||
)
|
||||
else:
|
||||
await game_board.save(
|
||||
StorageLoader.build(server.storage_type),
|
||||
file_path=os.path.join(server.data_path, 'data'),
|
||||
database=os.getenv('EDGEDB_DATABASE', None),
|
||||
)
|
||||
|
||||
await server.gameplay_tracking.record_gameplay_end(game_state)
|
||||
await server.dashboard_query.push_dashboard_games_update(game_state)
|
||||
await await_log(server.logger.info(f'GAME ENDED: Winner is {[x['name'] for x in game_state['board']['snakes']]}'))
|
||||
await server.game_runtime.delete_game_board(game_state)
|
||||
await server.metrics_collector.record_game_end(game_state)
|
||||
return 'ok'
|
||||
|
||||
# @blueprint.get('/cleanup')
|
||||
# async def cleanup():
|
||||
# results = server._cleanup_database()
|
||||
# return jsonify(data=json.loads(results), status=200)
|
||||
|
||||
return blueprint
|
||||
@@ -0,0 +1,118 @@
|
||||
from typing import TYPE_CHECKING
|
||||
import asyncio, json, os
|
||||
|
||||
from quart import (
|
||||
Blueprint,
|
||||
render_template,
|
||||
send_from_directory,
|
||||
request,
|
||||
websocket,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from server.Server import Server
|
||||
|
||||
def create_dashboard_blueprint(server:'Server') -> Blueprint:
|
||||
blueprint = Blueprint('dashboard', __name__)
|
||||
|
||||
@blueprint.get('/dashboard')
|
||||
async def dashboard_view():
|
||||
initial_game_id = request.args.get('game_id', '')
|
||||
initial_summary = await server.dashboard_query.get_dashboard_summary()
|
||||
initial_games = await server.dashboard_query.get_dashboard_games(limit=100)
|
||||
return await render_template(
|
||||
'dashboard.htm',
|
||||
initial_game_id=initial_game_id,
|
||||
initial_summary=initial_summary,
|
||||
initial_games=initial_games,
|
||||
battlesnake_url=os.getenv('BATTLESNAKE_GAMEBOARD_URL', 'https://play.battlesnake.com/game')
|
||||
)
|
||||
|
||||
@blueprint.get('/dashboard/customizations/<path:asset_path>')
|
||||
async def dashboard_customizations_asset(asset_path:str):
|
||||
customization_root = os.path.join(
|
||||
server.app.static_folder,
|
||||
'customizations',
|
||||
)
|
||||
return await send_from_directory(customization_root, asset_path)
|
||||
|
||||
@blueprint.websocket('/dashboard/ws/games')
|
||||
async def dashboard_games_ws():
|
||||
ws_hub = server.dashboard_ws_hub
|
||||
websocket_task = asyncio.current_task()
|
||||
if websocket_task is not None:
|
||||
await ws_hub.register_task(websocket_task)
|
||||
|
||||
subscriber_queue:asyncio.Queue[str] = asyncio.Queue(maxsize=20)
|
||||
await ws_hub.register_subscriber(subscriber_queue)
|
||||
try:
|
||||
initial_payload = await server.dashboard_query.build_dashboard_games_event()
|
||||
await asyncio.wait_for(
|
||||
websocket.send(json.dumps(initial_payload)), timeout=1.5
|
||||
)
|
||||
while True:
|
||||
queue_task = asyncio.create_task(subscriber_queue.get())
|
||||
receive_task = asyncio.create_task(websocket.receive())
|
||||
try:
|
||||
done, _ = await asyncio.wait(
|
||||
{queue_task, receive_task},
|
||||
timeout=1.0,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
if len(done) == 0:
|
||||
if ws_hub.shutdown_event.is_set():
|
||||
await asyncio.wait_for(
|
||||
websocket.send(ws_hub.shutdown_message),
|
||||
timeout=1.5,
|
||||
)
|
||||
break
|
||||
continue
|
||||
|
||||
if receive_task in done:
|
||||
try:
|
||||
request_payload_raw = receive_task.result()
|
||||
except Exception:
|
||||
break
|
||||
|
||||
response_event = await server.dashboard_query.handle_dashboard_ws_request(request_payload_raw)
|
||||
if response_event is not None:
|
||||
await asyncio.wait_for(
|
||||
websocket.send(json.dumps(response_event)),
|
||||
timeout=1.5,
|
||||
)
|
||||
|
||||
if queue_task in done:
|
||||
event_payload = queue_task.result()
|
||||
if event_payload == ws_hub.shutdown_message:
|
||||
await asyncio.wait_for(
|
||||
websocket.send(event_payload), timeout=1.5
|
||||
)
|
||||
break
|
||||
await asyncio.wait_for(
|
||||
websocket.send(event_payload), timeout=1.5
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if ws_hub.shutdown_event.is_set():
|
||||
await asyncio.wait_for(
|
||||
websocket.send(ws_hub.shutdown_message),
|
||||
timeout=1.5,
|
||||
)
|
||||
break
|
||||
finally:
|
||||
for pending_task in (queue_task, receive_task):
|
||||
if not pending_task.done():
|
||||
pending_task.cancel()
|
||||
await asyncio.gather(
|
||||
queue_task, receive_task, return_exceptions=True
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await ws_hub.unregister_subscriber(subscriber_queue)
|
||||
if websocket_task is not None:
|
||||
await ws_hub.unregister_task(websocket_task)
|
||||
|
||||
return blueprint
|
||||
@@ -0,0 +1,25 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from server.Server import Server
|
||||
|
||||
def create_metrics_blueprint(server:'Server') -> Blueprint:
|
||||
blueprint = Blueprint('metrics', __name__)
|
||||
|
||||
@blueprint.get('/metrics')
|
||||
async def metrics():
|
||||
snapshot = await server.metrics_collector.build_snapshot(
|
||||
server.game_runtime.game_last_seen_unix,
|
||||
server.game_runtime.game_move_counts,
|
||||
)
|
||||
|
||||
if 'prometheus' in (request.headers.get('User-Agent') or '').lower():
|
||||
return (
|
||||
server.metrics_collector.build_prometheus_metrics(snapshot),
|
||||
200,
|
||||
{'Content-Type': 'text/plain; version=0.0.4; charset=utf-8'},
|
||||
)
|
||||
|
||||
return jsonify(snapshot)
|
||||
return blueprint
|
||||
@@ -2,6 +2,7 @@ from typing import TypedDict
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
from quart_common.web.env import env_bool, env_int
|
||||
from server.Server import Server
|
||||
|
||||
class RunConfig(TypedDict):
|
||||
@@ -9,35 +10,26 @@ class RunConfig(TypedDict):
|
||||
port: int
|
||||
debug: bool
|
||||
|
||||
def env_bool(name:str, default:bool=False) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.lower() in {'1', 'true', 'yes', 'on'}
|
||||
|
||||
def build_server_from_env(default_snake_type:str) -> Server:
|
||||
data_path = str(Path(__file__).resolve().parent.parent)
|
||||
game_state_backend = os.environ.get('GAME_STATE_BACKEND', 'memory')
|
||||
game_state_redis_url = os.environ.get('GAME_STATE_REDIS_URL', 'redis://localhost:6379/0')
|
||||
game_state_ttl_sec = int(os.environ.get('GAME_STATE_TTL_SEC', '900'))
|
||||
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
metrics_backend = os.environ.get('METRICS_BACKEND', None)
|
||||
if metrics_backend is None:
|
||||
metrics_backend = ('redis' if game_state_backend.strip().lower() == 'redis' else 'memory')
|
||||
metrics_backend = os.environ.get('BACKEND', 'memory')
|
||||
|
||||
metrics_redis_url = os.environ.get('METRICS_REDIS_URL', game_state_redis_url)
|
||||
metrics_redis_url = os.environ.get('METRICS_REDIS_URL', redis_url)
|
||||
metrics_ttl_sec_raw = os.environ.get('METRICS_TTL_SEC', None)
|
||||
if metrics_ttl_sec_raw is None:
|
||||
metrics_ttl_sec = (game_state_ttl_sec if metrics_backend.strip().lower() == 'redis' else None)
|
||||
else:
|
||||
metrics_ttl_sec = int(metrics_ttl_sec_raw)
|
||||
metrics_ttl_sec = env_int('METRICS_TTL_SEC', 900) if metrics_ttl_sec_raw is not None else None
|
||||
|
||||
gameplay_db_enabled = env_bool('GAMEPLAY_DB_ENABLED', True)
|
||||
gameplay_db_backend = os.environ.get('GAMEPLAY_DB_BACKEND', 'sqlite')
|
||||
gameplay_db_path = os.environ.get(
|
||||
'GAMEPLAY_DB_PATH',
|
||||
os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3'),
|
||||
)
|
||||
gameplay_db_busy_timeout_ms = int(os.environ.get('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(
|
||||
data_path=data_path,
|
||||
@@ -45,16 +37,14 @@ def build_server_from_env(default_snake_type:str) -> Server:
|
||||
storage_type=os.environ.get('STORAGE', 'LocalStorage'),
|
||||
debug=env_bool('DEBUG_SERVER'),
|
||||
check_tls_security=False,
|
||||
game_state_backend=game_state_backend,
|
||||
game_state_redis_url=game_state_redis_url,
|
||||
game_state_ttl_sec=game_state_ttl_sec,
|
||||
game_state_local_cache=env_bool('GAME_STATE_LOCAL_CACHE', default=True),
|
||||
metrics_backend=metrics_backend,
|
||||
metrics_redis_url=metrics_redis_url,
|
||||
metrics_ttl_sec=metrics_ttl_sec,
|
||||
gameplay_db_enabled=gameplay_db_enabled,
|
||||
gameplay_db_backend=gameplay_db_backend,
|
||||
gameplay_db_path=gameplay_db_path,
|
||||
gameplay_db_busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
||||
gameplay_db_pg_dsn=gameplay_db_pg_dsn,
|
||||
)
|
||||
|
||||
if env_bool('STORE_GAME_HISTORY'):
|
||||
@@ -65,6 +55,6 @@ def build_server_from_env(default_snake_type:str) -> Server:
|
||||
def build_run_config() -> RunConfig:
|
||||
return {
|
||||
'host': os.environ.get('HOST', '0.0.0.0'),
|
||||
'port': int(os.environ.get('PORT', '8000')),
|
||||
'port': env_int('PORT', 8000),
|
||||
'debug': env_bool('DEBUG'),
|
||||
}
|
||||
|
||||
@@ -1,502 +1,37 @@
|
||||
from datetime import datetime, timezone
|
||||
import asyncio, sqlite3, json
|
||||
from pathlib import Path
|
||||
from .backend.Template import GameplayBackendTemplate
|
||||
|
||||
class GameplayDatabase:
|
||||
def __init__(self, db_path:str, busy_timeout_ms:int=5000):
|
||||
self.db_path = db_path
|
||||
self.busy_timeout_ms = max(1000, int(busy_timeout_ms))
|
||||
self._initialize_database()
|
||||
"""Thin facade that delegates all operations to a GameplayBackendTemplate.
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(
|
||||
self.db_path,
|
||||
timeout=max(1, self.busy_timeout_ms // 1000),
|
||||
isolation_level=None,
|
||||
)
|
||||
connection.row_factory = sqlite3.Row
|
||||
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 _initialize_database(self) -> None:
|
||||
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
||||
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
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_turns_game_turn ON turns(game_id, turn);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_status ON games(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_snake_turns_game_turn ON snake_turns(game_id, turn);
|
||||
""")
|
||||
self._ensure_column_exists(connection, "turns", "my_thinking_json", "TEXT")
|
||||
self._ensure_column_exists(connection, "games", "your_snake_type", "TEXT")
|
||||
self._ensure_column_exists(connection, "games", "your_snake_version", "TEXT")
|
||||
connection.execute("PRAGMA optimize")
|
||||
|
||||
def _ensure_column_exists(self, connection:sqlite3.Connection, table_name:str, column_name:str, column_type:str) -> None:
|
||||
existing = connection.execute(f"PRAGMA table_info({table_name})").fetchall()
|
||||
if any(row["name"] == column_name for row in existing):
|
||||
return
|
||||
|
||||
connection.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}")
|
||||
|
||||
def _utc_now(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _to_json(self, payload:dict) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
def _from_json(self, payload:str|None):
|
||||
if payload is None or payload == "":
|
||||
return None
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
def _extract_snakes(self, game_state:dict) -> list[dict]:
|
||||
return list(game_state.get("board", {}).get("snakes", []))
|
||||
|
||||
def _extract_you(self, game_state:dict) -> dict:
|
||||
return dict(game_state.get("you", {}))
|
||||
|
||||
def _infer_direction(self, old_head:tuple[int, int]|None, new_head:tuple[int, int]|None) -> str|None:
|
||||
if old_head is None or new_head is None:
|
||||
return None
|
||||
|
||||
delta_x = new_head[0] - old_head[0]
|
||||
delta_y = new_head[1] - old_head[1]
|
||||
if delta_x == 1 and delta_y == 0:
|
||||
return "right"
|
||||
if delta_x == -1 and delta_y == 0:
|
||||
return "left"
|
||||
if delta_x == 0 and delta_y == 1:
|
||||
return "up"
|
||||
if delta_x == 0 and delta_y == -1:
|
||||
return "down"
|
||||
return None
|
||||
|
||||
def _record_game_start_sync(self, game_state:dict, 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", {})
|
||||
|
||||
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, 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,
|
||||
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,
|
||||
),
|
||||
)
|
||||
connection.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
connection.execute("PRAGMA incremental_vacuum(200)")
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(game_id, turn, snake_id) DO UPDATE SET
|
||||
snake_name = excluded.snake_name,
|
||||
health = excluded.health,
|
||||
length = excluded.length,
|
||||
head_x = excluded.head_x,
|
||||
head_y = excluded.head_y,
|
||||
body_json = excluded.body_json,
|
||||
is_you = excluded.is_you,
|
||||
inferred_move = excluded.inferred_move
|
||||
""",
|
||||
(
|
||||
game_id,
|
||||
turn,
|
||||
snake_id,
|
||||
snake.get("name"),
|
||||
snake.get("health"),
|
||||
snake.get("length"),
|
||||
head_x,
|
||||
head_y,
|
||||
self._to_json(snake.get("body", [])),
|
||||
1 if snake_id == you_id else 0,
|
||||
inferred,
|
||||
),
|
||||
)
|
||||
|
||||
connection.execute("""
|
||||
UPDATE games
|
||||
SET final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END
|
||||
WHERE game_id = ?
|
||||
""",
|
||||
(turn, turn, game_id),
|
||||
)
|
||||
|
||||
def _record_game_end_sync(self, game_state:dict) -> None:
|
||||
game = game_state.get("game", {})
|
||||
game_id = game.get("id")
|
||||
board = game_state.get("board", {})
|
||||
snakes = list(board.get("snakes", []))
|
||||
you = self._extract_you(game_state)
|
||||
winner_names = [snake.get("name") for snake in snakes if snake.get("name")]
|
||||
you_id = you.get("id")
|
||||
winner_you = any(snake.get("id") == you_id for snake in snakes)
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("""
|
||||
UPDATE games
|
||||
SET ended_at = ?,
|
||||
winner_names_json = ?,
|
||||
winner_you = ?,
|
||||
final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END,
|
||||
status = 'finished'
|
||||
WHERE game_id = ?
|
||||
""",
|
||||
(
|
||||
self._utc_now(),
|
||||
self._to_json(winner_names),
|
||||
1 if winner_you else 0,
|
||||
int(game_state.get("turn", 0)),
|
||||
int(game_state.get("turn", 0)),
|
||||
game_id,
|
||||
),
|
||||
)
|
||||
|
||||
def _get_summary_sync(self, recent_limit:int=15) -> dict:
|
||||
with self._connect() as connection:
|
||||
totals = connection.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total_games,
|
||||
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running_games,
|
||||
SUM(CASE WHEN status = 'finished' THEN 1 ELSE 0 END) AS finished_games,
|
||||
SUM(CASE WHEN status = 'finished' AND winner_you = 1 THEN 1 ELSE 0 END) AS wins,
|
||||
SUM(CASE WHEN status = 'finished' AND winner_you = 0 THEN 1 ELSE 0 END) AS losses,
|
||||
AVG(CASE WHEN status = 'finished' THEN final_turn ELSE NULL END) AS avg_turns
|
||||
FROM games
|
||||
Construct via GameplayBackendBuilder.build() or pass a backend directly.
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
recent = connection.execute("""
|
||||
SELECT game_id, started_at, ended_at, map_name, 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()
|
||||
def __init__(self, backend:GameplayBackendTemplate):
|
||||
self._backend = backend
|
||||
|
||||
return {
|
||||
"database": self.db_path,
|
||||
"total_games": int(totals["total_games"] or 0),
|
||||
"running_games": int(totals["running_games"] or 0),
|
||||
"finished_games": int(totals["finished_games"] or 0),
|
||||
"wins": int(totals["wins"] or 0),
|
||||
"losses": int(totals["losses"] or 0),
|
||||
"avg_turns_finished": round(float(totals["avg_turns"] or 0.0), 2),
|
||||
"recent_games": [{
|
||||
"game_id": row["game_id"],
|
||||
"started_at": row["started_at"],
|
||||
"ended_at": row["ended_at"],
|
||||
"map": row["map_name"],
|
||||
"snake": row["your_snake_name"],
|
||||
"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,
|
||||
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"],
|
||||
"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, 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
|
||||
FROM snake_turns
|
||||
WHERE game_id = ?
|
||||
ORDER BY turn ASC, is_you DESC, snake_name ASC
|
||||
""",
|
||||
(game_id,),
|
||||
).fetchall()
|
||||
|
||||
snakes_by_turn:dict[int, list[dict]] = {}
|
||||
for row in snake_rows:
|
||||
turn = int(row["turn"])
|
||||
snakes_by_turn.setdefault(turn, []).append({
|
||||
"snake_id": row["snake_id"],
|
||||
"snake_name": row["snake_name"],
|
||||
"health": row["health"],
|
||||
"length": row["length"],
|
||||
"head": {"x": row["head_x"], "y": row["head_y"]},
|
||||
"body": self._from_json(row["body_json"]) or [],
|
||||
"is_you": bool(row["is_you"]),
|
||||
"inferred_move": row["inferred_move"],
|
||||
})
|
||||
|
||||
replay_turns = []
|
||||
for row in turn_rows:
|
||||
turn_number = int(row["turn"])
|
||||
replay_turns.append({
|
||||
"turn": turn_number,
|
||||
"observed_at": row["observed_at"],
|
||||
"my_move": row["my_move"],
|
||||
"my_thinking": self._from_json(row["my_thinking_json"]),
|
||||
"board": self._from_json(row["board_state_json"]),
|
||||
"food": self._from_json(row["food_json"]) or [],
|
||||
"hazards": self._from_json(row["hazards_json"]) or [],
|
||||
"you": self._from_json(row["you_json"]) or {},
|
||||
"snakes": snakes_by_turn.get(turn_number, []),
|
||||
})
|
||||
|
||||
return {
|
||||
"game": {
|
||||
"game_id": game_row["game_id"],
|
||||
"started_at": game_row["started_at"],
|
||||
"ended_at": game_row["ended_at"],
|
||||
"width": game_row["width"],
|
||||
"height": game_row["height"],
|
||||
"source": game_row["source"],
|
||||
"map": game_row["map_name"],
|
||||
"ruleset_name": game_row["ruleset_name"],
|
||||
"ruleset_version": game_row["ruleset_version"],
|
||||
"your_snake_id": game_row["your_snake_id"],
|
||||
"your_snake_name": game_row["your_snake_name"],
|
||||
"your_snake_type": game_row["your_snake_type"],
|
||||
"your_snake_version": game_row["your_snake_version"],
|
||||
"winner_names": self._from_json(game_row["winner_names_json"]) or [],
|
||||
"winner_you": bool(game_row["winner_you"]),
|
||||
"final_turn": int(game_row["final_turn"] or 0),
|
||||
"status": game_row["status"],
|
||||
},
|
||||
"turns": replay_turns,
|
||||
}
|
||||
async def initialize(self) -> None:
|
||||
await self._backend.initialize()
|
||||
|
||||
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
|
||||
await asyncio.to_thread(self._record_game_start_sync, game_state, snake_type, snake_version)
|
||||
await self._backend.record_game_start(game_state, snake_type, snake_version)
|
||||
|
||||
async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None:
|
||||
await asyncio.to_thread(self._record_turn_sync, game_state, my_move, my_thinking)
|
||||
await self._backend.record_turn(game_state, my_move, my_thinking)
|
||||
|
||||
async def record_game_end(self, game_state:dict) -> None:
|
||||
await asyncio.to_thread(self._record_game_end_sync, game_state)
|
||||
await self._backend.record_game_end(game_state)
|
||||
|
||||
async def get_summary(self, recent_limit:int=15) -> dict:
|
||||
return await asyncio.to_thread(self._get_summary_sync, recent_limit)
|
||||
return await self._backend.get_summary(recent_limit)
|
||||
|
||||
async def list_games(self, limit:int=50) -> list[dict]:
|
||||
return await asyncio.to_thread(self._list_games_sync, limit)
|
||||
return await self._backend.list_games(limit)
|
||||
|
||||
async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
|
||||
return await self._backend.finalize_stale_running_games(stale_after_seconds)
|
||||
|
||||
async def get_game_replay(self, game_id:str) -> dict|None:
|
||||
return await asyncio.to_thread(self._get_game_replay_sync, game_id)
|
||||
return await self._backend.get_game_replay(game_id)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
await self._backend.close()
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from server.dataset.Dataset import Dataset
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
from server.dataset.Dataset import Dataset
|
||||
from server.Files import save_file
|
||||
|
||||
import aiofiles
|
||||
@@ -21,7 +25,7 @@ class LocalStorage:
|
||||
self.dataset_compress_rotated = os.getenv("DATASET_COMPRESS_ROTATED", "true").strip().lower() in ("1", "true", "yes", "on")
|
||||
self.dataset_max_bytes = int(float(os.getenv("DATASET_JSONL_MAX_MB", "50")) * 1024 * 1024)
|
||||
|
||||
def _get_active_dataset_path(self, game_board:GameBoard):
|
||||
def _get_active_dataset_path(self, game_board:'GameBoard'):
|
||||
if not self.dataset_rotate_daily:
|
||||
return self.dataset_jsonl_path
|
||||
|
||||
@@ -60,7 +64,7 @@ class LocalStorage:
|
||||
|
||||
self._gzip_file(os.path.join(folder, name))
|
||||
|
||||
async def _rotate_if_needed(self, active_path:str, game_board:GameBoard):
|
||||
async def _rotate_if_needed(self, active_path:str, game_board:'GameBoard'):
|
||||
if self.dataset_max_bytes <= 0:
|
||||
return
|
||||
if not await aiofiles.os.path.exists(active_path):
|
||||
@@ -81,7 +85,7 @@ class LocalStorage:
|
||||
if self.dataset_compress_rotated:
|
||||
self._gzip_file(rotated_path)
|
||||
|
||||
def _build_dataset_rows(self, dataset_payload:dict, game_board:GameBoard):
|
||||
def _build_dataset_rows(self, dataset_payload:dict, game_board:'GameBoard'):
|
||||
game_info = dataset_payload.get("game", {})
|
||||
snake_info = dataset_payload.get("snake", {})
|
||||
|
||||
@@ -102,7 +106,7 @@ class LocalStorage:
|
||||
})
|
||||
return rows
|
||||
|
||||
async def _append_dataset_jsonl(self, dataset_payload:dict, game_board:GameBoard):
|
||||
async def _append_dataset_jsonl(self, dataset_payload:dict, game_board:'GameBoard'):
|
||||
rows = self._build_dataset_rows(dataset_payload, game_board)
|
||||
if len(rows) == 0:
|
||||
return
|
||||
@@ -116,7 +120,7 @@ class LocalStorage:
|
||||
for row in rows:
|
||||
await f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
|
||||
def _get_correct_folder_for_save_file(self, game_board:GameBoard, file_name:str, game_type:str, leader_board:bool, winner:bool):
|
||||
def _get_correct_folder_for_save_file(self, game_board:'GameBoard', file_name:str, game_type:str, leader_board:bool, winner:bool):
|
||||
storage_folder = self.file_path
|
||||
if leader_board:
|
||||
storage_folder = os.path.join(storage_folder, "00_Leaderboards")
|
||||
@@ -136,7 +140,7 @@ class LocalStorage:
|
||||
|
||||
return os.path.join(storage_folder, file_name)
|
||||
|
||||
async def save(self, game_board:GameBoard):
|
||||
async def save(self, game_board:'GameBoard'):
|
||||
game_type = game_board.get_type_of_game()
|
||||
dataset = Dataset(game_board).build(only_good_moves=True)
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
from .GameplayDatabase import GameplayDatabase
|
||||
from .backend import GameplayBackendBuilder
|
||||
|
||||
from .LocalStorage import LocalStorage
|
||||
from .EdgeDB import EdgeDB
|
||||
|
||||
class StorageLoader:
|
||||
@classmethod
|
||||
def build(self, selected_storage:str) -> LocalStorage|EdgeDB:
|
||||
storage_module = __import__(f"server.database.{selected_storage}", fromlist=[selected_storage])
|
||||
storage_class = getattr(storage_module, selected_storage)
|
||||
return storage_class
|
||||
|
||||
@@ -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,9 +1,12 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
class Dataset:
|
||||
VALID_MOVES = {"up", "down", "left", "right"}
|
||||
|
||||
def __init__(self, game_board:GameBoard):
|
||||
def __init__(self, game_board:'GameBoard'):
|
||||
self.game_board = game_board
|
||||
|
||||
def _did_we_win(self):
|
||||
|
||||
@@ -2,34 +2,18 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
import os
|
||||
|
||||
from quart_common.web.env import env_bool, env_int
|
||||
from server.dataset.DatasetIO import DatasetIO
|
||||
|
||||
class RLBootstrapDataset:
|
||||
def __init__(self):
|
||||
self.enabled = self._env_bool("RL_BOOTSTRAP_ENABLED", default=False)
|
||||
self.min_base_rows = self._env_int("RL_MIN_BASE_ROWS", default=5000)
|
||||
self.enabled = env_bool("RL_BOOTSTRAP_ENABLED", default=False)
|
||||
self.min_base_rows = env_int("RL_MIN_BASE_ROWS", default=5000)
|
||||
self.base_dataset_path = Path(os.getenv("RL_BASE_DATASET", "data/dataset/best_moves.jsonl"))
|
||||
self.output_path = Path(os.getenv("RL_BOOTSTRAP_OUTPUT", "data/dataset/rl_bootstrap.jsonl"))
|
||||
self.max_bytes = int(float(os.getenv("RL_BOOTSTRAP_MAX_MB", "50")) * 1024 * 1024)
|
||||
self.needs_more_data = False
|
||||
|
||||
@staticmethod
|
||||
def _env_bool(name:str, default:bool=False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
@staticmethod
|
||||
def _env_int(name:str, default:int) -> int:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
def refresh_state(self):
|
||||
if not self.enabled:
|
||||
self.needs_more_data = False
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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,58 +0,0 @@
|
||||
from server.GameBoard import GameBoard
|
||||
import inspect, pickle
|
||||
|
||||
class RedisGameBoardStore:
|
||||
def __init__(self, redis_url:str="redis://localhost:6379/0", key_prefix:str="snake:gameboard", ttl_seconds:int=900, **kwargs):
|
||||
self.redis_url = redis_url
|
||||
self.key_prefix = key_prefix
|
||||
self.ttl_seconds = max(60, int(ttl_seconds))
|
||||
self._redis = None
|
||||
|
||||
async def _get_redis(self):
|
||||
if self._redis is not None:
|
||||
return self._redis
|
||||
|
||||
try:
|
||||
import redis.asyncio as aioredis # type: ignore[import-not-found]
|
||||
except ImportError as error: # pragma: no cover
|
||||
raise RuntimeError("Redis backend selected but 'redis' package with asyncio support is not installed") from error
|
||||
|
||||
self._redis = aioredis.from_url(self.redis_url)
|
||||
return self._redis
|
||||
|
||||
def _key(self, game_id:str) -> str:
|
||||
return f"{self.key_prefix}:{game_id}"
|
||||
|
||||
async def save(self, game_id:str, game_board:GameBoard) -> None:
|
||||
redis = await self._get_redis()
|
||||
payload = pickle.dumps(game_board, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
await redis.set(self._key(game_id), payload, ex=self.ttl_seconds)
|
||||
|
||||
async def load(self, game_id:str):
|
||||
redis = await self._get_redis()
|
||||
payload = await redis.get(self._key(game_id))
|
||||
if payload is None:
|
||||
return None
|
||||
return pickle.loads(payload)
|
||||
|
||||
async def delete(self, game_id:str) -> None:
|
||||
redis = await self._get_redis()
|
||||
await redis.delete(self._key(game_id))
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._redis is None:
|
||||
return
|
||||
|
||||
aclose_method = getattr(self._redis, "aclose", None)
|
||||
if callable(aclose_method):
|
||||
maybe_result = aclose_method()
|
||||
if inspect.isawaitable(maybe_result):
|
||||
await maybe_result
|
||||
else:
|
||||
close_method = getattr(self._redis, "close", None)
|
||||
if callable(close_method):
|
||||
close_result = close_method()
|
||||
if inspect.isawaitable(close_result):
|
||||
await close_result
|
||||
|
||||
self._redis = None
|
||||
@@ -1,10 +0,0 @@
|
||||
from .MemoryGameBoardStore import MemoryGameBoardStore
|
||||
from .RedisGameBoardStore import RedisGameBoardStore
|
||||
|
||||
class GameStateStoreBuilder:
|
||||
@classmethod
|
||||
def build(self, backend:str="memory", **kwargs) -> MemoryGameBoardStore|RedisGameBoardStore:
|
||||
selected = (backend or "memory").strip().lower()
|
||||
if selected == "redis":
|
||||
return RedisGameBoardStore(**kwargs)
|
||||
return MemoryGameBoardStore(**kwargs)
|
||||
@@ -3,12 +3,11 @@ from server.metrics.backends.Template import StoreTemplate
|
||||
import time
|
||||
|
||||
class MetricsCollector:
|
||||
def __init__(self, metrics_manager:StoreTemplate, game_state_local_cache:bool, metrics_backend:str, game_state_backend:str, stale_game_timeout_sec:int, game_last_seen_unix:dict, game_move_counts:dict):
|
||||
def __init__(self, metrics_manager:StoreTemplate, metrics_backend:str, stale_game_timeout_sec:int, game_last_seen_unix:dict, game_move_counts:dict):
|
||||
self._manager = metrics_manager
|
||||
self._stale_game_timeout_sec = stale_game_timeout_sec
|
||||
self._game_last_seen_unix = game_last_seen_unix
|
||||
self._game_move_counts = game_move_counts
|
||||
self._game_state_backend_is_redis = game_state_backend.strip().lower() == 'redis'
|
||||
self._metrics = {
|
||||
'games_started': 0,
|
||||
'games_ended': 0,
|
||||
@@ -39,7 +38,6 @@ class MetricsCollector:
|
||||
'last_game_end_unix': 0,
|
||||
'last_move_unix': 0,
|
||||
'games_stuck_removed': 0,
|
||||
'game_state_local_cache_enabled': bool(game_state_local_cache),
|
||||
'metrics_backend': metrics_backend,
|
||||
}
|
||||
|
||||
@@ -101,8 +99,6 @@ class MetricsCollector:
|
||||
await self._auto_publish()
|
||||
|
||||
async def record_stuck_removed(self) -> None:
|
||||
if self._game_state_backend_is_redis:
|
||||
return
|
||||
self._metrics['games_stuck_removed'] += 1
|
||||
await self._auto_publish()
|
||||
|
||||
@@ -117,20 +113,6 @@ class MetricsCollector:
|
||||
if now - last_seen >= self._stale_game_timeout_sec
|
||||
)
|
||||
|
||||
if self._game_state_backend_is_redis:
|
||||
# Redis auto-expires stale keys via TTL, so stale games are already gone from the
|
||||
# server's perspective. We exclude them from all metrics so we only report games
|
||||
# that are actually still alive in Redis.
|
||||
report_active_games = len(game_last_seen_unix) - stale_candidates
|
||||
report_stale_candidates = 0
|
||||
# Only include non-stale timestamps when calculating the oldest active game age,
|
||||
# so a game that Redis already deleted doesn't inflate the age metric.
|
||||
active_last_seen = [
|
||||
last_seen
|
||||
for last_seen in game_last_seen_unix.values()
|
||||
if now - last_seen < self._stale_game_timeout_sec
|
||||
]
|
||||
else:
|
||||
report_active_games = len(game_last_seen_unix)
|
||||
report_stale_candidates = stale_candidates
|
||||
active_last_seen = list(game_last_seen_unix.values())
|
||||
|
||||
@@ -79,7 +79,6 @@ class StoreTemplate:
|
||||
"last_game_end_unix": 0,
|
||||
"last_move_unix": 0,
|
||||
"games_stuck_removed": 0,
|
||||
"game_state_local_cache_enabled": False,
|
||||
"metrics_backend": "redis",
|
||||
"active_games": 0,
|
||||
"tracked_games": 0,
|
||||
@@ -122,7 +121,6 @@ class StoreTemplate:
|
||||
merged["last_move_unix"] = max(merged["last_move_unix"], int(worker.get("last_move_unix", 0)))
|
||||
merged["oldest_active_game_age_sec"] = max(merged["oldest_active_game_age_sec"], int(worker.get("oldest_active_game_age_sec", 0)))
|
||||
merged["stale_game_timeout_sec"] = max(merged["stale_game_timeout_sec"], int(worker.get("stale_game_timeout_sec", 0)))
|
||||
merged["game_state_local_cache_enabled"] = merged["game_state_local_cache_enabled"] or bool(worker.get("game_state_local_cache_enabled", False))
|
||||
|
||||
for endpoint in merged["http_requests_by_endpoint"]:
|
||||
merged["http_requests_by_endpoint"][endpoint] += int(worker.get("http_requests_by_endpoint", {}).get(endpoint, 0))
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from .dashboard_events import DashboardEventsService
|
||||
from .dashboard_ws_hub import DashboardWebSocketHub
|
||||
from .game_runtime import GameRuntimeService
|
||||
from .gameplay_tracking import GameplayTrackingService
|
||||
from .dashboard_query import DashboardQueryService
|
||||
@@ -0,0 +1,127 @@
|
||||
from quart_common.web.logger import await_log
|
||||
|
||||
from typing import Awaitable, Callable
|
||||
import asyncio, inspect, json, time
|
||||
|
||||
class DashboardEventsService:
|
||||
def __init__(self, enabled:bool, redis_url:str, channel:str, event_origin:str, shutdown_event:asyncio.Event, on_notice:Callable[[str], Awaitable[None]], logger):
|
||||
self.enabled = enabled
|
||||
self.redis_url = redis_url
|
||||
self.channel = channel
|
||||
self.event_origin = event_origin
|
||||
self.shutdown_event = shutdown_event
|
||||
self.on_notice = on_notice
|
||||
self.logger = logger
|
||||
|
||||
self.listener_task:asyncio.Task|None=None
|
||||
self.redis = None
|
||||
self.pubsub = None
|
||||
|
||||
async def start_listener(self) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
if self.listener_task is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
import redis.asyncio as aioredis # type: ignore[import-not-found]
|
||||
|
||||
self.redis = aioredis.from_url(self.redis_url)
|
||||
self.pubsub = self.redis.pubsub()
|
||||
await self.pubsub.subscribe(self.channel)
|
||||
self.listener_task = asyncio.create_task(self._listener_loop())
|
||||
except Exception as error:
|
||||
self.listener_task = None
|
||||
self.pubsub = None
|
||||
self.redis = None
|
||||
await await_log(self.logger.warning(f'Dashboard events listener disabled (redis unavailable): {error}'))
|
||||
|
||||
async def stop_listener(self) -> None:
|
||||
listener_task = self.listener_task
|
||||
self.listener_task = None
|
||||
if listener_task is not None:
|
||||
listener_task.cancel()
|
||||
await asyncio.gather(listener_task, return_exceptions=True)
|
||||
|
||||
pubsub = self.pubsub
|
||||
self.pubsub = None
|
||||
if pubsub is not None:
|
||||
try:
|
||||
await pubsub.unsubscribe(self.channel)
|
||||
except Exception:
|
||||
pass
|
||||
close_method = getattr(pubsub, 'aclose', None)
|
||||
if callable(close_method):
|
||||
try:
|
||||
maybe_result = close_method()
|
||||
if inspect.isawaitable(maybe_result):
|
||||
await maybe_result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
redis_client = self.redis
|
||||
self.redis = None
|
||||
if redis_client is not None:
|
||||
close_method = getattr(redis_client, 'aclose', None)
|
||||
if callable(close_method):
|
||||
try:
|
||||
maybe_result = close_method()
|
||||
if inspect.isawaitable(maybe_result):
|
||||
await maybe_result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def publish_notice(self, trigger:str) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
if self.redis is None:
|
||||
return
|
||||
if trigger not in {'game_saved', 'stale_finalized', 'manual'}:
|
||||
return
|
||||
|
||||
message = {
|
||||
'type': 'dashboard_games_update_notice',
|
||||
'origin': self.event_origin,
|
||||
'trigger': trigger,
|
||||
'sent_at': int(time.time()),
|
||||
}
|
||||
try:
|
||||
await self.redis.publish(self.channel, json.dumps(message))
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Dashboard events publish failed: {error}'))
|
||||
|
||||
async def _listener_loop(self) -> None:
|
||||
pubsub = self.pubsub
|
||||
if pubsub is None:
|
||||
return
|
||||
|
||||
try:
|
||||
while not self.shutdown_event.is_set():
|
||||
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
||||
if message is None:
|
||||
continue
|
||||
|
||||
raw_data = message.get('data')
|
||||
if isinstance(raw_data, bytes):
|
||||
payload_raw = raw_data.decode('utf-8', errors='replace')
|
||||
else:
|
||||
payload_raw = str(raw_data)
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if payload.get('type') != 'dashboard_games_update_notice':
|
||||
continue
|
||||
if payload.get('origin') == self.event_origin:
|
||||
continue
|
||||
|
||||
notice_trigger = str(payload.get('trigger') or 'game_saved')
|
||||
await self.on_notice(notice_trigger)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Dashboard events listener stopped unexpectedly: {error}'))
|
||||
@@ -0,0 +1,144 @@
|
||||
from quart_common.web.logger import await_log, logging
|
||||
|
||||
from typing import Awaitable, Callable
|
||||
import json
|
||||
|
||||
from .dashboard_ws_hub import DashboardWebSocketHub
|
||||
from server.database import GameplayDatabase
|
||||
|
||||
class DashboardQueryService:
|
||||
def __init__(self, gameplay_database:GameplayDatabase, ws_hub:DashboardWebSocketHub, logger:logging, dashboard_running_game_stale_sec:int):
|
||||
self.gameplay_database = gameplay_database
|
||||
self.ws_hub = ws_hub
|
||||
self.logger = logger
|
||||
self.dashboard_running_game_stale_sec = dashboard_running_game_stale_sec
|
||||
self.publish_notice:Callable[[str], Awaitable[None]] | None = None
|
||||
|
||||
def set_publish_notice(self, publish_notice:Callable[[str], Awaitable[None]]) -> None:
|
||||
self.publish_notice = publish_notice
|
||||
|
||||
async def on_dashboard_games_update_notice(self, trigger:str) -> None:
|
||||
await self.push_dashboard_games_update(
|
||||
game_state=None,
|
||||
publish_cluster=False,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
async def build_dashboard_games_event(self, game_state:dict|None=None, trigger_override:str|None=None) -> dict:
|
||||
games_payload = await self.get_dashboard_games(limit=100)
|
||||
summary_payload = await self.get_dashboard_summary()
|
||||
game_id = None
|
||||
if game_state is not None:
|
||||
game_id = game_state.get('game', {}).get('id')
|
||||
trigger = trigger_override or ('game_saved' if game_id else 'snapshot')
|
||||
|
||||
return {
|
||||
'type': 'dashboard_games_update',
|
||||
'trigger': trigger,
|
||||
'games': games_payload,
|
||||
'summary': summary_payload,
|
||||
}
|
||||
|
||||
async def build_dashboard_game_replay_event(self, game_id:str, request_id:str|None=None) -> dict:
|
||||
replay_payload = await self.get_dashboard_game_replay(game_id)
|
||||
if replay_payload is None:
|
||||
return {
|
||||
'type': 'dashboard_game_replay',
|
||||
'request_id': request_id,
|
||||
'game_id': game_id,
|
||||
'error': 'game_not_found',
|
||||
}
|
||||
|
||||
return {
|
||||
'type': 'dashboard_game_replay',
|
||||
'request_id': request_id,
|
||||
'game_id': game_id,
|
||||
'replay': replay_payload,
|
||||
}
|
||||
|
||||
async def handle_dashboard_ws_request(self, payload_raw:object) -> dict|None:
|
||||
if not isinstance(payload_raw, str):
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
if payload.get('type') != 'dashboard_game_replay_request':
|
||||
return None
|
||||
|
||||
game_id = str(payload.get('game_id') or '').strip()
|
||||
request_id_raw = payload.get('request_id')
|
||||
request_id = None if request_id_raw is None else str(request_id_raw)
|
||||
if game_id == '':
|
||||
return {
|
||||
'type': 'dashboard_game_replay',
|
||||
'request_id': request_id,
|
||||
'error': 'missing_game_id',
|
||||
}
|
||||
|
||||
return await self.build_dashboard_game_replay_event(
|
||||
game_id=game_id,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
async def push_dashboard_games_update(self, game_state:dict|None=None, publish_cluster:bool=True, trigger:str|None=None) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
event_payload = await self.build_dashboard_games_event(
|
||||
game_state,
|
||||
trigger_override=trigger,
|
||||
)
|
||||
await self.ws_hub.broadcast_payload(event_payload)
|
||||
if publish_cluster and self.publish_notice is not None:
|
||||
await self.publish_notice(str(event_payload.get('trigger') or ''))
|
||||
|
||||
async def get_dashboard_summary(self) -> dict:
|
||||
if self.gameplay_database is None:
|
||||
return {'enabled': False}
|
||||
try:
|
||||
await self._finalize_stale_dashboard_games()
|
||||
summary = await self.gameplay_database.get_summary()
|
||||
summary['enabled'] = True
|
||||
return summary
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB summary failed:{error}'))
|
||||
return {'enabled': True, 'error': ' summary_unavailable'}
|
||||
|
||||
async def get_dashboard_games(self, limit:int=50) -> dict:
|
||||
if self.gameplay_database is None:
|
||||
return {'enabled': False, 'games': []}
|
||||
try:
|
||||
await self._finalize_stale_dashboard_games()
|
||||
games = await self.gameplay_database.list_games(limit=limit)
|
||||
return {'enabled': True, 'games': games}
|
||||
except Exception as error:
|
||||
await await_log(
|
||||
self.logger.warning(f'Gameplay DB game list failed:{error}')
|
||||
)
|
||||
return {'enabled': True, 'error': 'games_unavailable', 'games': []}
|
||||
|
||||
async def get_dashboard_game_replay(self, game_id:str) -> dict|None:
|
||||
if self.gameplay_database is None:
|
||||
return {'enabled': False, 'error': 'database_disabled', 'game_id': game_id}
|
||||
try:
|
||||
replay = await self.gameplay_database.get_game_replay(game_id)
|
||||
if replay is None:
|
||||
return None
|
||||
replay['enabled'] = True
|
||||
return replay
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB replay failed:{error}'))
|
||||
return {'enabled': True, 'error': 'replay_unavailable', 'game_id': game_id}
|
||||
|
||||
async def _finalize_stale_dashboard_games(self) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
try:
|
||||
await self.gameplay_database.finalize_stale_running_games(stale_after_seconds=self.dashboard_running_game_stale_sec)
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f'Gameplay DB stale running game finalize failed:{error}'))
|
||||
@@ -0,0 +1,61 @@
|
||||
import asyncio, json
|
||||
|
||||
class DashboardWebSocketHub:
|
||||
def __init__(self):
|
||||
self.subscribers:set[asyncio.Queue[str]] = set()
|
||||
self.subscribers_lock = asyncio.Lock()
|
||||
|
||||
self.ws_tasks:set[asyncio.Task] = set()
|
||||
self.ws_tasks_lock = asyncio.Lock()
|
||||
|
||||
self.shutdown_event = asyncio.Event()
|
||||
self.shutdown_message = json.dumps({"type": "dashboard_ws_shutdown"})
|
||||
|
||||
async def register_subscriber(self, subscriber_queue:asyncio.Queue[str]) -> None:
|
||||
async with self.subscribers_lock:
|
||||
self.subscribers.add(subscriber_queue)
|
||||
|
||||
async def unregister_subscriber(self, subscriber_queue:asyncio.Queue[str]) -> None:
|
||||
async with self.subscribers_lock:
|
||||
self.subscribers.discard(subscriber_queue)
|
||||
|
||||
async def register_task(self, websocket_task:asyncio.Task) -> None:
|
||||
async with self.ws_tasks_lock:
|
||||
self.ws_tasks.add(websocket_task)
|
||||
|
||||
async def unregister_task(self, websocket_task:asyncio.Task) -> None:
|
||||
async with self.ws_tasks_lock:
|
||||
self.ws_tasks.discard(websocket_task)
|
||||
|
||||
async def broadcast_payload(self, payload:dict) -> None:
|
||||
encoded_payload = json.dumps(payload)
|
||||
async with self.subscribers_lock:
|
||||
subscribers = tuple(self.subscribers)
|
||||
|
||||
for subscriber_queue in subscribers:
|
||||
if subscriber_queue.full():
|
||||
try:
|
||||
subscriber_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
|
||||
try:
|
||||
subscriber_queue.put_nowait(encoded_payload)
|
||||
except asyncio.QueueFull:
|
||||
continue
|
||||
|
||||
def request_shutdown(self) -> None:
|
||||
if self.shutdown_event.is_set():
|
||||
return
|
||||
|
||||
self.shutdown_event.set()
|
||||
for subscriber_queue in tuple(self.subscribers):
|
||||
if subscriber_queue.full():
|
||||
try:
|
||||
subscriber_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
subscriber_queue.put_nowait(self.shutdown_message)
|
||||
except asyncio.QueueFull:
|
||||
continue
|
||||
@@ -0,0 +1,81 @@
|
||||
import time
|
||||
|
||||
from server.metrics import MetricsCollector
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
from snakes import SnakeBuilder
|
||||
|
||||
class GameRuntimeService:
|
||||
def __init__(self, snake_type:str, stale_game_timeout_sec:int):
|
||||
self.snake_type = snake_type
|
||||
self.stale_game_timeout_sec = stale_game_timeout_sec
|
||||
self.metrics_collector = None
|
||||
|
||||
self.running_games: dict[str, GameBoard] = {}
|
||||
self.game_move_counts: dict[str, int] = {}
|
||||
self.game_last_seen_unix: dict[str, int] = {}
|
||||
|
||||
def attach_metrics_collector(self, metrics_collector:MetricsCollector) -> None:
|
||||
self.metrics_collector = metrics_collector
|
||||
|
||||
async def create_game_board(self, game_state:dict) -> GameBoard:
|
||||
game_id = game_state['game']['id']
|
||||
new_game_board = GameBoard(
|
||||
game_id=game_id,
|
||||
width=game_state['board']['width'],
|
||||
height=game_state['board']['height'],
|
||||
ruleset=game_state['game']['ruleset'],
|
||||
source=game_state['game']['source'],
|
||||
map=game_state['game']['map'],
|
||||
snake_class=SnakeBuilder.build(self.snake_type),
|
||||
)
|
||||
await new_game_board.start_game(game_state)
|
||||
|
||||
self.running_games[game_id] = new_game_board
|
||||
self.game_move_counts[game_id] = 0
|
||||
self.game_last_seen_unix[game_id] = int(time.time())
|
||||
if self.metrics_collector is not None:
|
||||
await self.metrics_collector.record_game_started(len(self.game_last_seen_unix))
|
||||
return new_game_board
|
||||
|
||||
async def delete_game_board(self, game_state:dict) -> None:
|
||||
game_id = game_state['game']['id']
|
||||
self.running_games.pop(game_id, None)
|
||||
self.game_move_counts.pop(game_id, None)
|
||||
self.game_last_seen_unix.pop(game_id, None)
|
||||
|
||||
async def get_game_board(self, game_state:dict, end:bool=False) -> GameBoard:
|
||||
game_id = game_state['game']['id']
|
||||
if game_id in self.running_games:
|
||||
game_board = self.running_games[game_id]
|
||||
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:
|
||||
self.game_move_counts[game_id] = self.game_move_counts.get(game_id, 0) + 1
|
||||
self.game_last_seen_unix[game_id] = int(time.time())
|
||||
|
||||
game_board.read_game_data(game_state)
|
||||
if end:
|
||||
game_board.end_game(game_state)
|
||||
|
||||
return game_board
|
||||
|
||||
async def prune_stale_games(self) -> None:
|
||||
if not self.game_last_seen_unix:
|
||||
return
|
||||
|
||||
now = int(time.time())
|
||||
stale_ids = [
|
||||
game_id
|
||||
for game_id, last_seen in self.game_last_seen_unix.items()
|
||||
if now - last_seen >= self.stale_game_timeout_sec
|
||||
]
|
||||
for game_id in stale_ids:
|
||||
self.running_games.pop(game_id, None)
|
||||
self.game_move_counts.pop(game_id, None)
|
||||
self.game_last_seen_unix.pop(game_id, None)
|
||||
if self.metrics_collector is not None:
|
||||
await self.metrics_collector.record_stuck_removed()
|
||||
@@ -0,0 +1,50 @@
|
||||
from quart_common.web.logger import await_log, logging
|
||||
|
||||
from server.database import GameplayDatabase
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
class GameplayTrackingService:
|
||||
def __init__(self, gameplay_database:GameplayDatabase, logger:logging):
|
||||
self.gameplay_database = gameplay_database
|
||||
self.logger = logger
|
||||
|
||||
async def record_gameplay_start(self, game_state:dict, game_board:GameBoard) -> None:
|
||||
snake_name, snake_version = game_board.get_snake_name_and_version()
|
||||
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
try:
|
||||
await self.gameplay_database.record_game_start(
|
||||
game_state,
|
||||
snake_type=snake_name,
|
||||
snake_version=snake_version,
|
||||
)
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f"Gameplay DB start record failed:{error}"))
|
||||
|
||||
async def record_gameplay_turn(self, game_state:dict, my_move:str, game_board:GameBoard) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
try:
|
||||
thinking = self._extract_latest_snake_thinking(game_board)
|
||||
await self.gameplay_database.record_turn(game_state, my_move, thinking)
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f"Gameplay DB turn record failed:{error}"))
|
||||
|
||||
async def record_gameplay_end(self, game_state:dict) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
try:
|
||||
await self.gameplay_database.record_game_end(game_state)
|
||||
except Exception as error:
|
||||
await await_log(self.logger.warning(f"Gameplay DB end record failed:{error}"))
|
||||
|
||||
def _extract_latest_snake_thinking(self, game_board:GameBoard) -> dict|None:
|
||||
try:
|
||||
history = game_board.snake_class.get_history()
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(history, list) or len(history) == 0:
|
||||
return None
|
||||
latest = history[-1]
|
||||
return latest if isinstance(latest, dict) else None
|
||||
@@ -1,6 +0,0 @@
|
||||
class StorageLoader:
|
||||
@classmethod
|
||||
def build(self, selected_storage: str):
|
||||
storage_module = __import__(f"server.storage.{selected_storage}", fromlist=[selected_storage])
|
||||
storage_class = getattr(storage_module, selected_storage)
|
||||
return storage_class
|
||||
@@ -4,6 +4,7 @@ from typing import Any, cast
|
||||
from time import perf_counter
|
||||
import os
|
||||
|
||||
from quart_common.web.env import env_int
|
||||
from server.dataset.RLBootstrapDataset import RLBootstrapDataset
|
||||
|
||||
from snakes.TemplateSnake import TemplateSnake
|
||||
@@ -43,9 +44,9 @@ class BestBattleSnake(TemplateSnake):
|
||||
self.duel_style = self._get_duel_style()
|
||||
self.timeout_buffer_ms = self._get_timeout_buffer_ms()
|
||||
self.rl_bootstrap = RLBootstrapDataset()
|
||||
self.future_planning_depth = max(1, min(4, self._env_int("BATTLE_FUTURE_PLANNING_DEPTH", default=2)))
|
||||
self.future_planning_branch = max(1, min(3, self._env_int("BATTLE_FUTURE_PLANNING_BRANCH", default=2)))
|
||||
self.future_planning_min_time_ms = max(25, self._env_int("BATTLE_FUTURE_PLANNING_MIN_MS", default=70))
|
||||
self.future_planning_depth = max(1, min(4, env_int("BATTLE_FUTURE_PLANNING_DEPTH", default=2)))
|
||||
self.future_planning_branch = max(1, min(3, env_int("BATTLE_FUTURE_PLANNING_BRANCH", default=2)))
|
||||
self.future_planning_min_time_ms = max(25, env_int("BATTLE_FUTURE_PLANNING_MIN_MS", default=70))
|
||||
|
||||
def _get_duel_style(self) -> str:
|
||||
"""Resolve duel tuning style from `BATTLE_SNAKE_DUEL_STYLE` or `DUEL_STYLE`."""
|
||||
@@ -86,15 +87,6 @@ class BestBattleSnake(TemplateSnake):
|
||||
except ValueError:
|
||||
return 120
|
||||
|
||||
def _env_int(self, name:str, default:int) -> int:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
def choose_move(self, game_data:GameBoard) -> str:
|
||||
"""Pick the next move from a Battlesnake move request.
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
import random
|
||||
|
||||
class TemplateSnake:
|
||||
@@ -22,7 +26,7 @@ class TemplateSnake:
|
||||
def add_calculations(self, calculations:dict):
|
||||
self.calculations.append(calculations)
|
||||
|
||||
def choose_move(self, game_data:GameBoard):
|
||||
def choose_move(self, game_data:'GameBoard'):
|
||||
self.game_board = game_data
|
||||
self.calculations = []
|
||||
self.eat_the_snake_overwrite = False
|
||||
@@ -198,3 +202,12 @@ class TemplateSnake:
|
||||
def set_target_food(self, target_food:dict):
|
||||
self.target_food = target_food
|
||||
return True
|
||||
|
||||
def __getstate__(self):
|
||||
state = self.__dict__.copy()
|
||||
state['history'] = [] # strip history — grows per turn, not needed for moves
|
||||
state.pop('game_board', None) # re-set at top of every choose_move; circular ref
|
||||
state.pop('calculations', None) # re-initialised at top of every choose_move
|
||||
state.pop('eat_the_snake_overwrite', None) # re-initialised at top of every choose_move
|
||||
state.pop('kill_the_snake', None) # per-call transient
|
||||
return state
|
||||
|
||||
@@ -4,6 +4,8 @@ from typing import Any, cast
|
||||
from time import perf_counter
|
||||
import heapq, os
|
||||
|
||||
from quart_common.web.env import env_int
|
||||
|
||||
from snakes.TemplateSnake import TemplateSnake
|
||||
from server.GameBoard import GameBoard
|
||||
from server.dataset.RLBootstrapDataset import RLBootstrapDataset
|
||||
@@ -92,12 +94,23 @@ class UltimateBattleSnake(TemplateSnake):
|
||||
self._bfs_cache: dict[tuple, int] = {}
|
||||
self._bfs_cache_turn: int = -1
|
||||
# Config
|
||||
self._planning_depth = max(1, min(4, self._env_int("BATTLE_FUTURE_PLANNING_DEPTH", 2)))
|
||||
self._planning_branch = max(1, min(3, self._env_int("BATTLE_FUTURE_PLANNING_BRANCH", 2)))
|
||||
self._planning_min_ms = max(25, self._env_int("BATTLE_FUTURE_PLANNING_MIN_MS", 70))
|
||||
self._planning_depth = max(1, min(4, env_int("BATTLE_FUTURE_PLANNING_DEPTH", 2)))
|
||||
self._planning_branch = max(1, min(3, env_int("BATTLE_FUTURE_PLANNING_BRANCH", 2)))
|
||||
self._planning_min_ms = max(25, env_int("BATTLE_FUTURE_PLANNING_MIN_MS", 70))
|
||||
# RL bootstrap dataset recorder
|
||||
self.rl_bootstrap = RLBootstrapDataset()
|
||||
|
||||
def __getstate__(self):
|
||||
state = super().__getstate__()
|
||||
# strip per-turn precomputed state — all re-assigned at the top of choose_move
|
||||
state['_enemy_dmaps'] = []
|
||||
state['_enemy_heads'] = []
|
||||
state['_base_blocked'] = set()
|
||||
state['_is_snail'] = False
|
||||
state['_bfs_cache'] = {}
|
||||
state['_bfs_cache_turn'] = -1
|
||||
return state
|
||||
|
||||
# ── Env helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_timeout_buffer_ms(self) -> int:
|
||||
@@ -106,12 +119,6 @@ class UltimateBattleSnake(TemplateSnake):
|
||||
except ValueError:
|
||||
return 130
|
||||
|
||||
def _env_int(self, name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.getenv(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
def _get_duel_style(self) -> str:
|
||||
raw = os.getenv("BATTLE_SNAKE_DUEL_STYLE", os.getenv("DUEL_STYLE", "balanced"))
|
||||
style = raw.strip().lower()
|
||||
@@ -138,7 +145,7 @@ class UltimateBattleSnake(TemplateSnake):
|
||||
self.game_board = game_data
|
||||
self.calculations = []
|
||||
|
||||
timeout_ms = game_data.get_timeout() if hasattr(game_data, "get_timeout") else 500
|
||||
timeout_ms = (game_data.get_timeout() if hasattr(game_data, "get_timeout") else 500)
|
||||
deadline = perf_counter() + (max(50, timeout_ms - self._get_timeout_buffer_ms()) / 1000.0)
|
||||
|
||||
game_id = getattr(game_data, "id", None)
|
||||
|
||||
@@ -9,6 +9,15 @@ SNAKE_REGISTRY = {
|
||||
"BestBattleSnake": "2.6.0",
|
||||
"TrainedBattleSnake": "0.1.0",
|
||||
"UltimateBattleSnake": "4.5.0",
|
||||
"ApexBattleSnake": "1.0.0",
|
||||
}
|
||||
|
||||
DEFAULT_SNAKE_CONFIG = {
|
||||
'apiversion': '1',
|
||||
'author': '',
|
||||
'color': '#888888',
|
||||
'head': 'default',
|
||||
'tail': 'default',
|
||||
}
|
||||
|
||||
def build_snake(selected_snake:str):
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-1: #f2eee6;
|
||||
--bg-2: #e7dcc8;
|
||||
--panel: #fffcf6;
|
||||
--line: #d9ccb6;
|
||||
--ink: #252119;
|
||||
--muted: #6f6657;
|
||||
--accent: #146a4b;
|
||||
--accent-soft: #e5f2ed;
|
||||
--danger: #b0492a;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #fffdf8;
|
||||
--row-hover: #fdf4e7;
|
||||
--row-active: #edf8f3;
|
||||
--shadow: rgba(41, 29, 11, 0.08);
|
||||
--you: #1a7a56;
|
||||
--enemy: #bf5b33;
|
||||
--snake-1: #bf5b33;
|
||||
--snake-2: #2f6fdd;
|
||||
--snake-3: #8d4ad6;
|
||||
--snake-4: #cc7a11;
|
||||
--snake-5: #0f8f84;
|
||||
--snake-6: #be3f70;
|
||||
--snake-7: #6b8a12;
|
||||
--snake-8: #9a4a2f;
|
||||
--snake-9: #2e8698;
|
||||
--snake-10: #7f5fdd;
|
||||
--food: #cca100;
|
||||
--hazard: #6a5a9b;
|
||||
--grid: #e6dbc8;
|
||||
--cell: #ffffff;
|
||||
--head-ring: #111111;
|
||||
--mono-bg: #1d1b18;
|
||||
--mono-ink: #ecdfcb;
|
||||
--mono-vh-offset: 430px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-1: #151819;
|
||||
--bg-2: #1b2022;
|
||||
--panel: #1f2527;
|
||||
--line: #374144;
|
||||
--ink: #e6e8e9;
|
||||
--muted: #a8b1b3;
|
||||
--accent: #4ec894;
|
||||
--accent-soft: #233e35;
|
||||
--danger: #d1734f;
|
||||
--surface: #232b2e;
|
||||
--surface-soft: #273134;
|
||||
--row-hover: #2b3538;
|
||||
--row-active: #224338;
|
||||
--shadow: rgba(0, 0, 0, 0.35);
|
||||
--you: #4ec894;
|
||||
--enemy: #e2815a;
|
||||
--snake-1: #e2815a;
|
||||
--snake-2: #7ea8ff;
|
||||
--snake-3: #c198ff;
|
||||
--snake-4: #f2b857;
|
||||
--snake-5: #67d2c8;
|
||||
--snake-6: #ea86ad;
|
||||
--snake-7: #b8d86b;
|
||||
--snake-8: #e29d83;
|
||||
--snake-9: #75c9da;
|
||||
--snake-10: #b7a0ff;
|
||||
--food: #ebc14b;
|
||||
--hazard: #9b86d8;
|
||||
--grid: #3b464a;
|
||||
--cell: #1a2022;
|
||||
--head-ring: #f3f5f6;
|
||||
--mono-bg: #101416;
|
||||
--mono-ink: #dce7e9;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background: linear-gradient(180deg, var(--bg-1), var(--bg-2));
|
||||
}
|
||||
|
||||
.page {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
box-shadow: 0 8px 28px var(--shadow);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.12rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(90px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
border: 1px solid #eadfcd;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat .k {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat .v {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 330px 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 28px var(--shadow);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eadfcd;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.games {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #efe5d5;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
tbody tr.active {
|
||||
background: var(--row-active);
|
||||
}
|
||||
|
||||
#games-body tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.right {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
padding: 2px 8px 1px;
|
||||
border-bottom: 1px solid #eadfcd;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
line-height: 1;
|
||||
min-height: 0;
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.controls>* {
|
||||
margin: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid #d2c3ab;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
border-radius: 6px;
|
||||
padding: 2px 7px;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.controls label span {
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.controls select {
|
||||
height: 24px;
|
||||
font-size: 0.8rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.controls input[type="range"] {
|
||||
width: 150px;
|
||||
min-width: 130px;
|
||||
margin: 0;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: #0f5a3f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#prev-btn,
|
||||
#next-btn {
|
||||
width: 52px;
|
||||
min-width: 52px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#play-btn {
|
||||
width: 62px;
|
||||
min-width: 62px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="range"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.turn-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 42%) 1fr;
|
||||
gap: 12px;
|
||||
padding: 8px 12px 12px;
|
||||
align-items: stretch;
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 8px;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: thin;
|
||||
font-size: 0.74rem;
|
||||
color: var(--muted);
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin-right: 5px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.board {
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
background: var(--grid);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.cell {
|
||||
background: var(--cell);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
aspect-ratio: 1 / 1;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.snake-turn-cell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--turn-color, transparent);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 50% = quarter-circle at the inner corner of the bend */
|
||||
.snake-turn-cell.snake-turn-ur::after {
|
||||
border-top-right-radius: 50%;
|
||||
}
|
||||
|
||||
.snake-turn-cell.snake-turn-ul::after {
|
||||
border-top-left-radius: 50%;
|
||||
}
|
||||
|
||||
.snake-turn-cell.snake-turn-dr::after {
|
||||
border-bottom-right-radius: 50%;
|
||||
}
|
||||
|
||||
.snake-turn-cell.snake-turn-dl::after {
|
||||
border-bottom-left-radius: 50%;
|
||||
}
|
||||
|
||||
.food {
|
||||
background-image: radial-gradient(circle at center, #d73a31 0 45%, transparent 48%);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 78% 78%;
|
||||
}
|
||||
|
||||
.hazard {
|
||||
background-color: var(--hazard);
|
||||
}
|
||||
|
||||
.hazard::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 10, 50, 0.38) repeating-linear-gradient(135deg,
|
||||
rgba(80, 60, 140, 0.6) 0,
|
||||
rgba(80, 60, 140, 0.6) 2px,
|
||||
transparent 2px,
|
||||
transparent 6px);
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.snake-you {
|
||||
background: var(--you);
|
||||
}
|
||||
|
||||
.snake-enemy {
|
||||
background: var(--enemy);
|
||||
}
|
||||
|
||||
.snake-head {
|
||||
outline: 2px solid var(--head-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.snake-head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 30%;
|
||||
top: 30%;
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
border-radius: 50%;
|
||||
background: var(--head-ring);
|
||||
opacity: 0.9;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.snake-head.head-style-1::after {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.snake-head.head-style-2::after {
|
||||
border-radius: 2px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.snake-head.head-style-3::after {
|
||||
width: 52%;
|
||||
height: 28%;
|
||||
top: 36%;
|
||||
left: 24%;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.snake-head.head-style-4::after {
|
||||
width: 24%;
|
||||
height: 56%;
|
||||
top: 22%;
|
||||
left: 38%;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.snake-head.head-style-5::after {
|
||||
width: 46%;
|
||||
height: 46%;
|
||||
top: 27%;
|
||||
left: 27%;
|
||||
clip-path: polygon(50% 0, 100% 100%, 0 100%);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.snake-head.has-head-icon::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.snake-head.has-head-icon {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.snake-tail-you::after,
|
||||
.snake-tail-enemy::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 6%;
|
||||
top: 32%;
|
||||
width: 38%;
|
||||
height: 36%;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.snake-tail-you.tail-style-1::after,
|
||||
.snake-tail-enemy.tail-style-1::after {
|
||||
width: 38%;
|
||||
height: 36%;
|
||||
}
|
||||
|
||||
.snake-tail-you.tail-style-2::after,
|
||||
.snake-tail-enemy.tail-style-2::after {
|
||||
width: 24%;
|
||||
height: 56%;
|
||||
right: 10%;
|
||||
top: 22%;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.snake-tail-you.tail-style-3::after,
|
||||
.snake-tail-enemy.tail-style-3::after {
|
||||
width: 44%;
|
||||
height: 24%;
|
||||
right: 8%;
|
||||
top: 38%;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.snake-tail-you.tail-style-4::after,
|
||||
.snake-tail-enemy.tail-style-4::after {
|
||||
width: 34%;
|
||||
height: 34%;
|
||||
right: 10%;
|
||||
top: 32%;
|
||||
transform: rotate(45deg);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.snake-tail-you.tail-style-5::after,
|
||||
.snake-tail-enemy.tail-style-5::after {
|
||||
width: 42%;
|
||||
height: 42%;
|
||||
right: 8%;
|
||||
top: 29%;
|
||||
clip-path: polygon(100% 50%, 0 0, 0 100%);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.snake-tail-you.has-tail-icon::after,
|
||||
.snake-tail-enemy.has-tail-icon::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.snake-tail-you.has-tail-icon,
|
||||
.snake-tail-enemy.has-tail-icon {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.icon-layer {
|
||||
position: absolute;
|
||||
inset: 2%;
|
||||
background: var(--icon-color, currentColor);
|
||||
-webkit-mask-image: var(--icon-url);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
mask-image: var(--icon-url);
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
transform: var(--icon-transform, rotate(0deg));
|
||||
transform-origin: center;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.icon-layer--tail {
|
||||
z-index: 2;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.icon-layer--head {
|
||||
z-index: 3;
|
||||
opacity: 1;
|
||||
background: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.icon-layer--head>svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.thinking {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #e8dcc8;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.raw-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.think-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid #ebdfcb;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.chip .k {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chip .v {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.reason-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.score-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.score-table td,
|
||||
.score-table th {
|
||||
border-bottom: 1px solid #f0e7d7;
|
||||
padding: 6px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.snake-row {
|
||||
background: var(--snake-row-bg, transparent);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, filter 0.15s;
|
||||
}
|
||||
|
||||
.snake-row td {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.snake-row td:first-child {
|
||||
border-left: 4px solid var(--snake-row-color, transparent);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.snake-row.highlighted {
|
||||
outline: 2px solid var(--snake-row-color, var(--line));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.snakes-section.has-highlight .snake-row:not(.highlighted) {
|
||||
opacity: 0.25;
|
||||
filter: grayscale(0.6);
|
||||
}
|
||||
|
||||
.snake-row.dead-row {
|
||||
filter: grayscale(0.55);
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.num-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.health-wrap {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 120, 120, 0.18);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.health-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
transition: width 120ms linear;
|
||||
}
|
||||
|
||||
.health-text {
|
||||
margin-left: 6px;
|
||||
font-size: 0.74rem;
|
||||
color: inherit;
|
||||
opacity: 0.82;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mono {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", "Consolas", monospace;
|
||||
font-size: 0.75rem;
|
||||
background: var(--mono-bg);
|
||||
color: var(--mono-ink);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
width: -webkit-fill-available;
|
||||
height: -webkit-fill-available;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
resize: none;
|
||||
max-height: calc(100vh - var(--mono-vh-offset));
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.topbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats {
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(6, minmax(80px, 1fr));
|
||||
}
|
||||
|
||||
.main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.games {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.turn-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.think-grid {
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.think-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 748 B After Width: | Height: | Size: 748 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 904 B After Width: | Height: | Size: 904 B |
|
Before Width: | Height: | Size: 174 B After Width: | Height: | Size: 174 B |
|
Before Width: | Height: | Size: 523 B After Width: | Height: | Size: 523 B |
|
Before Width: | Height: | Size: 671 B After Width: | Height: | Size: 671 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 875 B After Width: | Height: | Size: 875 B |
|
Before Width: | Height: | Size: 1020 B After Width: | Height: | Size: 1020 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 315 B After Width: | Height: | Size: 315 B |
|
Before Width: | Height: | Size: 501 B After Width: | Height: | Size: 501 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 416 B After Width: | Height: | Size: 416 B |
|
Before Width: | Height: | Size: 256 B After Width: | Height: | Size: 256 B |
|
Before Width: | Height: | Size: 610 B After Width: | Height: | Size: 610 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 682 B After Width: | Height: | Size: 682 B |
|
Before Width: | Height: | Size: 504 B After Width: | Height: | Size: 504 B |
|
Before Width: | Height: | Size: 637 B After Width: | Height: | Size: 637 B |
|
Before Width: | Height: | Size: 80 B After Width: | Height: | Size: 80 B |
|
Before Width: | Height: | Size: 224 B After Width: | Height: | Size: 224 B |
|
Before Width: | Height: | Size: 313 B After Width: | Height: | Size: 313 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 797 B After Width: | Height: | Size: 797 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 494 B After Width: | Height: | Size: 494 B |
|
Before Width: | Height: | Size: 955 B After Width: | Height: | Size: 955 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 434 B After Width: | Height: | Size: 434 B |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 865 B After Width: | Height: | Size: 865 B |
|
Before Width: | Height: | Size: 1005 B After Width: | Height: | Size: 1005 B |
|
Before Width: | Height: | Size: 894 B After Width: | Height: | Size: 894 B |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |