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:
@@ -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
|
||||
Reference in New Issue
Block a user