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
+34 -15
View File
@@ -1,6 +1,21 @@
import asyncio
import unittest
from unittest.mock import AsyncMock
from server.database.benchmark_states import _build_state, is_postgresql_source
from server.database.benchmark_states import is_postgresql_source, replay_turn_to_state, sample_states
GAME = {
"game_id": "game-id", "source": "league", "map": "standard",
"ruleset_name": "standard", "ruleset_version": "v1",
}
def _turn(number:int) -> dict:
you = {"id": "you-id", "head": {"x": 1, "y": 2}, "body": [{"x": 1, "y": 2}]}
return {
"turn": number,
"board": {"width": 11, "height": 11, "food": [{"x": 5, "y": 5}], "hazards": [], "snakes": [you]},
"you": you,
}
class TestBenchmarkStates(unittest.TestCase):
def test_detects_postgresql_dsn(self):
@@ -8,23 +23,27 @@ class TestBenchmarkStates(unittest.TestCase):
self.assertTrue(is_postgresql_source("postgres://user:pass@example.com/db"))
self.assertFalse(is_postgresql_source("/tmp/gameplay.sqlite3"))
def test_builds_normalized_state_from_postgresql_json_values(self):
row = (
1, {}, {}, [{"x": 5, "y": 5}], [], "you-id", "You", 11, 11,
"game-id", "league", "standard", "standard", "v1", 7,
)
snake_rows = [
("you-id", "You", 90, 3, 1, 2, [{"x": 1, "y": 2}], {"head": "default"}),
]
state = _build_state(row, snake_rows)
self.assertIsNotNone(state)
board, metadata = state
def test_builds_state_from_replay_turn(self):
board, metadata = replay_turn_to_state(GAME, _turn(7))
self.assertEqual(board["food"], [{"x": 5, "y": 5}])
self.assertEqual(board["snakes"][0]["id"], "you-id")
self.assertEqual(metadata["you"]["id"], "you-id")
self.assertEqual(metadata["turn"], 7)
self.assertEqual(metadata["ruleset"]["version"], "v1")
def test_turn_without_snakes_is_skipped(self):
self.assertIsNone(replay_turn_to_state(GAME, {"turn": 0, "board": {"snakes": []}, "you": {}}))
def test_samples_every_stride_turn_from_newest_games(self):
backend = AsyncMock()
backend.list_game_ids.return_value = ["old", "new"]
backend.get_game_replay.side_effect = lambda game_id: {
"game": {**GAME, "game_id": game_id},
"turns": [_turn(number) for number in range(4)],
}
states = asyncio.run(sample_states(backend, samples=3, stride=3))
self.assertEqual([(m["game_id"], m["turn"]) for _, m in states], [("new", 3), ("new", 0), ("old", 1)])
if __name__ == "__main__":
unittest.main()