feat: add live replays and PostgreSQL maintenance tools
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
- Stream compact live replay updates across local and clustered dashboards. - Render responsive snake bodies as SVG paths with aligned custom icons. - Add cache-busted assets, replay fallback routes, and live-follow playback. - Support PostgreSQL benchmark sampling and idempotent SQLite migration. - Add dry-run cleanup for old low-quality PostgreSQL replay payloads. - Reward safe perimeter lanes and bump Prism to version 1.5.0. - Add backend, migration, dashboard, and perimeter regression coverage.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import unittest
|
||||
|
||||
from snakes.engine.perimeter import perimeter_geometry_score
|
||||
|
||||
class TestPerimeterGeometryScore(unittest.TestCase):
|
||||
def score(self, point, **overrides):
|
||||
values = {
|
||||
"point": point,
|
||||
"current_head": (0, 5),
|
||||
"width": 11,
|
||||
"height": 11,
|
||||
"occupancy": 0.35,
|
||||
"snake_length": 12,
|
||||
"reachable_space": 80,
|
||||
"required_space": 12,
|
||||
"liberties": 2,
|
||||
"next_options": 2,
|
||||
"safe_next_options": 2,
|
||||
"tail_escape": True,
|
||||
"dead_end": False,
|
||||
"losing_head_to_head": False,
|
||||
}
|
||||
values.update(overrides)
|
||||
return perimeter_geometry_score(**values)
|
||||
|
||||
def test_safe_wall_lane_can_outscore_adjacent_inner_lane(self):
|
||||
wall = self.score((0, 6))
|
||||
inner = self.score((1, 5))
|
||||
|
||||
self.assertGreater(wall, inner)
|
||||
|
||||
def test_unsafe_wall_does_not_receive_perimeter_reward(self):
|
||||
safe_wall = self.score((0, 6))
|
||||
unsafe_wall = self.score((0, 6), safe_next_options=1)
|
||||
|
||||
self.assertGreater(safe_wall, unsafe_wall + 30.0)
|
||||
|
||||
def test_corner_is_less_attractive_than_straight_edge(self):
|
||||
corner = self.score((0, 0), current_head=(0, 1))
|
||||
straight_edge = self.score((0, 6))
|
||||
|
||||
self.assertGreater(straight_edge, corner)
|
||||
|
||||
def test_losing_head_to_head_never_gets_perimeter_reward(self):
|
||||
safe_wall = self.score((0, 6))
|
||||
contested_wall = self.score((0, 6), losing_head_to_head=True)
|
||||
|
||||
self.assertGreater(safe_wall, contested_wall + 30.0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -52,8 +52,8 @@ class TestPrismBattleSnake_GPT_5_6_Sol(unittest.TestCase):
|
||||
snake = PrismBattleSnake_GPT_5_6_Sol()
|
||||
|
||||
self.assertEqual(snake.name, "PrismBattleSnake")
|
||||
self.assertEqual(snake.version, "1.4.0")
|
||||
self.assertEqual(get_snake_version("PrismBattleSnake_GPT_5_6_Sol"), "1.4.0")
|
||||
self.assertEqual(snake.version, "1.5.0")
|
||||
self.assertEqual(get_snake_version("PrismBattleSnake_GPT_5_6_Sol"), "1.5.0")
|
||||
self.assertGreaterEqual(snake._planning_depth, 4)
|
||||
self.assertIsInstance(SnakeBuilder.build("PrismBattleSnake_GPT_5_6_Sol"), PrismBattleSnake_GPT_5_6_Sol)
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import unittest
|
||||
|
||||
from server.database.benchmark_states import _build_state, is_postgresql_source
|
||||
|
||||
class TestBenchmarkStates(unittest.TestCase):
|
||||
def test_detects_postgresql_dsn(self):
|
||||
self.assertTrue(is_postgresql_source("postgresql://user:pass@example.com/db"))
|
||||
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
|
||||
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)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,59 @@
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from server.services.dashboard_query import DashboardQueryService
|
||||
|
||||
class _Logger:
|
||||
def warning(self, message):
|
||||
return message
|
||||
|
||||
class TestDashboardQueryService(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.database = AsyncMock()
|
||||
self.hub = AsyncMock()
|
||||
self.service = DashboardQueryService(
|
||||
gameplay_database=self.database,
|
||||
ws_hub=self.hub,
|
||||
logger=_Logger(),
|
||||
dashboard_running_game_stale_sec=600,
|
||||
)
|
||||
|
||||
async def test_pushes_compact_latest_turn_event(self):
|
||||
self.database.get_game_replay.return_value = {
|
||||
"game": {"game_id": "game-1", "status": "running"},
|
||||
"turns": [{"turn": 1}, {"turn": 2, "my_move": "up"}],
|
||||
}
|
||||
|
||||
await self.service.push_dashboard_game_replay_update("game-1")
|
||||
|
||||
payload = self.hub.broadcast_payload.await_args.args[0]
|
||||
self.assertEqual(payload["type"], "dashboard_game_replay_update")
|
||||
self.assertEqual(payload["game_id"], "game-1")
|
||||
self.assertEqual(payload["turn"], {"turn": 2, "my_move": "up"})
|
||||
self.assertNotIn("turns", payload)
|
||||
|
||||
async def test_publishes_game_id_for_cluster_live_updates(self):
|
||||
self.database.get_game_replay.return_value = {
|
||||
"game": {"game_id": "game-1", "status": "running"},
|
||||
"turns": [{"turn": 3}],
|
||||
}
|
||||
publish = AsyncMock()
|
||||
self.service.set_publish_notice(publish)
|
||||
|
||||
await self.service.push_dashboard_game_replay_update("game-1")
|
||||
|
||||
publish.assert_awaited_once_with("game_turn", "game-1")
|
||||
|
||||
async def test_remote_turn_notice_loads_and_broadcasts_turn(self):
|
||||
self.database.get_game_replay.return_value = {
|
||||
"game": {"game_id": "game-1", "status": "running"},
|
||||
"turns": [{"turn": 4}],
|
||||
}
|
||||
|
||||
await self.service.on_dashboard_games_update_notice("game_turn", "game-1")
|
||||
|
||||
payload = self.hub.broadcast_payload.await_args.args[0]
|
||||
self.assertEqual(payload["turn"]["turn"], 4)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from server.Server import Server
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
REPLAY = {
|
||||
'game': {'game_id': 'game-1', 'width': 11, 'height': 11},
|
||||
'turns': [{'turn': 0, 'snakes': []}],
|
||||
}
|
||||
|
||||
class TestDashboardReplayRoute(unittest.IsolatedAsyncioTestCase):
|
||||
"""The dashboard falls back to this route when the replay websocket is down."""
|
||||
|
||||
def setUp(self):
|
||||
self.server = Server(
|
||||
data_path=REPO_ROOT,
|
||||
snake_type='PrismBattleSnake',
|
||||
storage_type='memory',
|
||||
metrics_backend='memory',
|
||||
gameplay_db_enabled=False,
|
||||
)
|
||||
self.database = AsyncMock()
|
||||
self.server.dashboard_query.gameplay_database = self.database
|
||||
|
||||
async def test_serves_replay_payload(self):
|
||||
self.database.get_game_replay.return_value = dict(REPLAY)
|
||||
|
||||
response = await self.server.app.test_client().get('/dashboard/game/game-1')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = await response.get_json()
|
||||
self.assertEqual(payload['game']['game_id'], 'game-1')
|
||||
self.assertEqual(len(payload['turns']), 1)
|
||||
self.database.get_game_replay.assert_awaited_once_with('game-1')
|
||||
|
||||
async def test_unknown_game_returns_404(self):
|
||||
self.database.get_game_replay.return_value = None
|
||||
|
||||
response = await self.server.app.test_client().get('/dashboard/game/nope')
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
payload = await response.get_json()
|
||||
self.assertEqual(payload['error'], 'game_not_found')
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from server.Server import Server
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
class TestDashboardStaticAssets(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.server = Server(
|
||||
data_path=REPO_ROOT,
|
||||
snake_type='PrismBattleSnake',
|
||||
storage_type='memory',
|
||||
metrics_backend='memory',
|
||||
gameplay_db_enabled=False,
|
||||
)
|
||||
|
||||
def _static_url(self):
|
||||
return self.server.app.jinja_env.globals['static_url']
|
||||
|
||||
async def test_static_url_carries_file_mtime(self):
|
||||
asset = 'js/GameBoard.js'
|
||||
expected_version = int(os.path.getmtime(
|
||||
os.path.join(self.server.app.static_folder, asset)
|
||||
))
|
||||
|
||||
async with self.server.app.test_request_context('/dashboard'):
|
||||
url = self._static_url()(asset)
|
||||
|
||||
self.assertEqual(url, f'/files/{asset}?v={expected_version}')
|
||||
|
||||
async def test_static_url_tolerates_missing_asset(self):
|
||||
async with self.server.app.test_request_context('/dashboard'):
|
||||
url = self._static_url()('js/DoesNotExist.js')
|
||||
|
||||
self.assertEqual(url, '/files/js/DoesNotExist.js?v=0')
|
||||
|
||||
async def test_dashboard_page_versions_every_static_asset(self):
|
||||
client = self.server.app.test_client()
|
||||
response = await client.get('/dashboard')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = await response.get_data(as_text=True)
|
||||
|
||||
references = re.findall(r'(?:href|src)="(/files/[^"]+)"', body)
|
||||
self.assertTrue(references, 'dashboard page referenced no static assets')
|
||||
for reference in references:
|
||||
self.assertRegex(reference, r'\?v=\d+$', f'unversioned static asset: {reference}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,34 @@
|
||||
import unittest
|
||||
|
||||
from scripts.migrate_sqlite_to_postgresql import parse_json, parse_timestamp, transform_row
|
||||
|
||||
class TestMigrateSqliteToPostgresql(unittest.TestCase):
|
||||
def test_transform_row_converts_timestamp_boolean_and_json(self):
|
||||
columns = ("started_at", "winner_you", "quality_reasons", "game_id")
|
||||
row = ("2026-08-01T10:20:30+00:00", 1, '["complete"]', "game-1")
|
||||
|
||||
transformed = transform_row(columns, row)
|
||||
|
||||
self.assertEqual(transformed[0].isoformat(), "2026-08-01T10:20:30+00:00")
|
||||
self.assertIs(transformed[1], True)
|
||||
self.assertEqual(transformed[2], '["complete"]')
|
||||
self.assertEqual(transformed[3], "game-1")
|
||||
|
||||
def test_json_defaults_match_not_null_postgresql_columns(self):
|
||||
columns = ("customizations", "board_state", "snakes", "you", "food", "hazards", "body")
|
||||
self.assertEqual(
|
||||
transform_row(columns, (None,) * len(columns)),
|
||||
("{}", "{}", "[]", "{}", "[]", "[]", "[]"),
|
||||
)
|
||||
|
||||
def test_invalid_json_aborts_instead_of_silently_losing_data(self):
|
||||
with self.assertRaisesRegex(ValueError, "Invalid JSON"):
|
||||
parse_json("{invalid")
|
||||
|
||||
def test_z_suffix_timestamp_is_timezone_aware(self):
|
||||
parsed = parse_timestamp("2026-08-01T10:20:30Z")
|
||||
self.assertIsNotNone(parsed.tzinfo)
|
||||
self.assertEqual(parsed.utcoffset().total_seconds(), 0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,65 @@
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from server.database.backend.PostgresqlGameplayBackend import PostgresqlGameplayBackend
|
||||
|
||||
class TestPostgresqlGameplayMigration(unittest.TestCase):
|
||||
def _create_source(self, path:Path, winner_column:str, winner_value:str|None) -> None:
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.executescript(f"""
|
||||
CREATE TABLE games (
|
||||
game_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, ended_at TEXT,
|
||||
width INTEGER, height INTEGER, source TEXT, map_name TEXT,
|
||||
ruleset_name TEXT, ruleset_version TEXT, your_snake_id TEXT,
|
||||
your_snake_name TEXT, your_snake_type TEXT, your_snake_version TEXT,
|
||||
game_type TEXT, {winner_column} TEXT, winner_you INTEGER,
|
||||
final_turn INTEGER, status TEXT
|
||||
);
|
||||
CREATE TABLE turns (
|
||||
game_id TEXT, turn INTEGER, observed_at TEXT, my_move TEXT,
|
||||
my_thinking_json TEXT, board_state_json TEXT, snakes_json TEXT,
|
||||
you_json TEXT, food_json TEXT, hazards_json TEXT
|
||||
);
|
||||
CREATE TABLE snake_turns (
|
||||
game_id TEXT, turn INTEGER, snake_id TEXT, snake_name TEXT,
|
||||
health INTEGER, length INTEGER, head_x INTEGER, head_y INTEGER,
|
||||
body_json TEXT, is_you INTEGER, inferred_move TEXT, latency TEXT
|
||||
);
|
||||
""")
|
||||
connection.execute(
|
||||
f"""INSERT INTO games (
|
||||
game_id, started_at, {winner_column}, winner_you, final_turn, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
("game-1", "2026-08-01T10:00:00+00:00", winner_value, 1, 4, "finished"),
|
||||
)
|
||||
|
||||
def test_reads_current_winner_name_schema(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "gameplay.sqlite3"
|
||||
self._create_source(path, "winner_name", "Prism")
|
||||
backend = PostgresqlGameplayBackend("postgresql://example", sqlite_migration_path=str(path))
|
||||
|
||||
games, turns, snake_turns = backend._read_sqlite_data_sync(str(path))
|
||||
|
||||
self.assertEqual(games[0]["winner_name"], "Prism")
|
||||
self.assertEqual(turns, [])
|
||||
self.assertEqual(snake_turns, [])
|
||||
self.assertEqual(backend._migrated_winner_name(games[0]["winner_name"]), "Prism")
|
||||
|
||||
def test_reads_legacy_winner_names_json_schema(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "gameplay.sqlite3"
|
||||
self._create_source(path, "winner_names_json", '["Prism"]')
|
||||
backend = PostgresqlGameplayBackend("postgresql://example", sqlite_migration_path=str(path))
|
||||
|
||||
games, _, _ = backend._read_sqlite_data_sync(str(path))
|
||||
|
||||
self.assertEqual(
|
||||
backend._migrated_winner_name(games[0]["winner_name"]),
|
||||
"Prism",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user