feat(snake): optimize duels and persist customizations
Build and Push Docker Container / build-and-push (push) Successful in 7m29s
Build and Push Docker Container / build-and-push (push) Successful in 7m29s
- Add deadline-aware iterative duel search with bitboards and tuple bodies. - Reuse transposition bounds and move-order hints across search depths. - Persist snake colors, heads, and tails in SQLite and PostgreSQL. - Restore customization metadata when hydrating dashboard replays. - Cover duel deadlines, cache reuse, schema storage, and replay output.
This commit is contained in:
@@ -76,6 +76,7 @@ CREATE TABLE IF NOT EXISTS game_snakes (
|
|||||||
snake_id TEXT NOT NULL,
|
snake_id TEXT NOT NULL,
|
||||||
snake_name TEXT,
|
snake_name TEXT,
|
||||||
is_you BOOLEAN NOT NULL DEFAULT FALSE,
|
is_you BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
customizations JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
PRIMARY KEY (game_id, snake_id)
|
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 games ADD COLUMN IF NOT EXISTS quality_reasons JSONB;
|
||||||
ALTER TABLE turns ADD COLUMN IF NOT EXISTS my_thinking 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 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
|
# 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 pool.acquire() as conn:
|
||||||
async with conn.transaction():
|
async with conn.transaction():
|
||||||
await conn.executemany("""
|
await conn.executemany("""
|
||||||
INSERT INTO game_snakes (game_id, snake_id, snake_name, is_you)
|
INSERT INTO game_snakes (
|
||||||
VALUES ($1,$2,$3,$4)
|
game_id, snake_id, snake_name, is_you, customizations
|
||||||
|
) VALUES ($1,$2,$3,$4,$5)
|
||||||
ON CONFLICT (game_id, snake_id) DO UPDATE SET
|
ON CONFLICT (game_id, snake_id) DO UPDATE SET
|
||||||
snake_name = EXCLUDED.snake_name,
|
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
|
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,
|
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,
|
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.is_you, st.is_you) AS is_you,
|
||||||
|
COALESCE(gs.customizations, '{}'::jsonb) AS customizations_json,
|
||||||
st.inferred_move, st.latency
|
st.inferred_move, st.latency
|
||||||
FROM snake_turns AS st
|
FROM snake_turns AS st
|
||||||
LEFT JOIN game_snakes AS gs
|
LEFT JOIN game_snakes AS gs
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
snake_id TEXT NOT NULL,
|
snake_id TEXT NOT NULL,
|
||||||
snake_name TEXT,
|
snake_name TEXT,
|
||||||
is_you INTEGER NOT NULL DEFAULT 0,
|
is_you INTEGER NOT NULL DEFAULT 0,
|
||||||
|
customizations_json TEXT NOT NULL DEFAULT '{}',
|
||||||
PRIMARY KEY (game_id, snake_id),
|
PRIMARY KEY (game_id, snake_id),
|
||||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
|
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", "your_snake_version", "TEXT")
|
||||||
self._ensure_column_exists(connection, "games", "game_type", "TEXT")
|
self._ensure_column_exists(connection, "games", "game_type", "TEXT")
|
||||||
self._ensure_column_exists(connection, "snake_turns", "latency", "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", "winner_name", "TEXT")
|
||||||
self._ensure_column_exists(connection, "games", "has_replay", "INTEGER NOT NULL DEFAULT 1")
|
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'")
|
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:
|
with self._connect() as connection:
|
||||||
connection.executemany("""
|
connection.executemany("""
|
||||||
INSERT INTO game_snakes (game_id, snake_id, snake_name, is_you)
|
INSERT INTO game_snakes (
|
||||||
VALUES (?, ?, ?, ?)
|
game_id, snake_id, snake_name, is_you, customizations_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(game_id, snake_id) DO UPDATE SET
|
ON CONFLICT(game_id, snake_id) DO UPDATE SET
|
||||||
snake_name = excluded.snake_name,
|
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
|
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,
|
COALESCE(gs.snake_name, st.snake_name) AS snake_name,
|
||||||
st.health, st.length, st.head_x, st.head_y, st.body_json,
|
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.is_you, st.is_you) AS is_you,
|
||||||
|
COALESCE(gs.customizations_json, '{}') AS customizations_json,
|
||||||
st.inferred_move, st.latency
|
st.inferred_move, st.latency
|
||||||
FROM snake_turns AS st
|
FROM snake_turns AS st
|
||||||
LEFT JOIN game_snakes AS gs
|
LEFT JOIN game_snakes AS gs
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ def hydrate_replay_turns(game_row, turn_rows, snake_rows, decode_json:Callable)
|
|||||||
api_snakes = []
|
api_snakes = []
|
||||||
for snake_row in rows_by_turn.get(turn, []):
|
for snake_row in rows_by_turn.get(turn, []):
|
||||||
body = decode_json(snake_row["body_json"]) or []
|
body = decode_json(snake_row["body_json"]) or []
|
||||||
|
customizations = decode_json(snake_row["customizations_json"]) or {}
|
||||||
api_snake = {
|
api_snake = {
|
||||||
"id": snake_row["snake_id"],
|
"id": snake_row["snake_id"],
|
||||||
"name": snake_row["snake_name"],
|
"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"],
|
"length": snake_row["length"],
|
||||||
"head": {"x": snake_row["head_x"], "y": snake_row["head_y"]},
|
"head": {"x": snake_row["head_x"], "y": snake_row["head_y"]},
|
||||||
"body": body,
|
"body": body,
|
||||||
|
"customizations": customizations,
|
||||||
}
|
}
|
||||||
if snake_row["latency"] is not None:
|
if snake_row["latency"] is not None:
|
||||||
api_snake["latency"] = snake_row["latency"]
|
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"],
|
"length": snake_row["length"],
|
||||||
"head": api_snake["head"],
|
"head": api_snake["head"],
|
||||||
"body": body,
|
"body": body,
|
||||||
|
"customizations": customizations,
|
||||||
"is_you": bool(snake_row["is_you"]),
|
"is_you": bool(snake_row["is_you"]),
|
||||||
"inferred_move": snake_row["inferred_move"],
|
"inferred_move": snake_row["inferred_move"],
|
||||||
"latency": snake_row["latency"],
|
"latency": snake_row["latency"],
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ Key speedups:
|
|||||||
S10: _legal_moves override uses bitboard neighbour mask instead of
|
S10: _legal_moves override uses bitboard neighbour mask instead of
|
||||||
per-direction Python loop + _in_bounds calls.
|
per-direction Python loop + _in_bounds calls.
|
||||||
S11: _future_survival_tree inlines legal-move check with bitboard ops.
|
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
|
from __future__ import annotations
|
||||||
@@ -31,6 +33,7 @@ from time import perf_counter
|
|||||||
|
|
||||||
from snakes.ApexBattleSnake import ApexBattleSnake
|
from snakes.ApexBattleSnake import ApexBattleSnake
|
||||||
from snakes.bitboard import BitBoard
|
from snakes.bitboard import BitBoard
|
||||||
|
from snakes.bitboard_duel_search import BitboardDuelSearch
|
||||||
from server.GameBoard import GameBoard
|
from server.GameBoard import GameBoard
|
||||||
|
|
||||||
# Direction offsets for coord-dict → tuple conversion
|
# 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)
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
return bb.open_neighbor_count(bb.idx(head["x"], head["y"]), blocked_bits)
|
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) ────────────────────────
|
# ── S9: Optimised survival tree (bitboard-native) ────────────────────────
|
||||||
|
|
||||||
def _future_position_score(
|
def _future_position_score(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
from snakes import SnakeBuilder, get_snake_version
|
from snakes import SnakeBuilder, get_snake_version
|
||||||
from snakes.ApexBattleSnake import ApexBattleSnake
|
from snakes.ApexBattleSnake import ApexBattleSnake
|
||||||
from snakes.PrismBattleSnake_GPT_5_6_Sol import PrismBattleSnake_GPT_5_6_Sol
|
from snakes.PrismBattleSnake_GPT_5_6_Sol import PrismBattleSnake_GPT_5_6_Sol
|
||||||
from snakes.bitboard import BitBoard
|
from snakes.bitboard import BitBoard
|
||||||
|
from snakes.bitboard_duel_search import BitboardDuelSearch
|
||||||
|
|
||||||
class TestBitBoard(unittest.TestCase):
|
class TestBitBoard(unittest.TestCase):
|
||||||
|
|
||||||
@@ -64,5 +66,51 @@ class TestPrismBattleSnake_GPT_5_6_Sol(unittest.TestCase):
|
|||||||
self.assertEqual(open_count, 9)
|
self.assertEqual(open_count, 9)
|
||||||
self.assertEqual(trapped_count, 1)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase):
|
|||||||
"name": "Me",
|
"name": "Me",
|
||||||
"health": 90,
|
"health": 90,
|
||||||
"length": 3,
|
"length": 3,
|
||||||
|
"customizations": {
|
||||||
|
"color": "#00ff00",
|
||||||
|
"head": "ferret",
|
||||||
|
"tail": "swirl",
|
||||||
|
},
|
||||||
"head": {"x": me_head[0], "y": me_head[1]},
|
"head": {"x": me_head[0], "y": me_head[1]},
|
||||||
"body": [
|
"body": [
|
||||||
{"x": me_head[0], "y": me_head[1]},
|
{"x": me_head[0], "y": me_head[1]},
|
||||||
@@ -99,10 +104,13 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase):
|
|||||||
""", ("game-abc", 2, "me")).fetchone()[0]
|
""", ("game-abc", 2, "me")).fetchone()[0]
|
||||||
self.assertNotEqual(stored_body, "[]")
|
self.assertNotEqual(stored_body, "[]")
|
||||||
identities = connection.execute("""
|
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
|
WHERE game_id = ? ORDER BY snake_id
|
||||||
""", ("game-abc",)).fetchall()
|
""", ("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("""
|
repeated_identity = connection.execute("""
|
||||||
SELECT snake_name, is_you FROM snake_turns
|
SELECT snake_name, is_you FROM snake_turns
|
||||||
WHERE game_id = ? AND turn = ? AND snake_id = ?
|
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]["my_thinking"]["reason"], "food")
|
||||||
self.assertEqual(replay["turns"][1]["food"], [{"x": 2, "y": 2}])
|
self.assertEqual(replay["turns"][1]["food"], [{"x": 2, "y": 2}])
|
||||||
self.assertEqual(replay["turns"][1]["you"]["id"], "me")
|
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()
|
connection.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user