feat(snake): modularize engine and add tournament tools
- Split active strategies, reusable engine code, core classes, and legacy snakes. - Replace implicit snake imports with explicit module registrations. - Extract Prism duel, spatial, and survival behavior into focused mixins. - Improve duel scoring with food races, pressure, caches, and depth metrics. - Add deterministic arena scenarios and paired seeded engine tournaments. - Expand benchmark telemetry and bump Prism to version 1.3.0. - Update documentation and tests for the new package layout and tooling.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
"""Reusable bitboard and adversarial survival rollouts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from time import perf_counter
|
||||
|
||||
from snakes.engine.survival_search import CompactSurvivalSearch
|
||||
|
||||
class BitboardSurvivalMixin:
|
||||
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
|
||||
|
||||
def _future_position_score(
|
||||
self, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool,
|
||||
width: int, height: int, enemy_can_grow: dict, deadline: float | None,
|
||||
) -> float:
|
||||
"""S9: Bitboard-native position scoring for the survival tree.
|
||||
|
||||
Builds blocked bitboard directly from body lists (no intermediate set).
|
||||
Uses precomputed enemy bits instead of rebuilding attack map per node.
|
||||
"""
|
||||
if deadline is not None and perf_counter() >= deadline:
|
||||
return 0.0
|
||||
|
||||
bb = self._bb # already initialised in choose_move
|
||||
w = bb.width
|
||||
head = my_body[0]
|
||||
hx, hy = head["x"], head["y"]
|
||||
head_idx = hy * w + hx
|
||||
head_bit = 1 << head_idx
|
||||
body_len = len(my_body)
|
||||
|
||||
# ── Build blocked bitboard directly (no set) ──────────────────────
|
||||
my_bits = 0
|
||||
for seg in my_body:
|
||||
my_bits |= 1 << (seg["y"] * w + seg["x"])
|
||||
|
||||
# Own tail vacates unless stacked or constrictor
|
||||
if not is_constrictor and body_len >= 2:
|
||||
t, t2 = my_body[-1], my_body[-2]
|
||||
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
|
||||
my_bits &= ~(1 << (t["y"] * w + t["x"]))
|
||||
|
||||
# Enemy body (precomputed) minus vacating tails
|
||||
en_bits = self._enemy_body_bits & ~self._enemy_tail_bits
|
||||
|
||||
blocked_bits = (my_bits | en_bits) & ~head_bit
|
||||
|
||||
# ── Reachable space ───────────────────────────────────────────────
|
||||
reachable = bb.flood_count(head_idx, blocked_bits)
|
||||
required = body_len + max(3, body_len // 6) if is_constrictor else body_len
|
||||
if reachable < required:
|
||||
return -5000.0
|
||||
|
||||
# ── Open neighbours (liberties) ───────────────────────────────────
|
||||
nb_free = bb._neighbor_masks[head_idx] & ~blocked_bits & bb.board_mask
|
||||
liberties = nb_free.bit_count()
|
||||
if liberties == 0:
|
||||
return -5000.0
|
||||
|
||||
# ── Safe next options (enemy-attack aware) ────────────────────────
|
||||
# Rebuild danger for the simulated length. The root-turn danger mask is
|
||||
# stale after eating and includes enemy moves blocked in this future body.
|
||||
danger_here = 0
|
||||
for enemy in other_snakes:
|
||||
enemy_len = enemy.get("length", len(enemy["body"]))
|
||||
if enemy_len < body_len:
|
||||
continue
|
||||
enemy_head = enemy["head"]
|
||||
enemy_idx = enemy_head["y"] * w + enemy_head["x"]
|
||||
danger_here |= bb._neighbor_masks[enemy_idx]
|
||||
danger_here &= ~blocked_bits
|
||||
safe_nb = nb_free & ~danger_here
|
||||
en_safe = safe_nb.bit_count()
|
||||
|
||||
if en_safe == 0:
|
||||
return -4000.0
|
||||
|
||||
next_opts = liberties
|
||||
sc = reachable * 1.9 + liberties * 14.0 + next_opts * 11.0 + en_safe * 26.0
|
||||
if en_safe == 1:
|
||||
sc -= 420.0
|
||||
return sc
|
||||
|
||||
def _future_survival_tree(
|
||||
self, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool,
|
||||
width: int, height: int, enemy_can_grow: dict,
|
||||
depth: int, branch: int, deadline: float | None,
|
||||
) -> float:
|
||||
"""S9/S11: Bitboard-accelerated survival tree.
|
||||
|
||||
Inlines legal-move check with bitboard ops instead of per-direction
|
||||
Python loops. Uses the bitboard-native _future_position_score.
|
||||
"""
|
||||
if depth <= 0 or (deadline is not None and perf_counter() >= deadline):
|
||||
return 0.0
|
||||
|
||||
bb = self._bb
|
||||
w = bb.width
|
||||
head = my_body[0]
|
||||
hx, hy = head["x"], head["y"]
|
||||
head_idx = hy * w + hx
|
||||
body_len = len(my_body)
|
||||
|
||||
# ── Build occupied bitboard for legal-move check ──────────────────
|
||||
occupied_bits = 0
|
||||
for seg in my_body:
|
||||
occupied_bits |= 1 << (seg["y"] * w + seg["x"])
|
||||
occupied_bits |= self._enemy_body_bits
|
||||
|
||||
# Own tail can be stepped on if not stacked/constrictor
|
||||
passable = 0
|
||||
if not is_constrictor and body_len >= 2:
|
||||
t, t2 = my_body[-1], my_body[-2]
|
||||
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
|
||||
passable |= 1 << (t["y"] * w + t["x"])
|
||||
|
||||
# Enemy vacating tails are also steppable
|
||||
passable |= self._enemy_tail_bits
|
||||
|
||||
# Legal moves: free neighbours OR passable tiles
|
||||
legal_bits = bb._neighbor_masks[head_idx] & ((~occupied_bits & bb.board_mask) | passable)
|
||||
|
||||
if not legal_bits:
|
||||
return -5000.0
|
||||
|
||||
# ── Precompute food bitboard once ─────────────────────────────────
|
||||
food_bits_local = 0
|
||||
for fx, fy in food_set:
|
||||
food_bits_local |= 1 << (fy * w + fx)
|
||||
|
||||
# ── Score each legal move ─────────────────────────────────────────
|
||||
scored: list[tuple[float, list]] = []
|
||||
temp = legal_bits
|
||||
while temp:
|
||||
if deadline is not None and perf_counter() >= deadline:
|
||||
break
|
||||
bit = temp & (-temp)
|
||||
temp ^= bit
|
||||
idx = bit.bit_length() - 1
|
||||
nx, ny = idx % w, idx // w
|
||||
pos = {"x": nx, "y": ny}
|
||||
ate = bool(bit & food_bits_local)
|
||||
fb = self._future_body(my_body, pos, ate, is_constrictor)
|
||||
sc = self._future_position_score(
|
||||
fb, other_snakes, food_set, is_constrictor,
|
||||
width, height, enemy_can_grow, deadline,
|
||||
)
|
||||
scored.append((sc, fb))
|
||||
|
||||
if not scored:
|
||||
return -5000.0
|
||||
|
||||
DEATH = self._TREE_DEATH_THRESHOLD
|
||||
viable = [(sc, fb) for sc, fb in scored if sc > DEATH]
|
||||
if not viable:
|
||||
return max(sc for sc, _ in scored)
|
||||
|
||||
viable.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
if depth == 1:
|
||||
return viable[0][0]
|
||||
|
||||
best = viable[0][0]
|
||||
for sc, fb in viable[:branch]:
|
||||
if deadline is not None and perf_counter() >= deadline:
|
||||
break
|
||||
cont = self._future_survival_tree(
|
||||
fb, other_snakes, food_set, is_constrictor,
|
||||
width, height, enemy_can_grow, depth - 1, branch, deadline,
|
||||
)
|
||||
total = sc + cont * 0.72
|
||||
if total > best:
|
||||
best = total
|
||||
return best
|
||||
Reference in New Issue
Block a user