6 Commits

Author SHA1 Message Date
daniel156161 f14d780f29 feat: add live replays and PostgreSQL maintenance tools
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
- Stream compact live replay updates across local and clustered dashboards.
- Render responsive snake bodies as SVG paths with aligned custom icons.
- Add cache-busted assets, replay fallback routes, and live-follow playback.
- Support PostgreSQL benchmark sampling and idempotent SQLite migration.
- Add dry-run cleanup for old low-quality PostgreSQL replay payloads.
- Reward safe perimeter lanes and bump Prism to version 1.5.0.
- Add backend, migration, dashboard, and perimeter regression coverage.
2026-08-02 00:50:46 +02:00
daniel156161 9b99b526e4 perf(snake): cache survival rollout state
Build and Push Docker Container / build-and-push (push) Successful in 6m1s
- Cache occupancy bitboards for repeated multiplayer rollout positions.
- Memoize position evaluations to avoid duplicate flood-fill calculations.
- Reuse shared values while ranking simultaneous enemy responses.
- Include evaluation hits in Prism rollout telemetry.
- Document the optimization and bump Prism to version 1.4.0.
2026-08-01 20:39:56 +02:00
daniel156161 3a9af3f54d 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.
2026-08-01 20:25:07 +02:00
daniel156161 cb6c8d4dc8 feat(snake): add adaptive adversarial search
- Share duel search contexts and transpositions across candidate moves.
- Add aspiration windows, principal variation ordering, and body caches.
- Model simultaneous multiplayer responses with a compact beam rollout.
- Adapt search depth and response breadth to the remaining deadline.
- Add a deterministic arena benchmark with optional JSON reporting.
- Expose search metrics, document benchmarking, and bump Prism to 1.2.0.
2026-08-01 19:26:19 +02:00
daniel156161 6643eb35af fix: resolve duel roots and recover legacy snake data
- Resolve selected moves and enemy replies on the same simulated turn.
- Add an Apex candidate hook and bump the Prism snake to version 1.1.0.
- Rebuild benchmark states from normalized turn data when snapshots are empty.
- Synthesize missing game snake identities during legacy database migration.
- Add regression coverage for duel timing and partial legacy schemas.
2026-08-01 19:11:32 +02:00
daniel156161 c646392b84 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.
2026-08-01 18:16:04 +02:00
72 changed files with 4320 additions and 1291 deletions
+42 -13
View File
@@ -1,5 +1,4 @@
# Battlesnake Python Starter Project
An official Battlesnake template written in Python. Get started at [play.battlesnake.com](https://play.battlesnake.com).
![Battlesnake Logo](https://media.battlesnake.com/social/StarterSnakeGitHubRepos_Python.png)
@@ -7,11 +6,9 @@ An official Battlesnake template written in Python. Get started at [play.battles
This project is a great starting point for anyone wanting to program their first Battlesnake in Python. It can be run locally or easily deployed to a cloud provider of your choosing. See the [Battlesnake API Docs](https://docs.battlesnake.com/api) for more detail.
## Technologies Used
This project uses [Python 3](https://www.python.org/) and [Flask](https://flask.palletsprojects.com/). It also comes with an optional [Dockerfile](https://docs.docker.com/engine/reference/builder/) to help with deployment.
## Run Your Battlesnake
Install dependencies using pip
```sh
@@ -19,7 +16,6 @@ pip install -r requirements.txt
```
Start your Battlesnake
```sh
python main.py
```
@@ -39,47 +35,56 @@ Open [localhost:8000](http://localhost:8000) in your browser and you should see
```
## Play a Game Locally
Install the [Battlesnake CLI](https://github.com/BattlesnakeOfficial/rules/tree/main/cli)
* You can [download compiled binaries here](https://github.com/BattlesnakeOfficial/rules/releases)
* or [install as a go package](https://github.com/BattlesnakeOfficial/rules/tree/main/cli#installation) (requires Go 1.18 or higher)
Command to run a local game
```sh
battlesnake play -W 11 -H 11 --name 'Python Starter Project' --url http://localhost:8000 -g solo --browser
```
## Next Steps
Continue with the [Battlesnake Quickstart Guide](https://docs.battlesnake.com/quickstart) to customize and improve your Battlesnake's behavior.
## Included Competitive Snake
This repo now includes `snakes/BestBattleSnake.py`, a stronger default snake that combines:
This repo retains `snakes/legacy/BestBattleSnake.py`, a stronger historical snake that combines:
- collision and head-to-head risk checks
- flood-fill space evaluation to avoid traps
- food routing that gets more aggressive as health drops
- tail access checks for better long-term survival
Run it explicitly with:
```sh
SNAKE=BestBattleSnake python main.py
```
Optional duel tuning (when only 2 snakes are alive):
```sh
BATTLE_SNAKE_DUEL_STYLE=balanced python main.py
```
Allowed values: `safe`, `balanced`, `aggressive`.
## Snake package layout
The snake code is split by responsibility:
- `snakes/strategies/` — actively maintained Apex and Prism entry points
- `snakes/engine/` — reusable bitboards, spatial mixins, duel search, and survival search
- `snakes/core/` — shared base classes
- `snakes/legacy/` — historical snakes retained for compatibility and benchmarks
Snake selection still uses the existing registry names, so deployment values such
as `SNAKE=PrismBattleSnake_GPT_5_6_Sol` remain unchanged.
## PrismBattleSnake_GPT_5_6_Sol
`PrismBattleSnake_GPT_5_6_Sol` is a separate snake that keeps Apex's strategy while
accelerating hot spatial operations with a Python-integer bitboard engine. Its
filename, class, and registry key include the model name, while its public
Battlesnake API name remains `PrismBattleSnake`.
accelerating hot spatial operations with a Python-integer bitboard engine. It
also shares duel transpositions across candidate moves, uses principal-variation
ordering, aspiration windows, path-aware food races, and a deeper tactical
horizon, and runs a compact adversarial multiplayer rollout with simultaneous
enemy responses and cached occupancy/evaluation states. Its filename, class, and registry
key include the model name, while its public Battlesnake API name remains
`PrismBattleSnake`.
Run it with:
```sh
@@ -96,6 +101,30 @@ python scripts/benchmark_snakes_from_db.py \
The benchmark opens SQLite read-only and reports mean, median, p95, and maximum
move latency. Increase `--samples` for a broader but slower comparison.
Run the deterministic CI-friendly arena benchmark without a gameplay database:
```sh
just bench-snake-arena positions=100
```
It rotates through duel, hazard, multiplayer, constrictor, and cramped-endgame
positions. It reports latency, completed duel/rollout depth, searched nodes,
cache hits, deadline exits, and move disagreements between Apex and Prism. Use
`--scenario hazard` (repeatable) when invoking the Python script to isolate a
scenario. Add `output=data/arena-report.json` to save a machine-readable report.
For representative strategy evaluation, provide recorded positions to
`scripts/benchmark_snake_arena.py --database /path/to/gameplay.sqlite3`.
Run paired seeded games through the official local Battlesnake rules engine:
```sh
just bench-snake-tournament games=20 gametype=standard map=standard
```
Each seed is played twice with Apex and Prism swapping initial engine slots. The
report includes wins, draws, win rates, and average game length. Save all
per-game results with `output=data/tournament-report.json`. The tournament starts
both snake servers with gameplay persistence disabled, adds the engine identity
header required by the API, and shuts them down when finished.
## Compact gameplay database
New gameplay turns use normalized storage: the turn row stores food, hazards,
move, and thinking data once; snake identity is stored once per game in
+49
View File
@@ -62,6 +62,42 @@ bench-best-snake iterations="1000":
PYTHONPATH="{{justfile_directory()}}" python "{{justfile_directory()}}/tests/bench_best_battle_snake.py" --iterations "{{iterations}}"
bench-snake-arena positions="100" output="" database="":
#!/usr/bin/env bash
set -euo pipefail
args=(--positions "{{positions}}")
if [ -n "{{database}}" ]; then args+=(--database "{{database}}"); fi
if [ -n "{{output}}" ]; then args+=(--json-output "{{output}}"); fi
PYTHONPATH="{{justfile_directory()}}" uv run python "{{justfile_directory()}}/scripts/benchmark_snake_arena.py" "${args[@]}"
bench-snake-arena-postgres positions="100" output="":
#!/usr/bin/env bash
set -euo pipefail
: "${GAMEPLAY_DB_PG_DSN:?Set GAMEPLAY_DB_PG_DSN in .env or the environment}"
args=(--positions "{{positions}}" --database "$GAMEPLAY_DB_PG_DSN")
if [ -n "{{output}}" ]; then args+=(--json-output "{{output}}"); fi
PYTHONPATH="{{justfile_directory()}}" uv run python "{{justfile_directory()}}/scripts/benchmark_snake_arena.py" "${args[@]}"
cleanup-gameplay-postgres older_than_days="30" execute="false" vacuum="false":
#!/usr/bin/env bash
set -euo pipefail
: "${GAMEPLAY_DB_PG_DSN:?Set GAMEPLAY_DB_PG_DSN in .env or the environment}"
args=(--dsn "$GAMEPLAY_DB_PG_DSN" --older-than-days "{{older_than_days}}")
if [ "{{execute}}" = "true" ]; then args+=(--execute); fi
if [ "{{vacuum}}" = "true" ]; then args+=(--vacuum); fi
PYTHONPATH="{{justfile_directory()}}" uv run python "{{justfile_directory()}}/scripts/cleanup_postgresql_gameplay.py" "${args[@]}"
bench-snake-tournament games="20" gametype="standard" map="standard" output="":
#!/usr/bin/env bash
set -euo pipefail
args=(--games "{{games}}" --gametype "{{gametype}}" --map "{{map}}")
if [ -n "{{output}}" ]; then args+=(--json-output "{{output}}"); fi
PYTHONPATH="{{justfile_directory()}}" python "{{justfile_directory()}}/scripts/run_seeded_snake_tournament.py" "${args[@]}"
build-battlesnake-cli:
#!/usr/bin/env bash
set -euo pipefail
@@ -93,6 +129,19 @@ battlesnake-cli-version:
# Testing helpers
# ------------------------------------------------------------------------------
test-unit:
#!/usr/bin/env bash
set -euo pipefail
PYTHONPATH="{{justfile_directory()}}" python -m unittest discover -s "{{justfile_directory()}}/tests" -p "test_*.py"
# Dashboard front-end logic (head/tail orientation, board geometry).
test-js:
#!/usr/bin/env bash
set -euo pipefail
node --test "{{justfile_directory()}}"/tests/js/*.test.mjs
test-constrictor: build-battlesnake-cli
#!/usr/bin/env bash
set -euo pipefail
+1
View File
@@ -9,6 +9,7 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"aiofiles>=25.1.0",
"aiologger>=0.7.0",
"dotenv>=0.9.9",
"httpx>=0.28.0",
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Compare snake decisions and latency on deterministic synthetic positions.
For outcome/win-rate tournaments use the local Battlesnake CLI. This harness is
fast enough for CI and detects move disagreements, crashes, and latency changes.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from statistics import mean, median
from time import perf_counter
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.benchmark_snakes_from_db import load_states, percentile
from scripts.snake_arena_scenarios import SCENARIOS, synthetic_states
from server.GameBoard import GameBoard
from snakes import SnakeBuilder
def evaluate(name: str, states: list[tuple[dict, dict]]) -> tuple[list[str], dict]:
moves: list[str] = []
durations: list[float] = []
duel_depths: list[int] = []
rollout_depths: list[int] = []
duel_nodes = 0
rollout_nodes = 0
cache_hits = 0
deadline_exits = 0
scenario_durations: dict[str, list[float]] = {}
for index, (board_data, metadata) in enumerate(states):
snake = SnakeBuilder.build(name)
game_id = f"arena-{name}-{index}-{metadata['game_id']}"
board = GameBoard(
game_id=game_id, width=board_data["width"], height=board_data["height"],
ruleset=metadata["ruleset"], source=metadata["source"],
map=metadata["map"], snake_class=snake,
)
board.read_game_data({
"game": {
"id": game_id, "ruleset": metadata["ruleset"],
"source": metadata["source"], "map": metadata["map"], "timeout": 500,
},
"turn": metadata["turn"], "board": board_data, "you": metadata["you"],
})
started = perf_counter()
moves.append(snake.choose_move(board))
duration = (perf_counter() - started) * 1000.0
durations.append(duration)
scenario = metadata.get("scenario", "recorded")
scenario_durations.setdefault(scenario, []).append(duration)
history = snake.get_history() if hasattr(snake, "get_history") else []
if history:
thinking = history[-1]
duel_depths.append(int(thinking.get("prism_duel_depth", thinking.get("minimax_depth_reached", 0))))
rollout_depths.append(int(thinking.get("prism_rollout_depth", 0)))
duel_nodes += int(thinking.get("prism_duel_nodes", 0))
rollout_nodes += int(thinking.get("prism_rollout_nodes", 0))
cache_hits += int(thinking.get("prism_duel_cache_hits", 0))
cache_hits += int(thinking.get("prism_rollout_cache_hits", 0))
deadline_exits += int(thinking.get("prism_duel_deadline_exits", 0))
deadline_exits += int(thinking.get("prism_rollout_deadline_exits", 0))
return moves, {
"snake": name, "positions": len(states),
"mean_ms": mean(durations), "median_ms": median(durations),
"p95_ms": percentile(durations, 0.95), "max_ms": max(durations),
"mean_duel_depth": mean(duel_depths) if duel_depths else 0.0,
"mean_rollout_depth": mean(rollout_depths) if rollout_depths else 0.0,
"duel_nodes": duel_nodes, "rollout_nodes": rollout_nodes,
"cache_hits": cache_hits, "deadline_exits": deadline_exits,
"scenario_mean_ms": {
scenario: mean(values) for scenario, values in scenario_durations.items()
},
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--snake", action="append", default=[])
parser.add_argument("--database")
parser.add_argument("--positions", type=int, default=100)
parser.add_argument("--stride", type=int, default=997)
parser.add_argument("--scenario", action="append", choices=sorted(SCENARIOS))
parser.add_argument("--json-output")
args = parser.parse_args()
states = (
load_states(args.database, max(1, args.positions), max(1, args.stride))
if args.database else synthetic_states(max(1, args.positions), args.scenario)
)
if not states:
raise SystemExit("No benchmark positions found")
names = args.snake or ["ApexBattleSnake", "PrismBattleSnake_GPT_5_6_Sol"]
move_sets: dict[str, list[str]] = {}
reports: list[dict] = []
for name in names:
moves, report = evaluate(name, states)
move_sets[name] = moves
reports.append(report)
print(
f"{name}: mean={report['mean_ms']:.3f} ms, "
f"p95={report['p95_ms']:.3f} ms, max={report['max_ms']:.3f} ms, "
f"duel-depth={report['mean_duel_depth']:.2f}, "
f"rollout-depth={report['mean_rollout_depth']:.2f}, "
f"nodes={report['duel_nodes'] + report['rollout_nodes']}, "
f"cache-hits={report['cache_hits']}, deadline-exits={report['deadline_exits']}"
)
baseline = names[0]
disagreements = {
name: sum(a != b for a, b in zip(move_sets[baseline], move_sets[name]))
for name in names[1:]
}
if disagreements:
print(f"Move disagreements versus {baseline}: {disagreements}")
scenario_disagreements = {}
for name in names[1:]:
counts: dict[str, int] = {}
for index, (baseline_move, candidate_move) in enumerate(
zip(move_sets[baseline], move_sets[name])
):
if baseline_move != candidate_move:
scenario = states[index][1].get("scenario", "recorded")
counts[scenario] = counts.get(scenario, 0) + 1
scenario_disagreements[name] = counts
if any(scenario_disagreements.values()):
print(f"Disagreements by scenario: {scenario_disagreements}")
payload = {
"reports": reports,
"baseline": baseline,
"disagreements": disagreements,
"scenario_disagreements": scenario_disagreements,
}
if args.json_output:
Path(args.json_output).write_text(json.dumps(payload, indent=2) + "\n")
if __name__ == "__main__":
main()
+27 -23
View File
@@ -4,7 +4,6 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sqlite3
from statistics import mean, median
@@ -13,6 +12,9 @@ from time import perf_counter
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from server.database.benchmark_states import (
_build_state, is_postgresql_source, load_postgresql_states,
)
from server.GameBoard import GameBoard
from snakes import SnakeBuilder
@@ -22,6 +24,9 @@ def percentile(values: list[float], quantile: float) -> float:
return ordered[index]
def load_states(db_path: str, samples: int, stride: int) -> list[tuple[dict, dict]]:
if is_postgresql_source(db_path):
return load_postgresql_states(db_path, samples, stride)
connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
connection.execute("PRAGMA query_only = ON")
max_id = int(connection.execute("SELECT max(id) FROM turns").fetchone()[0] or 0)
@@ -31,7 +36,8 @@ def load_states(db_path: str, samples: int, stride: int) -> list[tuple[dict, dic
states: list[tuple[dict, dict]] = []
next_id = max(1, max_id - (samples - 1) * stride)
query = """
SELECT t.board_state_json, t.you_json, g.your_snake_id,
SELECT t.id, t.board_state_json, t.you_json, t.food_json, t.hazards_json,
g.your_snake_id, g.your_snake_name, g.width, g.height,
g.game_id, g.source, g.map_name,
g.ruleset_name, g.ruleset_version, t.turn
FROM turns AS t
@@ -40,30 +46,25 @@ def load_states(db_path: str, samples: int, stride: int) -> list[tuple[dict, dic
ORDER BY t.id
LIMIT 1
"""
snake_query = """
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name),
st.health, st.length, st.head_x, st.head_y, st.body_json,
COALESCE(gs.customizations_json, '{}')
FROM snake_turns AS st
LEFT JOIN game_snakes AS gs
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
WHERE st.game_id = ? AND st.turn = ?
ORDER BY st.id
"""
while len(states) < samples and next_id <= max_id:
row = connection.execute(query, (next_id,)).fetchone()
if row is None:
break
board = json.loads(row[0])
you = json.loads(row[1])
if not you:
you = next(
(snake for snake in board.get("snakes", []) if snake.get("id") == row[2]),
{},
)
metadata = {
"game_id": row[3],
"source": row[4] or "custom",
"map": row[5] or "standard",
"ruleset": {
"name": row[6] or "standard",
"version": row[7] or "v1.0.0",
"settings": {},
},
"turn": int(row[8]),
}
states.append((board, {"you": you, **metadata}))
next_id += stride
snake_rows = connection.execute(snake_query, (row[9], row[14])).fetchall()
state = _build_state(row, snake_rows)
if state is not None:
states.append(state)
next_id = int(row[0]) + stride
connection.close()
return states
@@ -112,7 +113,10 @@ def benchmark(snake_name: str, states: list[tuple[dict, dict]], repeat: int) ->
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--database", required=True)
parser.add_argument(
"--database", required=True,
help="SQLite path or postgresql:// DSN",
)
parser.add_argument("--snake", action="append", default=[])
parser.add_argument("--samples", type=int, default=100)
parser.add_argument("--stride", type=int, default=997)
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Delete old low-quality replay payloads while preserving game results."""
from __future__ import annotations
import argparse
import asyncio
from datetime import datetime, timedelta, timezone
async def cleanup(dsn: str, older_than_days: int, dry_run: bool, vacuum: bool) -> None:
try:
import asyncpg
except ImportError as exc:
raise RuntimeError("asyncpg is required for PostgreSQL cleanup") from exc
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, older_than_days))
connection = await asyncpg.connect(dsn=dsn)
try:
candidates = int(await connection.fetchval("""
SELECT count(*)
FROM games
WHERE has_replay
AND quality_status = 'low_quality'
AND COALESCE(ended_at, started_at) < $1
""", cutoff))
rows = await connection.fetchrow("""
SELECT
(SELECT count(*) FROM turns t JOIN games g USING (game_id)
WHERE g.has_replay AND g.quality_status = 'low_quality'
AND COALESCE(g.ended_at, g.started_at) < $1) AS turns,
(SELECT count(*) FROM snake_turns t JOIN games g USING (game_id)
WHERE g.has_replay AND g.quality_status = 'low_quality'
AND COALESCE(g.ended_at, g.started_at) < $1) AS snake_turns,
(SELECT count(*) FROM game_snakes t JOIN games g USING (game_id)
WHERE g.has_replay AND g.quality_status = 'low_quality'
AND COALESCE(g.ended_at, g.started_at) < $1) AS game_snakes
""", cutoff)
print(
f"candidates before {cutoff.isoformat()}: games={candidates:,}, "
f"turns={rows['turns']:,}, snake_turns={rows['snake_turns']:,}, "
f"game_snakes={rows['game_snakes']:,}"
)
if dry_run or candidates == 0:
print("dry run: no rows changed" if dry_run else "nothing to clean")
return
async with connection.transaction():
game_ids = await connection.fetch("""
SELECT game_id FROM games
WHERE has_replay
AND quality_status = 'low_quality'
AND COALESCE(ended_at, started_at) < $1
FOR UPDATE
""", cutoff)
ids = [row["game_id"] for row in game_ids]
await connection.execute("DELETE FROM snake_turns WHERE game_id = ANY($1::text[])", ids)
await connection.execute("DELETE FROM turns WHERE game_id = ANY($1::text[])", ids)
await connection.execute("DELETE FROM game_snakes WHERE game_id = ANY($1::text[])", ids)
await connection.execute("""
UPDATE games
SET has_replay = FALSE, quality_status = 'low_quality'
WHERE game_id = ANY($1::text[])
""", ids)
print(f"cleaned replay payloads for {len(ids):,} games; result rows preserved")
if vacuum:
await connection.execute("VACUUM (ANALYZE) games")
await connection.execute("VACUUM (ANALYZE) game_snakes")
await connection.execute("VACUUM (ANALYZE) turns")
await connection.execute("VACUUM (ANALYZE) snake_turns")
print("vacuum/analyze complete")
finally:
await connection.close()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dsn", required=True)
parser.add_argument("--older-than-days", type=int, default=30)
parser.add_argument("--execute", action="store_true", help="Apply deletion; default is dry-run")
parser.add_argument("--vacuum", action="store_true")
args = parser.parse_args()
asyncio.run(cleanup(args.dsn, args.older_than_days, not args.execute, args.vacuum))
if __name__ == "__main__":
main()
+15 -4
View File
@@ -117,15 +117,26 @@ def copy_game_snakes(source:sqlite3.Connection, destination:sqlite3.Connection,
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='game_snakes'"
).fetchone()
if has_table:
cursor = source.execute(
"SELECT game_id, snake_id, snake_name, is_you FROM game_snakes ORDER BY game_id, snake_id"
columns = object_columns(source, "game_snakes")
customizations = (
"COALESCE(customizations_json, '{}') AS customizations_json"
if "customizations_json" in columns else "'{}' AS customizations_json"
)
cursor = source.execute(f"""
SELECT game_id, snake_id, snake_name, is_you, {customizations}
FROM game_snakes ORDER BY game_id, snake_id
""")
else:
cursor = source.execute("""
SELECT game_id, snake_id, MAX(snake_name), MAX(is_you)
SELECT game_id, snake_id, MAX(snake_name), MAX(is_you),
'{}' AS customizations_json
FROM snake_turns GROUP BY game_id, snake_id ORDER BY game_id, snake_id
""")
sql = "INSERT INTO game_snakes (game_id,snake_id,snake_name,is_you) VALUES (?,?,?,?)"
sql = """
INSERT INTO game_snakes (
game_id,snake_id,snake_name,is_you,customizations_json
) VALUES (?,?,?,?,?)
"""
count = 0
while rows := cursor.fetchmany(batch_size):
values = [tuple(row) for row in rows if row[0] in allowed]
+25 -10
View File
@@ -143,27 +143,42 @@ def copy_game_snakes(source:sqlite3.Connection, destination:sqlite3.Connection,
has_game_snakes = source.execute("""
SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'game_snakes'
""").fetchone() is not None
sql = """
INSERT OR IGNORE INTO game_snakes (
game_id, snake_id, snake_name, is_you, customizations_json
) VALUES (?, ?, ?, ?, ?)
"""
count = 0
if has_game_snakes:
cursor = source.execute("""
SELECT game_id, snake_id, snake_name, is_you
columns = object_columns(source, "game_snakes")
customizations = (
"COALESCE(customizations_json, '{}') AS customizations_json"
if "customizations_json" in columns else "'{}' AS customizations_json"
)
cursor = source.execute(f"""
SELECT game_id, snake_id, snake_name, is_you, {customizations}
FROM game_snakes ORDER BY game_id, snake_id
""")
else:
while rows := cursor.fetchmany(batch_size):
retained_rows = [tuple(row) for row in rows if row[0] in retained_ids]
before = destination.total_changes
destination.executemany(sql, retained_rows)
count += destination.total_changes - before
# Older databases can contain an empty or only partially populated
# game_snakes table. Always synthesize missing identities from snake_turns.
cursor = source.execute("""
SELECT game_id, snake_id, MAX(snake_name), MAX(is_you)
SELECT game_id, snake_id, MAX(snake_name), MAX(is_you),
'{}' AS customizations_json
FROM snake_turns
GROUP BY game_id, snake_id
ORDER BY game_id, snake_id
""")
sql = """
INSERT INTO game_snakes (game_id, snake_id, snake_name, is_you)
VALUES (?, ?, ?, ?)
"""
count = 0
while rows := cursor.fetchmany(batch_size):
retained_rows = [tuple(row) for row in rows if row[0] in retained_ids]
before = destination.total_changes
destination.executemany(sql, retained_rows)
count += len(retained_rows)
count += destination.total_changes - before
return count
def decode_json(value:str|None, fallback):
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Stream a normalized Battlesnake SQLite database into PostgreSQL.
The source is opened read-only. Rows are copied in bounded batches through
temporary PostgreSQL tables, then inserted idempotently with ON CONFLICT. The
script verifies source/inserted row counts and never modifies the SQLite file.
"""
from __future__ import annotations
import argparse
import asyncio
from datetime import datetime
import json
from pathlib import Path
import sqlite3
from time import perf_counter
from server.database.backend.PostgresqlGameplayBackend import PostgresqlGameplayBackend
TABLES = (
(
"games",
(
"game_id", "started_at", "ended_at", "width", "height", "source",
"map_name", "ruleset_name", "ruleset_version", "your_snake_id",
"your_snake_name", "your_snake_type", "your_snake_version", "game_type",
"winner_name", "winner_you", "final_turn", "status", "has_replay",
"quality_status", "quality_score", "quality_tier", "quality_reasons",
),
"game_id",
),
(
"game_snakes",
("game_id", "snake_id", "snake_name", "is_you", "customizations"),
"game_id, snake_id",
),
(
"turns",
(
"game_id", "turn", "observed_at", "my_move", "my_thinking",
"board_state", "snakes", "you", "food", "hazards",
),
"game_id, turn",
),
(
"snake_turns",
(
"game_id", "turn", "snake_id", "snake_name", "health", "length",
"head_x", "head_y", "body", "is_you", "inferred_move", "latency",
),
"game_id, turn, snake_id",
),
)
SQLITE_SELECTS = {
"games": """
SELECT game_id, started_at, ended_at, width, height, source, map_name,
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
your_snake_type, your_snake_version, game_type, winner_name, winner_you,
final_turn, status, has_replay, quality_status, quality_score,
quality_tier, quality_reasons_json
FROM games ORDER BY game_id
""",
"game_snakes": """
SELECT game_id, snake_id, snake_name, is_you, customizations_json
FROM game_snakes ORDER BY game_id, snake_id
""",
"turns": """
SELECT game_id, turn, observed_at, my_move, my_thinking_json,
board_state_json, snakes_json, you_json, food_json, hazards_json
FROM turns ORDER BY id
""",
"snake_turns": """
SELECT game_id, turn, snake_id, snake_name, health, length, head_x, head_y,
body_json, is_you, inferred_move, latency
FROM snake_turns ORDER BY id
""",
}
TIMESTAMP_FIELDS = {"started_at", "ended_at", "observed_at"}
BOOLEAN_FIELDS = {"winner_you", "has_replay", "is_you"}
JSON_FIELDS = {
"quality_reasons", "customizations", "my_thinking", "board_state",
"snakes", "you", "food", "hazards", "body",
}
JSON_DEFAULTS = {
"customizations": {}, "board_state": {}, "snakes": [], "you": {},
"food": [], "hazards": [], "body": [],
}
def parse_timestamp(value: str | None) -> datetime | None:
if not value:
return None
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
return parsed
def parse_json(value: str | None, default: object = None) -> object:
if value in (None, ""):
return default
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError) as exc:
raise ValueError(f"Invalid JSON value: {str(value)[:120]}") from exc
def transform_row(columns: tuple[str, ...], row: sqlite3.Row) -> tuple:
output = []
for column, value in zip(columns, row, strict=True):
if column in TIMESTAMP_FIELDS:
value = parse_timestamp(value)
elif column in BOOLEAN_FIELDS:
value = bool(value)
elif column in JSON_FIELDS:
parsed = parse_json(value, JSON_DEFAULTS.get(column))
value = None if parsed is None else json.dumps(parsed, separators=(",", ":"))
output.append(value)
return tuple(output)
def open_source(path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=60)
connection.row_factory = sqlite3.Row
return connection
async def import_table(pool, source: sqlite3.Connection, table: str,
columns: tuple[str, ...], conflict_columns: str,
batch_size: int) -> tuple[int, int]:
source_count = int(source.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
async with pool.acquire() as connection:
before = int(await connection.fetchval(f"SELECT COUNT(*) FROM {table}"))
stage = f"migration_{table}"
await connection.execute(
f"CREATE TEMP TABLE {stage} (LIKE {table} INCLUDING DEFAULTS)"
)
json_columns = [column for column in columns if column in JSON_FIELDS]
for column in json_columns:
await connection.execute(
f"ALTER TABLE {stage} ALTER COLUMN {column} TYPE TEXT USING {column}::text"
)
cursor = source.execute(SQLITE_SELECTS[table])
copied = 0
while rows := cursor.fetchmany(batch_size):
records = [transform_row(columns, row) for row in rows]
async with connection.transaction():
await connection.copy_records_to_table(stage, records=records, columns=columns)
selected = ", ".join(columns)
source_expressions = ", ".join(
f"{column}::jsonb" if column in JSON_FIELDS else column
for column in columns
)
await connection.execute(
f"INSERT INTO {table} ({selected}) SELECT {source_expressions} FROM {stage} "
f"ON CONFLICT ({conflict_columns}) DO NOTHING"
)
await connection.execute(f"TRUNCATE {stage}")
copied += len(records)
print(f"{table}: streamed {copied:,}/{source_count:,}", flush=True)
after = int(await connection.fetchval(f"SELECT COUNT(*) FROM {table}"))
inserted = after - before
if copied != source_count:
raise RuntimeError(f"{table}: source changed while reading ({source_count} -> {copied})")
print(f"{table}: source={source_count:,}, inserted={inserted:,}, conflicts={source_count - inserted:,}")
return source_count, inserted
async def migrate(source_path: Path, dsn: str, batch_size: int) -> None:
source = open_source(source_path)
try:
quick_check = source.execute("PRAGMA quick_check").fetchone()[0]
if quick_check != "ok":
raise RuntimeError(f"SQLite quick_check failed: {quick_check}")
backend = PostgresqlGameplayBackend(dsn=dsn)
await backend.initialize()
pool = await backend._get_pool()
started = perf_counter()
results = {}
try:
for table, columns, conflicts in TABLES:
results[table] = await import_table(
pool, source, table, columns, conflicts, max(100, batch_size),
)
async with pool.acquire() as connection:
invalid = int(await connection.fetchval("""
SELECT
(SELECT COUNT(*) FROM turns t LEFT JOIN games g USING (game_id) WHERE g.game_id IS NULL) +
(SELECT COUNT(*) FROM game_snakes s LEFT JOIN games g USING (game_id) WHERE g.game_id IS NULL) +
(SELECT COUNT(*) FROM snake_turns s LEFT JOIN games g USING (game_id) WHERE g.game_id IS NULL)
"""))
if invalid:
raise RuntimeError(f"PostgreSQL foreign-key verification found {invalid} orphan rows")
counts = {
table: int(await connection.fetchval(f"SELECT COUNT(*) FROM {table}"))
for table, _, _ in TABLES
}
print(f"verified PostgreSQL counts: {counts}")
print(f"migration elapsed: {perf_counter() - started:.1f}s")
finally:
await backend.close()
finally:
source.close()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--dsn", required=True)
parser.add_argument("--batch-size", type=int, default=10_000)
args = parser.parse_args()
asyncio.run(migrate(args.source.expanduser().resolve(), args.dsn, args.batch_size))
if __name__ == "__main__":
main()
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Run paired seeded games through the official local Battlesnake rules engine."""
from __future__ import annotations
import argparse
import json
import os
import re
import signal
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from collections import Counter
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from pathlib import Path
from statistics import mean
ROOT = Path(__file__).resolve().parents[1]
ENGINE_USER_AGENT = "BattlesnakeEngine/local-tournament"
RESULT_RE = re.compile(
r"Game completed after (\d+) turns\.(?: (.+?) was the winner\.| It was a draw\.)"
)
def wait_for_server(url: str, process: subprocess.Popen, timeout: float = 15.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError(f"Snake server exited with status {process.returncode}")
try:
with urllib.request.urlopen(url, timeout=0.5) as response:
if response.status == 200:
return
except OSError:
time.sleep(0.1)
raise TimeoutError(f"Snake server did not become ready at {url}")
class _EngineHeaderProxy(BaseHTTPRequestHandler):
target: str
def do_GET(self) -> None:
self._forward()
def do_POST(self) -> None:
self._forward()
def _forward(self) -> None:
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else None
request = urllib.request.Request(
f"{self.target}{self.path}", data=body, method=self.command,
headers={
"Content-Type": self.headers.get("Content-Type", "application/json"),
"User-Agent": ENGINE_USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=2.0) as response:
payload = response.read()
self.send_response(response.status)
self.send_header("Content-Type", response.headers.get("Content-Type", "application/json"))
except urllib.error.HTTPError as error:
payload = error.read()
self.send_response(error.code)
self.send_header("Content-Type", error.headers.get("Content-Type", "text/plain"))
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, format: str, *args) -> None:
pass
def start_proxy(port: int, target_port: int) -> tuple[ThreadingHTTPServer, Thread]:
handler = type(
f"EngineHeaderProxy{port}",
(_EngineHeaderProxy,),
{"target": f"http://127.0.0.1:{target_port}"},
)
server = ThreadingHTTPServer(("127.0.0.1", port), handler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread
def start_server(snake: str, port: int) -> subprocess.Popen:
env = os.environ.copy()
env.update({
"HOST": "127.0.0.1",
"PORT": str(port),
"SNAKE": snake,
"DEBUG": "false",
"DEBUG_SERVER": "false",
"STORE_GAME_HISTORY": "false",
"GAMEPLAY_DB_ENABLED": "false",
"METRICS_CLEAR_WORKERS_ON_STARTUP": "false",
})
process = subprocess.Popen(
[sys.executable, str(ROOT / "main.py")],
cwd=ROOT,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
wait_for_server(f"http://127.0.0.1:{port}", process)
return process
def stop_server(process: subprocess.Popen) -> None:
if process.poll() is not None:
return
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=5)
def run_game(
cli: Path,
seed: int,
game_type: str,
map_name: str,
players: list[tuple[str, str]],
width: int,
height: int,
timeout_ms: int,
) -> dict:
with tempfile.NamedTemporaryFile(prefix="snake-arena-", suffix=".jsonl") as output:
command = [
str(cli), "play", "-W", str(width), "-H", str(height),
"-g", game_type, "--map", map_name, "--seed", str(seed),
"--timeout", str(timeout_ms), "--output", output.name,
]
for name, url in players:
command.extend(("--name", name, "--url", url))
completed = subprocess.run(
command, cwd=ROOT, text=True, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=180, check=False,
)
if completed.returncode != 0:
raise RuntimeError(
f"Rules engine failed for seed {seed}:\n{completed.stdout[-2000:]}"
)
output.seek(0)
lines = [json.loads(line) for line in output if line.strip()]
terminal = lines[-1] if lines else {}
match = RESULT_RE.search(completed.stdout)
turns = int(match.group(1)) if match else max(0, len(lines) - 2)
winner = terminal.get("winnerName") or None
draw = bool(terminal.get("isDraw", winner is None))
return {"seed": seed, "winner": winner, "draw": draw, "turns": turns}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--games", type=int, default=20, help="Number of unique seeds")
parser.add_argument("--seed-start", type=int, default=1)
parser.add_argument("--gametype", default="standard")
parser.add_argument("--map", default="standard")
parser.add_argument("--width", type=int, default=11)
parser.add_argument("--height", type=int, default=11)
parser.add_argument("--timeout", type=int, default=500)
parser.add_argument("--base-port", type=int, default=9301)
parser.add_argument("--cli", default=str(ROOT / ".testing/tools/battlesnake-cli/battlesnake"))
parser.add_argument("--json-output")
args = parser.parse_args()
cli = Path(args.cli)
if not cli.is_file():
raise SystemExit(f"Battlesnake CLI not found: {cli}. Run: just build-battlesnake-cli")
apex_port, prism_port = args.base_port, args.base_port + 1
apex_proxy_port, prism_proxy_port = args.base_port + 2, args.base_port + 3
servers: list[subprocess.Popen] = []
proxies: list[tuple[ThreadingHTTPServer, Thread]] = []
results: list[dict] = []
started = time.perf_counter()
try:
servers = [
start_server("ApexBattleSnake", apex_port),
start_server("PrismBattleSnake_GPT_5_6_Sol", prism_port),
]
proxies = [
start_proxy(apex_proxy_port, apex_port),
start_proxy(prism_proxy_port, prism_port),
]
urls = {
"Apex": f"http://127.0.0.1:{apex_proxy_port}",
"Prism": f"http://127.0.0.1:{prism_proxy_port}",
}
for offset in range(max(1, args.games)):
seed = args.seed_start + offset
# Swap engine slots for every seed. This controls for deterministic map
# spawn positions and gives each strategy both initial placements.
for order in (("Apex", "Prism"), ("Prism", "Apex")):
result = run_game(
cli=cli, seed=seed, game_type=args.gametype, map_name=args.map,
players=[(name, urls[name]) for name in order],
width=args.width, height=args.height, timeout_ms=args.timeout,
)
result["order"] = list(order)
results.append(result)
print(
f"seed={seed} order={'/'.join(order)} winner={result['winner'] or 'draw'} "
f"turns={result['turns']}"
)
finally:
for proxy, thread in reversed(proxies):
proxy.shutdown()
proxy.server_close()
thread.join(timeout=2)
for server in reversed(servers):
stop_server(server)
wins = Counter(result["winner"] or "draw" for result in results)
summary = {
"games": len(results),
"unique_seeds": max(1, args.games),
"gametype": args.gametype,
"map": args.map,
"wins": dict(wins),
"win_rates": {
key: value / len(results) for key, value in wins.items()
},
"mean_turns": mean(result["turns"] for result in results),
"elapsed_seconds": time.perf_counter() - started,
"results": results,
}
print(json.dumps({key: value for key, value in summary.items() if key != "results"}, indent=2))
if args.json_output:
Path(args.json_output).write_text(json.dumps(summary, indent=2) + "\n")
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
"""Deterministic scenario corpus for the local snake arena."""
from __future__ import annotations
from copy import deepcopy
from tests.bench_best_battle_snake import build_game_state
def _state(payload: dict, scenario: str, index: int) -> tuple[dict, dict]:
payload["game"]["id"] = f"arena-{scenario}-{index}"
return payload["board"], {
"game_id": payload["game"]["id"],
"source": "custom",
"map": payload["game"].get("map", "standard"),
"ruleset": payload["game"]["ruleset"],
"turn": payload["turn"],
"you": payload["you"],
"scenario": scenario,
}
def _standard_duel(index: int) -> dict:
payload = build_game_state()
payload["turn"] = 20 + index
payload["board"]["food"] = [
{"x": 1 + index % 3, "y": 9},
{"x": 9, "y": 1 + (index // 3) % 3},
]
return payload
def _hazard_duel(index: int) -> dict:
payload = _standard_duel(index)
payload["you"]["health"] = 38 + index % 12
payload["board"]["snakes"][0]["health"] = payload["you"]["health"]
hazard_x = 5 + index % 2
payload["board"]["hazards"] = [
{"x": hazard_x, "y": y} for y in range(1, 10) if y != 5
]
return payload
def _multiplayer(index: int) -> dict:
payload = _standard_duel(index)
third = {
"id": "enemy-2",
"name": "enemy-2",
"health": 65,
"length": 5,
"head": {"x": 2, "y": 8},
"body": [
{"x": 2, "y": 8}, {"x": 2, "y": 9}, {"x": 2, "y": 10},
{"x": 1, "y": 10}, {"x": 0, "y": 10},
],
}
payload["board"]["snakes"].append(third)
return payload
def _constrictor(index: int) -> dict:
payload = _multiplayer(index)
payload["game"]["ruleset"] = deepcopy(payload["game"]["ruleset"])
payload["game"]["ruleset"]["name"] = "constrictor"
payload["board"]["food"] = []
return payload
def _cramped_duel(index: int) -> dict:
payload = _standard_duel(index)
payload["board"]["width"] = 7
payload["board"]["height"] = 7
mine = {
"id": "me", "name": "me", "health": 72, "length": 7,
"head": {"x": 2, "y": 3},
"body": [
{"x": 2, "y": 3}, {"x": 2, "y": 2}, {"x": 2, "y": 1},
{"x": 1, "y": 1}, {"x": 1, "y": 2}, {"x": 1, "y": 3},
{"x": 1, "y": 4},
],
}
enemy = {
"id": "enemy", "name": "enemy", "health": 72, "length": 7,
"head": {"x": 4, "y": 3},
"body": [
{"x": 4, "y": 3}, {"x": 4, "y": 2}, {"x": 4, "y": 1},
{"x": 5, "y": 1}, {"x": 5, "y": 2}, {"x": 5, "y": 3},
{"x": 5, "y": 4},
],
}
payload["you"] = mine
payload["board"]["snakes"] = [mine, enemy]
payload["board"]["food"] = [{"x": 3, "y": 5 + index % 2}]
payload["board"]["hazards"] = []
return payload
SCENARIOS = {
"duel": _standard_duel,
"hazard": _hazard_duel,
"multi": _multiplayer,
"constrictor": _constrictor,
"cramped": _cramped_duel,
}
def synthetic_states(count: int, scenarios: list[str] | None = None) -> list[tuple[dict, dict]]:
selected = scenarios or list(SCENARIOS)
unknown = set(selected) - set(SCENARIOS)
if unknown:
raise ValueError(f"Unknown arena scenarios: {', '.join(sorted(unknown))}")
states: list[tuple[dict, dict]] = []
for index in range(count):
scenario = selected[index % len(selected)]
states.append(_state(SCENARIOS[scenario](index), scenario, index))
return states
+1 -1
View File
@@ -1,4 +1,4 @@
from snakes.TemplateSnake import TemplateSnake
from snakes.core.template import TemplateSnake
from datetime import datetime
class GameBoard:
+17 -4
View File
@@ -13,8 +13,8 @@ from server.metrics import (
MetricsCollector,
)
import asyncio, signal, logging, os, re, time
from quart import Quart
import asyncio, signal, logging, time, os, re
from quart import Quart, url_for
from server.blueprints import (
create_battlesnake_blueprint,
@@ -110,6 +110,17 @@ class Server:
self.app.register_blueprint(create_metrics_blueprint(self))
self.app.register_blueprint(create_dashboard_blueprint(self))
@self.app.template_global()
def static_url(filename:str) -> str:
# Static assets are served with a long max-age, so the URL carries the
# file mtime to make browsers pick up dashboard changes immediately.
static_root = self.app.static_folder or ''
try:
version = int(os.path.getmtime(os.path.join(static_root, filename)))
except OSError:
version = 0
return f'{url_for("static", filename=filename)}?v={version}'
@self.app.after_request
async def identify_server(response):
response.headers.set('server', 'battlesnake/gitea/snake-python')
@@ -185,5 +196,7 @@ class Server:
storage = StorageLoader.build(self.storage_type)
return storage.cleanup()
async def _on_dashboard_games_update_notice(self, trigger:str) -> None:
await self.dashboard_query.on_dashboard_games_update_notice(trigger)
async def _on_dashboard_games_update_notice(
self, trigger:str, game_id:str|None=None,
) -> None:
await self.dashboard_query.on_dashboard_games_update_notice(trigger, game_id)
+4
View File
@@ -52,6 +52,9 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
game_state = await request.get_json()
game_board = await server.game_runtime.create_game_board(game_state)
await server.gameplay_tracking.record_gameplay_start(game_state, game_board)
await server.dashboard_query.push_dashboard_games_update(
game_state, trigger='game_started',
)
await await_log(server.logger.info(f'GAME START: {game_state['game']}'))
return 'ok'
@@ -78,6 +81,7 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
await await_log(server.logger.warning(f'MOVE TIMEOUT: turn={game_state.get("turn")}, game={game_id}, returning fallback {next_move!r}'))
await server.gameplay_tracking.record_gameplay_turn(game_state, next_move, game_board)
await server.dashboard_query.push_dashboard_game_replay_update(game_id)
elapsed_ms = (time.perf_counter() - move_started) * 1000.0
await server.metrics_collector.record_move(next_move, elapsed_ms)
+8
View File
@@ -28,6 +28,14 @@ def create_dashboard_blueprint(server:'Server') -> Blueprint:
battlesnake_url=os.getenv('BATTLESNAKE_GAMEBOARD_URL', 'https://play.battlesnake.com/game')
)
@blueprint.get('/dashboard/game/<game_id>')
async def dashboard_game_replay(game_id:str):
# Fallback the dashboard falls back to when the replay websocket is down.
replay = await server.dashboard_query.get_dashboard_game_replay(game_id)
if replay is None:
return {'error': 'game_not_found', 'game_id': game_id}, 404
return replay
@blueprint.get('/dashboard/customizations/<path:asset_path>')
async def dashboard_customizations_asset(asset_path:str):
customization_root = os.path.join(
+31 -3
View File
@@ -1,12 +1,40 @@
from typing import TYPE_CHECKING, Any
from .GameplayDatabase import GameplayDatabase
from .backend import GameplayBackendBuilder
from .LocalStorage import LocalStorage
if TYPE_CHECKING:
from .EdgeDB import EdgeDB
from .LocalStorage import LocalStorage
__all__ = (
"EdgeDB",
"GameplayBackendBuilder",
"GameplayDatabase",
"LocalStorage",
"StorageLoader",
)
def __getattr__(name:str):
"""Load optional storage backends only when explicitly requested.
Database maintenance scripts import SQLite backend modules through this
package. Eagerly importing LocalStorage used to make those scripts require
unrelated web-storage dependencies such as aiofiles.
"""
if name == "LocalStorage":
from .LocalStorage import LocalStorage
return LocalStorage
if name == "EdgeDB":
from .EdgeDB import EdgeDB
return EdgeDB
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
class StorageLoader:
@classmethod
def build(self, selected_storage:str) -> LocalStorage|EdgeDB:
storage_module = __import__(f"server.database.{selected_storage}", fromlist=[selected_storage])
def build(cls, selected_storage:str) -> Any:
storage_module = __import__(
f"server.database.{selected_storage}", fromlist=[selected_storage],
)
storage_class = getattr(storage_module, selected_storage)
return storage_class
@@ -12,7 +12,7 @@ Connection: pass a DSN via the `dsn` constructor argument, e.g.
or set GAMEPLAY_DB_PG_DSN in the environment.
"""
import asyncio, json, logging, sqlite3, sys
import asyncio, logging, sqlite3, json, sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse, urlunparse
@@ -263,11 +263,20 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
conn.row_factory = sqlite3.Row
try:
games = conn.execute("""
game_columns = {
row["name"] for row in conn.execute("PRAGMA table_info(games)").fetchall()
}
if "winner_name" in game_columns:
winner_name_expression = "winner_name"
elif "winner_names_json" in game_columns:
winner_name_expression = "winner_names_json"
else:
winner_name_expression = "NULL"
games = conn.execute(f"""
SELECT game_id, started_at, ended_at, width, height, source, map_name,
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
your_snake_type, your_snake_version, game_type,
winner_names_json, winner_you, final_turn, status
{winner_name_expression} AS winner_name, winner_you, final_turn, status
FROM games
ORDER BY started_at ASC
""").fetchall()
@@ -301,6 +310,15 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
except (json.JSONDecodeError, TypeError):
return None
def _migrated_winner_name(self, value:str|None) -> str|None:
"""Accept both the current winner_name and legacy winner_names_json value."""
if not value:
return None
parsed = self._parse_json(value)
if isinstance(parsed, list):
return next((str(name) for name in parsed if name), None)
return value
async def _insert_migrated_data(self, games:list, turns:list, snake_turns:list) -> None:
assert self._pool is not None
async with self._pool.acquire() as conn:
@@ -332,7 +350,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
row["your_snake_type"],
row["your_snake_version"],
row["game_type"],
(self._parse_json(row["winner_names_json"]) or [None])[0],
self._migrated_winner_name(row["winner_name"]),
bool(row["winner_you"]),
row["final_turn"],
row["status"],
+111
View File
@@ -0,0 +1,111 @@
"""Load sampled gameplay positions from SQLite or PostgreSQL."""
from __future__ import annotations
import asyncio
import json
from urllib.parse import urlparse
def is_postgresql_source(source: str) -> bool:
return urlparse(source).scheme.lower() in {"postgres", "postgresql"}
def _decode_json(value, default):
if value is None:
return default
if isinstance(value, str):
return json.loads(value)
return value
def _build_state(row, snake_rows) -> tuple[dict, dict] | None:
board = _decode_json(row[1], {})
you = _decode_json(row[2], {})
if not board.get("snakes"):
snakes = []
for snake_row in snake_rows:
snake_id = snake_row[0]
snake_name = snake_row[1] or (row[6] if snake_id == row[5] else snake_id)
snakes.append({
"id": snake_id,
"name": snake_name,
"health": snake_row[2],
"length": snake_row[3],
"head": {"x": snake_row[4], "y": snake_row[5]},
"body": _decode_json(snake_row[6], []),
"customizations": _decode_json(snake_row[7], {}),
})
board = {
"width": row[7],
"height": row[8],
"food": _decode_json(row[3], []),
"hazards": _decode_json(row[4], []),
"snakes": snakes,
}
if not you:
you = next(
(snake for snake in board.get("snakes", []) if snake.get("id") == row[5]),
{},
)
if not you or not board.get("snakes"):
return None
return board, {
"you": you,
"game_id": row[9],
"source": row[10] or "custom",
"map": row[11] or "standard",
"ruleset": {
"name": row[12] or "standard",
"version": row[13] or "v1.0.0",
"settings": {},
},
"turn": int(row[14]),
}
async def _load_postgresql_states(dsn: str, samples: int, stride: int) -> list[tuple[dict, dict]]:
try:
import asyncpg
except ImportError as exc:
raise RuntimeError("asyncpg is required for PostgreSQL benchmark sources") from exc
connection = await asyncpg.connect(dsn=dsn)
try:
max_id = int(await connection.fetchval("SELECT max(id) FROM turns") or 0)
if max_id == 0:
return []
query = """
SELECT t.id, t.board_state, t.you, t.food, t.hazards,
g.your_snake_id, g.your_snake_name, g.width, g.height,
g.game_id, g.source, g.map_name,
g.ruleset_name, g.ruleset_version, t.turn
FROM turns AS t
JOIN games AS g ON g.game_id = t.game_id
WHERE t.id >= $1
ORDER BY t.id
LIMIT 1
"""
snake_query = """
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name),
st.health, st.length, st.head_x, st.head_y, st.body,
COALESCE(gs.customizations, '{}'::jsonb)
FROM snake_turns AS st
LEFT JOIN game_snakes AS gs
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
WHERE st.game_id = $1 AND st.turn = $2
ORDER BY st.id
"""
states = []
next_id = max(1, max_id - (samples - 1) * stride)
while len(states) < samples and next_id <= max_id:
row = await connection.fetchrow(query, next_id)
if row is None:
break
snake_rows = await connection.fetch(snake_query, row[9], row[14])
state = _build_state(row, snake_rows)
if state is not None:
states.append(state)
next_id = int(row[0]) + stride
return states
finally:
await connection.close()
def load_postgresql_states(dsn: str, samples: int, stride: int) -> list[tuple[dict, dict]]:
return asyncio.run(_load_postgresql_states(dsn, samples, stride))
+9 -4
View File
@@ -4,7 +4,7 @@ from typing import Awaitable, Callable
import asyncio, inspect, json, time
class DashboardEventsService:
def __init__(self, enabled:bool, redis_url:str, channel:str, event_origin:str, shutdown_event:asyncio.Event, on_notice:Callable[[str], Awaitable[None]], logger):
def __init__(self, enabled:bool, redis_url:str, channel:str, event_origin:str, shutdown_event:asyncio.Event, on_notice:Callable[[str, str|None], Awaitable[None]], logger):
self.enabled = enabled
self.redis_url = redis_url
self.channel = channel
@@ -71,18 +71,19 @@ class DashboardEventsService:
except Exception:
pass
async def publish_notice(self, trigger:str) -> None:
async def publish_notice(self, trigger:str, game_id:str|None=None) -> None:
if not self.enabled:
return
if self.redis is None:
return
if trigger not in {'game_saved', 'stale_finalized', 'manual'}:
if trigger not in {'game_started', 'game_turn', 'game_saved', 'stale_finalized', 'manual'}:
return
message = {
'type': 'dashboard_games_update_notice',
'origin': self.event_origin,
'trigger': trigger,
'game_id': game_id,
'sent_at': int(time.time()),
}
try:
@@ -120,7 +121,11 @@ class DashboardEventsService:
continue
notice_trigger = str(payload.get('trigger') or 'game_saved')
await self.on_notice(notice_trigger)
notice_game_id_raw = payload.get('game_id')
notice_game_id = (
None if notice_game_id_raw is None else str(notice_game_id_raw)
)
await self.on_notice(notice_trigger, notice_game_id)
except asyncio.CancelledError:
pass
except Exception as error:
+47 -4
View File
@@ -12,12 +12,22 @@ class DashboardQueryService:
self.ws_hub = ws_hub
self.logger = logger
self.dashboard_running_game_stale_sec = dashboard_running_game_stale_sec
self.publish_notice:Callable[[str], Awaitable[None]] | None = None
self.publish_notice:Callable[[str, str|None], Awaitable[None]] | None = None
def set_publish_notice(self, publish_notice:Callable[[str], Awaitable[None]]) -> None:
def set_publish_notice(
self, publish_notice:Callable[[str, str|None], Awaitable[None]],
) -> None:
self.publish_notice = publish_notice
async def on_dashboard_games_update_notice(self, trigger:str) -> None:
async def on_dashboard_games_update_notice(
self, trigger:str, game_id:str|None=None,
) -> None:
if trigger == 'game_turn' and game_id:
await self.push_dashboard_game_replay_update(
game_id,
publish_cluster=False,
)
return
await self.push_dashboard_games_update(
game_state=None,
publish_cluster=False,
@@ -56,6 +66,22 @@ class DashboardQueryService:
'replay': replay_payload,
}
async def build_dashboard_game_replay_update_event(self, game_id:str) -> dict:
replay_payload = await self.get_dashboard_game_replay(game_id)
if replay_payload is None:
return {
'type': 'dashboard_game_replay_update',
'game_id': game_id,
'error': 'game_not_found',
}
turns = replay_payload.get('turns', [])
return {
'type': 'dashboard_game_replay_update',
'game_id': game_id,
'game': replay_payload.get('game', {}),
'turn': turns[-1] if turns else None,
}
async def handle_dashboard_ws_request(self, payload_raw:object) -> dict|None:
if not isinstance(payload_raw, str):
return None
@@ -95,7 +121,24 @@ class DashboardQueryService:
)
await self.ws_hub.broadcast_payload(event_payload)
if publish_cluster and self.publish_notice is not None:
await self.publish_notice(str(event_payload.get('trigger') or ''))
game_id = None
if game_state is not None:
game_id = game_state.get('game', {}).get('id')
await self.publish_notice(
str(event_payload.get('trigger') or ''), game_id,
)
async def push_dashboard_game_replay_update(
self, game_id:str, publish_cluster:bool=True,
) -> None:
if self.gameplay_database is None:
return
event_payload = await self.build_dashboard_game_replay_update_event(game_id)
if event_payload.get('error'):
return
await self.ws_hub.broadcast_payload(event_payload)
if publish_cluster and self.publish_notice is not None:
await self.publish_notice('game_turn', game_id)
async def get_dashboard_summary(self) -> dict:
if self.gameplay_database is None:
-555
View File
@@ -1,555 +0,0 @@
"""PrismBattleSnake_GPT_5_6_Sol 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.
S12: Duel minimax uses tuple bodies and bitboard move generation.
S13: Iterative deepening reuses a transposition table and move-order hints.
"""
from __future__ import annotations
from typing import Any
from time import perf_counter
from snakes.ApexBattleSnake import ApexBattleSnake
from snakes.bitboard import BitBoard
from snakes.bitboard_duel_search import BitboardDuelSearch
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 PrismBattleSnake_GPT_5_6_Sol(ApexBattleSnake):
VERSION = "1.0.0"
def __init__(self) -> None:
super().__init__()
self.name = "PrismBattleSnake"
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
# 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 blocked cells to bits without stale identity-based caching."""
return self._get_bb(width, height).set_to_bits(blocked)
# ── choose_move override: precompute enemy bits ──────────────────────────
def choose_move(self, game_data: GameBoard) -> str:
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_idx, blocked_bits, width, height)
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)
# ── S12/S13: compact bitboard duel search ───────────────────────────────
def _new_duel_search(
self, food_set: set, hazard_set: set, hazard_count: dict,
hazard_damage: int, width: int, height: int, deadline: float | None,
) -> BitboardDuelSearch:
return BitboardDuelSearch(
board=self._get_bb(width, height),
food=food_set,
hazards=hazard_set,
hazard_count=hazard_count,
hazard_damage=hazard_damage,
deadline=deadline,
)
def _minimax_sim_id(
self, my_body: list, enemy_body: list, food_set: set, hazard_set: set,
my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict,
width: int, height: int, max_depth: int, alpha: float, beta: float,
deadline: float | None, previous_hazard_set: set | None = None,
) -> tuple[float, int]:
"""Run iterative deepening with one reusable compact search context."""
search = self._new_duel_search(
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
return search.search(
my_body=my_body,
enemy_body=enemy_body,
my_health=my_health,
enemy_health=enemy_health,
max_depth=max_depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
def _minimax_sim(
self, my_body: list, enemy_body: list, food_set: set, hazard_set: set,
my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict,
width: int, height: int, depth: int, alpha: float, beta: float,
deadline: float | None, previous_hazard_set: set | None = None,
) -> float:
"""Compatibility entry point for tests and callers requesting one depth."""
search = self._new_duel_search(
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
return search.search_depth(
my_body=my_body,
enemy_body=enemy_body,
my_health=my_health,
enemy_health=enemy_health,
depth=depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
# ── 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
+43 -22
View File
@@ -1,40 +1,61 @@
import importlib
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class SnakeRegistration:
module: str
version: str
SNAKE_REGISTRATIONS = {
"TemplateSnake": SnakeRegistration("snakes.core.template", "1.0.0"),
"ApexBattleSnake": SnakeRegistration("snakes.strategies.apex", "1.0.0"),
"PrismBattleSnake_GPT_5_6_Sol": SnakeRegistration(
"snakes.strategies.prism", "1.5.0"
),
"DummSnake": SnakeRegistration("snakes.legacy.DummSnake", "1.0.0"),
"LogicSnake": SnakeRegistration("snakes.legacy.LogicSnake", "1.1.0"),
"MasterSnake": SnakeRegistration("snakes.legacy.MasterSnake", "1.2.0"),
"BetterMasterSnake": SnakeRegistration("snakes.legacy.BetterMasterSnake", "1.3.0"),
"BestBattleSnake": SnakeRegistration("snakes.legacy.BestBattleSnake", "2.6.0"),
"TrainedBattleSnake": SnakeRegistration(
"snakes.legacy.TrainedBattleSnake", "0.1.0"
),
"UltimateBattleSnake": SnakeRegistration(
"snakes.legacy.UltimateBattleSnake", "4.5.0"
),
"SupremeBattleSnake_ClaudeOpus4_6": SnakeRegistration(
"snakes.legacy.SupremeBattleSnake_ClaudeOpus4_6",
"1.0.0",
),
}
# Backward-compatible public version map.
SNAKE_REGISTRY = {
"TemplateSnake": "1.0.0",
"DummSnake": "1.0.0",
"LogicSnake": "1.1.0",
"MasterSnake": "1.2.0",
"BetterMasterSnake": "1.3.0",
"BestBattleSnake": "2.6.0",
"TrainedBattleSnake": "0.1.0",
"UltimateBattleSnake": "4.5.0",
"ApexBattleSnake": "1.0.0",
"SupremeBattleSnake_ClaudeOpus4_6": "1.0.0",
"PrismBattleSnake_GPT_5_6_Sol": "1.0.0",
name: registration.version for name, registration in SNAKE_REGISTRATIONS.items()
}
DEFAULT_SNAKE_CONFIG = {
'apiversion': '1',
'author': '',
'color': '#888888',
'head': 'default',
'tail': 'default',
"apiversion": "1",
"author": "",
"color": "#888888",
"head": "default",
"tail": "default",
}
def build_snake(selected_snake: str):
if selected_snake not in SNAKE_REGISTRY:
registration = SNAKE_REGISTRATIONS.get(selected_snake)
if registration is None:
raise ValueError(f"Unknown snake: {selected_snake}")
snake_module = importlib.import_module(f"snakes.{selected_snake}")
snake_module = importlib.import_module(registration.module)
snake_class = getattr(snake_module, selected_snake)
return snake_class()
def get_snake_version(selected_snake: str) -> str | None:
version = SNAKE_REGISTRY.get(selected_snake)
if version is None:
return None
return str(version)
registration = SNAKE_REGISTRATIONS.get(selected_snake)
return registration.version if registration is not None else None
class SnakeBuilder:
@classmethod
-291
View File
@@ -1,291 +0,0 @@
"""Deadline-aware simultaneous duel search using compact tuple bodies and bitboards."""
from __future__ import annotations
from dataclasses import dataclass
from time import perf_counter
from typing import Iterable
from snakes.bitboard import BitBoard
Body = tuple[int, ...]
@dataclass(frozen=True, slots=True)
class DuelState:
my_body: Body
enemy_body: Body
food_bits: int
my_health: int
enemy_health: int
previous_hazard_bits: int
class BitboardDuelSearch:
"""Iterative-deepening paranoid minimax for a two-snake game.
The public API still accepts Battlesnake body dictionaries. Search nodes use
flat cell indices, immutable tuples, and integer masks to avoid allocation of
coordinate dictionaries and sets in the hot path.
"""
WIN = 100_000.0
LOSS = -100_000.0
def __init__(
self,
board: BitBoard,
food: Iterable[tuple[int, int]],
hazards: Iterable[tuple[int, int]],
hazard_count: dict[tuple[int, int], int],
hazard_damage: int,
deadline: float | None,
) -> None:
self.board = board
self.deadline = deadline
self.hazard_damage = hazard_damage
self.food_bits = board.set_to_bits(set(food))
self.hazard_bits = board.set_to_bits(set(hazards))
self.hazard_stacks = {
board.idx(x, y): count for (x, y), count in hazard_count.items()
}
self.transposition: dict[tuple[DuelState, int], tuple[float, str]] = {}
self.killer_moves: dict[int, int] = {}
self.history: dict[int, int] = {}
self.nodes = 0
self.cache_hits = 0
def body_from_dicts(self, body: list[dict]) -> Body:
return tuple(self.board.idx(seg["x"], seg["y"]) for seg in body)
def search(
self,
my_body: list[dict],
enemy_body: list[dict],
my_health: int,
enemy_health: int,
max_depth: int,
previous_hazards: Iterable[tuple[int, int]],
) -> tuple[float, int]:
state = DuelState(
my_body=self.body_from_dicts(my_body),
enemy_body=self.body_from_dicts(enemy_body),
food_bits=self.food_bits,
my_health=my_health,
enemy_health=enemy_health,
previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)),
)
result = self._evaluate(state)
completed_depth = 0
for depth in range(1, max_depth + 1):
if self._out_of_time(5.0):
break
value, completed = self._search(state, depth, -float("inf"), float("inf"))
if not completed:
break
result = value
completed_depth = depth
return result, completed_depth
def search_depth(
self,
my_body: list[dict],
enemy_body: list[dict],
my_health: int,
enemy_health: int,
depth: int,
previous_hazards: Iterable[tuple[int, int]],
) -> float:
state = DuelState(
my_body=self.body_from_dicts(my_body),
enemy_body=self.body_from_dicts(enemy_body),
food_bits=self.food_bits,
my_health=my_health,
enemy_health=enemy_health,
previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)),
)
value, _ = self._search(state, depth, -float("inf"), float("inf"))
return value
def _search(self, state: DuelState, depth: int, alpha: float, beta: float) -> tuple[float, bool]:
self.nodes += 1
if self._out_of_time():
return self._evaluate(state), False
if depth <= 0:
return self._evaluate(state), True
cache_key = (state, depth)
original_alpha, original_beta = alpha, beta
cached = self.transposition.get(cache_key)
if cached is not None:
self.cache_hits += 1
cached_value, bound = cached
if bound == "exact":
return cached_value, True
if bound == "lower":
alpha = max(alpha, cached_value)
else:
beta = min(beta, cached_value)
if alpha >= beta:
return cached_value, True
my_moves = self._legal_targets(state.my_body, state.enemy_body)
enemy_moves = self._legal_targets(state.enemy_body, state.my_body)
if not my_moves:
return self.LOSS - depth, True
if not enemy_moves:
return self.WIN + depth, True
my_moves = self._ordered_moves(my_moves, state, depth, True)
enemy_moves = self._ordered_moves(enemy_moves, state, depth, False)
best = -float("inf")
for my_target in my_moves:
worst = float("inf")
for enemy_target in enemy_moves:
if self._out_of_time():
return (best if best != -float("inf") else self._evaluate(state)), False
child, terminal = self._advance(state, my_target, enemy_target)
if terminal is not None:
value = terminal
completed = True
else:
value, completed = self._search(child, depth - 1, alpha, beta)
if not completed:
return (best if best != -float("inf") else value), False
worst = min(worst, value)
if worst <= alpha:
self.killer_moves[depth] = my_target
self.history[my_target] = self.history.get(my_target, 0) + depth * depth
break
best = max(best, worst)
alpha = max(alpha, best)
if alpha >= beta:
break
if best <= original_alpha:
bound = "upper"
elif best >= original_beta:
bound = "lower"
else:
bound = "exact"
self.transposition[cache_key] = (best, bound)
return best, True
def _advance(self, state: DuelState, my_target: int, enemy_target: int) -> tuple[DuelState, float | None]:
my_ate = bool((1 << my_target) & state.food_bits)
enemy_ate = bool((1 << enemy_target) & state.food_bits)
my_body = self._advance_body(state.my_body, my_target, my_ate)
enemy_body = self._advance_body(state.enemy_body, enemy_target, enemy_ate)
my_dead = my_target in my_body[1:] or my_target in enemy_body[1:]
enemy_dead = enemy_target in enemy_body[1:] or enemy_target in my_body[1:]
if my_target == enemy_target:
if len(my_body) <= len(enemy_body):
my_dead = True
if len(enemy_body) <= len(my_body):
enemy_dead = True
my_health = 100 if my_ate else state.my_health - 1
enemy_health = 100 if enemy_ate else state.enemy_health - 1
if not my_ate:
my_health -= self._hazard_cost(my_target, state.previous_hazard_bits)
if not enemy_ate:
enemy_health -= self._hazard_cost(enemy_target, state.previous_hazard_bits)
my_dead = my_dead or my_health <= 0
enemy_dead = enemy_dead or enemy_health <= 0
if my_dead and enemy_dead:
return state, -500.0
if my_dead:
return state, self.LOSS
if enemy_dead:
return state, self.WIN
eaten_bits = 0
if my_ate:
eaten_bits |= 1 << my_target
if enemy_ate:
eaten_bits |= 1 << enemy_target
child = DuelState(
my_body=my_body,
enemy_body=enemy_body,
food_bits=state.food_bits & ~eaten_bits,
my_health=my_health,
enemy_health=enemy_health,
previous_hazard_bits=self.hazard_bits,
)
return child, None
def _legal_targets(self, body: Body, other_body: Body) -> list[int]:
occupied = self._body_bits(body) | self._body_bits(other_body)
if not self._tail_stacked(body):
occupied &= ~(1 << body[-1])
if not self._tail_stacked(other_body):
occupied &= ~(1 << other_body[-1])
legal = self.board.neighbors_of(body[0]) & ~occupied & self.board.board_mask
return list(self._iter_bits(legal))
def _ordered_moves(self, moves: list[int], state: DuelState, depth: int, mine: bool) -> list[int]:
body = state.my_body if mine else state.enemy_body
other = state.enemy_body if mine else state.my_body
killer = self.killer_moves.get(depth)
center_x = (self.board.width - 1) / 2.0
center_y = (self.board.height - 1) / 2.0
def score(target: int) -> tuple[float, int]:
x, y = self.board.coord(target)
food_bonus = 200.0 if (1 << target) & state.food_bits else 0.0
space = self.board.flood_count(target, (self._body_bits(body[1:]) | self._body_bits(other[1:])) & ~(1 << target))
center = -(abs(x - center_x) + abs(y - center_y))
killer_bonus = 10_000.0 if target == killer else 0.0
return killer_bonus + self.history.get(target, 0) + food_bonus + space * 2.0 + center, -target
# Our strongest-looking moves first; enemy ordering uses the same quality
# estimate because dangerous enemy replies tend to gain space and food.
return sorted(moves, key=score, reverse=True)
def _evaluate(self, state: DuelState) -> float:
my_blocked = self._body_bits(state.my_body[1:]) | self._body_bits(state.enemy_body[1:])
my_space = self.board.flood_count(state.my_body[0], my_blocked)
enemy_space = self.board.flood_count(state.enemy_body[0], my_blocked)
my_liberties = self.board.open_neighbor_count(state.my_body[0], my_blocked)
enemy_liberties = self.board.open_neighbor_count(state.enemy_body[0], my_blocked)
length_score = (len(state.my_body) - len(state.enemy_body)) * 18.0
health_score = (state.my_health - state.enemy_health) * 0.15
return (my_space - enemy_space) * 2.0 + (my_liberties - enemy_liberties) * 12.0 + length_score + health_score
def _hazard_cost(self, target: int, previous_hazard_bits: int) -> int:
bit = 1 << target
if not (bit & self.hazard_bits & previous_hazard_bits):
return 0
return self.hazard_damage * self.hazard_stacks.get(target, 1)
@staticmethod
def _advance_body(body: Body, target: int, ate: bool) -> Body:
return (target,) + body if ate else (target,) + body[:-1]
@staticmethod
def _tail_stacked(body: Body) -> bool:
return len(body) >= 2 and body[-1] == body[-2]
@staticmethod
def _body_bits(body: Body) -> int:
bits = 0
for cell in body:
bits |= 1 << cell
return bits
@staticmethod
def _iter_bits(bits: int):
while bits:
bit = bits & -bits
yield bit.bit_length() - 1
bits ^= bit
def _out_of_time(self, reserve_ms: float = 0.0) -> bool:
if self.deadline is None:
return False
return perf_counter() + reserve_ms / 1000.0 >= self.deadline
+5
View File
@@ -0,0 +1,5 @@
"""Shared snake base classes."""
from snakes.core.template import TemplateSnake
__all__ = ("TemplateSnake",)
+18
View File
@@ -0,0 +1,18 @@
"""Reusable high-performance board and search engines for competitive snakes."""
from snakes.engine.bitboard import BitBoard
from snakes.engine.duel import BitboardDuelMixin
from snakes.engine.duel_search import BitboardDuelSearch, DuelState
from snakes.engine.spatial import BitboardSpatialMixin
from snakes.engine.survival import BitboardSurvivalMixin
from snakes.engine.survival_search import CompactSurvivalSearch
__all__ = (
"BitBoard",
"BitboardDuelMixin",
"BitboardDuelSearch",
"BitboardSpatialMixin",
"BitboardSurvivalMixin",
"CompactSurvivalSearch",
"DuelState",
)
@@ -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
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:
# 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)
) & remaining
en_exp = 0
if en_front:
) & free & ~my_seen
en_exp = (
((en_front & nrc) << 1)
| ((en_front & nlc) >> 1)
| (en_front << w)
| (en_front >> w)
) & remaining
) & free & ~en_seen
# 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,44 @@ class BitBoard:
if start_bit & food_bits:
return 0, start_idx
frontier = start_bit
seen = frontier
# 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]
seen = start_bit
cursor = 0
layer_end = 1
dist = 0
w = self.width
nrc = self._not_rightcol
nlc = self._not_leftcol
size = self.size
while frontier:
while cursor < len(queue):
cell = queue[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)
if cursor == layer_end:
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
layer_end = len(queue)
return None, None
+89
View File
@@ -0,0 +1,89 @@
"""Reusable compact duel-search integration for Apex-style snakes."""
from __future__ import annotations
from snakes.engine.duel_search import BitboardDuelSearch
class BitboardDuelMixin:
def _new_duel_search(
self, food_set: set, hazard_set: set, hazard_count: dict,
hazard_damage: int, width: int, height: int, deadline: float | None,
) -> BitboardDuelSearch:
if self._duel_search_context is None:
self._duel_search_context = BitboardDuelSearch(
board=self._get_bb(width, height),
food=food_set,
hazards=hazard_set,
hazard_count=hazard_count,
hazard_damage=hazard_damage,
deadline=deadline,
)
return self._duel_search_context
def _minimax_candidate_id(
self, my_body: list, enemy_body: list, my_target: tuple[int, int],
food_set: set, hazard_set: set,
my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict,
width: int, height: int, max_depth: int, alpha: float, beta: float,
deadline: float | None, previous_hazard_set: set | None = None,
) -> tuple[float, int]:
"""Resolve our selected move and every enemy reply simultaneously."""
search = self._new_duel_search(
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
adaptive_depth = max_depth
remaining = self._remaining_ms(deadline)
if remaining > 250:
adaptive_depth = min(7, max_depth + 1)
elif remaining < 120:
adaptive_depth = min(max_depth, 2)
return search.search_candidate(
my_body=my_body,
enemy_body=enemy_body,
my_target=my_target,
my_health=my_health,
enemy_health=enemy_health,
max_depth=adaptive_depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
def _minimax_sim_id(
self, my_body: list, enemy_body: list, food_set: set, hazard_set: set,
my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict,
width: int, height: int, max_depth: int, alpha: float, beta: float,
deadline: float | None, previous_hazard_set: set | None = None,
) -> tuple[float, int]:
"""Run iterative deepening with one reusable compact search context."""
search = self._new_duel_search(
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
return search.search(
my_body=my_body,
enemy_body=enemy_body,
my_health=my_health,
enemy_health=enemy_health,
max_depth=max_depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
def _minimax_sim(
self, my_body: list, enemy_body: list, food_set: set, hazard_set: set,
my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict,
width: int, height: int, depth: int, alpha: float, beta: float,
deadline: float | None, previous_hazard_set: set | None = None,
) -> float:
"""Compatibility entry point for tests and callers requesting one depth."""
search = self._new_duel_search(
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
return search.search_depth(
my_body=my_body,
enemy_body=enemy_body,
my_health=my_health,
enemy_health=enemy_health,
depth=depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
+453
View File
@@ -0,0 +1,453 @@
"""Deadline-aware simultaneous duel search using compact tuple bodies and bitboards."""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from time import perf_counter
from snakes.engine.bitboard import BitBoard
Body = tuple[int, ...]
@dataclass(frozen=True, slots=True)
class DuelState:
my_body: Body
enemy_body: Body
food_bits: int
my_health: int
enemy_health: int
previous_hazard_bits: int
class BitboardDuelSearch:
"""Iterative-deepening paranoid minimax for a two-snake game.
The public API still accepts Battlesnake body dictionaries. Search nodes use
flat cell indices, immutable tuples, and integer masks to avoid allocation of
coordinate dictionaries and sets in the hot path.
"""
WIN = 100_000.0
LOSS = -100_000.0
def __init__(
self,
board: BitBoard,
food: Iterable[tuple[int, int]],
hazards: Iterable[tuple[int, int]],
hazard_count: dict[tuple[int, int], int],
hazard_damage: int,
deadline: float | None,
) -> None:
self.board = board
self.deadline = deadline
self.hazard_damage = hazard_damage
self.food_bits = board.set_to_bits(set(food))
self.hazard_bits = board.set_to_bits(set(hazards))
self.hazard_stacks = {
board.idx(x, y): count for (x, y), count in hazard_count.items()
}
self.transposition: dict[tuple[DuelState, int], tuple[float, str, int | None]] = {}
self.killer_moves: dict[int, int] = {}
self.history: dict[int, int] = {}
self._body_bits_cache: dict[Body, int] = {}
self._evaluation_cache: dict[DuelState, float] = {}
self.nodes = 0
self.cache_hits = 0
self.evaluation_cache_hits = 0
self.completed_depth = 0
self.deadline_exits = 0
def body_from_dicts(self, body: list[dict]) -> Body:
return tuple(self.board.idx(seg["x"], seg["y"]) for seg in body)
def search(
self,
my_body: list[dict],
enemy_body: list[dict],
my_health: int,
enemy_health: int,
max_depth: int,
previous_hazards: Iterable[tuple[int, int]],
) -> tuple[float, int]:
state = DuelState(
my_body=self.body_from_dicts(my_body),
enemy_body=self.body_from_dicts(enemy_body),
food_bits=self.food_bits,
my_health=my_health,
enemy_health=enemy_health,
previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)),
)
result = self._evaluate(state)
completed_depth = 0
for depth in range(1, max_depth + 1):
if self._out_of_time(5.0):
break
window = 80.0 if completed_depth else float("inf")
alpha, beta = result - window, result + window
value, completed = self._search(state, depth, alpha, beta)
if completed and window != float("inf") and (value <= alpha or value >= beta):
value, completed = self._search(state, depth, -float("inf"), float("inf"))
if not completed:
break
result = value
completed_depth = depth
self.completed_depth = max(self.completed_depth, completed_depth)
return result, completed_depth
def search_candidate(
self,
my_body: list[dict],
enemy_body: list[dict],
my_target: tuple[int, int],
my_health: int,
enemy_health: int,
max_depth: int,
previous_hazards: Iterable[tuple[int, int]],
) -> tuple[float, int]:
"""Evaluate one selected move against every simultaneous enemy reply.
``max_depth`` counts the selected root turn, so a completed depth of one
means all opponent replies to that move were resolved.
"""
state = DuelState(
my_body=self.body_from_dicts(my_body),
enemy_body=self.body_from_dicts(enemy_body),
food_bits=self.food_bits,
my_health=my_health,
enemy_health=enemy_health,
previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)),
)
target_idx = self.board.idx(my_target[0], my_target[1])
if not self.board.neighbors_of(state.my_body[0]) & (1 << target_idx):
return self.LOSS, 0
result = self._evaluate(state)
completed_depth = 0
for depth in range(1, max_depth + 1):
if self._out_of_time(5.0):
break
window = 80.0 if completed_depth else float("inf")
alpha, beta = result - window, result + window
value, completed = self._search_selected_move(state, target_idx, depth, alpha, beta)
if completed and window != float("inf") and (value <= alpha or value >= beta):
value, completed = self._search_selected_move(
state, target_idx, depth, -float("inf"), float("inf")
)
if not completed:
break
result = value
completed_depth = depth
self.completed_depth = max(self.completed_depth, completed_depth)
return result, completed_depth
def search_depth(
self,
my_body: list[dict],
enemy_body: list[dict],
my_health: int,
enemy_health: int,
depth: int,
previous_hazards: Iterable[tuple[int, int]],
) -> float:
state = DuelState(
my_body=self.body_from_dicts(my_body),
enemy_body=self.body_from_dicts(enemy_body),
food_bits=self.food_bits,
my_health=my_health,
enemy_health=enemy_health,
previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)),
)
value, completed = self._search(state, depth, -float("inf"), float("inf"))
if completed:
self.completed_depth = max(self.completed_depth, depth)
return value
def _search_selected_move(
self,
state: DuelState,
my_target: int,
depth: int,
alpha: float,
beta: float,
) -> tuple[float, bool]:
"""Resolve the selected root move with the opponent on the same turn."""
self.nodes += 1
if self._out_of_time():
return self._evaluate(state), False
enemy_moves = self._candidate_targets(state.enemy_body)
if not enemy_moves:
return self.WIN + depth, True
enemy_moves = self._ordered_moves(enemy_moves, state, depth, False)
worst = float("inf")
for enemy_target in enemy_moves:
if self._out_of_time():
return (worst if worst != float("inf") else self._evaluate(state)), False
child, terminal = self._advance(state, my_target, enemy_target)
if terminal is not None:
value = terminal
completed = True
elif depth <= 1:
value = self._evaluate(child)
completed = True
else:
value, completed = self._search(child, depth - 1, alpha, beta)
if not completed:
return (worst if worst != float("inf") else value), False
worst = min(worst, value)
beta = min(beta, worst)
if beta <= alpha:
break
return worst, True
def _search(self, state: DuelState, depth: int, alpha: float, beta: float) -> tuple[float, bool]:
self.nodes += 1
if self._out_of_time():
return self._evaluate(state), False
if depth <= 0:
return self._evaluate(state), True
cache_key = (state, depth)
original_alpha, original_beta = alpha, beta
cached = self.transposition.get(cache_key)
if cached is not None:
self.cache_hits += 1
cached_value, bound, preferred_move = cached
if bound == "exact":
return cached_value, True
if bound == "lower":
alpha = max(alpha, cached_value)
else:
beta = min(beta, cached_value)
if alpha >= beta:
return cached_value, True
my_moves = self._candidate_targets(state.my_body)
enemy_moves = self._candidate_targets(state.enemy_body)
if not my_moves:
return self.LOSS - depth, True
if not enemy_moves:
return self.WIN + depth, True
my_moves = self._ordered_moves(my_moves, state, depth, True, preferred_move if cached is not None else None)
enemy_moves = self._ordered_moves(enemy_moves, state, depth, False)
best = -float("inf")
best_move: int | None = None
for my_target in my_moves:
worst = float("inf")
for enemy_target in enemy_moves:
if self._out_of_time():
return (best if best != -float("inf") else self._evaluate(state)), False
child, terminal = self._advance(state, my_target, enemy_target)
if terminal is not None:
value = terminal
completed = True
else:
value, completed = self._search(child, depth - 1, alpha, beta)
if not completed:
return (best if best != -float("inf") else value), False
worst = min(worst, value)
if worst <= alpha:
self.killer_moves[depth] = my_target
self.history[my_target] = self.history.get(my_target, 0) + depth * depth
break
if worst > best:
best = worst
best_move = my_target
alpha = max(alpha, best)
if alpha >= beta:
break
if best <= original_alpha:
bound = "upper"
elif best >= original_beta:
bound = "lower"
else:
bound = "exact"
self.transposition[cache_key] = (best, bound, best_move)
return best, True
def _advance(self, state: DuelState, my_target: int, enemy_target: int) -> tuple[DuelState, float | None]:
my_ate = bool((1 << my_target) & state.food_bits)
enemy_ate = bool((1 << enemy_target) & state.food_bits)
my_body = self._advance_body(state.my_body, my_target, my_ate)
enemy_body = self._advance_body(state.enemy_body, enemy_target, enemy_ate)
my_dead = my_target in my_body[1:] or my_target in enemy_body[1:]
enemy_dead = enemy_target in enemy_body[1:] or enemy_target in my_body[1:]
if my_target == enemy_target:
if len(my_body) <= len(enemy_body):
my_dead = True
if len(enemy_body) <= len(my_body):
enemy_dead = True
my_health = 100 if my_ate else state.my_health - 1
enemy_health = 100 if enemy_ate else state.enemy_health - 1
if not my_ate:
my_health -= self._hazard_cost(my_target, state.previous_hazard_bits)
if not enemy_ate:
enemy_health -= self._hazard_cost(enemy_target, state.previous_hazard_bits)
my_dead = my_dead or my_health <= 0
enemy_dead = enemy_dead or enemy_health <= 0
if my_dead and enemy_dead:
return state, -500.0
if my_dead:
return state, self.LOSS
if enemy_dead:
return state, self.WIN
eaten_bits = 0
if my_ate:
eaten_bits |= 1 << my_target
if enemy_ate:
eaten_bits |= 1 << enemy_target
child = DuelState(
my_body=my_body,
enemy_body=enemy_body,
food_bits=state.food_bits & ~eaten_bits,
my_health=my_health,
enemy_health=enemy_health,
previous_hazard_bits=self.hazard_bits,
)
return child, None
def _candidate_targets(self, body: Body) -> list[int]:
"""Return in-bounds targets; `_advance` resolves simultaneous collisions.
Delaying occupancy checks until both targets and food growth are known is
essential: whether either tail vacates depends on that snake eating.
"""
return list(self._iter_bits(self.board.neighbors_of(body[0])))
def _ordered_moves(
self, moves: list[int], state: DuelState, depth: int, mine: bool,
preferred: int | None = None,
) -> list[int]:
body = state.my_body if mine else state.enemy_body
other = state.enemy_body if mine else state.my_body
killer = self.killer_moves.get(depth)
center_x = (self.board.width - 1) / 2.0
center_y = (self.board.height - 1) / 2.0
def score(target: int) -> tuple[float, int]:
x, y = self.board.coord(target)
food_bonus = 200.0 if (1 << target) & state.food_bits else 0.0
space = self.board.flood_count(target, (self._body_bits(body[1:]) | self._body_bits(other[1:])) & ~(1 << target))
center = -(abs(x - center_x) + abs(y - center_y))
preferred_bonus = 20_000.0 if target == preferred else 0.0
killer_bonus = 10_000.0 if target == killer else 0.0
return preferred_bonus + killer_bonus + self.history.get(target, 0) + food_bonus + space * 2.0 + center, -target
# Our strongest-looking moves first; enemy ordering uses the same quality
# estimate because dangerous enemy replies tend to gain space and food.
return sorted(moves, key=score, reverse=True)
def _evaluate(self, state: DuelState) -> float:
cached = self._evaluation_cache.get(state)
if cached is not None:
self.evaluation_cache_hits += 1
return cached
my_blocked = self._body_bits(state.my_body[1:]) | self._body_bits(state.enemy_body[1:])
my_head, enemy_head = state.my_body[0], state.enemy_body[0]
my_space = self.board.flood_count(my_head, my_blocked)
enemy_space = self.board.flood_count(enemy_head, my_blocked)
my_liberties = self.board.open_neighbor_count(my_head, my_blocked)
enemy_liberties = self.board.open_neighbor_count(enemy_head, my_blocked)
territory = self.board.territory(my_head, [enemy_head], my_blocked)
my_tail_path = self.board.path_distance(my_head, state.my_body[-1], my_blocked)
enemy_tail_path = self.board.path_distance(enemy_head, state.enemy_body[-1], my_blocked)
tail_score = (12.0 if my_tail_path is not None else -24.0) - (12.0 if enemy_tail_path is not None else -24.0)
my_hazard = self._hazard_cost(my_head, state.previous_hazard_bits)
enemy_hazard = self._hazard_cost(enemy_head, state.previous_hazard_bits)
length_delta = len(state.my_body) - len(state.enemy_body)
length_score = length_delta * 20.0
health_score = (state.my_health - state.enemy_health) * 0.18
forced_score = (my_liberties > 1) * 10.0 - (enemy_liberties > 1) * 10.0
# Food races matter most when health is low or eating changes head-to-head
# priority. Compare actual path lengths rather than Manhattan distance so a
# food tile behind a body wall is not treated as reachable.
food_score = 0.0
if state.food_bits:
my_food = self.board.nearest_food(my_head, state.food_bits, my_blocked)
enemy_food = self.board.nearest_food(enemy_head, state.food_bits, my_blocked)
my_distance = my_food[0] if my_food[0] is not None else 200
enemy_distance = enemy_food[0] if enemy_food[0] is not None else 200
my_urgency = max(0.0, (55.0 - state.my_health) / 55.0)
enemy_urgency = max(0.0, (55.0 - state.enemy_health) / 55.0)
food_score += (enemy_distance - my_distance) * 2.5
food_score -= my_distance * my_urgency * 5.0
food_score += enemy_distance * enemy_urgency * 3.0
if length_delta == 0 and my_distance < enemy_distance:
food_score += 14.0
elif length_delta < 0 and my_distance <= enemy_distance:
food_score += 20.0
# Reward maintaining safe pressure around the opposing head. This captures
# two-turn head traps that raw territory and flood counts often score as a
# neutral position.
head_distance = self.board.path_distance(my_head, enemy_head, my_blocked)
pressure_score = 0.0
if head_distance is not None and head_distance <= 3:
pressure = (4 - head_distance) * 6.0
pressure_score = pressure if length_delta > 0 else -pressure if length_delta < 0 else 0.0
value = (
(my_space - enemy_space) * 1.5 + territory * 1.2
+ (my_liberties - enemy_liberties) * 14.0 + length_score + health_score
+ tail_score + forced_score + food_score + pressure_score
+ (enemy_hazard - my_hazard) * 0.8
)
if len(self._evaluation_cache) < 32_768:
self._evaluation_cache[state] = value
return value
def _hazard_cost(self, target: int, previous_hazard_bits: int) -> int:
bit = 1 << target
if not (bit & self.hazard_bits & previous_hazard_bits):
return 0
return self.hazard_damage * self.hazard_stacks.get(target, 1)
@staticmethod
def _advance_body(body: Body, target: int, ate: bool) -> Body:
return (target,) + body if ate else (target,) + body[:-1]
@staticmethod
def _tail_stacked(body: Body) -> bool:
return len(body) >= 2 and body[-1] == body[-2]
def _body_bits(self, body: Body) -> int:
cached = self._body_bits_cache.get(body)
if cached is not None:
return cached
bits = 0
for cell in body:
bits |= 1 << cell
if len(self._body_bits_cache) < 16_384:
self._body_bits_cache[body] = bits
return bits
@staticmethod
def _iter_bits(bits: int):
while bits:
bit = bits & -bits
yield bit.bit_length() - 1
bits ^= bit
def _out_of_time(self, reserve_ms: float = 0.0) -> bool:
if self.deadline is None:
return False
expired = perf_counter() + reserve_ms / 1000.0 >= self.deadline
if expired:
self.deadline_exits += 1
return expired
+79
View File
@@ -0,0 +1,79 @@
"""Safety-gated board geometry scoring for perimeter-aware snakes."""
from __future__ import annotations
def _on_edge(point: tuple[int, int], width: int, height: int) -> bool:
x, y = point
return x in {0, width - 1} or y in {0, height - 1}
def _same_edge(
first: tuple[int, int], second: tuple[int, int], width: int, height: int,
) -> bool:
boundaries = ((0, 0), (0, width - 1), (1, 0), (1, height - 1))
return any(
first[index] == boundary and second[index] == boundary
for index, boundary in boundaries
)
def perimeter_geometry_score(
*,
point: tuple[int, int],
current_head: tuple[int, int],
width: int,
height: int,
occupancy: float,
snake_length: int,
reachable_space: int,
required_space: int,
liberties: int,
next_options: int,
safe_next_options: int,
tail_escape: bool,
dead_end: bool,
losing_head_to_head: bool,
) -> float:
"""Reward useful perimeter lanes without overriding tactical safety.
The normal move scorer already values liberties heavily, which naturally
makes wall cells less attractive. This adjustment offsets that bias only
when the wall position has room, a tail route, and multiple safe exits.
"""
x, y = point
cx, cy = (width - 1) / 2.0, (height - 1) / 2.0
center_score = 1.0 - (abs(x - cx) + abs(y - cy)) / max(1.0, cx + cy)
center_weight = max(2.0, 6.0 * (1.0 - min(1.0, occupancy / 0.5)))
score = center_score * center_weight
if not _on_edge(point, width, height):
return score
safely_usable = (
not dead_end
and not losing_head_to_head
and tail_escape
and liberties >= 2
and next_options >= 2
and safe_next_options >= 2
and reachable_space >= required_space + max(4, required_space // 2)
)
if not safely_usable:
return score
phase = min(1.0, occupancy / 0.34)
length_factor = min(1.0, snake_length / 12.0)
space_margin = min(
1.0,
max(0, reachable_space - required_space) / max(1, required_space),
)
score += 24.0 + phase * 12.0 + length_factor * 8.0 + space_margin * 8.0
# Continuing along one edge is more useful than repeatedly entering and
# leaving it: it keeps the body ordered and leaves the interior available.
if _same_edge(current_head, point, width, height):
score += 10.0
# Corners remove two exits. They remain usable, but should not become goals.
if x in {0, width - 1} and y in {0, height - 1}:
score -= 18.0
return score
+216
View File
@@ -0,0 +1,216 @@
"""Bitboard-backed spatial primitives shared by competitive snakes."""
from __future__ import annotations
from snakes.engine.bitboard import BitBoard
class BitboardSpatialMixin:
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 blocked cells to bits without stale identity-based caching."""
return self._get_bb(width, height).set_to_bits(blocked)
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_idx, blocked_bits, width, height)
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
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)
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
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()}
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,
)
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)
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)
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
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
+214
View File
@@ -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
+295
View File
@@ -0,0 +1,295 @@
"""Compact adversarial rollout for multiplayer Battlesnake positions."""
from __future__ import annotations
from itertools import product
from time import perf_counter
from snakes.engine.bitboard import BitBoard
Body = tuple[int, ...]
EnemyBodies = tuple[Body, ...]
StateKey = tuple[Body, EnemyBodies, int, int]
EvaluationKey = tuple[Body, EnemyBodies]
class CompactSurvivalSearch:
"""Small paranoid beam search with simultaneous enemy responses.
It is deliberately narrower than full multiplayer minimax: each enemy keeps
only its most dangerous replies and the combined response beam is capped.
This models moving opponents without exhausting the request deadline.
"""
DEATH = -5000.0
def __init__(
self,
board: BitBoard,
food: set[tuple[int, int]],
is_constrictor: bool,
deadline: float | None,
branch: int,
enemy_branch: int = 2,
response_cap: int = 8,
) -> None:
self.board = board
self.food_bits = board.set_to_bits(food)
self.is_constrictor = is_constrictor
self.deadline = deadline
self.branch = max(1, branch)
self.enemy_branch = max(1, enemy_branch)
self.response_cap = max(1, response_cap)
self.cache: dict[StateKey, float] = {}
self.evaluation_cache: dict[EvaluationKey, float] = {}
self.occupied_cache: dict[EvaluationKey, int] = {}
self.body_bits_cache: dict[Body, int] = {}
self.nodes = 0
self.cache_hits = 0
self.evaluation_cache_hits = 0
self.completed_depth = 0
self.deadline_exits = 0
def body_from_dicts(self, body: list[dict]) -> Body:
return tuple(self.board.idx(segment["x"], segment["y"]) for segment in body)
def search_selected(
self,
my_body: list[dict],
enemies: list[dict],
target: tuple[int, int],
depth: int,
) -> float:
mine = self.body_from_dicts(my_body)
enemy_bodies = tuple(self.body_from_dicts(enemy["body"]) for enemy in enemies)
target_idx = self.board.idx(*target)
if not self.board.neighbors_of(mine[0]) & (1 << target_idx):
return self.DEATH
value, completed = self._selected_root(
mine, enemy_bodies, self.food_bits, target_idx, depth,
)
if completed:
self.completed_depth = max(self.completed_depth, depth)
return value
def _selected_root(
self, mine: Body, enemies: EnemyBodies, food_bits: int, target: int, depth: int,
) -> tuple[float, bool]:
replies = self._enemy_responses(enemies, mine, target, food_bits)
if not replies:
replies = [()]
worst = float("inf")
completed = True
for response in replies:
if self._out_of_time():
completed = False
break
child = self._advance(mine, enemies, target, response, food_bits)
if child is None:
value = self.DEATH
else:
next_mine, next_enemies, next_food = child
value = self._evaluate(next_mine, next_enemies)
if depth > 1 and value > self.DEATH:
value += self._search(next_mine, next_enemies, next_food, depth - 1) * 0.72
worst = min(worst, value)
value = self._evaluate(mine, enemies) if worst == float("inf") else worst
return value, completed
def _search(self, mine: Body, enemies: EnemyBodies, food_bits: int, depth: int) -> float:
self.nodes += 1
if self._out_of_time() or depth <= 0:
return 0.0
key = (mine, enemies, food_bits, depth)
cached = self.cache.get(key)
if cached is not None:
self.cache_hits += 1
return cached
occupied = self._occupied(mine, enemies)
ranked: list[tuple[float, int]] = []
for target in self._iter_bits(self.board.neighbors_of(mine[0])):
# Collision legality is finalized simultaneously because eating controls
# whether tails vacate.
ate = bool((1 << target) & food_bits)
own_tail_blocked = self.is_constrictor or ate
body_blocked = self._body_bits(mine if own_tail_blocked else mine[:-1])
enemy_blocked = 0
for enemy in enemies:
enemy_blocked |= self._body_bits(enemy[:-1] if not self.is_constrictor else enemy)
if (1 << target) & (body_blocked | enemy_blocked):
continue
free_space = self.board.flood_count(target, occupied & ~(1 << target))
ranked.append((free_space + (20 if ate else 0), target))
ranked.sort(reverse=True)
if not ranked:
return self.DEATH
best = self.DEATH
for _, target in ranked[:self.branch]:
replies = self._enemy_responses(enemies, mine, target, food_bits) or [()]
worst = float("inf")
for response in replies:
if self._out_of_time():
break
child = self._advance(mine, enemies, target, response, food_bits)
if child is None:
value = self.DEATH
else:
next_mine, next_enemies, next_food = child
value = self._evaluate(next_mine, next_enemies)
if depth > 1 and value > self.DEATH:
value += self._search(next_mine, next_enemies, next_food, depth - 1) * 0.72
worst = min(worst, value)
if worst != float("inf"):
best = max(best, worst)
if not self._out_of_time() and len(self.cache) < 16_384:
self.cache[key] = best
return best
def _enemy_responses(
self, enemies: EnemyBodies, mine: Body, my_target: int, food_bits: int,
) -> list[tuple[int, ...]]:
if not enemies:
return []
choices: list[list[int]] = []
my_length_after = len(mine) + int(bool((1 << my_target) & food_bits))
occupied = self._occupied(mine, enemies)
mx, my = self.board.coord(my_target)
for enemy in enemies:
ranked: list[tuple[float, int]] = []
for target in self._iter_bits(self.board.neighbors_of(enemy[0])):
ate = bool((1 << target) & food_bits)
enemy_length_after = len(enemy) + int(ate)
score = 0.0
if target == my_target:
score += 1000.0 if enemy_length_after >= my_length_after else -1000.0
tx, ty = self.board.coord(target)
score -= abs(tx - mx) + abs(ty - my)
score += self.board.open_neighbor_count(target, occupied) * 3.0
score += 20.0 if ate else 0.0
ranked.append((score, target))
ranked.sort(reverse=True)
choices.append([target for _, target in ranked[:self.enemy_branch]])
responses: list[tuple[int, ...]] = []
for response in product(*choices):
responses.append(response)
if len(responses) >= self.response_cap:
break
return responses
def _advance(
self,
mine: Body,
enemies: EnemyBodies,
my_target: int,
enemy_targets: tuple[int, ...],
food_bits: int,
) -> tuple[Body, EnemyBodies, int] | None:
my_ate = bool((1 << my_target) & food_bits)
next_mine = self._advance_body(mine, my_target, my_ate)
next_enemies = tuple(
self._advance_body(body, target, bool((1 << target) & food_bits))
for body, target in zip(enemies, enemy_targets)
)
# Body and self collisions after all tails have moved.
if my_target in next_mine[1:]:
return None
if any(my_target in enemy[1:] for enemy in next_enemies):
return None
surviving: list[Body] = []
for index, enemy in enumerate(next_enemies):
target = enemy[0]
dead = target in enemy[1:] or target in next_mine[1:]
dead = dead or any(
target in other[1:] for other_index, other in enumerate(next_enemies)
if other_index != index
)
if target == my_target:
if len(enemy) >= len(next_mine):
return None
dead = True
if not dead:
# Enemy/enemy head collisions remove equal-length snakes and the shorter.
for other_index, other in enumerate(next_enemies):
if other_index != index and target == other[0] and len(enemy) <= len(other):
dead = True
break
if not dead:
surviving.append(enemy)
eaten = (1 << my_target) if my_ate else 0
for body, target in zip(enemies, enemy_targets):
if (1 << target) & food_bits:
eaten |= 1 << target
return next_mine, tuple(surviving), food_bits & ~eaten
def _evaluate(self, mine: Body, enemies: EnemyBodies) -> float:
key = (mine, enemies)
cached = self.evaluation_cache.get(key)
if cached is not None:
self.evaluation_cache_hits += 1
return cached
blocked = self._occupied(mine, enemies) & ~(1 << mine[0])
space = self.board.flood_count(mine[0], blocked)
liberties = self.board.open_neighbor_count(mine[0], blocked)
if liberties == 0 or space < len(mine):
return self.DEATH
enemy_pressure = 0.0
for enemy in enemies:
enemy_blocked = blocked & ~(1 << enemy[0])
enemy_space = self.board.flood_count(enemy[0], enemy_blocked)
enemy_liberties = self.board.open_neighbor_count(enemy[0], enemy_blocked)
enemy_pressure += max(0, 3 - enemy_liberties) * 18.0
if len(mine) > len(enemy):
enemy_pressure += max(0, 8 - enemy_space) * 8.0
value = space * 1.9 + liberties * 32.0 + enemy_pressure - len(enemies) * 4.0
if len(self.evaluation_cache) < 32_768:
self.evaluation_cache[key] = value
return value
def _occupied(self, mine: Body, enemies: EnemyBodies) -> int:
key = (mine, enemies)
cached = self.occupied_cache.get(key)
if cached is not None:
return cached
occupied = self._body_bits(mine)
for enemy in enemies:
occupied |= self._body_bits(enemy)
if len(self.occupied_cache) < 32_768:
self.occupied_cache[key] = occupied
return occupied
def _body_bits(self, body: Body) -> int:
cached = self.body_bits_cache.get(body)
if cached is not None:
return cached
bits = 0
for cell in body:
bits |= 1 << cell
if len(self.body_bits_cache) < 16_384:
self.body_bits_cache[body] = bits
return bits
def _advance_body(self, body: Body, target: int, ate: bool) -> Body:
if self.is_constrictor or ate:
return (target,) + body
return (target,) + body[:-1]
@staticmethod
def _iter_bits(bits: int):
while bits:
bit = bits & -bits
yield bit.bit_length() - 1
bits ^= bit
def _out_of_time(self) -> bool:
expired = self.deadline is not None and perf_counter() >= self.deadline
if expired:
self.deadline_exits += 1
return expired
@@ -7,7 +7,7 @@ import os
from quart_common.web.env import env_int
from server.dataset.RLBootstrapDataset import RLBootstrapDataset
from snakes.TemplateSnake import TemplateSnake
from snakes.core.template import TemplateSnake
from server.GameBoard import GameBoard
class BestBattleSnake(TemplateSnake):
@@ -1,4 +1,4 @@
from snakes.TemplateSnake import TemplateSnake
from snakes.core.template import TemplateSnake
from server.GameBoard import GameBoard
from collections import deque
@@ -1,4 +1,4 @@
from snakes.TemplateSnake import TemplateSnake
from snakes.core.template import TemplateSnake
import random
@@ -1,4 +1,4 @@
from snakes.TemplateSnake import TemplateSnake
from snakes.core.template import TemplateSnake
import random
from scipy import spatial
@@ -1,4 +1,4 @@
from snakes.TemplateSnake import TemplateSnake
from snakes.core.template import TemplateSnake
class MasterSnake(TemplateSnake):
VERSION = "1.2.0"
@@ -29,8 +29,8 @@ from __future__ import annotations
from typing import Any
from time import perf_counter
from snakes.ApexBattleSnake import ApexBattleSnake
from snakes.bitboard import BitBoard
from snakes.strategies.apex import ApexBattleSnake
from snakes.engine.bitboard import BitBoard
from server.GameBoard import GameBoard
# Direction offsets for coord-dict → tuple conversion
@@ -3,7 +3,7 @@ from typing import Any
import random, json, os
from server.TrainBattleSnakeAI import MOVES, extract_feature_values
from snakes.TemplateSnake import TemplateSnake
from snakes.core.template import TemplateSnake
class TrainedBattleSnake(TemplateSnake):
VERSION = "0.1.0"
@@ -6,7 +6,7 @@ import heapq, os
from quart_common.web.env import env_int
from snakes.TemplateSnake import TemplateSnake
from snakes.core.template import TemplateSnake
from server.GameBoard import GameBoard
from server.dataset.RLBootstrapDataset import RLBootstrapDataset
+1
View File
@@ -0,0 +1 @@
"""Historical snake strategies retained for replay and comparison."""
+6
View File
@@ -0,0 +1,6 @@
"""Actively maintained competitive snake strategies."""
from snakes.strategies.apex import ApexBattleSnake
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
__all__ = ("ApexBattleSnake", "PrismBattleSnake_GPT_5_6_Sol")
@@ -7,7 +7,8 @@ import heapq, os
from quart_common.web.env import env_int
from server.dataset.RLBootstrapDataset import RLBootstrapDataset
from snakes.TemplateSnake import TemplateSnake
from snakes.core.template import TemplateSnake
from snakes.engine.perimeter import perimeter_geometry_score
from server.GameBoard import GameBoard
class ApexBattleSnake(TemplateSnake):
@@ -468,15 +469,11 @@ class ApexBattleSnake(TemplateSnake):
if self._time_exceeded(deadline):
break
pos = safe_moves[m]
ate = (pos["x"], pos["y"]) in food_set
fb = self._future_body(my_body, pos, ate, False)
nmy_h = 100 if ate else my_health - 1
if (pos["x"], pos["y"]) in hazard_set and not ate:
nmy_h -= hazard_damage * hazard_count.get((pos["x"], pos["y"]), 1)
mm_val, depth_done = self._minimax_sim_id(
my_body=fb, enemy_body=enemy["body"],
mm_val, depth_done = self._minimax_candidate_id(
my_body=my_body, enemy_body=enemy["body"],
my_target=(pos["x"], pos["y"]),
food_set=food_set, hazard_set=hazard_set,
my_health=nmy_h, enemy_health=enemy_health,
my_health=my_health, enemy_health=enemy_health,
hazard_damage=hazard_damage, hazard_count=hazard_count,
width=width, height=height,
max_depth=self._planning_depth,
@@ -690,19 +687,22 @@ class ApexBattleSnake(TemplateSnake):
or (next_opts == 0 and not has_tail_escape)
)
cx, cy = (width - 1) / 2.0, (height - 1) / 2.0
center_score = 1.0 - (abs(point[0] - cx) + abs(point[1] - cy)) / max(1.0, cx + cy)
min_wall_dist = min(point[0], width - 1 - point[0], point[1], height - 1 - point[1])
if total_occupancy > 0.25:
if min_wall_dist == 0:
edge_penalty = 35.0 * total_occupancy
elif min_wall_dist == 1:
edge_penalty = 15.0 * total_occupancy
else:
edge_penalty = 0.0
else:
edge_penalty = 0.0
geometry_score = perimeter_geometry_score(
point=point,
current_head=(my_body[0]["x"], my_body[0]["y"]),
width=width,
height=height,
occupancy=total_occupancy,
snake_length=len(future_body),
reachable_space=reachable_space,
required_space=required_space,
liberties=liberties,
next_options=next_opts,
safe_next_options=en_safe_opts,
tail_escape=has_tail_escape,
dead_end=dead_end,
losing_head_to_head=losing_h2h,
)
hunger = max(0.0, (60.0 - my_health) / 60.0)
@@ -723,7 +723,7 @@ class ApexBattleSnake(TemplateSnake):
score += liberties * 20.0
score += next_opts * 10.0
score += en_safe_opts * 24.0
score += center_score * 14.0
score += geometry_score
if en_safe_opts == 0:
score -= 1700.0
@@ -731,7 +731,6 @@ class ApexBattleSnake(TemplateSnake):
score -= 420.0
score -= art_penalty
score -= edge_penalty
score -= h2h_dist2_penalty
if dead_end:
@@ -893,6 +892,55 @@ class ApexBattleSnake(TemplateSnake):
# ── A1: Iterative deepening minimax ──────────────────────────────────────────
def _minimax_candidate_id(
self,
my_body: list,
enemy_body: list,
my_target: tuple[int, int],
food_set: set,
hazard_set: set,
my_health: int,
enemy_health: int,
hazard_damage: int,
hazard_count: dict,
width: int,
height: int,
max_depth: int,
alpha: float,
beta: float,
deadline: float | None,
previous_hazard_set: set | None = None,
) -> tuple[float, int]:
"""Evaluate a selected move before continuing the legacy duel search.
Optimized subclasses can override this hook to resolve our selected move
and the opponent's reply simultaneously at the search root.
"""
pos = {"x": my_target[0], "y": my_target[1]}
ate = my_target in food_set
future_body = self._future_body(my_body, pos, ate, False)
future_health = 100 if ate else my_health - 1
effective_previous = previous_hazard_set if previous_hazard_set is not None else hazard_set
if my_target in hazard_set and my_target in effective_previous and not ate:
future_health -= hazard_damage * hazard_count.get(my_target, 1)
return self._minimax_sim_id(
my_body=future_body,
enemy_body=enemy_body,
food_set=food_set,
hazard_set=hazard_set,
my_health=future_health,
enemy_health=enemy_health,
hazard_damage=hazard_damage,
hazard_count=hazard_count,
width=width,
height=height,
max_depth=max_depth,
alpha=alpha,
beta=beta,
deadline=deadline,
previous_hazard_set=previous_hazard_set,
)
def _minimax_sim_id(
self,
my_body: list,
@@ -1376,7 +1424,7 @@ class ApexBattleSnake(TemplateSnake):
can_grow = self._enemy_can_grow_this_turn(snake, food_set)
if not can_grow:
enemy_vacating_tails.add((snake["body"][-1]["x"], snake["body"][-1]["y"]))
safe: MoveMap = {}
safe: ApexBattleSnake.MoveMap = {}
for move, (dx, dy) in self.DIRECTIONS.items():
pt = (my_head["x"] + dx, my_head["y"] + dy)
if not self._in_bounds(pt, width, height):
+171
View File
@@ -0,0 +1,171 @@
"""PrismBattleSnake_GPT_5_6_Sol v1.5.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.
S12: Duel minimax uses tuple bodies and bitboard move generation.
S13: Iterative deepening reuses a transposition table and move-order hints.
S14: Candidate duel moves and enemy replies resolve on the same root turn.
S15: Candidate moves share one duel transposition/search context per turn.
S16: Compact adversarial multiplayer rollout advances plausible enemy replies.
S17: Rollout memoization and adaptive depth spend time on ambiguous positions.
S18: Prism uses a deeper tactical horizon while retaining Apex's timeout reserve.
S19: Rollout occupancy and evaluation caches avoid repeated flood-fill work.
"""
from __future__ import annotations
from server.GameBoard import GameBoard
from snakes.engine.bitboard import BitBoard
from snakes.engine.duel import BitboardDuelMixin
from snakes.engine.duel_search import BitboardDuelSearch
from snakes.engine.spatial import BitboardSpatialMixin
from snakes.engine.survival import BitboardSurvivalMixin
from snakes.engine.survival_search import CompactSurvivalSearch
from snakes.strategies.apex import ApexBattleSnake
# Direction offsets for coord-dict → tuple conversion
_DIR_DELTAS = ((0, 1), (0, -1), (-1, 0), (1, 0))
_DIR_NAMES = ("up", "down", "left", "right")
class PrismBattleSnake_GPT_5_6_Sol(
BitboardDuelMixin,
BitboardSurvivalMixin,
BitboardSpatialMixin,
ApexBattleSnake,
):
VERSION = "1.5.0"
def __init__(self) -> None:
super().__init__()
self.name = "PrismBattleSnake"
self.version = self.VERSION
# Prism's compact state search is fast enough to inspect one additional
# turn. The existing deadline checks and Apex timeout reserve still cap the
# work on difficult positions.
self._planning_depth = max(self._planning_depth, 4)
# 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
# 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
# Shared per-turn search contexts. Candidate moves overlap heavily, so
# rebuilding their transposition tables wastes most iterative-deepening work.
self._duel_search_context: BitboardDuelSearch | None = None
self._survival_search_context: CompactSurvivalSearch | None = None
# ── choose_move override: precompute enemy bits ──────────────────────────
def choose_move(self, game_data: GameBoard) -> str:
bb = self._get_bb(game_data.get_width(), game_data.get_height())
self._duel_search_context = None
self._survival_search_context = None
# 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()}
all_occupied = {
(seg["x"], seg["y"])
for snake in [my_snake, *other_snakes]
for seg in snake["body"]
}
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, all_occupied
)
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
move = super().choose_move(game_data)
history = self.get_history()
if history:
thinking = history[-1]
if self._duel_search_context is not None:
thinking["prism_duel_depth"] = self._duel_search_context.completed_depth
thinking["prism_duel_nodes"] = self._duel_search_context.nodes
thinking["prism_duel_cache_hits"] = (
self._duel_search_context.cache_hits
+ self._duel_search_context.evaluation_cache_hits
)
thinking["prism_duel_deadline_exits"] = (
self._duel_search_context.deadline_exits
)
if self._survival_search_context is not None:
thinking["prism_rollout_depth"] = (
self._survival_search_context.completed_depth
)
thinking["prism_rollout_nodes"] = self._survival_search_context.nodes
thinking["prism_rollout_cache_hits"] = (
self._survival_search_context.cache_hits
+ self._survival_search_context.evaluation_cache_hits
)
thinking["prism_rollout_deadline_exits"] = (
self._survival_search_context.deadline_exits
)
return move
+76 -70
View File
@@ -262,11 +262,12 @@ input[type="range"] {
}
.board-wrap {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
gap: 8px;
min-height: 520px;
display: flex;
align-items: flex-start;
justify-content: center;
overflow: hidden;
}
.legend {
@@ -291,54 +292,38 @@ input[type="range"] {
}
.board {
position: relative;
min-width: 0;
min-height: 0;
height: auto;
width: 100%;
display: grid;
gap: 2px;
flex: none;
background: var(--grid);
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px;
align-content: start;
overflow: hidden;
}
/* Snake bodies are drawn as one rounded polyline per snake on top of the grid,
so bends and cell gaps need no per-cell patching. */
.snake-layer {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.cell {
background: var(--cell);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
aspect-ratio: 1 / 1;
position: relative;
border-radius: 2px;
}
.snake-turn-cell::after {
content: "";
position: absolute;
inset: 0;
background: var(--turn-color, transparent);
z-index: 0;
pointer-events: none;
}
/* 50% = quarter-circle at the inner corner of the bend */
.snake-turn-cell.snake-turn-ur::after {
border-top-right-radius: 50%;
}
.snake-turn-cell.snake-turn-ul::after {
border-top-left-radius: 50%;
}
.snake-turn-cell.snake-turn-dr::after {
border-bottom-right-radius: 50%;
}
.snake-turn-cell.snake-turn-dl::after {
border-bottom-left-radius: 50%;
}
.food {
background-image: radial-gradient(circle at center, #d73a31 0 45%, transparent 48%);
background-repeat: no-repeat;
@@ -350,6 +335,12 @@ input[type="range"] {
background-color: var(--hazard);
}
/* The hatch overlay (::before, z-index 4) still paints above the body stroke,
but the opaque fill would hide it. */
.hazard.hazard-over-snake {
background-color: transparent;
}
.hazard::before {
content: "";
position: absolute;
@@ -364,17 +355,11 @@ input[type="range"] {
border-radius: inherit;
}
.snake-you {
background: var(--you);
}
.snake-enemy {
background: var(--enemy);
}
/* Head/tail markers and icon layers use z-index >= 2 so they paint above
.snake-layer. The cells themselves stay unstacked, keeping their background
below the body stroke. */
.snake-head {
outline: 2px solid var(--head-ring);
outline-offset: -2px;
outline: none;
}
.snake-head::after {
@@ -494,43 +479,41 @@ input[type="range"] {
display: none;
}
.snake-tail-you.has-tail-icon,
.snake-tail-enemy.has-tail-icon {
box-shadow: none;
}
/* Spans the full cell along the travel axis and the body stroke width across
it, so the icon is exactly as thick as the body and its leading edge lands on
the cell border where the stroke ends. */
.icon-layer {
position: absolute;
inset: 2%;
background: var(--icon-color, currentColor);
-webkit-mask-image: var(--icon-url);
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
mask-image: var(--icon-url);
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
left: 0;
right: 0;
top: var(--icon-cross-inset, 7%);
bottom: var(--icon-cross-inset, 7%);
transform: var(--icon-transform, rotate(0deg));
transform-origin: center;
pointer-events: none;
z-index: 2;
}
.icon-layer--tail {
z-index: 2;
opacity: 0.92;
}
.icon-layer--head {
z-index: 3;
opacity: 1;
background: none;
-webkit-mask-image: none;
mask-image: none;
}
.icon-layer--head>svg {
/* Fallback for artwork that has not been fetched yet. An SVG used as a mask
image keeps its own preserveAspectRatio and letterboxes inside this
non-square box, so the inlined variant above is preferred. */
.icon-layer--masked {
background: var(--icon-color, currentColor);
-webkit-mask-image: var(--icon-url);
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: 100% 100%;
mask-image: var(--icon-url);
mask-repeat: no-repeat;
mask-position: center;
mask-size: 100% 100%;
}
.icon-layer>svg {
width: 100%;
height: 100%;
display: block;
@@ -693,6 +676,19 @@ input[type="range"] {
}
@media (max-width: 1100px) {
html,
body {
height: auto;
min-height: 100%;
overflow: auto;
}
.page {
height: auto;
min-height: 100vh;
overflow: visible;
}
.topbar {
grid-template-columns: 1fr;
}
@@ -704,6 +700,12 @@ input[type="range"] {
.main {
grid-template-columns: 1fr;
overflow: visible;
}
.panel,
.right {
overflow: visible;
}
.games {
@@ -714,8 +716,12 @@ input[type="range"] {
grid-template-columns: 1fr;
}
.thinking {
overflow: visible;
}
.board-wrap {
min-height: 360px;
min-height: min(70vw, 520px);
}
.turn-badge {
+7 -1
View File
@@ -1,11 +1,12 @@
class DashboardWebSocket {
constructor({ onGamesUpdate, onShutdown } = {}) {
constructor({ onGamesUpdate, onReplayUpdate, onShutdown } = {}) {
this._socket = null;
this._reconnectTimer = null;
this._shuttingDown = false;
this._pendingRequests = new Map();
this._requestSeq = 0;
this._onGamesUpdate = onGamesUpdate || (() => {});
this._onReplayUpdate = onReplayUpdate || (() => {});
this._onShutdown = onShutdown || (() => {});
}
@@ -58,6 +59,11 @@ class DashboardWebSocket {
return;
}
if (payload.type === "dashboard_game_replay_update") {
this._onReplayUpdate(payload);
return;
}
if (payload.type === "dashboard_games_update") {
this._onGamesUpdate(payload);
}
+333 -81
View File
@@ -1,12 +1,73 @@
class GameBoard {
static SVG_NS = "http://www.w3.org/2000/svg";
// Stroke width as a fraction of the cell size. Slightly wider than the cell
// interior so the stroke bridges the 2px grid gap between adjacent cells.
static BODY_WIDTH_RATIO = 0.86;
// How far the body stroke runs past the point where the icon artwork starts.
static SEAM_OVERLAP_PX = 1;
constructor(boardEl) {
this._boardEl = boardEl;
this._svgCache = new Map();
this._iconLeadInset = new Map();
this._measureCanvas = null;
this._boardWidth = 0;
this._boardHeight = 0;
this._snakeLayer = null;
this._lastPaint = null;
this._lastArgs = null;
this._resizeObserver = typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => {
this._fitBoard();
this._renderSnakeLayer();
});
if (this._resizeObserver && this._boardEl.parentElement) {
this._resizeObserver.observe(this._boardEl.parentElement);
}
}
clearBoard() {
this._boardEl.innerHTML = "";
this._boardEl.style.gridTemplateColumns = "none";
this._boardEl.style.gridTemplateRows = "none";
this._boardEl.style.width = "";
this._boardEl.style.height = "";
this._boardWidth = 0;
this._boardHeight = 0;
this._snakeLayer = null;
this._lastPaint = null;
this._lastArgs = null;
}
_fitBoard() {
const container = this._boardEl.parentElement;
if (!container || !this._boardWidth || !this._boardHeight) return;
const availableWidth = container.clientWidth;
const availableHeight = container.clientHeight;
if (availableWidth <= 0 || availableHeight <= 0) return;
const style = window.getComputedStyle(this._boardEl);
const horizontalChrome = Number.parseFloat(style.paddingLeft)
+ Number.parseFloat(style.paddingRight)
+ Number.parseFloat(style.borderLeftWidth)
+ Number.parseFloat(style.borderRightWidth);
const verticalChrome = Number.parseFloat(style.paddingTop)
+ Number.parseFloat(style.paddingBottom)
+ Number.parseFloat(style.borderTopWidth)
+ Number.parseFloat(style.borderBottomWidth);
const columnGap = Number.parseFloat(style.columnGap) || 0;
const rowGap = Number.parseFloat(style.rowGap) || 0;
const fixedWidth = horizontalChrome + (columnGap * Math.max(0, this._boardWidth - 1));
const fixedHeight = verticalChrome + (rowGap * Math.max(0, this._boardHeight - 1));
const cellSize = Math.max(0, Math.min(
(availableWidth - fixedWidth) / this._boardWidth,
(availableHeight - fixedHeight) / this._boardHeight,
));
this._boardEl.style.width = `${(cellSize * this._boardWidth) + fixedWidth}px`;
this._boardEl.style.height = `${(cellSize * this._boardHeight) + fixedHeight}px`;
}
async preloadSvgs(replay) {
@@ -27,14 +88,70 @@ class GameBoard {
async _loadSvg(url) {
if (this._svgCache.has(url)) return this._svgCache.get(url);
let text = null;
try {
const res = await fetch(url);
const text = res.ok ? await res.text() : null;
this._svgCache.set(url, text);
return text;
text = res.ok ? await res.text() : null;
} catch {
this._svgCache.set(url, null);
return null;
text = null;
}
this._svgCache.set(url, text);
await this._measureLeadInset(url, text);
return text;
}
// How far the artwork sits back from the edge that meets the body, as a
// fraction of the cell. Most icons touch it (0), but a handful of designs
// start further in and would leave a visible seam if the stroke stopped at
// the cell border. Measured once per icon by rasterising it at the same
// aspect ratio the layer uses. A null result means the artwork never spans
// the full body width, so the stroke should not be pulled back at all.
async _measureLeadInset(url, svgMarkup) {
if (this._iconLeadInset.has(url)) return;
this._iconLeadInset.set(url, null);
if (!svgMarkup) return;
const width = 200;
const height = Math.round(width * GameBoard.BODY_WIDTH_RATIO);
try {
const parsed = new DOMParser().parseFromString(
this._normalizeIconSvgMarkup(svgMarkup) || svgMarkup, "image/svg+xml",
);
const svgEl = parsed.querySelector("svg");
if (!svgEl) return;
svgEl.setAttribute("preserveAspectRatio", "none");
svgEl.setAttribute("width", String(width));
svgEl.setAttribute("height", String(height));
const image = new Image();
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svgEl))}`;
await image.decode();
if (!this._measureCanvas) this._measureCanvas = document.createElement("canvas");
const canvas = this._measureCanvas;
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.clearRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
// The edge must be covered across its whole width. Accepting "almost
// covered" leaves a notch at the seam corners that reads as a line, so
// the bar is every row inked, with a low alpha cut-off so antialiased
// edge pixels still count.
for (let x = 0; x < width / 2; x += 1) {
let covered = 0;
for (let y = 0; y < height; y += 1) {
if (pixels[(((y * width) + x) * 4) + 3] > 8) covered += 1;
}
if (covered === height) {
this._iconLeadInset.set(url, x / width);
return;
}
}
} catch {
// Leave the inset unknown; the stroke then runs to the cell centre.
}
}
@@ -97,7 +214,7 @@ class GameBoard {
return maxDepth;
}
_normalizeHeadSvgMarkup(svgMarkup) {
_normalizeIconSvgMarkup(svgMarkup) {
if (!svgMarkup) return null;
try {
const parser = new DOMParser();
@@ -124,41 +241,203 @@ class GameBoard {
const layer = document.createElement("div");
layer.className = type === "head" ? "icon-layer icon-layer--head" : "icon-layer icon-layer--tail";
layer.style.setProperty("--icon-transform", transformValue || "rotate(0deg)");
if (type === "head") {
// The icon artwork joins the body along its full leading edge, so the layer
// is squeezed to the body stroke width and stretched (no aspect ratio) to
// meet the stroke end flush.
layer.style.setProperty("--icon-cross-inset", `${(1 - GameBoard.BODY_WIDTH_RATIO) * 50}%`);
// Both head and tail artwork is inlined rather than used as a CSS mask: an
// SVG referenced as an image keeps its own preserveAspectRatio, so it would
// letterbox inside the non-square layer and leave a gap towards the body.
const svgMarkup = this._svgCache.get(iconUrl);
if (svgMarkup) {
layer.innerHTML = this._normalizeHeadSvgMarkup(svgMarkup);
layer.innerHTML = this._normalizeIconSvgMarkup(svgMarkup);
const svgEl = layer.querySelector("svg");
if (svgEl) {
svgEl.setAttribute("preserveAspectRatio", "none");
svgEl.style.width = "100%";
svgEl.style.height = "100%";
svgEl.style.fill = color || "currentColor";
svgEl.removeAttribute("width");
svgEl.removeAttribute("height");
}
return layer;
}
} else {
// Artwork not cached yet (live turn arriving before the preload finished):
// fall back to the mask so the icon still shows, and repaint once loaded.
layer.classList.add("icon-layer--masked");
layer.style.setProperty("--icon-url", `url(${iconUrl})`);
layer.style.setProperty("--icon-color", color || "var(--you)");
}
this._loadSvg(iconUrl).then((markup) => {
if (markup) this._repaintLast();
});
return layer;
}
_repaintLast() {
const args = this._lastArgs;
if (!args) return;
this.paintBoard(args.turnData, args.width, args.height, args.selectedSnakeId, args.replay);
}
_cellKey(x, y) {
return `${x}:${y}`;
}
// Collapses the stacked duplicate segments Battlesnake emits at spawn and
// right after eating, so the body becomes a clean orthogonal polyline.
_bodyPolyline(snake) {
const body = Array.isArray(snake && snake.body) ? snake.body : [];
const points = [];
for (const part of body) {
if (!part) continue;
const point = { x: Number(part.x), y: Number(part.y) };
if (Number.isNaN(point.x) || Number.isNaN(point.y)) continue;
const previous = points[points.length - 1];
if (previous && previous.x === point.x && previous.y === point.y) continue;
points.push(point);
}
return points;
}
// Pixel geometry of the grid, derived from the same box metrics the CSS grid
// uses, so SVG coordinates land exactly on cell centres.
_gridMetrics() {
if (!this._boardWidth || !this._boardHeight) return null;
const style = window.getComputedStyle(this._boardEl);
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
const paddingTop = Number.parseFloat(style.paddingTop) || 0;
const paddingRight = Number.parseFloat(style.paddingRight) || 0;
const paddingBottom = Number.parseFloat(style.paddingBottom) || 0;
const columnGap = Number.parseFloat(style.columnGap) || 0;
const rowGap = Number.parseFloat(style.rowGap) || 0;
const boxWidth = this._boardEl.clientWidth;
const boxHeight = this._boardEl.clientHeight;
const cellWidth = (boxWidth - paddingLeft - paddingRight - (columnGap * (this._boardWidth - 1))) / this._boardWidth;
const cellHeight = (boxHeight - paddingTop - paddingBottom - (rowGap * (this._boardHeight - 1))) / this._boardHeight;
if (!(cellWidth > 0) || !(cellHeight > 0)) return null;
return { paddingLeft, paddingTop, columnGap, rowGap, cellWidth, cellHeight, boxWidth, boxHeight };
}
_cellCenter(point, metrics) {
const row = this._boardHeight - 1 - point.y;
return {
x: metrics.paddingLeft + (point.x * (metrics.cellWidth + metrics.columnGap)) + (metrics.cellWidth / 2),
y: metrics.paddingTop + (row * (metrics.cellHeight + metrics.rowGap)) + (metrics.cellHeight / 2),
};
}
// Pulls the polyline end back so the customization icon owns its cell.
// Combined with a butt cap the stroke stops exactly where the artwork starts:
// at the cell border for the usual icon, deeper into the cell for artwork
// that sits back from that border (leadInset).
_retractEnd(endCenter, neighbourCenter, metrics, leadInset) {
const dx = endCenter.x - neighbourCenter.x;
const dy = endCenter.y - neighbourCenter.y;
const distance = Math.hypot(dx, dy);
if (!(distance > 0)) return endCenter;
const halfCell = Math.abs(dx) >= Math.abs(dy)
? metrics.cellWidth / 2
: metrics.cellHeight / 2;
// A full CSS pixel of overlap: both sides are the same colour, so overlap
// is free, and it keeps sub-pixel rounding from opening a hairline seam on
// displays with a fractional device pixel ratio.
const artworkOffset = (leadInset || 0) * halfCell * 2;
const pullBack = Math.min(distance, Math.max(0, halfCell - artworkOffset - GameBoard.SEAM_OVERLAP_PX));
return {
x: endCenter.x - ((dx / distance) * pullBack),
y: endCenter.y - ((dy / distance) * pullBack),
};
}
_appendRoundEnd(group, center, radius, color) {
const dot = document.createElementNS(GameBoard.SVG_NS, "circle");
dot.setAttribute("cx", `${center.x}`);
dot.setAttribute("cy", `${center.y}`);
dot.setAttribute("r", `${radius}`);
dot.setAttribute("fill", color);
group.appendChild(dot);
}
_renderSnakeLayer() {
if (!this._snakeLayer || !this._lastPaint) return;
const metrics = this._gridMetrics();
while (this._snakeLayer.firstChild) this._snakeLayer.removeChild(this._snakeLayer.firstChild);
if (!metrics) return;
this._snakeLayer.setAttribute("viewBox", `0 0 ${metrics.boxWidth} ${metrics.boxHeight}`);
this._snakeLayer.setAttribute("width", `${metrics.boxWidth}`);
this._snakeLayer.setAttribute("height", `${metrics.boxHeight}`);
const cellSize = Math.min(metrics.cellWidth, metrics.cellHeight);
const strokeWidth = cellSize * GameBoard.BODY_WIDTH_RATIO;
const { snakes, selectedSnakeId } = this._lastPaint;
for (const entry of snakes) {
const points = entry.points;
if (points.length === 0) continue;
const centers = points.map((point) => this._cellCenter(point, metrics));
const dimmed = Boolean(selectedSnakeId) && entry.snakeId !== selectedSnakeId;
// One group per snake so dimming applies once instead of stacking up on
// overlapping shapes.
const group = document.createElementNS(GameBoard.SVG_NS, "g");
if (dimmed) group.setAttribute("opacity", "0.2");
this._snakeLayer.appendChild(group);
if (centers.length === 1) {
// Fully stacked body (spawn turn): a single round blob, unless the head
// icon already fills that cell.
if (!entry.headIcon) {
this._appendRoundEnd(group, centers[0], strokeWidth / 2, entry.color);
}
continue;
}
const last = centers.length - 1;
// An icon only takes over its cell if its artwork spans the full body
// width somewhere; otherwise the stroke runs to the cell centre and keeps
// its rounded end, with the icon drawn on top. Ends without an icon keep
// the rounded look via an explicit cap circle, because linecap applies to
// both ends of the path at once.
if (entry.headIcon && entry.headLeadInset !== null) {
centers[0] = this._retractEnd(centers[0], centers[1], metrics, entry.headLeadInset);
} else {
this._appendRoundEnd(group, centers[0], strokeWidth / 2, entry.color);
}
if (entry.tailIcon && entry.tailLeadInset !== null) {
centers[last] = this._retractEnd(centers[last], centers[last - 1], metrics, entry.tailLeadInset);
} else {
this._appendRoundEnd(group, centers[last], strokeWidth / 2, entry.color);
}
const path = document.createElementNS(GameBoard.SVG_NS, "path");
path.setAttribute("d", centers.map((point, idx) => `${idx === 0 ? "M" : "L"}${point.x} ${point.y}`).join(" "));
path.setAttribute("fill", "none");
path.setAttribute("stroke", entry.color);
path.setAttribute("stroke-width", `${strokeWidth}`);
path.setAttribute("stroke-linecap", "butt");
path.setAttribute("stroke-linejoin", "round");
group.appendChild(path);
}
}
paintBoard(turnData, width, height, selectedSnakeId, replay) {
this.clearBoard();
if (!turnData || !width || !height) return;
this._lastArgs = { turnData, width, height, selectedSnakeId, replay };
const colorById = SnakeUtils.buildSnakeColorById(turnData, replay);
const customById = SnakeUtils.buildSnakeCustomizationById(turnData, replay);
this._boardEl.style.gridTemplateColumns = `repeat(${width}, 1fr)`;
this._boardWidth = Number(width);
this._boardHeight = Number(height);
this._boardEl.style.gridTemplateColumns = `repeat(${width}, minmax(0, 1fr))`;
this._boardEl.style.gridTemplateRows = `repeat(${height}, minmax(0, 1fr))`;
this._fitBoard();
const foods = new Set((turnData.food || []).map((p) => this._cellKey(p.x, p.y)));
const hazards = new Set((turnData.hazards || []).map((p) => this._cellKey(p.x, p.y)));
const snakeBody = new Map();
const occupiedCells = new Set();
const snakeHead = new Set();
const snakeTail = new Map();
const headVariantByCell = new Map();
@@ -169,6 +448,7 @@ class GameBoard {
const tailTransformByCell = new Map();
const snakeColorByCell = new Map();
const snakeIdByCell = new Map();
const snakeEntries = [];
(turnData.snakes || []).forEach((snake, idx) => {
if (!snake) return;
@@ -181,28 +461,48 @@ class GameBoard {
const tailIcon = SnakeUtils.buildCustomizationIconUrl("tails", custom.tail);
const headTransform = SnakeUtils.directionToHeadTransform(SnakeUtils.inferHeadDirection(snake));
const tailTransform = SnakeUtils.directionToTailTransform(SnakeUtils.inferTailDirection(snake));
const points = this._bodyPolyline(snake);
for (const part of (snake.body || [])) {
snakeBody.set(this._cellKey(part.x, part.y), bodyColor);
occupiedCells.add(this._cellKey(part.x, part.y));
snakeIdByCell.set(this._cellKey(part.x, part.y), snakeId);
}
if (snake.head) {
const headKey = this._cellKey(snake.head.x, snake.head.y);
// `head` is authoritative when present, but some payloads omit it; the
// polyline start is the same cell.
const headPoint = snake.head || points[0] || null;
const headKey = headPoint ? this._cellKey(headPoint.x, headPoint.y) : null;
if (headKey !== null) {
snakeHead.add(headKey);
headVariantByCell.set(headKey, headVariant);
headTransformByCell.set(headKey, headTransform);
snakeColorByCell.set(headKey, bodyColor);
if (headIcon) headIconByCell.set(headKey, headIcon);
}
if (Array.isArray(snake.body) && snake.body.length > 0) {
const tail = snake.body[snake.body.length - 1];
// A tail stacked under this snake's own head has no free cell to draw in.
// Another snake's head landing there must not suppress it, so the check is
// snake-local rather than against every head seen so far.
let drawTailIcon = false;
if (points.length > 0) {
const tail = points[points.length - 1];
const tailKey = this._cellKey(tail.x, tail.y);
drawTailIcon = Boolean(tailIcon) && tailKey !== headKey;
snakeTail.set(tailKey, snake.is_you ? "snake-tail-you" : "snake-tail-enemy");
tailVariantByCell.set(tailKey, tailVariant);
tailTransformByCell.set(tailKey, tailTransform);
snakeColorByCell.set(tailKey, bodyColor);
if (tailIcon) tailIconByCell.set(tailKey, tailIcon);
if (drawTailIcon) tailIconByCell.set(tailKey, tailIcon);
}
const leadInset = (url) => (this._iconLeadInset.has(url) ? this._iconLeadInset.get(url) : 0);
snakeEntries.push({
snakeId,
color: bodyColor,
points,
headIcon: Boolean(headIcon),
tailIcon: drawTailIcon,
headLeadInset: headIcon ? leadInset(headIcon) : 0,
tailLeadInset: drawTailIcon ? leadInset(tailIcon) : 0,
});
});
for (let y = height - 1; y >= 0; y--) {
@@ -210,75 +510,21 @@ class GameBoard {
const key = this._cellKey(x, y);
const cell = document.createElement("div");
cell.className = "cell";
if (hazards.has(key)) cell.classList.add("hazard");
if (foods.has(key)) cell.classList.add("food");
if (snakeBody.has(key)) {
const bodyColor = snakeBody.get(key);
const hasHeadIcon = headIconByCell.has(key);
const hasTailIcon = tailIconByCell.has(key);
const isIconCell = hasHeadIcon || hasTailIcon;
cell.style.borderRadius = "0";
if (!isIconCell) cell.style.background = bodyColor;
if (selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
const occupied = occupiedCells.has(key);
if (hazards.has(key)) {
cell.classList.add("hazard");
// Keep the hazard hatch readable on top of the snake stroke.
if (occupied) cell.classList.add("hazard-over-snake");
}
if (foods.has(key) && !occupied) cell.classList.add("food");
if (occupied && selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
cell.style.opacity = "0.2";
}
const snakeId = snakeIdByCell.get(key);
if (snakeId) {
const up = snakeIdByCell.get(this._cellKey(x, y + 1)) === snakeId;
const down = snakeIdByCell.get(this._cellKey(x, y - 1)) === snakeId;
const left = snakeIdByCell.get(this._cellKey(x - 1, y)) === snakeId;
const right = snakeIdByCell.get(this._cellKey(x + 1, y)) === snakeId;
if (!snakeHead.has(key) && !snakeTail.has(key)) {
if (up && right && !down && !left) {
cell.classList.add("snake-turn-cell", "snake-turn-dl");
cell.style.setProperty("--turn-color", bodyColor);
cell.style.background = "var(--cell)";
} else if (up && left && !down && !right) {
cell.classList.add("snake-turn-cell", "snake-turn-dr");
cell.style.setProperty("--turn-color", bodyColor);
cell.style.background = "var(--cell)";
} else if (down && right && !up && !left) {
cell.classList.add("snake-turn-cell", "snake-turn-ul");
cell.style.setProperty("--turn-color", bodyColor);
cell.style.background = "var(--cell)";
} else if (down && left && !up && !right) {
cell.classList.add("snake-turn-cell", "snake-turn-ur");
cell.style.setProperty("--turn-color", bodyColor);
cell.style.background = "var(--cell)";
}
}
// Outward shadows bridge the 2px gap to adjacent snake cells.
// For icon cells (head/tail), also add inset shadows to color the
// connecting edge of the cell itself, since the background stays
// transparent so the icon remains visible.
const bridgeShadows = [];
if (up) {
bridgeShadows.push(`0 -2px 0 ${bodyColor}`);
if (isIconCell) bridgeShadows.push(`inset 0 2px 0 ${bodyColor}`);
}
if (down) {
bridgeShadows.push(`0 2px 0 ${bodyColor}`);
if (isIconCell) bridgeShadows.push(`inset 0 -2px 0 ${bodyColor}`);
}
if (left) {
bridgeShadows.push(`-2px 0 0 ${bodyColor}`);
if (isIconCell) bridgeShadows.push(`inset 2px 0 0 ${bodyColor}`);
}
if (right) {
bridgeShadows.push(`2px 0 0 ${bodyColor}`);
if (isIconCell) bridgeShadows.push(`inset -2px 0 0 ${bodyColor}`);
}
if (bridgeShadows.length > 0) cell.style.boxShadow = bridgeShadows.join(", ");
}
}
if (snakeTail.has(key)) {
cell.classList.add(snakeTail.get(key));
cell.classList.add(`tail-style-${tailVariantByCell.get(key) || 1}`);
const tailIcon = tailIconByCell.get(key);
if (tailIcon && !snakeHead.has(key)) {
if (tailIcon) {
cell.classList.add("has-tail-icon", "icon-tail");
cell.appendChild(this._createIconLayer(
tailIcon,
@@ -305,5 +551,11 @@ class GameBoard {
this._boardEl.appendChild(cell);
}
}
this._snakeLayer = document.createElementNS(GameBoard.SVG_NS, "svg");
this._snakeLayer.setAttribute("class", "snake-layer");
this._boardEl.appendChild(this._snakeLayer);
this._lastPaint = { snakes: snakeEntries, selectedSnakeId: selectedSnakeId || null };
this._renderSnakeLayer();
}
}
+70 -3
View File
@@ -12,6 +12,7 @@ class GameState {
this.activeGameId = "";
this.selectedSnakeId = null;
this._timer = null;
this._followLive = false;
this._hasLoadedReplayOnce = false;
}
@@ -19,7 +20,20 @@ class GameState {
this._webSocket = webSocket;
}
get isPlaying() { return Boolean(this._timer); }
get isPlaying() { return Boolean(this._timer) || this._followLive; }
_isRunningReplay(replay = this.replay) {
return Boolean(replay && replay.game && replay.game.status === "running");
}
_isAtLatestTurn() {
return Boolean(
this.replay
&& Array.isArray(this.replay.turns)
&& this.replay.turns.length > 0
&& this.turnIndex >= this.replay.turns.length - 1
);
}
async loadReplay(gameId) {
let nextReplay = null;
@@ -44,6 +58,7 @@ class GameState {
nextReplay = await response.json();
}
this.stopPlayback();
this.replay = nextReplay;
this._hasLoadedReplayOnce = true;
this.activeGameId = String(gameId || "");
@@ -76,11 +91,44 @@ class GameState {
}
}
async applyLiveTurn(gameId, game, turn) {
if (!turn || String(gameId || "") !== this.activeGameId || !this.replay) return;
const wasFollowingLive = this._followLive;
const wasAtLatest = this._isAtLatestTurn();
const turnsBefore = Array.isArray(this.replay.turns) ? this.replay.turns : [];
const previousCount = turnsBefore.length;
const turnNumber = Number(turn.turn);
const existingIndex = turnsBefore.findIndex((item) => Number(item.turn) === turnNumber);
if (existingIndex >= 0) turnsBefore[existingIndex] = turn;
else turnsBefore.push(turn);
turnsBefore.sort((left, right) => Number(left.turn) - Number(right.turn));
this.replay.turns = turnsBefore;
if (game && typeof game === "object") {
this.replay.game = { ...(this.replay.game || {}), ...game };
}
await this._gameBoard.preloadSvgs({ turns: [turn] });
const turns = this.replay.turns;
this._sliderEl.max = String(Math.max(0, turns.length - 1));
if (wasFollowingLive || wasAtLatest || previousCount === 0) {
this.turnIndex = Math.max(0, turns.length - 1);
this.renderTurn();
} else {
this.turnIndex = Math.min(this.turnIndex, Math.max(0, turns.length - 1));
this.renderTurn();
}
if (!this._isRunningReplay()) this.stopPlayback();
}
stopPlayback() {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
this._followLive = false;
const playBtn = document.getElementById("play-btn");
playBtn.textContent = "▶";
playBtn.setAttribute("title", "Play");
@@ -88,15 +136,34 @@ class GameState {
}
startPlayback() {
if (!this.replay || !Array.isArray(this.replay.turns) || this.replay.turns.length < 2) return;
if (!this.replay || !Array.isArray(this.replay.turns) || this.replay.turns.length === 0) return;
this.stopPlayback();
if (this._isRunningReplay() && this._isAtLatestTurn()) {
this._followLive = true;
const playBtn = document.getElementById("play-btn");
playBtn.textContent = "●";
playBtn.setAttribute("title", "Following live game");
playBtn.setAttribute("aria-label", "Following live game");
return;
}
if (this.replay.turns.length < 2) return;
if (this.turnIndex >= this.replay.turns.length - 1) {
this.turnIndex = 0;
this.renderTurn();
}
this.stopPlayback();
const interval = Number(document.getElementById("speed").value || 650);
this._timer = setInterval(() => {
if (!this.replay || this.turnIndex >= this.replay.turns.length - 1) {
if (this._isRunningReplay()) {
clearInterval(this._timer);
this._timer = null;
this._followLive = true;
const liveBtn = document.getElementById("play-btn");
liveBtn.textContent = "●";
liveBtn.setAttribute("title", "Following live game");
liveBtn.setAttribute("aria-label", "Following live game");
return;
}
this.stopPlayback();
return;
}
+36 -41
View File
@@ -132,66 +132,61 @@ class SnakeUtils {
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})`;
}
static inferHeadDirection(snake) {
const body = Array.isArray(snake && snake.body) ? snake.body : [];
if (body.length >= 2) {
const head = body[0];
const neck = body[1];
if (head && neck) {
const dx = Number(head.x) - Number(neck.x);
const dy = Number(head.y) - Number(neck.y);
// Board coordinates are y-up, so a positive dy means "up".
static _deltaToDirection(dx, dy) {
if (dx > 0) return "right";
if (dx < 0) return "left";
if (dy > 0) return "up";
if (dy < 0) return "down";
}
return null;
}
static _fallbackDirection(snake) {
const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase();
if (["up", "down", "left", "right"].includes(inferred)) return inferred;
if (body.length < 2) return "right";
const head = body[0];
const neck = body[1];
if (!head || !neck) return "right";
const dx = Number(head.x) - Number(neck.x);
const dy = Number(head.y) - Number(neck.y);
if (dx > 0) return "right";
if (dx < 0) return "left";
if (dy > 0) return "up";
if (dy < 0) return "down";
return "right";
}
// Segments stack on spawn and right after eating, so both ends scan past
// duplicates to find the first cell that actually differs.
static inferHeadDirection(snake) {
const body = Array.isArray(snake && snake.body) ? snake.body : [];
const head = body[0];
if (!head) return SnakeUtils._fallbackDirection(snake);
for (let idx = 1; idx < body.length; idx += 1) {
const neck = body[idx];
if (!neck) continue;
if (Number(neck.x) === Number(head.x) && Number(neck.y) === Number(head.y)) continue;
const direction = SnakeUtils._deltaToDirection(
Number(head.x) - Number(neck.x),
Number(head.y) - Number(neck.y),
);
if (direction) return direction;
break;
}
return SnakeUtils._fallbackDirection(snake);
}
static inferTailDirection(snake) {
const body = Array.isArray(snake && snake.body) ? snake.body : [];
if (body.length < 2) return "right";
const tail = body[body.length - 1];
if (!tail) return "right";
if (!tail) return SnakeUtils._fallbackDirection(snake);
let beforeTail = null;
for (let idx = body.length - 2; idx >= 0; idx -= 1) {
const candidate = body[idx];
if (!candidate) continue;
if (Number(candidate.x) !== Number(tail.x) || Number(candidate.y) !== Number(tail.y)) {
beforeTail = candidate;
const beforeTail = body[idx];
if (!beforeTail) continue;
if (Number(beforeTail.x) === Number(tail.x) && Number(beforeTail.y) === Number(tail.y)) continue;
const direction = SnakeUtils._deltaToDirection(
Number(beforeTail.x) - Number(tail.x),
Number(beforeTail.y) - Number(tail.y),
);
if (direction) return direction;
break;
}
}
if (!beforeTail) {
const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase();
if (["up", "down", "left", "right"].includes(inferred)) return inferred;
return "right";
}
const dx = Number(beforeTail.x) - Number(tail.x);
const dy = Number(beforeTail.y) - Number(tail.y);
if (dx > 0) return "right";
if (dx < 0) return "left";
if (dy > 0) return "up";
if (dy < 0) return "down";
return "right";
return SnakeUtils._fallbackDirection(snake);
}
static directionToHeadTransform(direction) {
+32 -12
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/root.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<link rel="stylesheet" href="{{ static_url('css/root.css') }}">
<link rel="stylesheet" href="{{ static_url('css/styles.css') }}">
</head>
<body>
<div class="page">
@@ -51,6 +51,7 @@
<option value="400">1.5x</option>
<option value="250">2x</option>
<option value="160">2.5x</option>
<option value="120">3x</option>
</select>
</label>
<input type="range" min="0" max="0" step="1" id="turn-slider" value="0">
@@ -68,16 +69,16 @@
</main>
</div>
<script src="{{ url_for('static', filename='js/Utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/Snake.js') }}"></script>
<script src="{{ url_for('static', filename='js/MoveTable.js') }}"></script>
<script src="{{ url_for('static', filename='js/SnakeTable.js') }}"></script>
<script src="{{ url_for('static', filename='js/GameBoard.js') }}"></script>
<script src="{{ url_for('static', filename='js/OverallStats.js') }}"></script>
<script src="{{ url_for('static', filename='js/GamesTable.js') }}"></script>
<script src="{{ url_for('static', filename='js/Thinking.js') }}"></script>
<script src="{{ url_for('static', filename='js/DashboardWebSocket.js') }}"></script>
<script src="{{ url_for('static', filename='js/GameState.js') }}"></script>
<script src="{{ static_url('js/Utils.js') }}"></script>
<script src="{{ static_url('js/Snake.js') }}"></script>
<script src="{{ static_url('js/MoveTable.js') }}"></script>
<script src="{{ static_url('js/SnakeTable.js') }}"></script>
<script src="{{ static_url('js/GameBoard.js') }}"></script>
<script src="{{ static_url('js/OverallStats.js') }}"></script>
<script src="{{ static_url('js/GamesTable.js') }}"></script>
<script src="{{ static_url('js/Thinking.js') }}"></script>
<script src="{{ static_url('js/DashboardWebSocket.js') }}"></script>
<script src="{{ static_url('js/GameState.js') }}"></script>
<script>
const initialGameId = {{ initial_game_id|tojson }};
const initialSummary = {{ initial_summary|tojson }};
@@ -102,6 +103,20 @@
gameState._gamesTable = gamesTable;
const dashboardWS = new DashboardWebSocket({
onReplayUpdate: (payload) => {
const games = Array.isArray(dashboardGamesPayload.games)
? dashboardGamesPayload.games
: [];
const listedGame = games.find(
(game) => String(game.game_id) === String(payload.game_id),
);
if (listedGame && payload.game) {
listedGame.final_turn = payload.game.final_turn;
listedGame.status = payload.game.status;
gamesTable.render(games, gameState.activeGameId);
}
gameState.applyLiveTurn(payload.game_id, payload.game, payload.turn);
},
onGamesUpdate: (payload) => {
if (payload.summary) {
dashboardSummary = payload.summary;
@@ -111,6 +126,11 @@
dashboardGamesPayload = payload.games;
const games = Array.isArray(dashboardGamesPayload.games) ? dashboardGamesPayload.games : [];
gamesTable.render(games, gameState.activeGameId);
const activeGame = games.find((game) => String(game.game_id) === gameState.activeGameId);
if (activeGame && gameState.replay && gameState.replay.game) {
gameState.replay.game.status = activeGame.status;
gameState.replay.game.final_turn = activeGame.final_turn;
}
}
},
});
+1 -1
View File
@@ -3,7 +3,7 @@ import argparse
import time
from server.GameBoard import GameBoard
from snakes.BestBattleSnake import BestBattleSnake
from snakes.legacy.BestBattleSnake import BestBattleSnake
def build_game_state() -> dict:
return {
@@ -0,0 +1,54 @@
import io
import unittest
from unittest.mock import patch
from scripts.run_seeded_snake_tournament import ENGINE_USER_AGENT, run_game
class _Completed:
returncode = 0
stdout = "INFO Game completed after 42 turns. Prism was the winner.\n"
class _OutputFile:
def __init__(self, *args, **kwargs):
self.file = io.BytesIO(
b'{"turn":0}\n'
b'{"winnerId":"snake-id","winnerName":"Prism","isDraw":false}\n'
)
self.name = "arena-output.jsonl"
def __enter__(self):
return self
def __exit__(self, *args):
self.file.close()
def seek(self, offset):
return self.file.seek(offset)
def __iter__(self):
return iter(self.file)
class TestSeededSnakeTournament(unittest.TestCase):
def test_proxy_identifies_requests_as_battlesnake_engine(self):
self.assertIn("BattlesnakeEngine", ENGINE_USER_AGENT)
@patch("scripts.run_seeded_snake_tournament.subprocess.run", return_value=_Completed())
@patch("scripts.run_seeded_snake_tournament.tempfile.NamedTemporaryFile", _OutputFile)
def test_run_game_reads_official_engine_result(self, run):
result = run_game(
cli="battlesnake", seed=7, game_type="standard", map_name="standard",
players=[("Apex", "http://host:9001"), ("Prism", "http://host:9002")],
width=11, height=11, timeout_ms=500,
)
self.assertEqual(result, {
"seed": 7, "winner": "Prism", "draw": False, "turns": 42,
})
command = run.call_args.args[0]
self.assertIn("--seed", command)
self.assertIn("--output", command)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,31 @@
import unittest
from scripts.snake_arena_scenarios import SCENARIOS, synthetic_states
class TestSnakeArenaScenarios(unittest.TestCase):
def test_default_corpus_rotates_through_every_scenario(self):
states = synthetic_states(len(SCENARIOS))
self.assertEqual(
{metadata["scenario"] for _, metadata in states},
set(SCENARIOS),
)
def test_scenario_filter_is_deterministic(self):
first = synthetic_states(3, ["hazard"])
second = synthetic_states(3, ["hazard"])
self.assertEqual(first, second)
self.assertTrue(all(metadata["scenario"] == "hazard" for _, metadata in first))
def test_generated_you_is_present_on_board(self):
for board, metadata in synthetic_states(20):
with self.subTest(scenario=metadata["scenario"]):
ids = {snake["id"] for snake in board["snakes"]}
self.assertIn(metadata["you"]["id"], ids)
self.assertGreater(board["width"], 0)
self.assertGreater(board["height"], 0)
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
import unittest
from snakes.engine.perimeter import perimeter_geometry_score
class TestPerimeterGeometryScore(unittest.TestCase):
def score(self, point, **overrides):
values = {
"point": point,
"current_head": (0, 5),
"width": 11,
"height": 11,
"occupancy": 0.35,
"snake_length": 12,
"reachable_space": 80,
"required_space": 12,
"liberties": 2,
"next_options": 2,
"safe_next_options": 2,
"tail_escape": True,
"dead_end": False,
"losing_head_to_head": False,
}
values.update(overrides)
return perimeter_geometry_score(**values)
def test_safe_wall_lane_can_outscore_adjacent_inner_lane(self):
wall = self.score((0, 6))
inner = self.score((1, 5))
self.assertGreater(wall, inner)
def test_unsafe_wall_does_not_receive_perimeter_reward(self):
safe_wall = self.score((0, 6))
unsafe_wall = self.score((0, 6), safe_next_options=1)
self.assertGreater(safe_wall, unsafe_wall + 30.0)
def test_corner_is_less_attractive_than_straight_edge(self):
corner = self.score((0, 0), current_head=(0, 1))
straight_edge = self.score((0, 6))
self.assertGreater(straight_edge, corner)
def test_losing_head_to_head_never_gets_perimeter_reward(self):
safe_wall = self.score((0, 6))
contested_wall = self.score((0, 6), losing_head_to_head=True)
self.assertGreater(safe_wall, contested_wall + 30.0)
if __name__ == "__main__":
unittest.main()
@@ -2,10 +2,11 @@ import unittest
from time import perf_counter
from snakes import SnakeBuilder, get_snake_version
from snakes.ApexBattleSnake import ApexBattleSnake
from snakes.PrismBattleSnake_GPT_5_6_Sol import PrismBattleSnake_GPT_5_6_Sol
from snakes.bitboard import BitBoard
from snakes.bitboard_duel_search import BitboardDuelSearch
from snakes.engine.bitboard import BitBoard
from snakes.engine.duel_search import BitboardDuelSearch
from snakes.engine.survival_search import CompactSurvivalSearch
from snakes.strategies.apex import ApexBattleSnake
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
class TestBitBoard(unittest.TestCase):
@@ -21,20 +22,39 @@ class TestBitBoard(unittest.TestCase):
self.assertEqual(board.territory(board.idx(0, 0), [board.idx(4, 0)], 0), 0)
def test_territory_propagates_through_contested_cells(self):
board = BitBoard(5, 3)
blocked = board.set_to_bits({(0, 1), (1, 1), (3, 1), (4, 1)})
self.assertEqual(board.territory(board.idx(0, 0), [board.idx(4, 0)], blocked), 0)
def test_territory_ignores_enemy_only_disconnected_space_like_apex(self):
board = BitBoard(5, 1)
blocked = board.set_to_bits({(2, 0)})
self.assertEqual(board.territory(board.idx(0, 0), [board.idx(4, 0)], blocked), 2)
def test_nearest_food_returns_shortest_distance(self):
board = BitBoard(5, 5)
food = board.set_to_bits({(4, 4), (2, 1)})
self.assertEqual(board.nearest_food(board.idx(0, 0), food, 0), (3, board.idx(2, 1)))
def test_nearest_food_uses_apex_direction_order_for_ties(self):
board = BitBoard(3, 3)
food = board.set_to_bits({(1, 2), (0, 1), (2, 1), (1, 0)})
self.assertEqual(board.nearest_food(board.idx(1, 1), food, 0), (1, board.idx(1, 2)))
class TestPrismBattleSnake_GPT_5_6_Sol(unittest.TestCase):
def test_api_name_and_version_are_exposed(self):
snake = PrismBattleSnake_GPT_5_6_Sol()
self.assertEqual(snake.name, "PrismBattleSnake")
self.assertEqual(snake.version, "1.0.0")
self.assertEqual(get_snake_version("PrismBattleSnake_GPT_5_6_Sol"), "1.0.0")
self.assertEqual(snake.version, "1.5.0")
self.assertEqual(get_snake_version("PrismBattleSnake_GPT_5_6_Sol"), "1.5.0")
self.assertGreaterEqual(snake._planning_depth, 4)
self.assertIsInstance(SnakeBuilder.build("PrismBattleSnake_GPT_5_6_Sol"), PrismBattleSnake_GPT_5_6_Sol)
def test_bitboard_primitives_match_apex(self):
@@ -82,6 +102,128 @@ class TestPrismBattleSnake_GPT_5_6_Sol(unittest.TestCase):
self.assertGreater(value, 0)
def test_candidate_search_resolves_enemy_reply_on_the_same_turn(self):
board = BitBoard(3, 3)
search = BitboardDuelSearch(
board=board, food=set(), hazards=set(), hazard_count={},
hazard_damage=15, deadline=None,
)
my_body = [{"x": 0, "y": 1}, {"x": 0, "y": 0}]
enemy_body = [{"x": 2, "y": 1}, {"x": 2, "y": 0}]
value, depth = search.search_candidate(
my_body=my_body,
enemy_body=enemy_body,
my_target=(1, 1),
my_health=100,
enemy_health=100,
max_depth=1,
previous_hazards=set(),
)
self.assertEqual(value, -500.0)
self.assertEqual(depth, 1)
def test_candidate_search_does_not_advance_our_snake_twice_at_root(self):
board = BitBoard(4, 1)
search = BitboardDuelSearch(
board=board, food=set(), hazards=set(), hazard_count={},
hazard_damage=15, deadline=None,
)
my_body = [{"x": 0, "y": 0}]
enemy_body = [{"x": 3, "y": 0}]
value, depth = search.search_candidate(
my_body=my_body,
enemy_body=enemy_body,
my_target=(1, 0),
my_health=100,
enemy_health=100,
max_depth=1,
previous_hazards=set(),
)
self.assertEqual(value, 0.0)
self.assertEqual(depth, 1)
def test_duel_search_keeps_tail_blocked_when_its_snake_eats(self):
board = BitBoard(3, 3)
search = BitboardDuelSearch(
board=board, food={(0, 1)}, hazards=set(), hazard_count={},
hazard_damage=15, deadline=None,
)
body = (board.idx(0, 0), board.idx(1, 0), board.idx(1, 1))
advanced = search._advance_body(body, board.idx(0, 1), ate=True)
self.assertIn(board.idx(1, 1), advanced)
def test_candidate_duel_searches_share_the_same_context(self):
snake = PrismBattleSnake_GPT_5_6_Sol()
kwargs = {
"my_body": [{"x": 0, "y": 1}, {"x": 0, "y": 0}],
"enemy_body": [{"x": 3, "y": 1}, {"x": 3, "y": 0}],
"food_set": set(), "hazard_set": set(),
"my_health": 100, "enemy_health": 100,
"hazard_damage": 15, "hazard_count": {},
"width": 4, "height": 3, "max_depth": 2,
"alpha": -1e9, "beta": 1e9, "deadline": perf_counter() + 1.0,
}
snake._minimax_candidate_id(my_target=(1, 1), **kwargs)
first_context = snake._duel_search_context
snake._minimax_candidate_id(my_target=(0, 2), **kwargs)
self.assertIs(snake._duel_search_context, first_context)
self.assertGreater(first_context.nodes, 0)
self.assertGreater(first_context.completed_depth, 0)
def test_duel_evaluation_prioritizes_reachable_food_when_starving(self):
board = BitBoard(5, 3)
search = BitboardDuelSearch(
board=board, food={(2, 1)}, hazards=set(), hazard_count={},
hazard_damage=15, deadline=None,
)
my_body = [{"x": 0, "y": 1}, {"x": 0, "y": 0}]
enemy_body = [{"x": 4, "y": 1}, {"x": 4, "y": 0}]
hungry = search.search_depth(my_body, enemy_body, 15, 100, 0, set())
healthy = search.search_depth(my_body, enemy_body, 100, 100, 0, set())
self.assertLess(hungry, healthy)
def test_compact_rollout_models_lethal_enemy_head_response(self):
board = BitBoard(3, 3)
search = CompactSurvivalSearch(
board=board, food=set(), is_constrictor=False,
deadline=perf_counter() + 1.0, branch=2,
)
mine = [{"x": 0, "y": 1}, {"x": 0, "y": 0}]
enemies = [{
"body": [{"x": 2, "y": 1}, {"x": 2, "y": 0}, {"x": 1, "y": 0}],
}]
value = search.search_selected(mine, enemies, (1, 1), depth=1)
self.assertEqual(value, search.DEATH)
def test_compact_rollout_reuses_transpositions(self):
board = BitBoard(5, 5)
search = CompactSurvivalSearch(
board=board, food=set(), is_constrictor=False,
deadline=perf_counter() + 1.0, branch=2,
)
mine = [{"x": 1, "y": 1}, {"x": 1, "y": 0}]
enemies = [{"body": [{"x": 3, "y": 3}, {"x": 3, "y": 4}]}]
search.search_selected(mine, enemies, (2, 1), depth=3)
hits_before = search.cache_hits
search.search_selected(mine, enemies, (2, 1), depth=3)
self.assertGreater(search.cache_hits, hits_before)
self.assertGreater(search.evaluation_cache_hits, 0)
self.assertGreaterEqual(search.completed_depth, 3)
def test_bitboard_duel_search_reuses_transpositions(self):
board = BitBoard(5, 5)
search = BitboardDuelSearch(
+35
View File
@@ -0,0 +1,35 @@
import unittest
from snakes import SNAKE_REGISTRATIONS, SnakeBuilder
from snakes.engine import (
BitBoard,
BitboardDuelMixin,
BitboardSpatialMixin,
BitboardSurvivalMixin,
)
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
class TestSnakePackageLayout(unittest.TestCase):
def test_registry_uses_explicit_package_modules(self):
for name, registration in SNAKE_REGISTRATIONS.items():
with self.subTest(name=name):
self.assertTrue(registration.module.startswith("snakes."))
self.assertNotEqual(registration.module, f"snakes.{name}")
def test_registry_builds_active_strategies_after_package_move(self):
for name in ("ApexBattleSnake", "PrismBattleSnake_GPT_5_6_Sol"):
with self.subTest(name=name):
snake = SnakeBuilder.build(name)
self.assertEqual(snake.__class__.__name__, name)
def test_prism_composes_reusable_engine_mixins(self):
snake = PrismBattleSnake_GPT_5_6_Sol()
self.assertIsInstance(snake, BitboardDuelMixin)
self.assertIsInstance(snake, BitboardSpatialMixin)
self.assertIsInstance(snake, BitboardSurvivalMixin)
self.assertIsInstance(snake._get_bb(11, 11), BitBoard)
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -5,8 +5,8 @@ and that the bitboard engine itself is sound.
"""
import unittest
from snakes.SupremeBattleSnake_ClaudeOpus4_6 import SupremeBattleSnake_ClaudeOpus4_6 as SupremeBattleSnake
from snakes.bitboard import BitBoard
from snakes.legacy.SupremeBattleSnake_ClaudeOpus4_6 import SupremeBattleSnake_ClaudeOpus4_6 as SupremeBattleSnake
from snakes.engine.bitboard import BitBoard
from server.GameBoard import GameBoard
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -348,7 +348,7 @@ class TestParityWithApex(unittest.TestCase):
def test_trapped_corner(self):
"""Both snakes should survive a forced single-exit scenario."""
from snakes.ApexBattleSnake import ApexBattleSnake
from snakes.strategies.apex import ApexBattleSnake
state = gs(my_body=[(1, 1), (1, 2), (2, 2), (2, 1)],
other_bodies=[], foods=[(5, 5)], width=7, height=7)
+1 -1
View File
@@ -1,6 +1,6 @@
import unittest
from snakes.UltimateBattleSnake import UltimateBattleSnake
from snakes.legacy.UltimateBattleSnake import UltimateBattleSnake
from server.GameBoard import GameBoard
# ── Helpers ───────────────────────────────────────────────────────────────────
+30
View File
@@ -0,0 +1,30 @@
import unittest
from server.database.benchmark_states import _build_state, is_postgresql_source
class TestBenchmarkStates(unittest.TestCase):
def test_detects_postgresql_dsn(self):
self.assertTrue(is_postgresql_source("postgresql://user:pass@example.com/db"))
self.assertTrue(is_postgresql_source("postgres://user:pass@example.com/db"))
self.assertFalse(is_postgresql_source("/tmp/gameplay.sqlite3"))
def test_builds_normalized_state_from_postgresql_json_values(self):
row = (
1, {}, {}, [{"x": 5, "y": 5}], [], "you-id", "You", 11, 11,
"game-id", "league", "standard", "standard", "v1", 7,
)
snake_rows = [
("you-id", "You", 90, 3, 1, 2, [{"x": 1, "y": 2}], {"head": "default"}),
]
state = _build_state(row, snake_rows)
self.assertIsNotNone(state)
board, metadata = state
self.assertEqual(board["food"], [{"x": 5, "y": 5}])
self.assertEqual(board["snakes"][0]["id"], "you-id")
self.assertEqual(metadata["you"]["id"], "you-id")
self.assertEqual(metadata["turn"], 7)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1,6 +1,6 @@
import unittest
from snakes.BestBattleSnake import BestBattleSnake
from snakes.legacy.BestBattleSnake import BestBattleSnake
from server.GameBoard import GameBoard
def make_board(game_state):
+59
View File
@@ -0,0 +1,59 @@
import unittest
from unittest.mock import AsyncMock
from server.services.dashboard_query import DashboardQueryService
class _Logger:
def warning(self, message):
return message
class TestDashboardQueryService(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.database = AsyncMock()
self.hub = AsyncMock()
self.service = DashboardQueryService(
gameplay_database=self.database,
ws_hub=self.hub,
logger=_Logger(),
dashboard_running_game_stale_sec=600,
)
async def test_pushes_compact_latest_turn_event(self):
self.database.get_game_replay.return_value = {
"game": {"game_id": "game-1", "status": "running"},
"turns": [{"turn": 1}, {"turn": 2, "my_move": "up"}],
}
await self.service.push_dashboard_game_replay_update("game-1")
payload = self.hub.broadcast_payload.await_args.args[0]
self.assertEqual(payload["type"], "dashboard_game_replay_update")
self.assertEqual(payload["game_id"], "game-1")
self.assertEqual(payload["turn"], {"turn": 2, "my_move": "up"})
self.assertNotIn("turns", payload)
async def test_publishes_game_id_for_cluster_live_updates(self):
self.database.get_game_replay.return_value = {
"game": {"game_id": "game-1", "status": "running"},
"turns": [{"turn": 3}],
}
publish = AsyncMock()
self.service.set_publish_notice(publish)
await self.service.push_dashboard_game_replay_update("game-1")
publish.assert_awaited_once_with("game_turn", "game-1")
async def test_remote_turn_notice_loads_and_broadcasts_turn(self):
self.database.get_game_replay.return_value = {
"game": {"game_id": "game-1", "status": "running"},
"turns": [{"turn": 4}],
}
await self.service.on_dashboard_games_update_notice("game_turn", "game-1")
payload = self.hub.broadcast_payload.await_args.args[0]
self.assertEqual(payload["turn"]["turn"], 4)
if __name__ == "__main__":
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
import os
import unittest
from unittest.mock import AsyncMock
from server.Server import Server
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REPLAY = {
'game': {'game_id': 'game-1', 'width': 11, 'height': 11},
'turns': [{'turn': 0, 'snakes': []}],
}
class TestDashboardReplayRoute(unittest.IsolatedAsyncioTestCase):
"""The dashboard falls back to this route when the replay websocket is down."""
def setUp(self):
self.server = Server(
data_path=REPO_ROOT,
snake_type='PrismBattleSnake',
storage_type='memory',
metrics_backend='memory',
gameplay_db_enabled=False,
)
self.database = AsyncMock()
self.server.dashboard_query.gameplay_database = self.database
async def test_serves_replay_payload(self):
self.database.get_game_replay.return_value = dict(REPLAY)
response = await self.server.app.test_client().get('/dashboard/game/game-1')
self.assertEqual(response.status_code, 200)
payload = await response.get_json()
self.assertEqual(payload['game']['game_id'], 'game-1')
self.assertEqual(len(payload['turns']), 1)
self.database.get_game_replay.assert_awaited_once_with('game-1')
async def test_unknown_game_returns_404(self):
self.database.get_game_replay.return_value = None
response = await self.server.app.test_client().get('/dashboard/game/nope')
self.assertEqual(response.status_code, 404)
payload = await response.get_json()
self.assertEqual(payload['error'], 'game_not_found')
if __name__ == '__main__':
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
import os
import re
import unittest
from server.Server import Server
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
class TestDashboardStaticAssets(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.server = Server(
data_path=REPO_ROOT,
snake_type='PrismBattleSnake',
storage_type='memory',
metrics_backend='memory',
gameplay_db_enabled=False,
)
def _static_url(self):
return self.server.app.jinja_env.globals['static_url']
async def test_static_url_carries_file_mtime(self):
asset = 'js/GameBoard.js'
expected_version = int(os.path.getmtime(
os.path.join(self.server.app.static_folder, asset)
))
async with self.server.app.test_request_context('/dashboard'):
url = self._static_url()(asset)
self.assertEqual(url, f'/files/{asset}?v={expected_version}')
async def test_static_url_tolerates_missing_asset(self):
async with self.server.app.test_request_context('/dashboard'):
url = self._static_url()('js/DoesNotExist.js')
self.assertEqual(url, '/files/js/DoesNotExist.js?v=0')
async def test_dashboard_page_versions_every_static_asset(self):
client = self.server.app.test_client()
response = await client.get('/dashboard')
self.assertEqual(response.status_code, 200)
body = await response.get_data(as_text=True)
references = re.findall(r'(?:href|src)="(/files/[^"]+)"', body)
self.assertTrue(references, 'dashboard page referenced no static assets')
for reference in references:
self.assertRegex(reference, r'\?v=\d+$', f'unversioned static asset: {reference}')
if __name__ == '__main__':
unittest.main()
+32
View File
@@ -0,0 +1,32 @@
import subprocess
import sys
import unittest
from pathlib import Path
class TestDatabasePackageImports(unittest.TestCase):
def test_sqlite_backend_import_does_not_require_aiofiles(self):
project_root = Path(__file__).resolve().parents[1]
script = """
import builtins
original_import = builtins.__import__
def reject_aiofiles(name, *args, **kwargs):
if name == 'aiofiles' or name.startswith('aiofiles.'):
raise ModuleNotFoundError("aiofiles intentionally unavailable")
return original_import(name, *args, **kwargs)
builtins.__import__ = reject_aiofiles
from server.database.backend.SqliteGameplayBackend import SqliteGameplayBackend
assert SqliteGameplayBackend.__name__ == 'SqliteGameplayBackend'
"""
result = subprocess.run(
[sys.executable, "-c", script],
cwd=project_root,
text=True,
capture_output=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1,5 +1,5 @@
import unittest
from snakes.MasterSnake import MasterSnake
from snakes.legacy.MasterSnake import MasterSnake
class TestMasterSnake(unittest.TestCase):
def setUp(self):
+15 -4
View File
@@ -27,10 +27,14 @@ class TestMergeGameplayDatabases(unittest.TestCase):
90 if cleaned else None, "high" if cleaned else None,
'["already_scored"]' if cleaned else None,
))
connection.execute(
"INSERT INTO game_snakes (game_id,snake_id,snake_name,is_you) VALUES (?,?,?,?)",
(game_id, "me", "PrismBattleSnake", 1),
)
connection.execute("""
INSERT INTO game_snakes (
game_id,snake_id,snake_name,is_you,customizations_json
) VALUES (?,?,?,?,?)
""", (
game_id, "me", "PrismBattleSnake", 1,
'{"color":"#663399","head":"ferret","tail":"swirl"}',
))
connection.execute("""
INSERT INTO turns (
game_id,turn,observed_at,my_move,my_thinking_json,
@@ -69,6 +73,13 @@ class TestMergeGameplayDatabases(unittest.TestCase):
])
self.assertEqual(connection.execute("SELECT COUNT(*) FROM turns").fetchone()[0], 2)
self.assertEqual(connection.execute("SELECT COUNT(*) FROM snake_turns").fetchone()[0], 2)
customizations = connection.execute("""
SELECT game_id, customizations_json FROM game_snakes ORDER BY game_id
""").fetchall()
self.assertEqual(customizations, [
("base-game", '{"color":"#663399","head":"ferret","tail":"swirl"}'),
("delta-game", '{"color":"#663399","head":"ferret","tail":"swirl"}'),
])
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
def test_conflicting_duplicate_aborts_without_destination(self):
+137
View File
@@ -0,0 +1,137 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from scripts.migrate_gameplay_database import copy_game_snakes
from server.database.backend.SqliteGameplayBackend import SqliteGameplayBackend
class TestMigrateGameplayDatabase(unittest.TestCase):
def test_copy_game_snakes_preserves_customizations(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
source_path = root / "source.sqlite3"
destination_path = root / "destination.sqlite3"
SqliteGameplayBackend(str(source_path))
SqliteGameplayBackend(str(destination_path))
with sqlite3.connect(source_path) as source:
source.execute("PRAGMA foreign_keys = OFF")
source.execute("""
INSERT INTO game_snakes (
game_id, snake_id, snake_name, is_you, customizations_json
) VALUES (?, ?, ?, ?, ?)
""", (
"game-1", "snake-1", "PrismBattleSnake", 1,
'{"color":"#663399","head":"ferret","tail":"swirl"}',
))
source = sqlite3.connect(source_path)
destination = sqlite3.connect(destination_path)
try:
copied = copy_game_snakes(
source, destination, batch_size=10, retained_ids={"game-1"},
)
destination.commit()
row = destination.execute("""
SELECT snake_name, is_you, customizations_json
FROM game_snakes WHERE game_id = ? AND snake_id = ?
""", ("game-1", "snake-1")).fetchone()
finally:
source.close()
destination.close()
self.assertEqual(copied, 1)
self.assertEqual(row, (
"PrismBattleSnake", 1,
'{"color":"#663399","head":"ferret","tail":"swirl"}',
))
def test_copy_game_snakes_synthesizes_rows_when_table_is_empty(self):
source = sqlite3.connect(":memory:")
destination = sqlite3.connect(":memory:")
try:
source.execute("""
CREATE TABLE game_snakes (
game_id TEXT, snake_id TEXT, snake_name TEXT, is_you INTEGER,
customizations_json TEXT NOT NULL DEFAULT '{}'
)
""")
source.execute("""
CREATE TABLE snake_turns (
game_id TEXT, snake_id TEXT, snake_name TEXT, is_you INTEGER
)
""")
source.executemany(
"INSERT INTO snake_turns VALUES (?, ?, ?, ?)",
[
("game-1", "snake-1", "PrismBattleSnake", 1),
("game-1", "snake-1", "PrismBattleSnake", 1),
("game-1", "snake-2", "Enemy", 0),
],
)
destination.execute("""
CREATE TABLE game_snakes (
game_id TEXT, snake_id TEXT, snake_name TEXT, is_you INTEGER,
customizations_json TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (game_id, snake_id)
)
""")
copied = copy_game_snakes(
source, destination, batch_size=10, retained_ids={"game-1"},
)
rows = destination.execute("""
SELECT snake_id, snake_name, is_you, customizations_json
FROM game_snakes ORDER BY snake_id
""").fetchall()
finally:
source.close()
destination.close()
self.assertEqual(copied, 2)
self.assertEqual(rows, [
("snake-1", "PrismBattleSnake", 1, "{}"),
("snake-2", "Enemy", 0, "{}"),
])
def test_copy_game_snakes_defaults_legacy_schema_to_empty_customizations(self):
source = sqlite3.connect(":memory:")
destination = sqlite3.connect(":memory:")
try:
source.execute("""
CREATE TABLE game_snakes (
game_id TEXT, snake_id TEXT, snake_name TEXT, is_you INTEGER
)
""")
source.execute(
"INSERT INTO game_snakes VALUES (?, ?, ?, ?)",
("game-1", "snake-1", "LegacySnake", 0),
)
source.execute("""
CREATE TABLE snake_turns (
game_id TEXT, snake_id TEXT, snake_name TEXT, is_you INTEGER
)
""")
destination.execute("""
CREATE TABLE game_snakes (
game_id TEXT, snake_id TEXT, snake_name TEXT, is_you INTEGER,
customizations_json TEXT NOT NULL DEFAULT '{}'
)
""")
copied = copy_game_snakes(
source, destination, batch_size=10, retained_ids={"game-1"},
)
row = destination.execute(
"SELECT customizations_json FROM game_snakes"
).fetchone()
finally:
source.close()
destination.close()
self.assertEqual(copied, 1)
self.assertEqual(row, ("{}",))
if __name__ == "__main__":
unittest.main()
+34
View File
@@ -0,0 +1,34 @@
import unittest
from scripts.migrate_sqlite_to_postgresql import parse_json, parse_timestamp, transform_row
class TestMigrateSqliteToPostgresql(unittest.TestCase):
def test_transform_row_converts_timestamp_boolean_and_json(self):
columns = ("started_at", "winner_you", "quality_reasons", "game_id")
row = ("2026-08-01T10:20:30+00:00", 1, '["complete"]', "game-1")
transformed = transform_row(columns, row)
self.assertEqual(transformed[0].isoformat(), "2026-08-01T10:20:30+00:00")
self.assertIs(transformed[1], True)
self.assertEqual(transformed[2], '["complete"]')
self.assertEqual(transformed[3], "game-1")
def test_json_defaults_match_not_null_postgresql_columns(self):
columns = ("customizations", "board_state", "snakes", "you", "food", "hazards", "body")
self.assertEqual(
transform_row(columns, (None,) * len(columns)),
("{}", "{}", "[]", "{}", "[]", "[]", "[]"),
)
def test_invalid_json_aborts_instead_of_silently_losing_data(self):
with self.assertRaisesRegex(ValueError, "Invalid JSON"):
parse_json("{invalid")
def test_z_suffix_timestamp_is_timezone_aware(self):
parsed = parse_timestamp("2026-08-01T10:20:30Z")
self.assertIsNotNone(parsed.tzinfo)
self.assertEqual(parsed.utcoffset().total_seconds(), 0)
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from server.database.backend.PostgresqlGameplayBackend import PostgresqlGameplayBackend
class TestPostgresqlGameplayMigration(unittest.TestCase):
def _create_source(self, path:Path, winner_column:str, winner_value:str|None) -> None:
with sqlite3.connect(path) as connection:
connection.executescript(f"""
CREATE TABLE games (
game_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, ended_at TEXT,
width INTEGER, height INTEGER, source TEXT, map_name TEXT,
ruleset_name TEXT, ruleset_version TEXT, your_snake_id TEXT,
your_snake_name TEXT, your_snake_type TEXT, your_snake_version TEXT,
game_type TEXT, {winner_column} TEXT, winner_you INTEGER,
final_turn INTEGER, status TEXT
);
CREATE TABLE turns (
game_id TEXT, turn INTEGER, observed_at TEXT, my_move TEXT,
my_thinking_json TEXT, board_state_json TEXT, snakes_json TEXT,
you_json TEXT, food_json TEXT, hazards_json TEXT
);
CREATE TABLE snake_turns (
game_id TEXT, turn INTEGER, snake_id TEXT, snake_name TEXT,
health INTEGER, length INTEGER, head_x INTEGER, head_y INTEGER,
body_json TEXT, is_you INTEGER, inferred_move TEXT, latency TEXT
);
""")
connection.execute(
f"""INSERT INTO games (
game_id, started_at, {winner_column}, winner_you, final_turn, status
) VALUES (?, ?, ?, ?, ?, ?)""",
("game-1", "2026-08-01T10:00:00+00:00", winner_value, 1, 4, "finished"),
)
def test_reads_current_winner_name_schema(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "gameplay.sqlite3"
self._create_source(path, "winner_name", "Prism")
backend = PostgresqlGameplayBackend("postgresql://example", sqlite_migration_path=str(path))
games, turns, snake_turns = backend._read_sqlite_data_sync(str(path))
self.assertEqual(games[0]["winner_name"], "Prism")
self.assertEqual(turns, [])
self.assertEqual(snake_turns, [])
self.assertEqual(backend._migrated_winner_name(games[0]["winner_name"]), "Prism")
def test_reads_legacy_winner_names_json_schema(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "gameplay.sqlite3"
self._create_source(path, "winner_names_json", '["Prism"]')
backend = PostgresqlGameplayBackend("postgresql://example", sqlite_migration_path=str(path))
games, _, _ = backend._read_sqlite_data_sync(str(path))
self.assertEqual(
backend._migrated_winner_name(games[0]["winner_name"]),
"Prism",
)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -12,7 +12,7 @@ in the folder where this file exists:
"""
import unittest
from snakes.LogicSnake import avoid_my_neck
from snakes.legacy.LogicSnake import avoid_my_neck
class AvoidNeckTest(unittest.TestCase):
Generated
+2
View File
@@ -345,6 +345,7 @@ name = "snake-python"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },
{ name = "aiologger" },
{ name = "asyncpg" },
{ name = "dotenv" },
@@ -356,6 +357,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "aiofiles", specifier = ">=25.1.0" },
{ name = "aiologger", specifier = ">=0.7.0" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "dotenv", specifier = ">=0.9.9" },