feat(database): store gameplay replays in compact form
Build and Push Docker Container / build-and-push (push) Successful in 3m52s
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:
@@ -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"]]
|
||||
|
||||
Reference in New Issue
Block a user