Compare commits
6 Commits
4f022d3d01
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f14d780f29
|
|||
|
9b99b526e4
|
|||
|
3a9af3f54d
|
|||
|
cb6c8d4dc8
|
|||
|
6643eb35af
|
|||
|
c646392b84
|
@@ -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).
|
||||
|
||||

|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+142
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
if has_game_snakes:
|
||||
cursor = source.execute("""
|
||||
SELECT game_id, snake_id, snake_name, is_you
|
||||
FROM game_snakes ORDER BY game_id, snake_id
|
||||
""")
|
||||
else:
|
||||
cursor = source.execute("""
|
||||
SELECT game_id, snake_id, MAX(snake_name), MAX(is_you)
|
||||
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 (?, ?, ?, ?)
|
||||
INSERT OR IGNORE INTO game_snakes (
|
||||
game_id, snake_id, snake_name, is_you, customizations_json
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
"""
|
||||
count = 0
|
||||
if has_game_snakes:
|
||||
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
|
||||
""")
|
||||
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),
|
||||
'{}' AS customizations_json
|
||||
FROM snake_turns
|
||||
GROUP BY game_id, snake_id
|
||||
ORDER BY game_id, snake_id
|
||||
""")
|
||||
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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
from snakes.TemplateSnake import TemplateSnake
|
||||
from snakes.core.template import TemplateSnake
|
||||
from datetime import datetime
|
||||
|
||||
class GameBoard:
|
||||
|
||||
+17
-4
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .GameplayDatabase import GameplayDatabase
|
||||
from .backend import GameplayBackendBuilder
|
||||
|
||||
from .LocalStorage import LocalStorage
|
||||
from .EdgeDB import EdgeDB
|
||||
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"],
|
||||
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
+46
-25
@@ -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:
|
||||
|
||||
def build_snake(selected_snake: str):
|
||||
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)
|
||||
def get_snake_version(selected_snake: str) -> str | None:
|
||||
registration = SNAKE_REGISTRATIONS.get(selected_snake)
|
||||
return registration.version if registration is not None else None
|
||||
|
||||
|
||||
class SnakeBuilder:
|
||||
@classmethod
|
||||
@@ -42,5 +63,5 @@ class SnakeBuilder:
|
||||
return build_snake(selected_snake)
|
||||
|
||||
@classmethod
|
||||
def get_version(self, selected_snake:str) -> str|None:
|
||||
def get_version(self, selected_snake: str) -> str | None:
|
||||
return get_snake_version(selected_snake)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Shared snake base classes."""
|
||||
|
||||
from snakes.core.template import TemplateSnake
|
||||
|
||||
__all__ = ("TemplateSnake",)
|
||||
@@ -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
|
||||
# Each side must expand independently. A cell reached at the same depth is
|
||||
# unclaimed, but it is not a wall: both sides may route through it later.
|
||||
# Match Apex semantics by scoring only cells reachable from our head:
|
||||
# ours when we arrive first, theirs when an enemy arrives first, and zero
|
||||
# on ties. Enemy-only disconnected regions are intentionally ignored.
|
||||
score = (my_front & ~en_front).bit_count()
|
||||
enemy_before = 0
|
||||
while my_front:
|
||||
my_exp = (
|
||||
((my_front & nrc) << 1)
|
||||
| ((my_front & nlc) >> 1)
|
||||
| (my_front << w)
|
||||
| (my_front >> w)
|
||||
) & free & ~my_seen
|
||||
en_exp = (
|
||||
((en_front & nrc) << 1)
|
||||
| ((en_front & nlc) >> 1)
|
||||
| (en_front << w)
|
||||
| (en_front >> w)
|
||||
) & free & ~en_seen
|
||||
|
||||
while (my_front or en_front) and remaining:
|
||||
# Expand both sides simultaneously (same BFS depth → ties go to neither)
|
||||
my_exp = 0
|
||||
if my_front:
|
||||
my_exp = (
|
||||
((my_front & nrc) << 1)
|
||||
| ((my_front & nlc) >> 1)
|
||||
| (my_front << w)
|
||||
| (my_front >> w)
|
||||
) & remaining
|
||||
|
||||
en_exp = 0
|
||||
if en_front:
|
||||
en_exp = (
|
||||
((en_front & nrc) << 1)
|
||||
| ((en_front & nlc) >> 1)
|
||||
| (en_front << w)
|
||||
| (en_front >> w)
|
||||
) & remaining
|
||||
|
||||
# Contested cells (reached by both at the same depth) → neither claims
|
||||
contested = my_exp & en_exp
|
||||
my_exp &= ~contested
|
||||
en_exp &= ~contested
|
||||
|
||||
my_terr |= my_exp
|
||||
en_terr |= en_exp
|
||||
remaining &= ~(my_exp | en_exp | contested)
|
||||
enemy_before |= en_front
|
||||
score += (my_exp & ~enemy_before & ~en_exp).bit_count()
|
||||
score -= (my_exp & enemy_before).bit_count()
|
||||
|
||||
my_seen |= my_exp
|
||||
en_seen |= en_exp
|
||||
my_front = my_exp
|
||||
en_front = en_exp
|
||||
|
||||
return my_terr.bit_count() - en_terr.bit_count()
|
||||
return score
|
||||
|
||||
# ── Partition sizes (for articulation-point detection) ────────────────────
|
||||
|
||||
@@ -324,32 +321,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:
|
||||
dist += 1
|
||||
expanded = (
|
||||
((frontier & nrc) << 1)
|
||||
| ((frontier & nlc) >> 1)
|
||||
| (frontier << w)
|
||||
| (frontier >> w)
|
||||
) & free & ~seen
|
||||
|
||||
if not expanded:
|
||||
break
|
||||
|
||||
hit = expanded & food_bits
|
||||
if hit:
|
||||
# Return the first (lowest-index) food cell found
|
||||
first_bit = hit & (-hit)
|
||||
return dist, first_bit.bit_length() - 1
|
||||
|
||||
seen |= expanded
|
||||
frontier = expanded
|
||||
while cursor < len(queue):
|
||||
cell = queue[cursor]
|
||||
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
|
||||
layer_end = len(queue)
|
||||
|
||||
return None, None
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Historical snake strategies retained for replay and comparison."""
|
||||
@@ -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):
|
||||
@@ -18,20 +19,20 @@ class ApexBattleSnake(TemplateSnake):
|
||||
New improvements:
|
||||
|
||||
A1: Iterative deepening minimax — tries depth 1,2,...,N within time budget; keeps deepest
|
||||
fully-completed result instead of a fixed depth=2 call.
|
||||
fully-completed result instead of a fixed depth=2 call.
|
||||
A2: Hazard-aware starvation check — Dijkstra with per-tile hazard cost replaces BFS food
|
||||
distance when hazards are present and health < 55. Correctly models health depletion
|
||||
through hazard corridors when choosing whether to seek food.
|
||||
distance when hazards are present and health < 55. Correctly models health depletion
|
||||
through hazard corridors when choosing whether to seek food.
|
||||
A3: Phase-adaptive scoring weights — board occupancy drives a game_phase scalar [0,1].
|
||||
Territory weight scales up late-game; food bias scales down. Stored as self._game_phase.
|
||||
Territory weight scales up late-game; food bias scales down. Stored as self._game_phase.
|
||||
A4: Rich GameplayDatabase thinking data — add_to_history records game_phase, food_count,
|
||||
enemy lengths/healths, minimax_depth_reached, score_gap, safe_moves_count per turn.
|
||||
enemy lengths/healths, minimax_depth_reached, score_gap, safe_moves_count per turn.
|
||||
A5: Dynamic duel aggression — auto-adjusts head_pressure/distance_safety multipliers based
|
||||
on (my_len - enemy_len) delta on top of the configured duel style preset.
|
||||
on (my_len - enemy_len) delta on top of the configured duel style preset.
|
||||
A6: Constrictor endgame encirclement — when enemy is sealed in a region <= our body length,
|
||||
apply a strong encirclement bonus to close out the win efficiently.
|
||||
apply a strong encirclement bonus to close out the win efficiently.
|
||||
A7: Bounded BFS transposition cache — caps per-turn cache at 4096 entries to prevent
|
||||
memory growth in long games with many unique blocked-set combinations.
|
||||
memory growth in long games with many unique blocked-set combinations.
|
||||
"""
|
||||
|
||||
VERSION = "1.0.0"
|
||||
@@ -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):
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+345
-93
@@ -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") {
|
||||
const svgMarkup = this._svgCache.get(iconUrl);
|
||||
if (svgMarkup) {
|
||||
layer.innerHTML = this._normalizeHeadSvgMarkup(svgMarkup);
|
||||
const svgEl = layer.querySelector("svg");
|
||||
if (svgEl) {
|
||||
svgEl.style.width = "100%";
|
||||
svgEl.style.height = "100%";
|
||||
svgEl.style.fill = color || "currentColor";
|
||||
svgEl.removeAttribute("width");
|
||||
svgEl.removeAttribute("height");
|
||||
}
|
||||
// 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._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");
|
||||
}
|
||||
} else {
|
||||
layer.style.setProperty("--icon-url", `url(${iconUrl})`);
|
||||
layer.style.setProperty("--icon-color", color || "var(--you)");
|
||||
return layer;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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(", ");
|
||||
}
|
||||
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";
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+41
-46
@@ -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);
|
||||
if (dx > 0) return "right";
|
||||
if (dx < 0) return "left";
|
||||
if (dy > 0) return "up";
|
||||
if (dy < 0) return "down";
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// 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;
|
||||
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;
|
||||
break;
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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,6 +1,6 @@
|
||||
import unittest
|
||||
|
||||
from snakes.UltimateBattleSnake import UltimateBattleSnake
|
||||
from snakes.legacy.UltimateBattleSnake import UltimateBattleSnake
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,5 +1,5 @@
|
||||
import unittest
|
||||
from snakes.MasterSnake import MasterSnake
|
||||
from snakes.legacy.MasterSnake import MasterSnake
|
||||
|
||||
class TestMasterSnake(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user