Build and Push Docker Container / build-and-push (push) Successful in 3m6s
- Add read-only gameplay statistics with filters and opponent comparisons. - Report move-trace latency percentiles and external timeout counts. - Pair engine latency with the preceding turn to separate timing stages. - Adjust Zenith rollout deepening for its larger timeout reserve. - Bump Zenith to 1.2.0 and document the fix and snake-stats command. - Cover statistics, trace aggregation, rollout depth, and verdict budgets.
136 lines
6.1 KiB
Python
136 lines
6.1 KiB
Python
"""ZenithBattleSnake_ClaudeOpus5_5 v1.2.0
|
|
|
|
Built on PrismBattleSnake_GPT_5_6_Sol v1.5.0. Prism's bitboard engine, duel
|
|
search, and adversarial rollouts are inherited unchanged.
|
|
|
|
Prism only runs the exact duel search on near-tied heuristic candidates
|
|
(score gap <= 8). A move outside that window is never checked against every
|
|
enemy reply, so a forced loss with a strong heuristic score can still be
|
|
played, and a forced win with a weaker score can be skipped.
|
|
|
|
Z1: Root verdict pass — after Prism decides, every safe duel candidate is
|
|
resolved against all simultaneous enemy replies with the shared
|
|
transposition table (cheap: most subtrees are already cached).
|
|
Z2: Proven-loss veto — a candidate whose completed search proves a loss is
|
|
replaced by the best non-losing candidate.
|
|
Z3: Proven-win override — a candidate whose completed search proves a win is
|
|
played even if the heuristic preferred another move.
|
|
Z4: Elapsed-time rollout gate — Zenith keeps a larger timeout reserve than
|
|
Prism. The inherited multiplayer "deepen when > 250 ms remain" gate is
|
|
lowered by that extra reserve, so 3-snake rollouts deepen after the same
|
|
elapsed time as Prism instead of never reaching depth 3. The duel gate is
|
|
left alone: the verdict pass already spends the duel budget.
|
|
"""
|
|
from time import perf_counter
|
|
|
|
from snakes.engine.duel_search import BitboardDuelSearch
|
|
from snakes.strategies.zenith_survival import ZenithSurvivalMixin
|
|
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
|
|
|
|
# A completed search value beyond half of WIN/LOSS can only come from a terminal.
|
|
_PROVEN_WIN = BitboardDuelSearch.WIN / 2
|
|
_PROVEN_LOSS = BitboardDuelSearch.LOSS / 2
|
|
# Reserve kept for Apex's history bookkeeping after the verdict pass.
|
|
_VERDICT_RESERVE_MS = 25.0
|
|
_ZENITH_TIMEOUT_BUFFER_MS = 180
|
|
|
|
class ZenithBattleSnake_ClaudeOpus5_5(ZenithSurvivalMixin, PrismBattleSnake_GPT_5_6_Sol):
|
|
VERSION = "1.2.0"
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.name = "ZenithBattleSnake"
|
|
self.version = self.VERSION
|
|
# Z4: deepen after the same elapsed time as the inherited Prism gates.
|
|
extra_reserve = self._get_timeout_buffer_ms() - super()._get_timeout_buffer_ms()
|
|
self._rollout_deepen_ms = max(120.0, self._rollout_deepen_ms - extra_reserve)
|
|
|
|
def _get_timeout_buffer_ms(self):
|
|
# Leave room for executor scheduling, history, JSON and network delivery.
|
|
return max(_ZENITH_TIMEOUT_BUFFER_MS, super()._get_timeout_buffer_ms())
|
|
|
|
def _choose_duel_move(
|
|
self, safe_moves, my_body, my_len, my_health, other_snakes, food_set,
|
|
hazard_set, hazard_damage, hazard_count, previous_hazard_set,
|
|
enemy_attack_map, enemy_can_grow, total_occupancy, width, height, deadline,
|
|
):
|
|
best_move, scores, depth = super()._choose_duel_move(
|
|
safe_moves=safe_moves, my_body=my_body, my_len=my_len,
|
|
my_health=my_health, other_snakes=other_snakes, food_set=food_set,
|
|
hazard_set=hazard_set, hazard_damage=hazard_damage,
|
|
hazard_count=hazard_count, previous_hazard_set=previous_hazard_set,
|
|
enemy_attack_map=enemy_attack_map, enemy_can_grow=enemy_can_grow,
|
|
total_occupancy=total_occupancy, width=width, height=height,
|
|
deadline=deadline,
|
|
)
|
|
if len(safe_moves) < 2:
|
|
return best_move, scores, depth
|
|
|
|
verdicts = self._duel_verdicts(
|
|
safe_moves=safe_moves, my_body=my_body, enemy=other_snakes[0],
|
|
my_health=my_health, food_set=food_set, hazard_set=hazard_set,
|
|
hazard_damage=hazard_damage, hazard_count=hazard_count,
|
|
previous_hazard_set=previous_hazard_set, width=width, height=height,
|
|
deadline=deadline,
|
|
)
|
|
chosen = self._apply_verdicts(best_move, scores, verdicts)
|
|
if chosen != best_move:
|
|
self.add_to_history({
|
|
"turn": self.game_board.get_turn(), "mode": "duel",
|
|
"zenith_verdict_changed_move": True,
|
|
"from": best_move, "to": chosen,
|
|
"verdicts": {m: round(v, 1) for m, (v, _) in verdicts.items()},
|
|
})
|
|
depth = max([depth, *(d for _, d in verdicts.values())])
|
|
return chosen, scores, depth
|
|
|
|
def _duel_verdicts(
|
|
self, safe_moves, my_body, enemy, my_health, food_set, hazard_set,
|
|
hazard_damage, hazard_count, previous_hazard_set, width, height, deadline,
|
|
) -> dict[str, tuple[float, int]]:
|
|
"""Return completed exact-search values per candidate (Z1)."""
|
|
verdicts: dict[str, tuple[float, int]] = {}
|
|
# Search every root at equal depth. A full iterative search for the first
|
|
# move could consume the deadline before the other roots were visited.
|
|
search_deadline = None if deadline is None else deadline - _VERDICT_RESERVE_MS / 1000
|
|
if search_deadline is not None and perf_counter() >= search_deadline:
|
|
return verdicts
|
|
search = self._new_duel_search(
|
|
food_set, hazard_set, hazard_count, hazard_damage, width, height, search_deadline,
|
|
)
|
|
search.deadline = search_deadline
|
|
for depth in range(1, self._planning_depth + 1):
|
|
layer = {}
|
|
for move, pos in safe_moves.items():
|
|
value, completed = search.search_candidate(
|
|
my_body, enemy["body"], (pos["x"], pos["y"]),
|
|
my_health, enemy.get("health", 100), depth, previous_hazard_set,
|
|
)
|
|
if completed != depth:
|
|
return verdicts
|
|
layer[move] = (value, completed)
|
|
verdicts = layer
|
|
return verdicts
|
|
|
|
@staticmethod
|
|
def _apply_verdicts(
|
|
best_move: str, scores: dict[str, float],
|
|
verdicts: dict[str, tuple[float, int]],
|
|
) -> str:
|
|
"""Apply proven-win override (Z3) and proven-loss veto (Z2)."""
|
|
def rank(move: str) -> tuple[float, float]:
|
|
return verdicts[move][0], scores.get(move, float("-inf"))
|
|
|
|
wins = [m for m, (v, _) in verdicts.items() if v >= _PROVEN_WIN]
|
|
if wins and best_move not in wins:
|
|
return max(wins, key=rank)
|
|
|
|
best_verdict = verdicts.get(best_move)
|
|
if best_verdict is None or best_verdict[0] > _PROVEN_LOSS:
|
|
return best_move
|
|
alternatives = [m for m, (v, _) in verdicts.items() if v > _PROVEN_LOSS]
|
|
if not alternatives:
|
|
return best_move
|
|
# Prefer Prism's heuristic among surviving moves; fall back to search value.
|
|
return max(alternatives, key=lambda m: (scores.get(m, float("-inf")), verdicts[m][0]))
|