feat: add Prism snake and gameplay database lifecycle
Build and Push Docker Container / build-and-push (push) Successful in 8m3s

- Add bitboard-accelerated Prism and versioned Supreme snake implementations.
- Add database-backed move benchmarks and focused strategy tests.
- Normalize gameplay storage while preserving replay compatibility.
- Add deterministic game quality scoring and replay retention tiers.
- Add backup-first SQLite cleanup, verification, and replacement tooling.
- Add safe compact-plus-delta database merging with conflict detection.
- Extend SQLite and PostgreSQL schemas for replay and quality metadata.
- Add PostgreSQL development service and pytest import configuration.
- Update gameplay documentation and the quart_common submodule revision.
This commit is contained in:
2026-08-01 16:21:02 +02:00
parent 9a7f4de586
commit c704fbc742
21 changed files with 3156 additions and 88 deletions
+513
View File
@@ -0,0 +1,513 @@
"""SupremeBattleSnake v1.0.0
Built on ApexBattleSnake v1.0.0. All strategic logic is inherited.
Performance improvement: all spatial primitives (flood fill, territory,
articulation detection, distance maps, pathfinding) replaced by a
bitboard engine that uses integer arithmetic instead of Python sets/deques.
Key speedups:
S1: Bitboard flood fill — replaces BFS deque+set with integer bit-expansion.
~60× faster per call, eliminates _neighbors() generator overhead.
S2: Bitboard territory — dual-BFS expansion on ints replaces per-cell
distance-map comparison loop.
S3: Bitboard articulation — partition sizes via bit-flood instead of
_bounded_bfs with sets.
S4: Bitboard distance map — BFS via bit-expansion + bit-extract.
S5: Bitboard path distance — early-exit BFS on ints.
S6: Bitboard nearest food — BFS food search on ints.
S7: Per-turn BitBoard instance cached for board dimensions.
S8: Blocked-set → bitboard conversion cached within a turn to avoid
redundant O(n) conversions for the same frozen set.
S9: Survival-tree uses bitboards natively — enemy body/attack bits
precomputed once at tree root, no per-node set/dict rebuilds.
S10: _legal_moves override uses bitboard neighbour mask instead of
per-direction Python loop + _in_bounds calls.
S11: _future_survival_tree inlines legal-move check with bitboard ops.
"""
from __future__ import annotations
from typing import Any
from time import perf_counter
from snakes.ApexBattleSnake import ApexBattleSnake
from snakes.bitboard import BitBoard
from server.GameBoard import GameBoard
# Direction offsets for coord-dict → tuple conversion
_DIR_DELTAS = ((0, 1), (0, -1), (-1, 0), (1, 0))
_DIR_NAMES = ("up", "down", "left", "right")
class SupremeBattleSnake_ClaudeOpus4_6(ApexBattleSnake):
VERSION = "1.0.0"
def __init__(self) -> None:
super().__init__()
self.name = "SupremeBattleSnake"
self.version = self.VERSION
# S7: cached BitBoard instance (reused while board dimensions stay the same)
self._bb: BitBoard | None = None
self._bb_w: int = 0
self._bb_h: int = 0
# S8: per-turn frozenset → bitboard conversion cache
self._bits_cache: dict[int, int] = {}
self._bits_cache_turn: int = -1
# S9: precomputed enemy state for survival tree (set per turn in choose_move)
self._enemy_body_bits: int = 0 # all enemy body cells as bitboard
self._enemy_tail_bits: int = 0 # enemy tails that will vacate
self._enemy_attack_danger: int = 0 # tiles where enemy len >= our len
self._enemy_attack_opportunity: int = 0 # tiles where enemy len < our len
# ── BitBoard accessor ────────────────────────────────────────────────────
def _get_bb(self, width: int, height: int) -> BitBoard:
"""Return (possibly cached) BitBoard for the current dimensions."""
if self._bb is None or width != self._bb_w or height != self._bb_h:
self._bb = BitBoard(width, height)
self._bb_w = width
self._bb_h = height
return self._bb
def _blocked_to_bits(self, blocked: set[tuple[int, int]], width: int, height: int) -> int:
"""Convert a blocked set to a bitboard, with per-turn caching."""
bb = self._get_bb(width, height)
sid = id(blocked)
cached = self._bits_cache.get(sid)
if cached is not None:
return cached
bits = bb.set_to_bits(blocked)
self._bits_cache[sid] = bits
return bits
# ── choose_move override: reset caches + precompute enemy bits ───────────
def choose_move(self, game_data: GameBoard) -> str:
turn = game_data.get_turn()
if turn != self._bits_cache_turn:
self._bits_cache = {}
self._bits_cache_turn = turn
bb = self._get_bb(game_data.get_width(), game_data.get_height())
# S9: precompute enemy body / tail / attack bitboards for survival tree
other_snakes = game_data.get_other_snakes()
my_snake = game_data.get_my_snake()
my_len = my_snake.get("length", len(my_snake["body"]))
food_set = {(f["x"], f["y"]) for f in game_data.get_food()}
game_type = game_data.get_type()
is_constrictor = game_type == "constrictor"
w = bb.width
enemy_body_bits = 0
enemy_tail_bits = 0
enemy_attack_danger = 0
enemy_attack_opportunity = 0
for snake in other_snakes:
for seg in snake["body"]:
enemy_body_bits |= 1 << (seg["y"] * w + seg["x"])
body = snake["body"]
# Check if tail will vacate
if not is_constrictor and len(body) >= 2:
tail_stacked = (body[-1]["x"] == body[-2]["x"] and body[-1]["y"] == body[-2]["y"])
if not tail_stacked:
can_grow = self._enemy_can_grow_this_turn(snake, food_set)
if not can_grow:
enemy_tail_bits |= 1 << (body[-1]["y"] * w + body[-1]["x"])
# Attack map: tiles enemy head can reach in 1 move
eh = snake["head"]
e_len = snake.get("length", len(body))
ehx, ehy = eh["x"], eh["y"]
for dx, dy in _DIR_DELTAS:
nx, ny = ehx + dx, ehy + dy
if 0 <= nx < w and 0 <= ny < bb.height:
bit = 1 << (ny * w + nx)
if e_len >= my_len:
enemy_attack_danger |= bit
else:
enemy_attack_opportunity |= bit
self._enemy_body_bits = enemy_body_bits
self._enemy_tail_bits = enemy_tail_bits
self._enemy_attack_danger = enemy_attack_danger
self._enemy_attack_opportunity = enemy_attack_opportunity
return super().choose_move(game_data)
# ── S1: Bitboard flood fill ──────────────────────────────────────────────
def _flood_fill_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
# A7/E2: per-turn transposition cache (kept from Apex)
cache_key = (start, frozenset(blocked))
cached = self._bfs_cache.get(cache_key)
if cached is not None:
return cached
result = bb.flood_count(start_idx, blocked_bits)
if len(self._bfs_cache) < self._bfs_cache_max:
self._bfs_cache[cache_key] = result
return result
# ── S2: Bitboard territory ──────────────────────────────────────────────
def _territory_fast(
self, my_pos: tuple, blocked: set, width: int, height: int,
deadline: float | None = None,
) -> int:
if not self._enemy_heads:
return 0
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
my_idx = bb.idx(my_pos[0], my_pos[1])
enemy_idxs = [bb.idx(eh[0], eh[1]) for eh in self._enemy_heads]
return bb.territory(my_idx, enemy_idxs, blocked_bits)
# ── S3: Bitboard articulation penalty ────────────────────────────────────
def _articulation_penalty(
self, point: tuple, blocked: set, width: int, height: int, required_space: int,
) -> float:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
point_idx = bb.idx(point[0], point[1])
sizes = bb.partition_sizes(point_idx, blocked_bits)
if not sizes:
return 0.0
min_size = min(sizes)
if min_size < required_space:
return 1500.0
elif min_size < required_space * 2:
return 400.0
else:
return 85.0
def _bounded_bfs(self, start: tuple, blocked: set, width: int, height: int, limit: int) -> set:
"""Bitboard-accelerated bounded BFS. Returns a set for API compatibility."""
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
reachable_bits = bb.flood_fill(start_idx, blocked_bits)
result: set[tuple[int, int]] = set()
temp = reachable_bits
w = bb.width
while temp:
bit = temp & (-temp)
idx = bit.bit_length() - 1
result.add((idx % w, idx // w))
temp ^= bit
if len(result) >= limit:
break
return result
# ── S4: Bitboard distance map ───────────────────────────────────────────
def _distance_map(self, start: tuple, blocked: set, width: int, height: int) -> dict:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
idx_dmap = bb.distance_map(start_idx, blocked_bits)
w = bb.width
return {(idx % w, idx // w): d for idx, d in idx_dmap.items()}
# ── S5: Bitboard path distance ──────────────────────────────────────────
def _path_distance(
self, start: tuple, goal: tuple, blocked: set, width: int, height: int,
) -> int | None:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.path_distance(
bb.idx(start[0], start[1]),
bb.idx(goal[0], goal[1]),
blocked_bits,
)
# ── S6: Bitboard nearest food ───────────────────────────────────────────
def _nearest_food_info(
self, start: tuple, food_set: set, blocked: set, width: int, height: int,
) -> tuple[int | None, tuple | None]:
if not food_set:
return None, None
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
food_bits = bb.set_to_bits(food_set)
start_idx = bb.idx(start[0], start[1])
dist, cell_idx = bb.nearest_food(start_idx, food_bits, blocked_bits)
if dist is None or cell_idx is None:
return None, None
return dist, bb.coord(cell_idx)
# ── Bitboard open-neighbour helpers ──────────────────────────────────────
def _open_neighbor_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.open_neighbor_count(bb.idx(start[0], start[1]), blocked_bits)
def _next_turn_options(self, head: dict, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.open_neighbor_count(bb.idx(head["x"], head["y"]), blocked_bits)
# ── S9: Optimised survival tree (bitboard-native) ────────────────────────
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) ────────────────────────
# Remove tiles where an enemy of >= our length could head-to-head.
# The danger bitboard was precomputed; filter out tiles blocked by
# current body (enemy can't step there either).
danger_here = self._enemy_attack_danger & ~blocked_bits
safe_nb = nb_free & ~danger_here
en_safe = safe_nb.bit_count()
if en_safe == 0:
return -4000.0
sc = reachable * 1.9 + liberties * 14.0 + liberties * 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
h = bb.height
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
# ── S10: Bitboard legal moves ────────────────────────────────────────────
def _legal_moves(
self, my_head, my_body: list, other_snakes: list,
food_set: set, is_constrictor: bool, width: int, height: int,
enemy_can_grow: dict | None = None,
):
"""S10: Bitboard-accelerated legal move generation."""
bb = self._get_bb(width, height)
w = bb.width
# Build occupied bitboard
occupied = 0
for seg in my_body:
occupied |= 1 << (seg["y"] * w + seg["x"])
for snake in other_snakes:
for seg in snake["body"]:
occupied |= 1 << (seg["y"] * w + seg["x"])
hx, hy = my_head["x"], my_head["y"]
head_idx = hy * w + hx
# Own tail can be stepped on
passable = 0
if not is_constrictor and len(my_body) >= 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 tails that will vacate
if not is_constrictor:
for snake in other_snakes:
sbody = snake["body"]
if len(sbody) < 2:
continue
st, st2 = sbody[-1], sbody[-2]
if st["x"] == st2["x"] and st["y"] == st2["y"]:
continue # stacked
sid = snake.get("id")
can_grow = None
if enemy_can_grow is not None and sid is not None:
can_grow = enemy_can_grow.get(sid)
if can_grow is None:
can_grow = self._enemy_can_grow_this_turn(snake, food_set)
if not can_grow:
passable |= 1 << (st["y"] * w + st["x"])
legal = bb._neighbor_masks[head_idx] & ((~occupied & bb.board_mask) | passable)
safe: dict[str, dict[str, int]] = {}
for name, (dx, dy) in self.DIRECTIONS.items():
nx, ny = hx + dx, hy + dy
if 0 <= nx < w and 0 <= ny < bb.height:
if (1 << (ny * w + nx)) & legal:
safe[name] = {"x": nx, "y": ny}
return safe
# ── Enemy confinement (uses bitboard flood) ──────────────────────────────
def _enemy_confinement_metrics(
self, enemy_head: tuple, blocked: set, width: int, height: int,
) -> tuple[int, int]:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
eh_idx = bb.idx(enemy_head[0], enemy_head[1])
eb_bits = blocked_bits & ~(1 << eh_idx)
space = bb.flood_count(eh_idx, eb_bits)
options = bb.open_neighbor_count(eh_idx, eb_bits)
return space, options
def _enemy_constrictor_projection(
self, other_snakes: list, blocked: set, width: int, height: int,
) -> tuple[int, int]:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
best_space = 0
total_opts = 0
for enemy in other_snakes:
eh = (enemy["head"]["x"], enemy["head"]["y"])
eh_idx = bb.idx(eh[0], eh[1])
nb = bb.neighbors_of(eh_idx) & ~blocked_bits & bb.board_mask
temp = nb
while temp:
total_opts += 1
bit = temp & (-temp)
n_idx = bit.bit_length() - 1
sp = bb.flood_count(n_idx, blocked_bits | bit)
if sp > best_space:
best_space = sp
temp ^= bit
return best_space, total_opts