3a9af3f54d
- 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.
215 lines
8.1 KiB
Python
215 lines
8.1 KiB
Python
"""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
|