fix: preserve gameplay data and correct duel evaluation

- Preserve snake customizations across database migrations and merges.
- Lazily load optional storage backends for SQLite maintenance scripts.
- Match Apex territory and nearest-food tie-breaking semantics.
- Resolve duel occupancy after simultaneous movement and food growth.
- Recompute simulated head-to-head danger after body growth.
- Add regression coverage and declare the aiofiles dependency.
This commit is contained in:
2026-08-01 18:16:04 +02:00
parent 4f022d3d01
commit c646392b84
14 changed files with 323 additions and 107 deletions
+66 -59
View File
@@ -127,8 +127,9 @@ class BitBoard:
) -> int:
"""Simultaneous BFS from *my_idx* and all enemies.
Returns (my_cells enemy_cells). Cells equidistant from both sides are
counted for neither (contested).
Returns Apex-compatible territory over cells reachable from ``my_idx``:
+1 when we arrive first, -1 when an enemy arrives first, and 0 for ties.
Enemy-only disconnected regions are not counted.
"""
if not enemy_indices:
return 0
@@ -139,48 +140,44 @@ class BitBoard:
nlc = self._not_leftcol
my_front = 1 << my_idx
my_terr = my_front
my_seen = my_front
en_front = 0
for ei in enemy_indices:
en_front |= 1 << ei
en_terr = en_front
en_seen = en_front
remaining = free & ~my_terr & ~en_terr
# Each side must expand independently. A cell reached at the same depth is
# unclaimed, but it is not a wall: both sides may route through it later.
# Match Apex semantics by scoring only cells reachable from our head:
# ours when we arrive first, theirs when an enemy arrives first, and zero
# on ties. Enemy-only disconnected regions are intentionally ignored.
score = (my_front & ~en_front).bit_count()
enemy_before = 0
while my_front:
my_exp = (
((my_front & nrc) << 1)
| ((my_front & nlc) >> 1)
| (my_front << w)
| (my_front >> w)
) & free & ~my_seen
en_exp = (
((en_front & nrc) << 1)
| ((en_front & nlc) >> 1)
| (en_front << w)
| (en_front >> w)
) & free & ~en_seen
while (my_front or en_front) and remaining:
# Expand both sides simultaneously (same BFS depth → ties go to neither)
my_exp = 0
if my_front:
my_exp = (
((my_front & nrc) << 1)
| ((my_front & nlc) >> 1)
| (my_front << w)
| (my_front >> w)
) & remaining
en_exp = 0
if en_front:
en_exp = (
((en_front & nrc) << 1)
| ((en_front & nlc) >> 1)
| (en_front << w)
| (en_front >> w)
) & remaining
# Contested cells (reached by both at the same depth) → neither claims
contested = my_exp & en_exp
my_exp &= ~contested
en_exp &= ~contested
my_terr |= my_exp
en_terr |= en_exp
remaining &= ~(my_exp | en_exp | contested)
enemy_before |= en_front
score += (my_exp & ~enemy_before & ~en_exp).bit_count()
score -= (my_exp & enemy_before).bit_count()
my_seen |= my_exp
en_seen |= en_exp
my_front = my_exp
en_front = en_exp
return my_terr.bit_count() - en_terr.bit_count()
return score
# ── Partition sizes (for articulation-point detection) ────────────────────
@@ -324,32 +321,42 @@ class BitBoard:
if start_bit & food_bits:
return 0, start_idx
frontier = start_bit
seen = frontier
dist = 0
# Preserve Apex's deterministic up/down/left/right BFS tie-breaking. A
# pure bit frontier finds the right distance but selects the lowest flat
# index when several foods are equally close, which can change contested-
# food scoring and therefore the selected move.
queue = [start_idx]
distances = [0]
seen = start_bit
cursor = 0
w = self.width
nrc = self._not_rightcol
nlc = self._not_leftcol
size = self.size
while frontier:
dist += 1
expanded = (
((frontier & nrc) << 1)
| ((frontier & nlc) >> 1)
| (frontier << w)
| (frontier >> w)
) & free & ~seen
if not expanded:
break
hit = expanded & food_bits
if hit:
# Return the first (lowest-index) food cell found
first_bit = hit & (-hit)
return dist, first_bit.bit_length() - 1
seen |= expanded
frontier = expanded
while cursor < len(queue):
cell = queue[cursor]
dist = distances[cursor]
cursor += 1
x = cell % w
candidates = (
cell + w,
cell - w,
cell - 1,
cell + 1,
)
for direction, neighbor in enumerate(candidates):
if neighbor < 0 or neighbor >= size:
continue
if direction == 2 and x == 0:
continue
if direction == 3 and x == w - 1:
continue
bit = 1 << neighbor
if bit & seen or not bit & free:
continue
if bit & food_bits:
return dist + 1, neighbor
seen |= bit
queue.append(neighbor)
distances.append(dist + 1)
return None, None