feat(database): store gameplay replays in compact form
Build and Push Docker Container / build-and-push (push) Successful in 3m52s

- Add compact storage package: codec, records, hydrate, replay blobs, cache.
- Running games write game_turns and snake_states keyed by integer ids.
- Encode moves as 1-byte codes and bodies as head plus step codes.
- Store food and hazards only when they changed since the previous turn.
- Strip reasoning fields that duplicate stored values and restore on read.
- Pack finished games into one zstd blob per game in game_replays.
- Merge late turns into packed blobs during the stale-game sweep.
- Extract SQLite and PostgreSQL schemas into dedicated modules.
- Add read/import game bundle and list_game_ids to the backend template.
- Add legacy readers and converter for the old turns/snake_turns layout.
- Add migrate_gameplay_to_compact.py with in-place or cross-DB migration.
- Verify every migrated game and keep legacy rows on failure.
- Support --since, --minimum-quality and guarded --drop-legacy.
- Add just recipe migrate-gameplay-postgres.
- Rewrite merge script to copy game by game via backend bundles.
- Move benchmark state loading into benchmark_states for both backends.
- Update PostgreSQL cleanup script to the new replay tables.
- Drop sqlite-zstd build stages from the Dockerfile.
- Remove old migration scripts, normalized_turn and their tests.
- Add tests for compact storage, schema, legacy migration and PostgreSQL.
- Document the compact layout and migration workflow in the README.
This commit is contained in:
2026-11-05 00:02:56 +01:00
parent 4403978537
commit 39a7a53873
41 changed files with 2779 additions and 2493 deletions
+47 -64
View File
@@ -2,7 +2,8 @@ import json
from datetime import datetime, timezone
from typing import Any
from server.database.normalized_turn import hydrate_replay_turns
from server.database.compact.hydrate import hydrate_replay_turns
from server.database.compact.records import GameBundle, SnakeIdentity, SnakeState, TurnRecord
class GameplayBackendTemplate:
"""Abstract base for gameplay database backends.
@@ -40,6 +41,17 @@ class GameplayBackendTemplate:
async def get_game_replay(self, game_id:str) -> dict|None:
raise NotImplementedError
async def read_game_bundle(self, game_id:str) -> GameBundle|None:
"""Return all stored data of one game (used by migrations and merges)."""
raise NotImplementedError
async def import_game_bundle(self, bundle:GameBundle) -> None:
"""Store a complete game; finished games are packed into a replay blob."""
raise NotImplementedError
async def list_game_ids(self) -> list[str]:
raise NotImplementedError
async def close(self) -> None:
return None
@@ -79,22 +91,6 @@ class GameplayBackendTemplate:
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"
@@ -159,14 +155,21 @@ class GameplayBackendTemplate:
"status": row["status"],
} for row in rows]
def _build_game_replay_output(self, game_row, turn_rows, snake_rows, decode_json) -> dict:
"""Build the full replay dict.
decode_json: callable applied to raw column values that may be JSON strings
(SQLite) or already-decoded objects (PostgreSQL). Pass self._from_json for
SQLite; pass (lambda x: x) for PostgreSQL.
"""
hydrated_turns = hydrate_replay_turns(game_row, turn_rows, snake_rows, decode_json)
def _build_game_replay_output(
self,
game_row,
identities:list[SnakeIdentity],
turns:list[TurnRecord],
states:list[SnakeState],
) -> dict:
game = {
"width": game_row["width"],
"height": game_row["height"],
"your_snake_id": game_row["your_snake_id"],
}
hydrated_turns = hydrate_replay_turns(
game, {identity.snake_id: identity for identity in identities}, turns, states,
)
return {
"game": {
@@ -197,45 +200,6 @@ class GameplayBackendTemplate:
# ── shared write-path helpers ──────────────────────────────────────────────
def _build_snake_turn_params(
self,
snakes:list[dict],
you_id:str|None,
game_id:str|None,
turn:int,
previous_positions:dict,
) -> list[tuple]:
"""Return one parameter tuple per snake for a snake_turns INSERT."""
result = []
for snake in snakes:
snake_id = snake.get("id")
if snake_id is None:
continue
head = snake.get("head", {})
head_x = head.get("x")
head_y = head.get("y")
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)
result.append((
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"),
))
return result
def _extract_game_end_params(self, game_state:dict) -> tuple:
"""Return (game_id, winner_name, winner_you, turn) from a game_end payload."""
game = game_state.get("game", {})
@@ -249,6 +213,25 @@ class GameplayBackendTemplate:
turn = int(game_state.get("turn", 0))
return game_id, winner_name, winner_you, turn
def _survivors(self, identities:list[SnakeIdentity], states:list[SnakeState], final_turn:int) -> tuple[int, list[dict]]:
"""Snakes alive at the final (or latest stored) turn, you first, then by name."""
turns = {state.turn for state in states}
if final_turn not in turns and turns:
final_turn = max(turns)
by_id = {identity.snake_id: identity for identity in identities}
alive = []
for state in states:
if state.turn != final_turn:
continue
identity = by_id.get(state.snake_id)
alive.append({
"snake_id": state.snake_id,
"snake_name": identity.name if identity else None,
"is_you": bool(identity and identity.is_you),
})
alive.sort(key=lambda row: (not row["is_you"], row["snake_name"] is None, row["snake_name"] or ""))
return final_turn, alive
def _calculate_survivor(self, snake_rows, your_snake_id:str|None) -> tuple[bool, str|None]:
"""Return (winner_you, survivor_name) from a list of snake_turns rows."""
survivor_ids = [s["snake_id"] for s in snake_rows if s["snake_id"]]