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,124 @@
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from server.database.compact.codec import (
|
||||
decode_body, decode_latency, decode_move, decode_points, encode_body,
|
||||
encode_latency, encode_move, encode_points, from_epoch_us, to_epoch_us,
|
||||
)
|
||||
from server.database.compact.records import PointsState, SnakeState, TurnRecord, compact_turn
|
||||
from server.database.compact.replay_blob import pack_replay, unpack_replay
|
||||
from server.database.compact.thinking import derived_values, restore_thinking, slim_thinking
|
||||
|
||||
def _body(*points):
|
||||
return [{"x": x, "y": y} for x, y in points]
|
||||
|
||||
class TestCodec(unittest.TestCase):
|
||||
def test_body_roundtrip_including_stacked_tail(self):
|
||||
body = _body((5, 5), (5, 6), (4, 6), (4, 6))
|
||||
encoded = encode_body(body)
|
||||
self.assertEqual(decode_body(encoded), body)
|
||||
# 1 format byte + head pair + 3 step codes
|
||||
self.assertEqual(len(encoded), 6)
|
||||
|
||||
def test_body_roundtrip_with_wrapped_jump(self):
|
||||
body = _body((0, 5), (10, 5), (9, 5))
|
||||
self.assertEqual(decode_body(encode_body(body)), body)
|
||||
|
||||
def test_body_out_of_byte_range_falls_back_to_json(self):
|
||||
body = _body((-1, 3), (300, 3))
|
||||
self.assertEqual(decode_body(encode_body(body)), body)
|
||||
|
||||
def test_empty_body(self):
|
||||
self.assertEqual(decode_body(encode_body([])), [])
|
||||
|
||||
def test_points_keep_none_as_unchanged(self):
|
||||
self.assertIsNone(encode_points(None))
|
||||
self.assertIsNone(decode_points(None))
|
||||
self.assertEqual(decode_points(encode_points([])), [])
|
||||
food = _body((1, 2), (10, 0))
|
||||
self.assertEqual(decode_points(encode_points(food)), food)
|
||||
|
||||
def test_moves_and_latency(self):
|
||||
for move in ("up", "down", "left", "right", None):
|
||||
self.assertEqual(decode_move(encode_move(move)), move)
|
||||
self.assertEqual(decode_latency(encode_latency("77")), "77")
|
||||
self.assertIsNone(encode_latency(""))
|
||||
self.assertIsNone(encode_latency("timeout"))
|
||||
|
||||
def test_epoch_microseconds_roundtrip(self):
|
||||
value = datetime(2026, 11, 4, 21, 41, 48, 123456, tzinfo=timezone.utc)
|
||||
self.assertEqual(from_epoch_us(to_epoch_us(value)), value)
|
||||
self.assertEqual(from_epoch_us(to_epoch_us(value.isoformat())), value)
|
||||
|
||||
class TestThinking(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.derived = derived_values(
|
||||
turn=12, move="up", you={"head": {"x": 1, "y": 2}, "health": 90, "length": 4},
|
||||
food=[{"x": 0, "y": 0}], width=11, height=11, snake_count=2,
|
||||
)
|
||||
|
||||
def test_strips_and_restores_derived_fields(self):
|
||||
thinking = {
|
||||
"turn": 12, "move": "up", "head": {"x": 1, "y": 2}, "health": 90,
|
||||
"length": 4, "food_count": 1, "board_size": "11x11", "snakes": 2,
|
||||
"scores": {"up": 1.5}, "mode": "duel",
|
||||
}
|
||||
slim = slim_thinking(thinking, self.derived)
|
||||
self.assertEqual(set(slim), {"scores", "mode", "_derived"})
|
||||
self.assertEqual(restore_thinking(slim, self.derived), thinking)
|
||||
|
||||
def test_keeps_values_that_differ_from_derived(self):
|
||||
thinking = {"turn": 11, "health": 89, "snakes": True}
|
||||
slim = slim_thinking(thinking, self.derived)
|
||||
self.assertEqual(slim, thinking)
|
||||
self.assertEqual(restore_thinking(slim, self.derived), thinking)
|
||||
|
||||
def test_payload_using_marker_key_is_stored_verbatim(self):
|
||||
thinking = {"_derived": "mine", "turn": 12}
|
||||
self.assertEqual(restore_thinking(slim_thinking(thinking, self.derived), self.derived), thinking)
|
||||
|
||||
def test_non_dict_payload_passes_through(self):
|
||||
self.assertIsNone(slim_thinking(None, self.derived))
|
||||
self.assertEqual(slim_thinking(["a"], self.derived), ["a"])
|
||||
|
||||
class TestCompactTurn(unittest.TestCase):
|
||||
def _compact(self, turn, food, previous):
|
||||
states = [SnakeState("me", turn, 90, 5, _body((1, 1)))]
|
||||
return compact_turn(turn, None, "up", None, food, [], states, "me", 11, 11, previous)
|
||||
|
||||
def test_unchanged_food_is_stored_as_none_only_after_consecutive_turn(self):
|
||||
food = _body((3, 3))
|
||||
first, points = self._compact(1, food, None)
|
||||
second, points = self._compact(2, list(food), points)
|
||||
after_gap, _ = self._compact(4, list(food), points)
|
||||
self.assertEqual(first.food, food)
|
||||
self.assertIsNone(second.food)
|
||||
self.assertEqual(second.hazards, None)
|
||||
self.assertEqual(after_gap.food, food)
|
||||
|
||||
def test_previous_state_from_other_turn_is_not_used(self):
|
||||
record, _ = self._compact(5, [], PointsState(5, [], []))
|
||||
self.assertEqual(record.food, [])
|
||||
|
||||
class TestReplayBlob(unittest.TestCase):
|
||||
def test_pack_roundtrip(self):
|
||||
observed = datetime(2026, 11, 4, 12, 0, 0, 5, tzinfo=timezone.utc)
|
||||
turns = [
|
||||
TurnRecord(0, observed, None, None, _body((1, 1)), []),
|
||||
TurnRecord(1, observed, "left", {"scores": {"left": 1.0}, "_derived": 3}, None, None),
|
||||
]
|
||||
states = [
|
||||
SnakeState("me", 0, 100, None, _body((5, 5), (5, 5), (5, 5))),
|
||||
SnakeState("me", 1, 99, 42, _body((4, 5), (5, 5), (5, 5))),
|
||||
SnakeState("enemy", 1, 99, 7, _body((-1, 2))),
|
||||
]
|
||||
unpacked_turns, unpacked_states = unpack_replay(pack_replay(turns, states))
|
||||
self.assertEqual(unpacked_turns, turns)
|
||||
self.assertEqual(sorted(unpacked_states, key=lambda s: (s.snake_id, s.turn)), sorted(states, key=lambda s: (s.snake_id, s.turn)))
|
||||
|
||||
def test_unknown_format_is_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
unpack_replay(b"", 99)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user