feat(snake): add adaptive adversarial search

- Share duel search contexts and transpositions across candidate moves.
- Add aspiration windows, principal variation ordering, and body caches.
- Model simultaneous multiplayer responses with a compact beam rollout.
- Adapt search depth and response breadth to the remaining deadline.
- Add a deterministic arena benchmark with optional JSON reporting.
- Expose search metrics, document benchmarking, and bump Prism to 1.2.0.
This commit is contained in:
2026-08-01 19:26:19 +02:00
parent 6643eb35af
commit cb6c8d4dc8
9 changed files with 604 additions and 46 deletions
+87 -15
View File
@@ -26,23 +26,27 @@ Key speedups:
S12: Duel minimax uses tuple bodies and bitboard move generation.
S13: Iterative deepening reuses a transposition table and move-order hints.
S14: Candidate duel moves and enemy replies resolve on the same root turn.
S15: Candidate moves share one duel transposition/search context per turn.
S16: Compact adversarial multiplayer rollout advances plausible enemy replies.
S17: Rollout memoization and adaptive depth spend time on ambiguous positions.
"""
from __future__ import annotations
from typing import Any
from time import perf_counter
from server.GameBoard import GameBoard
from snakes.ApexBattleSnake import ApexBattleSnake
from snakes.bitboard import BitBoard
from snakes.bitboard_duel_search import BitboardDuelSearch
from server.GameBoard import GameBoard
from snakes.compact_survival_search import CompactSurvivalSearch
# Direction offsets for coord-dict → tuple conversion
_DIR_DELTAS = ((0, 1), (0, -1), (-1, 0), (1, 0))
_DIR_NAMES = ("up", "down", "left", "right")
class PrismBattleSnake_GPT_5_6_Sol(ApexBattleSnake):
VERSION = "1.1.0"
VERSION = "1.2.0"
def __init__(self) -> None:
super().__init__()
@@ -60,6 +64,11 @@ class PrismBattleSnake_GPT_5_6_Sol(ApexBattleSnake):
self._enemy_attack_danger: int = 0 # tiles where enemy len >= our len
self._enemy_attack_opportunity: int = 0 # tiles where enemy len < our len
# Shared per-turn search contexts. Candidate moves overlap heavily, so
# rebuilding their transposition tables wastes most iterative-deepening work.
self._duel_search_context: BitboardDuelSearch | None = None
self._survival_search_context: CompactSurvivalSearch | None = None
# ── BitBoard accessor ────────────────────────────────────────────────────
def _get_bb(self, width: int, height: int) -> BitBoard:
@@ -78,6 +87,8 @@ class PrismBattleSnake_GPT_5_6_Sol(ApexBattleSnake):
def choose_move(self, game_data: GameBoard) -> str:
bb = self._get_bb(game_data.get_width(), game_data.get_height())
self._duel_search_context = None
self._survival_search_context = None
# S9: precompute enemy body / tail / attack bitboards for survival tree
other_snakes = game_data.get_other_snakes()
@@ -128,7 +139,17 @@ class PrismBattleSnake_GPT_5_6_Sol(ApexBattleSnake):
self._enemy_attack_danger = enemy_attack_danger
self._enemy_attack_opportunity = enemy_attack_opportunity
return super().choose_move(game_data)
move = super().choose_move(game_data)
history = self.get_history()
if history:
thinking = history[-1]
if self._duel_search_context is not None:
thinking["prism_duel_nodes"] = self._duel_search_context.nodes
thinking["prism_duel_cache_hits"] = self._duel_search_context.cache_hits
if self._survival_search_context is not None:
thinking["prism_rollout_nodes"] = self._survival_search_context.nodes
thinking["prism_rollout_cache_hits"] = self._survival_search_context.cache_hits
return move
# ── S1: Bitboard flood fill ──────────────────────────────────────────────
@@ -260,14 +281,16 @@ class PrismBattleSnake_GPT_5_6_Sol(ApexBattleSnake):
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,
)
if self._duel_search_context is None:
self._duel_search_context = BitboardDuelSearch(
board=self._get_bb(width, height),
food=food_set,
hazards=hazard_set,
hazard_count=hazard_count,
hazard_damage=hazard_damage,
deadline=deadline,
)
return self._duel_search_context
def _minimax_candidate_id(
self, my_body: list, enemy_body: list, my_target: tuple[int, int],
@@ -281,13 +304,19 @@ class PrismBattleSnake_GPT_5_6_Sol(ApexBattleSnake):
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
adaptive_depth = max_depth
remaining = self._remaining_ms(deadline)
if remaining > 250:
adaptive_depth = min(7, max_depth + 1)
elif remaining < 120:
adaptive_depth = min(max_depth, 2)
return search.search_candidate(
my_body=my_body,
enemy_body=enemy_body,
my_target=my_target,
my_health=my_health,
enemy_health=enemy_health,
max_depth=max_depth,
max_depth=adaptive_depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
@@ -331,7 +360,51 @@ class PrismBattleSnake_GPT_5_6_Sol(ApexBattleSnake):
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
# ── S9: Optimised survival tree (bitboard-native) ────────────────────────
# ── S16/S17: compact adversarial survival rollout ───────────────────────
def _future_rollout_bonus(
self, move: str, safe_moves: dict, my_body: list, other_snakes: list,
food_set: set, is_constrictor: bool, width: int, height: int,
enemy_can_grow: dict, deadline: float | None,
) -> float:
pos = safe_moves.get(move)
if pos is None:
return -250.0
# Duel minimax already advances the opponent exactly. Keep the much faster
# bitboard-native solo rollout here instead of paying for the same response
# model twice. Constrictor and multiplayer still use adversarial rollouts.
if len(other_snakes) == 1 and not is_constrictor:
return super()._future_rollout_bonus(
move, safe_moves, my_body, other_snakes, food_set, is_constrictor,
width, height, enemy_can_grow, deadline,
)
if self._survival_search_context is None:
remaining = self._remaining_ms(deadline)
enemy_branch = 2 if len(other_snakes) <= 2 and remaining > 100 else 1
self._survival_search_context = CompactSurvivalSearch(
board=self._get_bb(width, height),
food=food_set,
is_constrictor=is_constrictor,
deadline=deadline,
branch=self._planning_branch,
enemy_branch=enemy_branch,
response_cap=8 if remaining > 150 else 4,
)
remaining = self._remaining_ms(deadline)
depth = min(self._planning_depth, 2 if len(other_snakes) > 1 else 3)
if remaining < 90:
depth = min(depth, 2)
elif remaining > 250 and len(other_snakes) <= 2:
depth = min(4, depth + 1)
raw = self._survival_search_context.search_selected(
my_body=my_body,
enemies=other_snakes,
target=(pos["x"], pos["y"]),
depth=depth,
)
return raw * 0.15
# ── S9: Optimised survival tree (compatibility fallback) ────────────────
def _future_position_score(
self, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool,
@@ -420,7 +493,6 @@ class PrismBattleSnake_GPT_5_6_Sol(ApexBattleSnake):
bb = self._bb
w = bb.width
h = bb.height
head = my_body[0]
hx, hy = head["x"], head["y"]
head_idx = hy * w + hx