feat: add Prism snake and gameplay database lifecycle
Build and Push Docker Container / build-and-push (push) Successful in 8m3s
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:
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from .Template import GameplayBackendTemplate
|
||||
from server.database.normalized_turn import compact_turn_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.handlers:
|
||||
@@ -47,7 +48,12 @@ CREATE TABLE IF NOT EXISTS games (
|
||||
winner_name TEXT,
|
||||
winner_you BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
final_turn INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'running'
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
has_replay BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
quality_status TEXT NOT NULL DEFAULT 'retained',
|
||||
quality_score INTEGER,
|
||||
quality_tier TEXT,
|
||||
quality_reasons JSONB
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS turns (
|
||||
@@ -65,6 +71,14 @@ CREATE TABLE IF NOT EXISTS turns (
|
||||
UNIQUE (game_id, turn)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS game_snakes (
|
||||
game_id TEXT NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
snake_id TEXT NOT NULL,
|
||||
snake_name TEXT,
|
||||
is_you BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (game_id, snake_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snake_turns (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
game_id TEXT NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
@@ -93,6 +107,11 @@ 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 games ADD COLUMN IF NOT EXISTS has_replay BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_status TEXT NOT NULL DEFAULT 'retained';
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_score INTEGER;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_tier TEXT;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_reasons JSONB;
|
||||
ALTER TABLE turns ADD COLUMN IF NOT EXISTS my_thinking JSONB;
|
||||
ALTER TABLE snake_turns ADD COLUMN IF NOT EXISTS latency TEXT;
|
||||
"""
|
||||
@@ -433,9 +452,23 @@ class PostgresqlGameplayBackend(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)
|
||||
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
await conn.executemany("""
|
||||
INSERT INTO game_snakes (game_id, snake_id, snake_name, is_you)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
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"), snake.get("id") == you.get("id"))
|
||||
for snake in snakes if snake.get("id") is not None
|
||||
],
|
||||
)
|
||||
await conn.execute("""
|
||||
INSERT INTO turns (
|
||||
game_id, turn, observed_at, my_move, my_thinking,
|
||||
@@ -456,11 +489,11 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
||||
self._utc_now_ts(),
|
||||
my_move,
|
||||
my_thinking,
|
||||
board,
|
||||
snakes,
|
||||
you,
|
||||
board.get("food", []),
|
||||
board.get("hazards", []),
|
||||
board_json,
|
||||
snakes_json,
|
||||
you_json,
|
||||
food_json,
|
||||
hazards_json,
|
||||
)
|
||||
|
||||
previous_positions:dict[str, tuple[int, int]] = {}
|
||||
@@ -498,8 +531,8 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
||||
inferred_move = EXCLUDED.inferred_move,
|
||||
latency = EXCLUDED.latency
|
||||
""",
|
||||
p_game_id, p_turn, p_snake_id, p_name, p_health, p_length,
|
||||
p_head_x, p_head_y, p_body, p_is_you, p_inferred, p_latency,
|
||||
p_game_id, p_turn, p_snake_id, None, p_health, p_length,
|
||||
p_head_x, p_head_y, p_body, False, p_inferred, p_latency,
|
||||
)
|
||||
|
||||
await conn.execute("""
|
||||
@@ -561,10 +594,12 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
||||
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
|
||||
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 = $1 AND st.turn = $2
|
||||
ORDER BY COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
|
||||
""",
|
||||
game_id, final_turn,
|
||||
)
|
||||
@@ -578,10 +613,12 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
||||
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
|
||||
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 = $1 AND st.turn = $2
|
||||
ORDER BY COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
|
||||
""",
|
||||
game_id, final_turn,
|
||||
)
|
||||
@@ -640,6 +677,7 @@ class PostgresqlGameplayBackend(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
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $1
|
||||
""",
|
||||
@@ -656,6 +694,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
||||
your_snake_name, your_snake_type, your_snake_version,
|
||||
winner_you, winner_name, final_turn, status
|
||||
FROM games
|
||||
WHERE has_replay
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $1
|
||||
""",
|
||||
@@ -673,7 +712,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
||||
your_snake_type, your_snake_version,
|
||||
winner_name, winner_you, final_turn, status
|
||||
FROM games
|
||||
WHERE game_id = $1
|
||||
WHERE game_id = $1 AND has_replay
|
||||
""",
|
||||
game_id,
|
||||
)
|
||||
@@ -696,11 +735,16 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
||||
)
|
||||
|
||||
snake_rows = await conn.fetch("""
|
||||
SELECT turn, snake_id, snake_name, health, length, head_x, head_y,
|
||||
body AS body_json, is_you, inferred_move, latency
|
||||
FROM snake_turns
|
||||
WHERE game_id = $1
|
||||
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 AS 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 = $1
|
||||
ORDER BY st.turn ASC, is_you DESC, snake_name ASC
|
||||
""",
|
||||
game_id,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,6 +2,8 @@ import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from server.database.normalized_turn import hydrate_replay_turns
|
||||
|
||||
class GameplayBackendTemplate:
|
||||
"""Abstract base for gameplay database backends.
|
||||
|
||||
@@ -164,19 +166,7 @@ class GameplayBackendTemplate:
|
||||
(SQLite) or already-decoded objects (PostgreSQL). Pass self._from_json for
|
||||
SQLite; pass (lambda x: x) for PostgreSQL.
|
||||
"""
|
||||
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": decode_json(row["body_json"]) or [],
|
||||
"is_you": bool(row["is_you"]),
|
||||
"inferred_move": row["inferred_move"],
|
||||
"latency": row["latency"],
|
||||
})
|
||||
hydrated_turns = hydrate_replay_turns(game_row, turn_rows, snake_rows, decode_json)
|
||||
|
||||
return {
|
||||
"game": {
|
||||
@@ -200,18 +190,8 @@ class GameplayBackendTemplate:
|
||||
"status": game_row["status"],
|
||||
},
|
||||
"turns": [
|
||||
{
|
||||
"turn": int(row["turn"]),
|
||||
"observed_at": self._ts_to_str(row["observed_at"]),
|
||||
"my_move": row["my_move"],
|
||||
"my_thinking": decode_json(row["my_thinking_json"]),
|
||||
"board": decode_json(row["board_state_json"]),
|
||||
"food": decode_json(row["food_json"]) or [],
|
||||
"hazards": decode_json(row["hazards_json"]) or [],
|
||||
"you": decode_json(row["you_json"]) or {},
|
||||
"snakes": snakes_by_turn.get(int(row["turn"]), []),
|
||||
}
|
||||
for row in turn_rows
|
||||
{**turn, "observed_at": self._ts_to_str(turn["observed_at"])}
|
||||
for turn in hydrated_turns
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Deterministic gameplay quality scoring.
|
||||
|
||||
Quality controls replay retention, never whether a game's result contributes to
|
||||
historical rates. Structural failures produce ``invalid``; otherwise strategic
|
||||
signals produce a 0-100 score and high/medium/low tier.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
QUALITY_ORDER = {"invalid": 0, "low": 1, "medium": 2, "high": 3}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameQualityInput:
|
||||
status:str
|
||||
final_turn:int
|
||||
turn_rows:int
|
||||
min_turn:int|None
|
||||
max_turn:int|None
|
||||
valid_moves:int
|
||||
thinking_rows:int
|
||||
distinct_moves:int
|
||||
snake_turn_rows:int
|
||||
winner_name:str|None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameQuality:
|
||||
score:int
|
||||
tier:str
|
||||
reasons:tuple[str, ...]
|
||||
|
||||
def rate_game_quality(data:GameQualityInput) -> GameQuality:
|
||||
reasons:list[str] = []
|
||||
expected_turns = max(1, data.final_turn)
|
||||
coverage = min(1.0, data.turn_rows / expected_turns)
|
||||
valid_ratio = data.valid_moves / data.turn_rows if data.turn_rows else 0.0
|
||||
thinking_ratio = data.thinking_rows / data.turn_rows if data.turn_rows else 0.0
|
||||
average_snakes = data.snake_turn_rows / data.turn_rows if data.turn_rows else 0.0
|
||||
|
||||
if data.status != "finished":
|
||||
reasons.append("unfinished_game")
|
||||
if data.turn_rows == 0:
|
||||
reasons.append("missing_turns")
|
||||
observed_span = (
|
||||
data.max_turn - data.min_turn + 1
|
||||
if data.min_turn is not None and data.max_turn is not None
|
||||
else 0
|
||||
)
|
||||
if coverage < 0.8 or observed_span != data.turn_rows:
|
||||
reasons.append("incomplete_turn_sequence")
|
||||
if valid_ratio < 0.95:
|
||||
reasons.append("invalid_or_missing_moves")
|
||||
if reasons:
|
||||
return GameQuality(score=0, tier="invalid", reasons=tuple(reasons))
|
||||
|
||||
score = 25.0 * coverage
|
||||
score += 10.0 * valid_ratio
|
||||
score += 20.0 * min(1.0, data.final_turn / 40.0)
|
||||
score += 15.0 * thinking_ratio
|
||||
score += 10.0 * min(1.0, data.distinct_moves / 3.0)
|
||||
if average_snakes >= 3.0:
|
||||
score += 15.0
|
||||
elif average_snakes >= 1.8:
|
||||
score += 10.0
|
||||
elif average_snakes >= 1.0:
|
||||
score += 3.0
|
||||
if data.winner_name:
|
||||
score += 5.0
|
||||
|
||||
if coverage >= 0.98:
|
||||
reasons.append("complete_turn_sequence")
|
||||
if valid_ratio == 1.0:
|
||||
reasons.append("valid_moves")
|
||||
if thinking_ratio >= 0.9:
|
||||
reasons.append("complete_thinking_data")
|
||||
elif thinking_ratio < 0.25:
|
||||
reasons.append("sparse_thinking_data")
|
||||
if average_snakes >= 1.8:
|
||||
reasons.append("competitive_game")
|
||||
else:
|
||||
reasons.append("limited_opposition_data")
|
||||
if data.final_turn < 3:
|
||||
reasons.append("very_short_game")
|
||||
elif data.final_turn < 10:
|
||||
reasons.append("short_game")
|
||||
else:
|
||||
reasons.append("substantial_game_length")
|
||||
if data.distinct_moves <= 1:
|
||||
reasons.append("low_move_diversity")
|
||||
|
||||
rounded_score = max(0, min(100, round(score)))
|
||||
if rounded_score >= 80 and data.final_turn >= 10:
|
||||
tier = "high"
|
||||
elif rounded_score >= 55:
|
||||
tier = "medium"
|
||||
else:
|
||||
tier = "low"
|
||||
return GameQuality(score=rounded_score, tier=tier, reasons=tuple(reasons))
|
||||
|
||||
def quality_meets_minimum(tier:str, minimum_tier:str) -> bool:
|
||||
return QUALITY_ORDER.get(tier, 0) >= QUALITY_ORDER[minimum_tier]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Normalized gameplay turn storage and replay hydration.
|
||||
|
||||
A turn is split into one board row plus one snake row per participating snake.
|
||||
Static snake identity belongs to ``game_snakes``. This avoids storing complete
|
||||
snake payloads in the board, snakes, you, and snake-turn columns simultaneously.
|
||||
"""
|
||||
|
||||
from typing import Callable
|
||||
|
||||
def compact_turn_json(board:dict) -> tuple[dict, list, dict, list, list]:
|
||||
"""Return compatibility JSON plus canonical food and hazard values."""
|
||||
return {}, [], {}, board.get("food", []), board.get("hazards", [])
|
||||
|
||||
def hydrate_replay_turns(game_row, turn_rows, snake_rows, decode_json:Callable) -> list[dict]:
|
||||
rows_by_turn:dict[int, list] = {}
|
||||
for row in snake_rows:
|
||||
rows_by_turn.setdefault(int(row["turn"]), []).append(row)
|
||||
|
||||
turns = []
|
||||
for row in turn_rows:
|
||||
turn = int(row["turn"])
|
||||
stored_board = decode_json(row["board_state_json"]) or {}
|
||||
food = decode_json(row["food_json"])
|
||||
hazards = decode_json(row["hazards_json"])
|
||||
|
||||
snakes = []
|
||||
api_snakes = []
|
||||
for snake_row in rows_by_turn.get(turn, []):
|
||||
body = decode_json(snake_row["body_json"]) or []
|
||||
api_snake = {
|
||||
"id": snake_row["snake_id"],
|
||||
"name": snake_row["snake_name"],
|
||||
"health": snake_row["health"],
|
||||
"length": snake_row["length"],
|
||||
"head": {"x": snake_row["head_x"], "y": snake_row["head_y"]},
|
||||
"body": body,
|
||||
}
|
||||
if snake_row["latency"] is not None:
|
||||
api_snake["latency"] = snake_row["latency"]
|
||||
api_snakes.append(api_snake)
|
||||
snakes.append({
|
||||
"snake_id": snake_row["snake_id"],
|
||||
"snake_name": snake_row["snake_name"],
|
||||
"health": snake_row["health"],
|
||||
"length": snake_row["length"],
|
||||
"head": api_snake["head"],
|
||||
"body": body,
|
||||
"is_you": bool(snake_row["is_you"]),
|
||||
"inferred_move": snake_row["inferred_move"],
|
||||
"latency": snake_row["latency"],
|
||||
})
|
||||
|
||||
board = stored_board or {
|
||||
"width": game_row["width"],
|
||||
"height": game_row["height"],
|
||||
"food": food or [],
|
||||
"hazards": hazards or [],
|
||||
"snakes": api_snakes,
|
||||
}
|
||||
if food is None:
|
||||
food = board.get("food", [])
|
||||
if hazards is None:
|
||||
hazards = board.get("hazards", [])
|
||||
|
||||
you = decode_json(row["you_json"]) or {}
|
||||
if not you:
|
||||
you = next(
|
||||
(snake for snake in api_snakes if snake["id"] == game_row["your_snake_id"]),
|
||||
{},
|
||||
)
|
||||
|
||||
turns.append({
|
||||
"turn": turn,
|
||||
"observed_at": row["observed_at"],
|
||||
"my_move": row["my_move"],
|
||||
"my_thinking": decode_json(row["my_thinking_json"]),
|
||||
"board": board,
|
||||
"food": food or [],
|
||||
"hazards": hazards or [],
|
||||
"you": you,
|
||||
"snakes": snakes,
|
||||
})
|
||||
|
||||
return turns
|
||||
Reference in New Issue
Block a user