feat: add Prism snake and gameplay database lifecycle
Build and Push Docker Container / build-and-push (push) Successful in 8m3s

- Add bitboard-accelerated Prism and versioned Supreme snake implementations.
- Add database-backed move benchmarks and focused strategy tests.
- Normalize gameplay storage while preserving replay compatibility.
- Add deterministic game quality scoring and replay retention tiers.
- Add backup-first SQLite cleanup, verification, and replacement tooling.
- Add safe compact-plus-delta database merging with conflict detection.
- Extend SQLite and PostgreSQL schemas for replay and quality metadata.
- Add PostgreSQL development service and pytest import configuration.
- Update gameplay documentation and the quart_common submodule revision.
This commit is contained in:
2026-08-01 16:21:02 +02:00
parent 9a7f4de586
commit c704fbc742
21 changed files with 3156 additions and 88 deletions
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from pathlib import Path
from server.database.backend.Template import GameplayBackendTemplate
from server.database.normalized_turn import compact_turn_json
logger = logging.getLogger(__name__)
if not logger.handlers:
@@ -16,10 +17,12 @@ if not logger.handlers:
_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):
def __init__(self, db_path:str, busy_timeout_ms:int=5000, initialize_indexes:bool=True, journal_mode:str="WAL"):
self.db_path = db_path
self.busy_timeout_ms = max(1000, int(busy_timeout_ms))
self._zstd_available = False
self._initialize_indexes = initialize_indexes
self._journal_mode = journal_mode
self._initialize_database()
# ── connection ─────────────────────────────────────────────────────────────
@@ -43,7 +46,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
connection.enable_load_extension(False)
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA journal_mode = WAL")
connection.execute(f"PRAGMA journal_mode = {self._journal_mode}")
connection.execute("PRAGMA synchronous = NORMAL")
connection.execute("PRAGMA temp_store = MEMORY")
connection.execute("PRAGMA journal_size_limit = 1048576")
@@ -80,7 +83,12 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
winner_name TEXT,
winner_you INTEGER NOT NULL DEFAULT 0,
final_turn INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running'
status TEXT NOT NULL DEFAULT 'running',
has_replay INTEGER NOT NULL DEFAULT 1,
quality_status TEXT NOT NULL DEFAULT 'retained',
quality_score INTEGER,
quality_tier TEXT,
quality_reasons_json TEXT
);
CREATE TABLE IF NOT EXISTS turns (
@@ -99,6 +107,15 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS game_snakes (
game_id TEXT NOT NULL,
snake_id TEXT NOT NULL,
snake_name TEXT,
is_you INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (game_id, snake_id),
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,
@@ -116,13 +133,19 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
);
""")
self._create_indexes_if_tables(connection)
if self._initialize_indexes:
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")
self._ensure_column_exists(connection, "games", "has_replay", "INTEGER NOT NULL DEFAULT 1")
self._ensure_column_exists(connection, "games", "quality_status", "TEXT NOT NULL DEFAULT 'retained'")
self._ensure_column_exists(connection, "games", "quality_score", "INTEGER")
self._ensure_column_exists(connection, "games", "quality_tier", "TEXT")
self._ensure_column_exists(connection, "games", "quality_reasons_json", "TEXT")
if self._zstd_available:
self._enable_zstd_compression(connection)
connection.execute("PRAGMA optimize")
@@ -239,7 +262,21 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
game_id = game.get("id")
turn = int(game_state.get("turn", 0))
board_json, snakes_json, you_json, food_json, hazards_json = compact_turn_json(board)
with self._connect() as connection:
connection.executemany("""
INSERT INTO game_snakes (game_id, snake_id, snake_name, is_you)
VALUES (?, ?, ?, ?)
ON CONFLICT(game_id, snake_id) DO UPDATE SET
snake_name = excluded.snake_name,
is_you = excluded.is_you
""",
[
(game_id, snake.get("id"), snake.get("name"), 1 if snake.get("id") == you.get("id") else 0)
for snake in snakes if snake.get("id") is not None
],
)
connection.execute("""
INSERT INTO turns (
game_id, turn, observed_at, my_move, my_thinking_json,
@@ -261,11 +298,11 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
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", [])),
self._to_json(board_json),
self._to_json(snakes_json),
self._to_json(you_json),
self._to_json(food_json),
self._to_json(hazards_json),
),
)
@@ -305,9 +342,9 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
latency = excluded.latency
""",
(
p_game_id, p_turn, p_snake_id, p_name, p_health, p_length,
p_game_id, p_turn, p_snake_id, None, p_health, p_length,
p_head_x, p_head_y, self._to_json(p_body),
1 if p_is_you else 0,
0,
p_inferred, p_latency,
),
)
@@ -368,10 +405,12 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
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
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name) AS snake_name
FROM snake_turns AS st
LEFT JOIN game_snakes AS gs
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
WHERE st.game_id = ? AND st.turn = ?
ORDER BY COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
""",
(game_id, final_turn),
).fetchall()
@@ -384,10 +423,12 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
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
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name) AS snake_name
FROM snake_turns AS st
LEFT JOIN game_snakes AS gs
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
WHERE st.game_id = ? AND st.turn = ?
ORDER BY COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
""",
(game_id, final_turn),
).fetchall()
@@ -448,6 +489,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
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
WHERE has_replay = 1
ORDER BY started_at DESC
LIMIT ?
""",
@@ -463,6 +505,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
your_snake_name, your_snake_type, your_snake_version,
winner_you, winner_name, final_turn, status
FROM games
WHERE has_replay = 1
ORDER BY started_at DESC
LIMIT ?
""",
@@ -479,7 +522,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
your_snake_type, your_snake_version,
winner_name, winner_you, final_turn, status
FROM games
WHERE game_id = ?
WHERE game_id = ? AND has_replay = 1
""",
(game_id,),
).fetchone()
@@ -498,11 +541,16 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
).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
SELECT st.turn, st.snake_id,
COALESCE(gs.snake_name, st.snake_name) AS snake_name,
st.health, st.length, st.head_x, st.head_y, st.body_json,
COALESCE(gs.is_you, st.is_you) AS is_you,
st.inferred_move, st.latency
FROM snake_turns AS st
LEFT JOIN game_snakes AS gs
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
WHERE st.game_id = ?
ORDER BY st.turn ASC, is_you DESC, snake_name ASC
""",
(game_id,),
).fetchall()