feat: add Prism snake and gameplay database lifecycle
Build and Push Docker Container / build-and-push (push) Successful in 8m3s
Build and Push Docker Container / build-and-push (push) Successful in 8m3s
- Add bitboard-accelerated Prism and versioned Supreme snake implementations. - Add database-backed move benchmarks and focused strategy tests. - Normalize gameplay storage while preserving replay compatibility. - Add deterministic game quality scoring and replay retention tiers. - Add backup-first SQLite cleanup, verification, and replacement tooling. - Add safe compact-plus-delta database merging with conflict detection. - Extend SQLite and PostgreSQL schemas for replay and quality metadata. - Add PostgreSQL development service and pytest import configuration. - Update gameplay documentation and the quart_common submodule revision.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import unittest
|
||||
|
||||
from snakes import SnakeBuilder, get_snake_version
|
||||
from snakes.ApexBattleSnake import ApexBattleSnake
|
||||
from snakes.PrismBattleSnake_GPT_5_6_Sol import PrismBattleSnake_GPT_5_6_Sol
|
||||
from snakes.bitboard import BitBoard
|
||||
|
||||
class TestBitBoard(unittest.TestCase):
|
||||
|
||||
def test_flood_fill_respects_walls(self):
|
||||
board = BitBoard(3, 3)
|
||||
blocked = board.set_to_bits({(1, 0), (1, 1), (1, 2)})
|
||||
|
||||
self.assertEqual(board.flood_count(board.idx(0, 1), blocked), 3)
|
||||
self.assertEqual(board.path_distance(board.idx(0, 1), board.idx(2, 1), blocked), None)
|
||||
|
||||
def test_territory_counts_ties_for_neither_side(self):
|
||||
board = BitBoard(5, 1)
|
||||
|
||||
self.assertEqual(board.territory(board.idx(0, 0), [board.idx(4, 0)], 0), 0)
|
||||
|
||||
def test_nearest_food_returns_shortest_distance(self):
|
||||
board = BitBoard(5, 5)
|
||||
food = board.set_to_bits({(4, 4), (2, 1)})
|
||||
|
||||
self.assertEqual(board.nearest_food(board.idx(0, 0), food, 0), (3, board.idx(2, 1)))
|
||||
|
||||
class TestPrismBattleSnake_GPT_5_6_Sol(unittest.TestCase):
|
||||
|
||||
def test_api_name_and_version_are_exposed(self):
|
||||
snake = PrismBattleSnake_GPT_5_6_Sol()
|
||||
|
||||
self.assertEqual(snake.name, "PrismBattleSnake")
|
||||
self.assertEqual(snake.version, "1.0.0")
|
||||
self.assertEqual(get_snake_version("PrismBattleSnake_GPT_5_6_Sol"), "1.0.0")
|
||||
self.assertIsInstance(SnakeBuilder.build("PrismBattleSnake_GPT_5_6_Sol"), PrismBattleSnake_GPT_5_6_Sol)
|
||||
|
||||
def test_bitboard_primitives_match_apex(self):
|
||||
apex = ApexBattleSnake()
|
||||
prism = PrismBattleSnake_GPT_5_6_Sol()
|
||||
blocked = {(1, 0), (1, 1), (3, 2), (3, 3)}
|
||||
|
||||
self.assertEqual(
|
||||
prism._flood_fill_count((0, 0), blocked, 5, 5),
|
||||
apex._flood_fill_count((0, 0), blocked, 5, 5),
|
||||
)
|
||||
self.assertEqual(
|
||||
prism._distance_map((0, 0), blocked, 5, 5),
|
||||
apex._distance_map((0, 0), blocked, 5, 5),
|
||||
)
|
||||
self.assertEqual(
|
||||
prism._path_distance((0, 0), (4, 4), blocked, 5, 5),
|
||||
apex._path_distance((0, 0), (4, 4), blocked, 5, 5),
|
||||
)
|
||||
|
||||
def test_mutated_blocked_set_does_not_return_stale_result(self):
|
||||
snake = PrismBattleSnake_GPT_5_6_Sol()
|
||||
blocked: set[tuple[int, int]] = set()
|
||||
|
||||
open_count = snake._flood_fill_count((1, 1), blocked, 3, 3)
|
||||
blocked.update({(0, 1), (1, 0), (2, 1), (1, 2)})
|
||||
trapped_count = snake._flood_fill_count((1, 1), blocked, 3, 3)
|
||||
|
||||
self.assertEqual(open_count, 9)
|
||||
self.assertEqual(trapped_count, 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Tests for SupremeBattleSnake.
|
||||
|
||||
Validates that the bitboard-accelerated snake produces correct results
|
||||
and that the bitboard engine itself is sound.
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from snakes.SupremeBattleSnake_ClaudeOpus4_6 import SupremeBattleSnake_ClaudeOpus4_6 as SupremeBattleSnake
|
||||
from snakes.bitboard import BitBoard
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def make_board(game_state: dict) -> GameBoard:
|
||||
snake = SupremeBattleSnake()
|
||||
board = GameBoard(
|
||||
game_id=game_state["game"]["id"],
|
||||
width=game_state["board"]["width"],
|
||||
height=game_state["board"]["height"],
|
||||
ruleset=game_state["game"]["ruleset"],
|
||||
source=game_state["game"].get("source", "custom"),
|
||||
map=game_state["game"].get("map", "standard"),
|
||||
snake_class=snake,
|
||||
)
|
||||
board.read_game_data(game_state)
|
||||
return board
|
||||
|
||||
def move(game_state: dict) -> str:
|
||||
return make_board(game_state).snake_neat_make_a_move()
|
||||
|
||||
def gs(
|
||||
my_body: list[tuple],
|
||||
other_bodies: list[list[tuple]] | None = None,
|
||||
foods: list[tuple] | None = None,
|
||||
hazards: list[tuple] | None = None,
|
||||
my_health: int = 90,
|
||||
my_id: str = "me",
|
||||
enemy_health: int = 90,
|
||||
game_type: str = "standard",
|
||||
game_map: str = "standard",
|
||||
hazard_damage: int = 14,
|
||||
width: int = 11,
|
||||
height: int = 11,
|
||||
turn: int = 20,
|
||||
game_id: str = "test-game",
|
||||
) -> dict:
|
||||
other_bodies = other_bodies or []
|
||||
foods = foods or []
|
||||
hazards = hazards or []
|
||||
|
||||
def body_dicts(coords):
|
||||
return [{"x": x, "y": y} for x, y in coords]
|
||||
|
||||
my_snake = {
|
||||
"id": my_id, "name": "SupremeBattleSnake", "health": my_health,
|
||||
"body": body_dicts(my_body),
|
||||
"head": {"x": my_body[0][0], "y": my_body[0][1]},
|
||||
"length": len(my_body),
|
||||
"latency": "50", "shout": "",
|
||||
}
|
||||
|
||||
snakes = [my_snake]
|
||||
for i, body in enumerate(other_bodies):
|
||||
snakes.append({
|
||||
"id": f"enemy-{i}", "name": f"Enemy{i}", "health": enemy_health,
|
||||
"body": body_dicts(body),
|
||||
"head": {"x": body[0][0], "y": body[0][1]},
|
||||
"length": len(body),
|
||||
"latency": "60", "shout": "",
|
||||
})
|
||||
|
||||
ruleset = {
|
||||
"name": game_type, "version": "v1.0.0",
|
||||
"settings": {"hazardDamagePerTurn": hazard_damage},
|
||||
}
|
||||
|
||||
return {
|
||||
"game": {"id": game_id, "ruleset": ruleset, "source": "custom", "map": game_map},
|
||||
"turn": turn,
|
||||
"board": {
|
||||
"height": height, "width": width,
|
||||
"food": body_dicts(foods),
|
||||
"hazards": body_dicts(hazards),
|
||||
"snakes": snakes,
|
||||
},
|
||||
"you": my_snake,
|
||||
}
|
||||
|
||||
# ── BitBoard unit tests ──────────────────────────────────────────────────────
|
||||
|
||||
class TestBitBoard(unittest.TestCase):
|
||||
|
||||
def test_flood_fill_open_board(self):
|
||||
bb = BitBoard(5, 5)
|
||||
count = bb.flood_count(bb.idx(2, 2), 0)
|
||||
self.assertEqual(count, 25)
|
||||
|
||||
def test_flood_fill_blocked_center(self):
|
||||
bb = BitBoard(5, 5)
|
||||
# Block all 4 neighbours of (2,2)
|
||||
blocked = (
|
||||
bb.pt_bit(1, 2) | bb.pt_bit(3, 2)
|
||||
| bb.pt_bit(2, 1) | bb.pt_bit(2, 3)
|
||||
)
|
||||
count = bb.flood_count(bb.idx(2, 2), blocked)
|
||||
self.assertEqual(count, 1) # only the start cell
|
||||
|
||||
def test_flood_fill_row_wall(self):
|
||||
bb = BitBoard(5, 5)
|
||||
# Block entire row y=2, except (2,2) itself
|
||||
blocked = 0
|
||||
for x in range(5):
|
||||
if x != 2:
|
||||
blocked |= bb.pt_bit(x, 2)
|
||||
# Start at (2,3) — should reach everything above the wall
|
||||
count_above = bb.flood_count(bb.idx(2, 3), blocked)
|
||||
self.assertGreater(count_above, 1)
|
||||
self.assertLess(count_above, 25)
|
||||
|
||||
def test_territory_center_vs_corner(self):
|
||||
bb = BitBoard(11, 11)
|
||||
score = bb.territory(bb.idx(5, 5), [bb.idx(0, 0)], 0)
|
||||
self.assertGreater(score, 0)
|
||||
|
||||
def test_territory_symmetric(self):
|
||||
bb = BitBoard(11, 11)
|
||||
score = bb.territory(bb.idx(0, 0), [bb.idx(10, 10)], 0)
|
||||
self.assertEqual(score, 0) # symmetric → tied
|
||||
|
||||
def test_partition_sizes_no_cut(self):
|
||||
bb = BitBoard(5, 5)
|
||||
sizes = bb.partition_sizes(bb.idx(2, 2), 0)
|
||||
# Open board — removing center doesn't split it (all neighbours connected)
|
||||
self.assertEqual(sizes, [])
|
||||
|
||||
def test_partition_sizes_bridge(self):
|
||||
bb = BitBoard(3, 3)
|
||||
# Block corners so (1,1) becomes a bridge:
|
||||
# . X .
|
||||
# X . X
|
||||
# . X .
|
||||
blocked = (
|
||||
bb.pt_bit(0, 0) | bb.pt_bit(2, 0)
|
||||
| bb.pt_bit(0, 2) | bb.pt_bit(2, 2)
|
||||
)
|
||||
sizes = bb.partition_sizes(bb.idx(1, 1), blocked)
|
||||
# Removing (1,1) from the cross → 4 isolated cells
|
||||
self.assertEqual(len(sizes), 4)
|
||||
self.assertTrue(all(s == 1 for s in sizes))
|
||||
|
||||
def test_distance_map_correctness(self):
|
||||
bb = BitBoard(5, 5)
|
||||
dmap = bb.distance_map(bb.idx(0, 0), 0)
|
||||
self.assertEqual(dmap[bb.idx(0, 0)], 0)
|
||||
self.assertEqual(dmap[bb.idx(1, 0)], 1)
|
||||
self.assertEqual(dmap[bb.idx(4, 4)], 8)
|
||||
|
||||
def test_path_distance_blocked(self):
|
||||
bb = BitBoard(5, 5)
|
||||
# Block a wall separating left from right
|
||||
blocked = 0
|
||||
for y in range(5):
|
||||
blocked |= bb.pt_bit(2, y)
|
||||
result = bb.path_distance(bb.idx(0, 0), bb.idx(4, 4), blocked)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_path_distance_unblocked(self):
|
||||
bb = BitBoard(5, 5)
|
||||
result = bb.path_distance(bb.idx(0, 0), bb.idx(4, 4), 0)
|
||||
self.assertEqual(result, 8)
|
||||
|
||||
def test_nearest_food_finds_closest(self):
|
||||
bb = BitBoard(11, 11)
|
||||
food = bb.pt_bit(5, 6) | bb.pt_bit(0, 0)
|
||||
dist, idx = bb.nearest_food(bb.idx(5, 5), food, 0)
|
||||
self.assertEqual(dist, 1)
|
||||
self.assertEqual(idx, bb.idx(5, 6))
|
||||
|
||||
def test_nearest_food_none(self):
|
||||
bb = BitBoard(5, 5)
|
||||
dist, idx = bb.nearest_food(bb.idx(2, 2), 0, 0)
|
||||
self.assertIsNone(dist)
|
||||
|
||||
def test_open_neighbor_count_center(self):
|
||||
bb = BitBoard(5, 5)
|
||||
self.assertEqual(bb.open_neighbor_count(bb.idx(2, 2), 0), 4)
|
||||
|
||||
def test_open_neighbor_count_corner(self):
|
||||
bb = BitBoard(5, 5)
|
||||
self.assertEqual(bb.open_neighbor_count(bb.idx(0, 0), 0), 2)
|
||||
|
||||
def test_set_to_bits_roundtrip(self):
|
||||
bb = BitBoard(11, 11)
|
||||
pts = {(3, 7), (0, 0), (10, 10), (5, 5)}
|
||||
bits = bb.set_to_bits(pts)
|
||||
for x, y in pts:
|
||||
self.assertTrue(bits & bb.pt_bit(x, y))
|
||||
self.assertEqual(bits.bit_count(), len(pts))
|
||||
|
||||
def test_no_row_wraparound(self):
|
||||
"""Right-column expansion must not wrap to the next row's left column."""
|
||||
bb = BitBoard(5, 5)
|
||||
start = bb.idx(4, 0) # rightmost column, bottom row
|
||||
# Block everything except start and (0,1) — if wrapping happened, (0,1) would be adjacent
|
||||
blocked = bb.board_mask & ~(1 << start) & ~bb.pt_bit(0, 1)
|
||||
reachable = bb.flood_fill(start, blocked)
|
||||
self.assertEqual(reachable.bit_count(), 1) # only start itself
|
||||
|
||||
# ── Snake safety tests ────────────────────────────────────────────────────────
|
||||
|
||||
class TestSupremeWallAndBodyAvoidance(unittest.TestCase):
|
||||
|
||||
def test_avoids_left_wall(self):
|
||||
result = move(gs(my_body=[(0, 5), (1, 5), (2, 5)],
|
||||
other_bodies=[[(9, 9), (9, 8), (9, 7)]]))
|
||||
self.assertNotEqual(result, "left")
|
||||
|
||||
def test_avoids_bottom_wall(self):
|
||||
result = move(gs(my_body=[(5, 0), (5, 1), (5, 2)],
|
||||
other_bodies=[[(9, 9), (9, 8), (9, 7)]]))
|
||||
self.assertNotEqual(result, "down")
|
||||
|
||||
def test_avoids_own_body(self):
|
||||
result = move(gs(my_body=[(5, 5), (6, 5), (7, 5), (8, 5)],
|
||||
other_bodies=[[(1, 1), (1, 2), (1, 3)]],
|
||||
foods=[(5, 9)]))
|
||||
self.assertNotEqual(result, "right")
|
||||
|
||||
def test_avoids_enemy_body(self):
|
||||
result = move(gs(my_body=[(5, 5), (5, 4), (5, 3)],
|
||||
other_bodies=[[(6, 5), (7, 5), (8, 5), (9, 5), (9, 6), (9, 7)]]))
|
||||
self.assertNotEqual(result, "right")
|
||||
|
||||
def test_only_one_safe_move_taken(self):
|
||||
result = move(gs(my_body=[(1, 1), (1, 2), (2, 2), (2, 1)],
|
||||
other_bodies=[], foods=[(5, 5)], width=7, height=7))
|
||||
self.assertEqual(result, "right")
|
||||
|
||||
def test_no_safe_moves_returns_valid_direction(self):
|
||||
result = move(gs(my_body=[(0, 0), (0, 1), (1, 1), (1, 0)], other_bodies=[]))
|
||||
self.assertIn(result, ("up", "down", "left", "right"))
|
||||
|
||||
# ── Duel mode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSupremeDuelMode(unittest.TestCase):
|
||||
|
||||
def test_avoids_h2h_with_equal_length(self):
|
||||
result = move(gs(my_body=[(5, 5), (5, 4), (5, 3)],
|
||||
other_bodies=[[(7, 5), (7, 4), (7, 3)]],
|
||||
foods=[(0, 0)]))
|
||||
self.assertNotEqual(result, "right")
|
||||
|
||||
def test_head_hunts_smaller_enemy(self):
|
||||
result = move(gs(
|
||||
my_body=[(5, 5), (5, 4), (5, 3), (5, 2), (5, 1), (4, 1), (4, 2)],
|
||||
other_bodies=[[(7, 5), (7, 4), (7, 3)]],
|
||||
foods=[(0, 0)]))
|
||||
self.assertEqual(result, "right")
|
||||
|
||||
def test_chases_food_when_low_health(self):
|
||||
result = move(gs(my_body=[(5, 5), (5, 4), (5, 3)],
|
||||
other_bodies=[[(9, 9), (9, 8), (9, 7)]],
|
||||
foods=[(5, 6)], my_health=10))
|
||||
self.assertEqual(result, "up")
|
||||
|
||||
# ── Constrictor mode ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestSupremeConstrictorMode(unittest.TestCase):
|
||||
|
||||
def test_returns_valid_move(self):
|
||||
result = move(gs(my_body=[(5, 5), (5, 4), (5, 3)],
|
||||
other_bodies=[[(3, 3), (3, 4), (3, 5)]],
|
||||
game_type="constrictor"))
|
||||
self.assertIn(result, ("up", "down", "left", "right"))
|
||||
|
||||
# ── Multi-snake mode ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestSupremeMultiSnakeMode(unittest.TestCase):
|
||||
|
||||
def test_returns_valid_move(self):
|
||||
result = move(gs(my_body=[(5, 5), (5, 4), (5, 3)],
|
||||
other_bodies=[[(2, 2), (2, 3), (2, 4)],
|
||||
[(8, 8), (8, 7), (8, 6)]],
|
||||
foods=[(3, 3), (7, 7)]))
|
||||
self.assertIn(result, ("up", "down", "left", "right"))
|
||||
|
||||
# ── Hazard tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSupremeHazard(unittest.TestCase):
|
||||
|
||||
def test_hazard_penalizes_score(self):
|
||||
snake = SupremeBattleSnake()
|
||||
snake._bb = BitBoard(11, 11)
|
||||
snake._bb_w = 11
|
||||
snake._bb_h = 11
|
||||
snake._bits_cache = {}
|
||||
snake._bits_cache_turn = 0
|
||||
snake.game_board = make_board(gs(
|
||||
my_body=[(5, 5), (5, 4), (5, 3)],
|
||||
hazards=[(6, 5)], hazard_damage=14))
|
||||
snake.previous_hazards = {(6, 5)}
|
||||
snake._enemy_dmaps = []
|
||||
snake._enemy_heads = []
|
||||
snake._base_blocked = set()
|
||||
|
||||
score_right, _ = snake._score_move(
|
||||
move="right", pos={"x": 6, "y": 5},
|
||||
my_body=[{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}],
|
||||
my_len=3, my_health=90,
|
||||
other_snakes=[], food_set=set(),
|
||||
hazard_set={(6, 5)}, hazard_damage=14, hazard_count={(6, 5): 1},
|
||||
previous_hazard_set={(6, 5)},
|
||||
is_constrictor=False, enemy_attack_map={},
|
||||
enemy_can_grow={}, total_occupancy=0.05,
|
||||
width=11, height=11, deadline=None)
|
||||
score_up, _ = snake._score_move(
|
||||
move="up", pos={"x": 5, "y": 6},
|
||||
my_body=[{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}],
|
||||
my_len=3, my_health=90,
|
||||
other_snakes=[], food_set=set(),
|
||||
hazard_set={(6, 5)}, hazard_damage=14, hazard_count={(6, 5): 1},
|
||||
previous_hazard_set={(6, 5)},
|
||||
is_constrictor=False, enemy_attack_map={},
|
||||
enemy_can_grow={}, total_occupancy=0.05,
|
||||
width=11, height=11, deadline=None)
|
||||
self.assertGreater(score_up, score_right)
|
||||
|
||||
# ── Version ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSupremeVersion(unittest.TestCase):
|
||||
|
||||
def test_version(self):
|
||||
self.assertEqual(SupremeBattleSnake.VERSION, "1.0.0")
|
||||
|
||||
def test_class_name_contains_claude(self):
|
||||
snake = SupremeBattleSnake()
|
||||
self.assertIn("Claude", snake.__class__.__name__)
|
||||
|
||||
def test_builder(self):
|
||||
from snakes import SnakeBuilder
|
||||
snake = SnakeBuilder.build("SupremeBattleSnake_ClaudeOpus4_6")
|
||||
self.assertIsInstance(snake, SupremeBattleSnake)
|
||||
|
||||
# ── Parity: Supreme makes same decisions as Apex on key scenarios ────────────
|
||||
|
||||
class TestParityWithApex(unittest.TestCase):
|
||||
"""Ensure the bitboard optimisations don't change strategic behaviour."""
|
||||
|
||||
def test_trapped_corner(self):
|
||||
"""Both snakes should survive a forced single-exit scenario."""
|
||||
from snakes.ApexBattleSnake import ApexBattleSnake
|
||||
|
||||
state = gs(my_body=[(1, 1), (1, 2), (2, 2), (2, 1)],
|
||||
other_bodies=[], foods=[(5, 5)], width=7, height=7)
|
||||
|
||||
apex_snake = ApexBattleSnake()
|
||||
apex_board = GameBoard(game_id="parity", width=7, height=7,
|
||||
ruleset=state["game"]["ruleset"],
|
||||
source="custom", map="standard",
|
||||
snake_class=apex_snake)
|
||||
apex_board.read_game_data(state)
|
||||
apex_move = apex_board.snake_neat_make_a_move()
|
||||
|
||||
supreme_move = move(state)
|
||||
|
||||
# Both must find the only safe exit
|
||||
self.assertEqual(apex_move, "right")
|
||||
self.assertEqual(supreme_move, "right")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
import unittest
|
||||
|
||||
from server.database.game_quality import GameQualityInput, quality_meets_minimum, rate_game_quality
|
||||
|
||||
class TestGameQuality(unittest.TestCase):
|
||||
def test_complete_competitive_game_is_high_quality(self):
|
||||
quality = rate_game_quality(GameQualityInput(
|
||||
status="finished", final_turn=80, turn_rows=80, min_turn=1, max_turn=80,
|
||||
valid_moves=80, thinking_rows=80, distinct_moves=4,
|
||||
snake_turn_rows=160, winner_name="PrismBattleSnake",
|
||||
))
|
||||
|
||||
self.assertEqual(quality.tier, "high")
|
||||
self.assertGreaterEqual(quality.score, 80)
|
||||
|
||||
def test_short_but_valid_game_is_not_invalid(self):
|
||||
quality = rate_game_quality(GameQualityInput(
|
||||
status="finished", final_turn=5, turn_rows=5, min_turn=1, max_turn=5,
|
||||
valid_moves=5, thinking_rows=5, distinct_moves=3,
|
||||
snake_turn_rows=10, winner_name="PrismBattleSnake",
|
||||
))
|
||||
|
||||
self.assertIn(quality.tier, ("low", "medium"))
|
||||
self.assertIn("short_game", quality.reasons)
|
||||
|
||||
def test_incomplete_game_is_invalid(self):
|
||||
quality = rate_game_quality(GameQualityInput(
|
||||
status="finished", final_turn=100, turn_rows=10, min_turn=1, max_turn=10,
|
||||
valid_moves=10, thinking_rows=10, distinct_moves=4,
|
||||
snake_turn_rows=20, winner_name=None,
|
||||
))
|
||||
|
||||
self.assertEqual(quality.tier, "invalid")
|
||||
self.assertIn("incomplete_turn_sequence", quality.reasons)
|
||||
|
||||
def test_minimum_tier_order(self):
|
||||
self.assertTrue(quality_meets_minimum("high", "medium"))
|
||||
self.assertTrue(quality_meets_minimum("medium", "medium"))
|
||||
self.assertFalse(quality_meets_minimum("low", "medium"))
|
||||
self.assertFalse(quality_meets_minimum("invalid", "low"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -88,6 +88,27 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase):
|
||||
turns_count = connection.execute("SELECT COUNT(*) FROM turns WHERE game_id = ?", ("game-abc",)).fetchone()[0]
|
||||
self.assertEqual(turns_count, 2)
|
||||
|
||||
compact_turn = connection.execute("""
|
||||
SELECT snakes_json, you_json, food_json, hazards_json
|
||||
FROM turns WHERE game_id = ? AND turn = ?
|
||||
""", ("game-abc", 2)).fetchone()
|
||||
self.assertEqual(compact_turn, ("[]", "{}", '[{"x":2,"y":2}]', "[]"))
|
||||
stored_body = connection.execute("""
|
||||
SELECT body_json FROM snake_turns
|
||||
WHERE game_id = ? AND turn = ? AND snake_id = ?
|
||||
""", ("game-abc", 2, "me")).fetchone()[0]
|
||||
self.assertNotEqual(stored_body, "[]")
|
||||
identities = connection.execute("""
|
||||
SELECT snake_id, snake_name, is_you FROM game_snakes
|
||||
WHERE game_id = ? ORDER BY snake_id
|
||||
""", ("game-abc",)).fetchall()
|
||||
self.assertEqual(identities, [("enemy", "Enemy", 0), ("me", "Me", 1)])
|
||||
repeated_identity = connection.execute("""
|
||||
SELECT snake_name, is_you FROM snake_turns
|
||||
WHERE game_id = ? AND turn = ? AND snake_id = ?
|
||||
""", ("game-abc", 2, "me")).fetchone()
|
||||
self.assertEqual(repeated_identity, (None, 0))
|
||||
|
||||
me_inferred = connection.execute("SELECT inferred_move FROM snake_turns WHERE game_id = ? AND turn = ? AND snake_id = ?", ("game-abc", 2, "me")).fetchone()[0]
|
||||
enemy_inferred = connection.execute("SELECT inferred_move FROM snake_turns WHERE game_id = ? AND turn = ? AND snake_id = ?", ("game-abc", 2, "enemy")).fetchone()[0]
|
||||
self.assertEqual(me_inferred, "up")
|
||||
@@ -104,6 +125,9 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(len(replay["turns"]), 2)
|
||||
self.assertEqual(replay["turns"][1]["my_move"], "up")
|
||||
self.assertEqual(replay["turns"][1]["my_thinking"]["reason"], "food")
|
||||
self.assertEqual(replay["turns"][1]["food"], [{"x": 2, "y": 2}])
|
||||
self.assertEqual(replay["turns"][1]["you"]["id"], "me")
|
||||
self.assertEqual(len(replay["turns"][1]["snakes"][0]["body"]), 3)
|
||||
|
||||
connection.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from server.database.backend.SqliteGameplayBackend import SqliteGameplayBackend
|
||||
|
||||
class TestMergeGameplayDatabases(unittest.TestCase):
|
||||
def _create_game(self, path:Path, game_id:str, winner_you:int, cleaned:bool=False) -> None:
|
||||
SqliteGameplayBackend(str(path))
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute("""
|
||||
INSERT INTO games (
|
||||
game_id, started_at, ended_at, width, height, source, map_name,
|
||||
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
|
||||
your_snake_type, your_snake_version, game_type, winner_name,
|
||||
winner_you, final_turn, status, has_replay, quality_status,
|
||||
quality_score, quality_tier, quality_reasons_json
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
game_id, "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z", 11, 11,
|
||||
"league", "standard", "standard", "v1", "me", "PrismBattleSnake",
|
||||
"PrismBattleSnake_GPT_5_6_Sol", "1.0.0", "duel", "PrismBattleSnake",
|
||||
winner_you, 1, "finished", 1, "retained",
|
||||
90 if cleaned else None, "high" if cleaned else None,
|
||||
'["already_scored"]' if cleaned else None,
|
||||
))
|
||||
connection.execute(
|
||||
"INSERT INTO game_snakes (game_id,snake_id,snake_name,is_you) VALUES (?,?,?,?)",
|
||||
(game_id, "me", "PrismBattleSnake", 1),
|
||||
)
|
||||
connection.execute("""
|
||||
INSERT INTO turns (
|
||||
game_id,turn,observed_at,my_move,my_thinking_json,
|
||||
board_state_json,snakes_json,you_json,food_json,hazards_json
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
""", (game_id, 1, "2026-01-01T00:00:01Z", "up", '{"score":1}', '{}', '[]', '{}', '[]', '[]'))
|
||||
connection.execute("""
|
||||
INSERT INTO snake_turns (
|
||||
game_id,turn,snake_id,health,length,head_x,head_y,body_json,is_you,inferred_move
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
""", (game_id, 1, "me", 90, 3, 1, 1, '[{"x":1,"y":1}]', 0, "up"))
|
||||
|
||||
def test_merges_base_and_delta_and_regenerates_row_ids(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
base = root / "base.sqlite3"
|
||||
delta = root / "delta.sqlite3"
|
||||
merged = root / "merged.sqlite3"
|
||||
self._create_game(base, "base-game", 1, cleaned=True)
|
||||
self._create_game(delta, "delta-game", 0)
|
||||
|
||||
result = subprocess.run([
|
||||
sys.executable, "scripts/merge_gameplay_databases.py",
|
||||
"--base", str(base), "--delta", str(delta),
|
||||
"--destination", str(merged), "--minimum-quality", "medium",
|
||||
], cwd=Path(__file__).resolve().parents[1], text=True, capture_output=True)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
with sqlite3.connect(merged) as connection:
|
||||
games = connection.execute(
|
||||
"SELECT game_id, winner_you, has_replay, quality_tier FROM games ORDER BY game_id"
|
||||
).fetchall()
|
||||
self.assertEqual(games, [
|
||||
("base-game", 1, 1, "high"),
|
||||
("delta-game", 0, 1, "medium"),
|
||||
])
|
||||
self.assertEqual(connection.execute("SELECT COUNT(*) FROM turns").fetchone()[0], 2)
|
||||
self.assertEqual(connection.execute("SELECT COUNT(*) FROM snake_turns").fetchone()[0], 2)
|
||||
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
|
||||
|
||||
def test_conflicting_duplicate_aborts_without_destination(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
base = root / "base.sqlite3"
|
||||
delta = root / "delta.sqlite3"
|
||||
merged = root / "merged.sqlite3"
|
||||
self._create_game(base, "same-game", 1, cleaned=True)
|
||||
self._create_game(delta, "same-game", 0)
|
||||
|
||||
result = subprocess.run([
|
||||
sys.executable, "scripts/merge_gameplay_databases.py",
|
||||
"--base", str(base), "--delta", str(delta), "--destination", str(merged),
|
||||
], cwd=Path(__file__).resolve().parents[1], text=True, capture_output=True)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Conflicting duplicate game_id", result.stderr)
|
||||
self.assertFalse(merged.exists())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user