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.
99 lines
4.3 KiB
Python
99 lines
4.3 KiB
Python
import json
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import closing
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from server.database.backend.SqliteGameplayBackend import SqliteGameplayBackend
|
|
from server.database.compact.records import GameBundle, SnakeIdentity, SnakeState, TurnRecord
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
CUSTOMIZATIONS = {"color": "#663399", "head": "ferret", "tail": "swirl"}
|
|
|
|
def _bundle(game_id:str, winner_you:bool, cleaned:bool=False) -> GameBundle:
|
|
turns = [
|
|
TurnRecord(turn, datetime(2026, 1, 1, 0, 0, turn, tzinfo=timezone.utc), "up", {"score": turn}, [] if turn == 1 else None, [] if turn == 1 else None)
|
|
for turn in range(1, 6)
|
|
]
|
|
states = [
|
|
SnakeState(snake_id, turn, 90, 10, [{"x": x, "y": turn}])
|
|
for turn in range(1, 6)
|
|
for snake_id, x in (("me", 1), ("a", 3), ("b", 5))
|
|
]
|
|
return GameBundle(
|
|
game={
|
|
"game_id": game_id, "started_at": "2026-01-01T00:00:00+00:00",
|
|
"ended_at": "2026-01-01T00:01:00+00:00", "width": 11, "height": 11,
|
|
"source": "league", "map_name": "standard", "ruleset_name": "standard",
|
|
"ruleset_version": "v1", "your_snake_id": "me", "your_snake_name": "PrismBattleSnake",
|
|
"your_snake_type": "PrismBattleSnake_GPT_5_6_Sol", "your_snake_version": "1.0.0",
|
|
"game_type": "standard", "winner_name": "PrismBattleSnake", "winner_you": winner_you,
|
|
"final_turn": 5, "status": "finished", "has_replay": True, "quality_status": "retained",
|
|
"quality_score": 90 if cleaned else None, "quality_tier": "high" if cleaned else None,
|
|
"quality_reasons": ["already_scored"] if cleaned else None,
|
|
},
|
|
snakes=[
|
|
SnakeIdentity("me", "PrismBattleSnake", True, CUSTOMIZATIONS),
|
|
SnakeIdentity("a", "A", False, {}),
|
|
SnakeIdentity("b", "B", False, {}),
|
|
],
|
|
turns=turns,
|
|
states=states,
|
|
)
|
|
|
|
class TestMergeGameplayDatabases(unittest.TestCase):
|
|
def _create(self, path:Path, bundle:GameBundle) -> None:
|
|
SqliteGameplayBackend(str(path)).import_game_bundle_sync(bundle)
|
|
|
|
def _merge(self, root:Path, *extra:str):
|
|
return subprocess.run([
|
|
sys.executable, "scripts/merge_gameplay_databases.py",
|
|
"--base", str(root / "base.sqlite3"), "--delta", str(root / "delta.sqlite3"),
|
|
"--destination", str(root / "merged.sqlite3"), *extra,
|
|
], cwd=REPO_ROOT, text=True, capture_output=True)
|
|
|
|
def test_merges_base_and_delta_with_fresh_ids(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
self._create(root / "base.sqlite3", _bundle("base-game", True, cleaned=True))
|
|
self._create(root / "delta.sqlite3", _bundle("delta-game", False))
|
|
|
|
result = self._merge(root, "--minimum-quality", "medium")
|
|
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
merged = SqliteGameplayBackend(str(root / "merged.sqlite3"))
|
|
with closing(sqlite3.connect(root / "merged.sqlite3")) as connection:
|
|
games = connection.execute(
|
|
"SELECT game_id, gid, winner_you, has_replay, quality_tier FROM games ORDER BY game_id"
|
|
).fetchall()
|
|
self.assertEqual(connection.execute("SELECT COUNT(*) FROM game_replays").fetchone()[0], 2)
|
|
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
|
|
self.assertEqual([(g[0], g[2], g[3]) for g in games], [("base-game", 1, 1), ("delta-game", 0, 1)])
|
|
self.assertEqual(games[0][4], "high")
|
|
self.assertEqual(len({g[1] for g in games}), 2)
|
|
for game_id in ("base-game", "delta-game"):
|
|
bundle = merged.read_game_bundle_sync(game_id)
|
|
self.assertEqual(len(bundle.turns), 5)
|
|
self.assertEqual(len(bundle.states), 15)
|
|
me = next(snake for snake in bundle.snakes if snake.snake_id == "me")
|
|
self.assertEqual(me.customizations, CUSTOMIZATIONS)
|
|
|
|
def test_conflicting_duplicate_aborts_without_destination(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
self._create(root / "base.sqlite3", _bundle("same-game", True, cleaned=True))
|
|
self._create(root / "delta.sqlite3", _bundle("same-game", False))
|
|
|
|
result = self._merge(root)
|
|
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("Conflicting duplicate game_id", result.stderr)
|
|
self.assertFalse((root / "merged.sqlite3").exists())
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|