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
@@ -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()
+5 -25
View File
@@ -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
],
}
+100
View File
@@ -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]
+84
View File
@@ -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