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.
245 lines
9.3 KiB
Python
245 lines
9.3 KiB
Python
import json
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
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.
|
|
|
|
Subclasses must override every method that raises NotImplementedError.
|
|
Shared pure-Python helpers (_utc_now, _to_json, etc.) live here so they
|
|
are available to both SQLite and PostgreSQL implementations.
|
|
"""
|
|
|
|
# ── public async interface ─────────────────────────────────────────────────
|
|
|
|
async def initialize(self) -> None:
|
|
"""Called once on server startup. Backends that need eager connection
|
|
(pool creation, schema init, migration) should override this."""
|
|
return None
|
|
|
|
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def record_game_end(self, game_state:dict) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def get_summary(self, recent_limit:int=15) -> dict:
|
|
raise NotImplementedError
|
|
|
|
async def list_games(self, limit:int=50) -> list[dict]:
|
|
raise NotImplementedError
|
|
|
|
async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
|
|
raise NotImplementedError
|
|
|
|
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
|
|
|
|
# ── shared pure-python helpers ─────────────────────────────────────────────
|
|
|
|
def _utc_now(self) -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
def _parse_utc_timestamp(self, value:str|None) -> datetime|None:
|
|
if not value:
|
|
return None
|
|
normalized = value.strip()
|
|
if normalized.endswith("Z"):
|
|
normalized = normalized[:-1] + "+00:00"
|
|
try:
|
|
parsed = datetime.fromisoformat(normalized)
|
|
except ValueError:
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
return parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
def _to_json(self, payload:object) -> str:
|
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
def _from_json(self, payload:str|None) -> Any:
|
|
if payload is None or payload == "":
|
|
return None
|
|
try:
|
|
return json.loads(payload)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
|
|
def _extract_snakes(self, game_state:dict) -> list[dict]:
|
|
return list(game_state.get("board", {}).get("snakes", []))
|
|
|
|
def _extract_you(self, game_state:dict) -> dict:
|
|
return dict(game_state.get("you", {}))
|
|
|
|
def _derive_game_type(self, board:dict, ruleset:dict) -> str:
|
|
if len(board.get("snakes", [])) == 2:
|
|
return "duel"
|
|
|
|
return ruleset.get("name") or "standard"
|
|
|
|
# ── shared output builders ─────────────────────────────────────────────────
|
|
|
|
def _ts_to_str(self, value) -> str|None:
|
|
"""Normalize a timestamp: pass str through, call .isoformat() on datetime."""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, str):
|
|
return value
|
|
return value.isoformat()
|
|
|
|
def _build_summary_output(self, totals, by_type_rows, recent_rows) -> dict:
|
|
return {
|
|
"total_games": int(totals["total_games"] or 0),
|
|
"running_games": int(totals["running_games"] or 0),
|
|
"finished_games": int(totals["finished_games"] or 0),
|
|
"wins": int(totals["wins"] or 0),
|
|
"losses": int(totals["losses"] or 0),
|
|
"avg_turns_finished": round(float(totals["avg_turns"] or 0.0), 2),
|
|
"by_game_type": [{
|
|
"game_type": row["type_label"],
|
|
"total": int(row["total"]),
|
|
"wins": int(row["wins"]),
|
|
"losses": int(row["losses"]),
|
|
} for row in by_type_rows],
|
|
"recent_games": [{
|
|
"game_id": row["game_id"],
|
|
"started_at": self._ts_to_str(row["started_at"]),
|
|
"ended_at": self._ts_to_str(row["ended_at"]),
|
|
"map": row["map_name"],
|
|
"ruleset": row["ruleset_name"],
|
|
"game_type": row["game_type"],
|
|
"snake": row["your_snake_name"],
|
|
"snake_type": row["your_snake_type"],
|
|
"snake_version": row["your_snake_version"],
|
|
"winner_you": bool(row["winner_you"]),
|
|
"final_turn": int(row["final_turn"] or 0),
|
|
"status": row["status"],
|
|
} for row in recent_rows],
|
|
}
|
|
|
|
def _build_game_list_output(self, rows) -> list[dict]:
|
|
return [{
|
|
"game_id": row["game_id"],
|
|
"started_at": self._ts_to_str(row["started_at"]),
|
|
"ended_at": self._ts_to_str(row["ended_at"]),
|
|
"map": row["map_name"],
|
|
"source": row["source"],
|
|
"ruleset": row["ruleset_name"],
|
|
"game_type": row["game_type"],
|
|
"snake": row["your_snake_name"],
|
|
"snake_type": row["your_snake_type"],
|
|
"snake_version": row["your_snake_version"],
|
|
"winner_you": bool(row["winner_you"]),
|
|
"winner_name": row["winner_name"],
|
|
"final_turn": int(row["final_turn"] or 0),
|
|
"status": row["status"],
|
|
} for row in rows]
|
|
|
|
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": {
|
|
"game_id": game_row["game_id"],
|
|
"started_at": self._ts_to_str(game_row["started_at"]),
|
|
"ended_at": self._ts_to_str(game_row["ended_at"]),
|
|
"width": game_row["width"],
|
|
"height": game_row["height"],
|
|
"source": game_row["source"],
|
|
"map": game_row["map_name"],
|
|
"ruleset_name": game_row["ruleset_name"],
|
|
"ruleset_version": game_row["ruleset_version"],
|
|
"game_type": game_row["game_type"],
|
|
"your_snake_id": game_row["your_snake_id"],
|
|
"your_snake_name": game_row["your_snake_name"],
|
|
"your_snake_type": game_row["your_snake_type"],
|
|
"your_snake_version": game_row["your_snake_version"],
|
|
"winner_name": game_row["winner_name"],
|
|
"winner_you": bool(game_row["winner_you"]),
|
|
"final_turn": int(game_row["final_turn"] or 0),
|
|
"status": game_row["status"],
|
|
},
|
|
"turns": [
|
|
{**turn, "observed_at": self._ts_to_str(turn["observed_at"])}
|
|
for turn in hydrated_turns
|
|
],
|
|
}
|
|
|
|
# ── shared write-path helpers ──────────────────────────────────────────────
|
|
|
|
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", {})
|
|
game_id = game.get("id")
|
|
board = game_state.get("board", {})
|
|
snakes = list(board.get("snakes", []))
|
|
you = self._extract_you(game_state)
|
|
winner_name = next((s.get("name") for s in snakes if s.get("name")), None)
|
|
you_id = you.get("id")
|
|
winner_you = any(s.get("id") == you_id for s in snakes)
|
|
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"]]
|
|
winner_you = bool(
|
|
your_snake_id
|
|
and your_snake_id in survivor_ids
|
|
and len(survivor_ids) == 1
|
|
)
|
|
survivor_name = next((s["snake_name"] for s in snake_rows if s["snake_name"]), None)
|
|
return winner_you, survivor_name
|