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
+100
View File
@@ -0,0 +1,100 @@
"""Rebuild the dashboard replay payload from compact records."""
from .codec import decode_latency
from .records import SnakeIdentity, SnakeState, TurnRecord, you_view
from .thinking import derived_values, restore_thinking
_DIRECTIONS = {(1, 0): "right", (-1, 0): "left", (0, 1): "up", (0, -1): "down"}
def _head(state:SnakeState) -> dict|None:
return state.body[0] if state.body else None
def _inferred_move(previous:SnakeState|None, current:SnakeState) -> str|None:
if previous is None:
return None
old, new = _head(previous), _head(current)
if old is None or new is None:
return None
return _DIRECTIONS.get((new["x"] - old["x"], new["y"] - old["y"]))
def _sort_key(identity:SnakeIdentity|None):
name = identity.name if identity else None
is_you = bool(identity and identity.is_you)
return (not is_you, name is None, name or "")
def hydrate_replay_turns(
game:dict,
identities:dict[str, SnakeIdentity],
turns:list[TurnRecord],
states:list[SnakeState],
) -> list[dict]:
states_by_turn:dict[int, dict[str, SnakeState]] = {}
for state in states:
states_by_turn.setdefault(state.turn, {})[state.snake_id] = state
width, height = game.get("width"), game.get("height")
you_id = game.get("your_snake_id")
food:list = []
hazards:list = []
output = []
for record in sorted(turns, key=lambda item: item.turn):
food = food if record.food is None else record.food
hazards = hazards if record.hazards is None else record.hazards
current = states_by_turn.get(record.turn, {})
previous = states_by_turn.get(record.turn - 1, {})
ordered = sorted(current.values(), key=lambda s: _sort_key(identities.get(s.snake_id)))
snakes = []
api_snakes = []
for state in ordered:
identity = identities.get(state.snake_id)
name = identity.name if identity else None
customizations = identity.customizations if identity else {}
latency = decode_latency(state.latency)
api_snake = {
"id": state.snake_id,
"name": name,
"health": state.health,
"length": len(state.body),
"head": _head(state) or {"x": None, "y": None},
"body": state.body,
"customizations": customizations,
}
if latency is not None:
api_snake["latency"] = latency
api_snakes.append(api_snake)
snakes.append({
"snake_id": state.snake_id,
"snake_name": name,
"health": state.health,
"length": len(state.body),
"head": api_snake["head"],
"body": state.body,
"customizations": customizations,
"is_you": bool(identity and identity.is_you),
"inferred_move": _inferred_move(previous.get(state.snake_id), state),
"latency": latency,
})
derived = derived_values(
record.turn, record.move, you_view(list(current.values()), you_id),
food, width, height, len(current),
)
output.append({
"turn": record.turn,
"observed_at": record.observed_at,
"my_move": record.move,
"my_thinking": restore_thinking(record.thinking, derived),
"board": {
"width": width,
"height": height,
"food": food,
"hazards": hazards,
"snakes": api_snakes,
},
"food": food,
"hazards": hazards,
"you": next((snake for snake in api_snakes if snake["id"] == you_id), {}),
"snakes": snakes,
})
return output