diff --git a/server/database/backend/PostgresqlGameplayBackend.py b/server/database/backend/PostgresqlGameplayBackend.py index cc91040..0b8f8ca 100644 --- a/server/database/backend/PostgresqlGameplayBackend.py +++ b/server/database/backend/PostgresqlGameplayBackend.py @@ -76,6 +76,7 @@ CREATE TABLE IF NOT EXISTS game_snakes ( snake_id TEXT NOT NULL, snake_name TEXT, is_you BOOLEAN NOT NULL DEFAULT FALSE, + customizations JSONB NOT NULL DEFAULT '{}'::jsonb, PRIMARY KEY (game_id, snake_id) ); @@ -114,6 +115,7 @@ ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_tier TEXT; ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_reasons JSONB; ALTER TABLE turns ADD COLUMN IF NOT EXISTS my_thinking JSONB; ALTER TABLE snake_turns ADD COLUMN IF NOT EXISTS latency TEXT; +ALTER TABLE game_snakes ADD COLUMN IF NOT EXISTS customizations JSONB NOT NULL DEFAULT '{}'::jsonb; """ # Force TOAST compression on the large JSONB columns so that even @@ -458,14 +460,22 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): async with pool.acquire() as conn: async with conn.transaction(): await conn.executemany(""" - INSERT INTO game_snakes (game_id, snake_id, snake_name, is_you) - VALUES ($1,$2,$3,$4) + INSERT INTO game_snakes ( + game_id, snake_id, snake_name, is_you, customizations + ) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (game_id, snake_id) DO UPDATE SET snake_name = EXCLUDED.snake_name, - is_you = EXCLUDED.is_you + is_you = EXCLUDED.is_you, + customizations = EXCLUDED.customizations """, [ - (game_id, snake.get("id"), snake.get("name"), snake.get("id") == you.get("id")) + ( + game_id, + snake.get("id"), + snake.get("name"), + snake.get("id") == you.get("id"), + snake.get("customizations") or {}, + ) for snake in snakes if snake.get("id") is not None ], ) @@ -739,6 +749,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): COALESCE(gs.snake_name, st.snake_name) AS snake_name, st.health, st.length, st.head_x, st.head_y, st.body AS body_json, COALESCE(gs.is_you, st.is_you) AS is_you, + COALESCE(gs.customizations, '{}'::jsonb) AS customizations_json, st.inferred_move, st.latency FROM snake_turns AS st LEFT JOIN game_snakes AS gs diff --git a/server/database/backend/SqliteGameplayBackend.py b/server/database/backend/SqliteGameplayBackend.py index df81ea6..8d5f8cc 100644 --- a/server/database/backend/SqliteGameplayBackend.py +++ b/server/database/backend/SqliteGameplayBackend.py @@ -112,6 +112,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate): snake_id TEXT NOT NULL, snake_name TEXT, is_you INTEGER NOT NULL DEFAULT 0, + customizations_json TEXT NOT NULL DEFAULT '{}', PRIMARY KEY (game_id, snake_id), FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE ); @@ -140,6 +141,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate): self._ensure_column_exists(connection, "games", "your_snake_version", "TEXT") self._ensure_column_exists(connection, "games", "game_type", "TEXT") self._ensure_column_exists(connection, "snake_turns", "latency", "TEXT") + self._ensure_column_exists(connection, "game_snakes", "customizations_json", "TEXT NOT NULL DEFAULT '{}'") self._ensure_column_exists(connection, "games", "winner_name", "TEXT") self._ensure_column_exists(connection, "games", "has_replay", "INTEGER NOT NULL DEFAULT 1") self._ensure_column_exists(connection, "games", "quality_status", "TEXT NOT NULL DEFAULT 'retained'") @@ -266,14 +268,22 @@ class SqliteGameplayBackend(GameplayBackendTemplate): with self._connect() as connection: connection.executemany(""" - INSERT INTO game_snakes (game_id, snake_id, snake_name, is_you) - VALUES (?, ?, ?, ?) + INSERT INTO game_snakes ( + game_id, snake_id, snake_name, is_you, customizations_json + ) VALUES (?, ?, ?, ?, ?) ON CONFLICT(game_id, snake_id) DO UPDATE SET snake_name = excluded.snake_name, - is_you = excluded.is_you + is_you = excluded.is_you, + customizations_json = excluded.customizations_json """, [ - (game_id, snake.get("id"), snake.get("name"), 1 if snake.get("id") == you.get("id") else 0) + ( + game_id, + snake.get("id"), + snake.get("name"), + 1 if snake.get("id") == you.get("id") else 0, + self._to_json(snake.get("customizations") or {}), + ) for snake in snakes if snake.get("id") is not None ], ) @@ -545,6 +555,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate): COALESCE(gs.snake_name, st.snake_name) AS snake_name, st.health, st.length, st.head_x, st.head_y, st.body_json, COALESCE(gs.is_you, st.is_you) AS is_you, + COALESCE(gs.customizations_json, '{}') AS customizations_json, st.inferred_move, st.latency FROM snake_turns AS st LEFT JOIN game_snakes AS gs diff --git a/server/database/normalized_turn.py b/server/database/normalized_turn.py index 227c3a1..460a705 100644 --- a/server/database/normalized_turn.py +++ b/server/database/normalized_turn.py @@ -27,6 +27,7 @@ def hydrate_replay_turns(game_row, turn_rows, snake_rows, decode_json:Callable) api_snakes = [] for snake_row in rows_by_turn.get(turn, []): body = decode_json(snake_row["body_json"]) or [] + customizations = decode_json(snake_row["customizations_json"]) or {} api_snake = { "id": snake_row["snake_id"], "name": snake_row["snake_name"], @@ -34,6 +35,7 @@ def hydrate_replay_turns(game_row, turn_rows, snake_rows, decode_json:Callable) "length": snake_row["length"], "head": {"x": snake_row["head_x"], "y": snake_row["head_y"]}, "body": body, + "customizations": customizations, } if snake_row["latency"] is not None: api_snake["latency"] = snake_row["latency"] @@ -45,6 +47,7 @@ def hydrate_replay_turns(game_row, turn_rows, snake_rows, decode_json:Callable) "length": snake_row["length"], "head": api_snake["head"], "body": body, + "customizations": customizations, "is_you": bool(snake_row["is_you"]), "inferred_move": snake_row["inferred_move"], "latency": snake_row["latency"], diff --git a/snakes/PrismBattleSnake_GPT_5_6_Sol.py b/snakes/PrismBattleSnake_GPT_5_6_Sol.py index 9e46762..73e7fe5 100644 --- a/snakes/PrismBattleSnake_GPT_5_6_Sol.py +++ b/snakes/PrismBattleSnake_GPT_5_6_Sol.py @@ -23,6 +23,8 @@ Key speedups: S10: _legal_moves override uses bitboard neighbour mask instead of per-direction Python loop + _in_bounds calls. S11: _future_survival_tree inlines legal-move check with bitboard ops. + S12: Duel minimax uses tuple bodies and bitboard move generation. + S13: Iterative deepening reuses a transposition table and move-order hints. """ from __future__ import annotations @@ -31,6 +33,7 @@ from time import perf_counter from snakes.ApexBattleSnake import ApexBattleSnake from snakes.bitboard import BitBoard +from snakes.bitboard_duel_search import BitboardDuelSearch from server.GameBoard import GameBoard # Direction offsets for coord-dict → tuple conversion @@ -245,6 +248,61 @@ class PrismBattleSnake_GPT_5_6_Sol(ApexBattleSnake): blocked_bits = self._blocked_to_bits(blocked, width, height) return bb.open_neighbor_count(bb.idx(head["x"], head["y"]), blocked_bits) + # ── S12/S13: compact bitboard duel search ─────────────────────────────── + + def _new_duel_search( + self, food_set: set, hazard_set: set, hazard_count: dict, + hazard_damage: int, width: int, height: int, deadline: float | None, + ) -> BitboardDuelSearch: + return BitboardDuelSearch( + board=self._get_bb(width, height), + food=food_set, + hazards=hazard_set, + hazard_count=hazard_count, + hazard_damage=hazard_damage, + deadline=deadline, + ) + + def _minimax_sim_id( + self, my_body: list, enemy_body: list, food_set: set, hazard_set: set, + my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict, + width: int, height: int, max_depth: int, alpha: float, beta: float, + deadline: float | None, previous_hazard_set: set | None = None, + ) -> tuple[float, int]: + """Run iterative deepening with one reusable compact search context.""" + search = self._new_duel_search( + food_set, hazard_set, hazard_count, hazard_damage, + width, height, deadline, + ) + return search.search( + my_body=my_body, + enemy_body=enemy_body, + my_health=my_health, + enemy_health=enemy_health, + max_depth=max_depth, + previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set, + ) + + def _minimax_sim( + self, my_body: list, enemy_body: list, food_set: set, hazard_set: set, + my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict, + width: int, height: int, depth: int, alpha: float, beta: float, + deadline: float | None, previous_hazard_set: set | None = None, + ) -> float: + """Compatibility entry point for tests and callers requesting one depth.""" + search = self._new_duel_search( + food_set, hazard_set, hazard_count, hazard_damage, + width, height, deadline, + ) + return search.search_depth( + my_body=my_body, + enemy_body=enemy_body, + my_health=my_health, + enemy_health=enemy_health, + depth=depth, + previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set, + ) + # ── S9: Optimised survival tree (bitboard-native) ──────────────────────── def _future_position_score( diff --git a/snakes/bitboard_duel_search.py b/snakes/bitboard_duel_search.py new file mode 100644 index 0000000..4a25367 --- /dev/null +++ b/snakes/bitboard_duel_search.py @@ -0,0 +1,291 @@ +"""Deadline-aware simultaneous duel search using compact tuple bodies and bitboards.""" + +from __future__ import annotations + +from dataclasses import dataclass +from time import perf_counter +from typing import Iterable + +from snakes.bitboard import BitBoard + +Body = tuple[int, ...] + +@dataclass(frozen=True, slots=True) +class DuelState: + my_body: Body + enemy_body: Body + food_bits: int + my_health: int + enemy_health: int + previous_hazard_bits: int + +class BitboardDuelSearch: + """Iterative-deepening paranoid minimax for a two-snake game. + + The public API still accepts Battlesnake body dictionaries. Search nodes use + flat cell indices, immutable tuples, and integer masks to avoid allocation of + coordinate dictionaries and sets in the hot path. + """ + + WIN = 100_000.0 + LOSS = -100_000.0 + + def __init__( + self, + board: BitBoard, + food: Iterable[tuple[int, int]], + hazards: Iterable[tuple[int, int]], + hazard_count: dict[tuple[int, int], int], + hazard_damage: int, + deadline: float | None, + ) -> None: + self.board = board + self.deadline = deadline + self.hazard_damage = hazard_damage + self.food_bits = board.set_to_bits(set(food)) + self.hazard_bits = board.set_to_bits(set(hazards)) + self.hazard_stacks = { + board.idx(x, y): count for (x, y), count in hazard_count.items() + } + self.transposition: dict[tuple[DuelState, int], tuple[float, str]] = {} + self.killer_moves: dict[int, int] = {} + self.history: dict[int, int] = {} + self.nodes = 0 + self.cache_hits = 0 + + def body_from_dicts(self, body: list[dict]) -> Body: + return tuple(self.board.idx(seg["x"], seg["y"]) for seg in body) + + def search( + self, + my_body: list[dict], + enemy_body: list[dict], + my_health: int, + enemy_health: int, + max_depth: int, + previous_hazards: Iterable[tuple[int, int]], + ) -> tuple[float, int]: + state = DuelState( + my_body=self.body_from_dicts(my_body), + enemy_body=self.body_from_dicts(enemy_body), + food_bits=self.food_bits, + my_health=my_health, + enemy_health=enemy_health, + previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)), + ) + result = self._evaluate(state) + completed_depth = 0 + + for depth in range(1, max_depth + 1): + if self._out_of_time(5.0): + break + value, completed = self._search(state, depth, -float("inf"), float("inf")) + if not completed: + break + result = value + completed_depth = depth + + return result, completed_depth + + def search_depth( + self, + my_body: list[dict], + enemy_body: list[dict], + my_health: int, + enemy_health: int, + depth: int, + previous_hazards: Iterable[tuple[int, int]], + ) -> float: + state = DuelState( + my_body=self.body_from_dicts(my_body), + enemy_body=self.body_from_dicts(enemy_body), + food_bits=self.food_bits, + my_health=my_health, + enemy_health=enemy_health, + previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)), + ) + value, _ = self._search(state, depth, -float("inf"), float("inf")) + return value + + def _search(self, state: DuelState, depth: int, alpha: float, beta: float) -> tuple[float, bool]: + self.nodes += 1 + if self._out_of_time(): + return self._evaluate(state), False + if depth <= 0: + return self._evaluate(state), True + + cache_key = (state, depth) + original_alpha, original_beta = alpha, beta + cached = self.transposition.get(cache_key) + if cached is not None: + self.cache_hits += 1 + cached_value, bound = cached + if bound == "exact": + return cached_value, True + if bound == "lower": + alpha = max(alpha, cached_value) + else: + beta = min(beta, cached_value) + if alpha >= beta: + return cached_value, True + + my_moves = self._legal_targets(state.my_body, state.enemy_body) + enemy_moves = self._legal_targets(state.enemy_body, state.my_body) + if not my_moves: + return self.LOSS - depth, True + if not enemy_moves: + return self.WIN + depth, True + + my_moves = self._ordered_moves(my_moves, state, depth, True) + enemy_moves = self._ordered_moves(enemy_moves, state, depth, False) + best = -float("inf") + + for my_target in my_moves: + worst = float("inf") + for enemy_target in enemy_moves: + if self._out_of_time(): + return (best if best != -float("inf") else self._evaluate(state)), False + child, terminal = self._advance(state, my_target, enemy_target) + if terminal is not None: + value = terminal + completed = True + else: + value, completed = self._search(child, depth - 1, alpha, beta) + if not completed: + return (best if best != -float("inf") else value), False + worst = min(worst, value) + if worst <= alpha: + self.killer_moves[depth] = my_target + self.history[my_target] = self.history.get(my_target, 0) + depth * depth + break + + best = max(best, worst) + alpha = max(alpha, best) + if alpha >= beta: + break + + if best <= original_alpha: + bound = "upper" + elif best >= original_beta: + bound = "lower" + else: + bound = "exact" + self.transposition[cache_key] = (best, bound) + return best, True + + def _advance(self, state: DuelState, my_target: int, enemy_target: int) -> tuple[DuelState, float | None]: + my_ate = bool((1 << my_target) & state.food_bits) + enemy_ate = bool((1 << enemy_target) & state.food_bits) + my_body = self._advance_body(state.my_body, my_target, my_ate) + enemy_body = self._advance_body(state.enemy_body, enemy_target, enemy_ate) + + my_dead = my_target in my_body[1:] or my_target in enemy_body[1:] + enemy_dead = enemy_target in enemy_body[1:] or enemy_target in my_body[1:] + + if my_target == enemy_target: + if len(my_body) <= len(enemy_body): + my_dead = True + if len(enemy_body) <= len(my_body): + enemy_dead = True + + my_health = 100 if my_ate else state.my_health - 1 + enemy_health = 100 if enemy_ate else state.enemy_health - 1 + if not my_ate: + my_health -= self._hazard_cost(my_target, state.previous_hazard_bits) + if not enemy_ate: + enemy_health -= self._hazard_cost(enemy_target, state.previous_hazard_bits) + my_dead = my_dead or my_health <= 0 + enemy_dead = enemy_dead or enemy_health <= 0 + + if my_dead and enemy_dead: + return state, -500.0 + if my_dead: + return state, self.LOSS + if enemy_dead: + return state, self.WIN + + eaten_bits = 0 + if my_ate: + eaten_bits |= 1 << my_target + if enemy_ate: + eaten_bits |= 1 << enemy_target + child = DuelState( + my_body=my_body, + enemy_body=enemy_body, + food_bits=state.food_bits & ~eaten_bits, + my_health=my_health, + enemy_health=enemy_health, + previous_hazard_bits=self.hazard_bits, + ) + return child, None + + def _legal_targets(self, body: Body, other_body: Body) -> list[int]: + occupied = self._body_bits(body) | self._body_bits(other_body) + if not self._tail_stacked(body): + occupied &= ~(1 << body[-1]) + if not self._tail_stacked(other_body): + occupied &= ~(1 << other_body[-1]) + legal = self.board.neighbors_of(body[0]) & ~occupied & self.board.board_mask + return list(self._iter_bits(legal)) + + def _ordered_moves(self, moves: list[int], state: DuelState, depth: int, mine: bool) -> list[int]: + body = state.my_body if mine else state.enemy_body + other = state.enemy_body if mine else state.my_body + killer = self.killer_moves.get(depth) + center_x = (self.board.width - 1) / 2.0 + center_y = (self.board.height - 1) / 2.0 + + def score(target: int) -> tuple[float, int]: + x, y = self.board.coord(target) + food_bonus = 200.0 if (1 << target) & state.food_bits else 0.0 + space = self.board.flood_count(target, (self._body_bits(body[1:]) | self._body_bits(other[1:])) & ~(1 << target)) + center = -(abs(x - center_x) + abs(y - center_y)) + killer_bonus = 10_000.0 if target == killer else 0.0 + return killer_bonus + self.history.get(target, 0) + food_bonus + space * 2.0 + center, -target + + # Our strongest-looking moves first; enemy ordering uses the same quality + # estimate because dangerous enemy replies tend to gain space and food. + return sorted(moves, key=score, reverse=True) + + def _evaluate(self, state: DuelState) -> float: + my_blocked = self._body_bits(state.my_body[1:]) | self._body_bits(state.enemy_body[1:]) + my_space = self.board.flood_count(state.my_body[0], my_blocked) + enemy_space = self.board.flood_count(state.enemy_body[0], my_blocked) + my_liberties = self.board.open_neighbor_count(state.my_body[0], my_blocked) + enemy_liberties = self.board.open_neighbor_count(state.enemy_body[0], my_blocked) + length_score = (len(state.my_body) - len(state.enemy_body)) * 18.0 + health_score = (state.my_health - state.enemy_health) * 0.15 + return (my_space - enemy_space) * 2.0 + (my_liberties - enemy_liberties) * 12.0 + length_score + health_score + + def _hazard_cost(self, target: int, previous_hazard_bits: int) -> int: + bit = 1 << target + if not (bit & self.hazard_bits & previous_hazard_bits): + return 0 + return self.hazard_damage * self.hazard_stacks.get(target, 1) + + @staticmethod + def _advance_body(body: Body, target: int, ate: bool) -> Body: + return (target,) + body if ate else (target,) + body[:-1] + + @staticmethod + def _tail_stacked(body: Body) -> bool: + return len(body) >= 2 and body[-1] == body[-2] + + @staticmethod + def _body_bits(body: Body) -> int: + bits = 0 + for cell in body: + bits |= 1 << cell + return bits + + @staticmethod + def _iter_bits(bits: int): + while bits: + bit = bits & -bits + yield bit.bit_length() - 1 + bits ^= bit + + def _out_of_time(self, reserve_ms: float = 0.0) -> bool: + if self.deadline is None: + return False + return perf_counter() + reserve_ms / 1000.0 >= self.deadline diff --git a/tests/snakes/test_PrismBattleSnake_GPT_5_6_Sol.py b/tests/snakes/test_PrismBattleSnake_GPT_5_6_Sol.py index d5ac323..7ca49d1 100644 --- a/tests/snakes/test_PrismBattleSnake_GPT_5_6_Sol.py +++ b/tests/snakes/test_PrismBattleSnake_GPT_5_6_Sol.py @@ -1,9 +1,11 @@ import unittest +from time import perf_counter 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 +from snakes.bitboard_duel_search import BitboardDuelSearch class TestBitBoard(unittest.TestCase): @@ -64,5 +66,51 @@ class TestPrismBattleSnake_GPT_5_6_Sol(unittest.TestCase): self.assertEqual(open_count, 9) self.assertEqual(trapped_count, 1) + def test_bitboard_duel_search_values_length_advantage(self): + snake = PrismBattleSnake_GPT_5_6_Sol() + my_body = [{"x": 1, "y": 0}, {"x": 0, "y": 0}, {"x": 0, "y": 1}] + enemy_body = [{"x": 1, "y": 2}, {"x": 0, "y": 2}] + + value = snake._minimax_sim( + my_body=my_body, enemy_body=enemy_body, + food_set=set(), hazard_set=set(), + my_health=100, enemy_health=100, + hazard_damage=15, hazard_count={}, + width=3, height=3, depth=2, + alpha=-1e9, beta=1e9, deadline=None, + ) + + self.assertGreater(value, 0) + + def test_bitboard_duel_search_reuses_transpositions(self): + board = BitBoard(5, 5) + search = BitboardDuelSearch( + board=board, food=set(), hazards=set(), hazard_count={}, + hazard_damage=15, deadline=perf_counter() + 1.0, + ) + my_body = [{"x": 1, "y": 1}, {"x": 1, "y": 0}, {"x": 0, "y": 0}] + enemy_body = [{"x": 3, "y": 3}, {"x": 3, "y": 4}, {"x": 4, "y": 4}] + + search.search_depth(my_body, enemy_body, 100, 100, 3, set()) + hits_before = search.cache_hits + search.search_depth(my_body, enemy_body, 100, 100, 3, set()) + + self.assertGreater(search.cache_hits, hits_before) + + def test_bitboard_duel_search_respects_deadline(self): + board = BitBoard(11, 11) + search = BitboardDuelSearch( + board=board, food=set(), hazards=set(), hazard_count={}, + hazard_damage=15, deadline=perf_counter() - 0.001, + ) + my_body = [{"x": 2, "y": 2}, {"x": 2, "y": 1}, {"x": 2, "y": 0}] + enemy_body = [{"x": 8, "y": 8}, {"x": 8, "y": 9}, {"x": 8, "y": 10}] + + started = perf_counter() + _, depth = search.search(my_body, enemy_body, 100, 100, 6, set()) + + self.assertEqual(depth, 0) + self.assertLess(perf_counter() - started, 0.05) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_GameplayDatabase.py b/tests/test_GameplayDatabase.py index 5cea211..72f32bc 100644 --- a/tests/test_GameplayDatabase.py +++ b/tests/test_GameplayDatabase.py @@ -13,6 +13,11 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase): "name": "Me", "health": 90, "length": 3, + "customizations": { + "color": "#00ff00", + "head": "ferret", + "tail": "swirl", + }, "head": {"x": me_head[0], "y": me_head[1]}, "body": [ {"x": me_head[0], "y": me_head[1]}, @@ -99,10 +104,13 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase): """, ("game-abc", 2, "me")).fetchone()[0] self.assertNotEqual(stored_body, "[]") identities = connection.execute(""" - SELECT snake_id, snake_name, is_you FROM game_snakes + SELECT snake_id, snake_name, is_you, customizations_json FROM game_snakes WHERE game_id = ? ORDER BY snake_id """, ("game-abc",)).fetchall() - self.assertEqual(identities, [("enemy", "Enemy", 0), ("me", "Me", 1)]) + self.assertEqual(identities, [ + ("enemy", "Enemy", 0, "{}"), + ("me", "Me", 1, '{"color":"#00ff00","head":"ferret","tail":"swirl"}'), + ]) repeated_identity = connection.execute(""" SELECT snake_name, is_you FROM snake_turns WHERE game_id = ? AND turn = ? AND snake_id = ? @@ -127,7 +135,15 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase): 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) + me = next(snake for snake in replay["turns"][1]["snakes"] if snake["snake_id"] == "me") + self.assertEqual(me["customizations"], { + "color": "#00ff00", + "head": "ferret", + "tail": "swirl", + }) + self.assertEqual(len(me["body"]), 3) + api_me = next(snake for snake in replay["turns"][1]["board"]["snakes"] if snake["id"] == "me") + self.assertEqual(api_me["customizations"], me["customizations"]) connection.close()