feat: add Prism snake and gameplay database lifecycle
Build and Push Docker Container / build-and-push (push) Successful in 8m3s
Build and Push Docker Container / build-and-push (push) Successful in 8m3s
- Add bitboard-accelerated Prism and versioned Supreme snake implementations. - Add database-backed move benchmarks and focused strategy tests. - Normalize gameplay storage while preserving replay compatibility. - Add deterministic game quality scoring and replay retention tiers. - Add backup-first SQLite cleanup, verification, and replacement tooling. - Add safe compact-plus-delta database merging with conflict detection. - Extend SQLite and PostgreSQL schemas for replay and quality metadata. - Add PostgreSQL development service and pytest import configuration. - Update gameplay documentation and the quart_common submodule revision.
This commit is contained in:
@@ -75,6 +75,76 @@ BATTLE_SNAKE_DUEL_STYLE=balanced python main.py
|
|||||||
|
|
||||||
Allowed values: `safe`, `balanced`, `aggressive`.
|
Allowed values: `safe`, `balanced`, `aggressive`.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
Run it with:
|
||||||
|
```sh
|
||||||
|
SNAKE=PrismBattleSnake_GPT_5_6_Sol python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Benchmark Apex and Prism against sampled positions from a gameplay database:
|
||||||
|
```sh
|
||||||
|
python scripts/benchmark_snakes_from_db.py \
|
||||||
|
--database /path/to/gameplay.sqlite3 \
|
||||||
|
--samples 100
|
||||||
|
```
|
||||||
|
|
||||||
|
The benchmark opens SQLite read-only and reports mean, median, p95, and maximum
|
||||||
|
move latency. Increase `--samples` for a broader but slower comparison.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
`game_snakes`; and changing snake state/body data lives in `snake_turns`. Replay
|
||||||
|
loading rebuilds the normal Battlesnake board payload.
|
||||||
|
|
||||||
|
Create and verify a separate compact copy of an existing SQLite database. By
|
||||||
|
default, replay-heavy rows are retained for games rated `medium` or `high` by
|
||||||
|
structural completeness, valid moves, thinking coverage, game length, move
|
||||||
|
diversity, opponent data, and terminal outcome. All game-result rows remain
|
||||||
|
stored, so historical win/loss rates stay persistent when low-quality replay
|
||||||
|
data is removed.
|
||||||
|
```sh
|
||||||
|
python scripts/migrate_gameplay_database.py \
|
||||||
|
--source /path/to/gameplay.sqlite3 \
|
||||||
|
--destination /path/to/gameplay.compact.sqlite3 \
|
||||||
|
--minimum-quality medium
|
||||||
|
```
|
||||||
|
|
||||||
|
After reviewing the compact copy, `--replace` renames the original to a
|
||||||
|
timestamped backup and puts the verified compact database at the original path.
|
||||||
|
Stop all writers before using it:
|
||||||
|
```sh
|
||||||
|
python scripts/migrate_gameplay_database.py \
|
||||||
|
--source /path/to/gameplay.sqlite3 \
|
||||||
|
--replace
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration never modifies the source in place. It verifies row counts and
|
||||||
|
runs SQLite's `integrity_check` before any replacement.
|
||||||
|
|
||||||
|
### Record new games while cleanup runs
|
||||||
|
Point the running server at a temporary delta database while the old database
|
||||||
|
is being compacted. After stopping the writer and flushing the delta database,
|
||||||
|
merge it into the cleaned copy:
|
||||||
|
```sh
|
||||||
|
python scripts/merge_gameplay_databases.py \
|
||||||
|
--base /path/to/gameplay.compact.sqlite3 \
|
||||||
|
--delta /path/to/gameplay.delta.sqlite3 \
|
||||||
|
--destination /path/to/gameplay.merged.sqlite3 \
|
||||||
|
--minimum-quality medium
|
||||||
|
```
|
||||||
|
|
||||||
|
The merger keeps all game results, quality-rates delta games, regenerates
|
||||||
|
numeric turn IDs, and verifies row counts, foreign keys, and database integrity.
|
||||||
|
Identical game IDs are skipped; conflicting duplicates abort the merge. After
|
||||||
|
reviewing the result, `--replace-base` backs up and replaces the cleaned base.
|
||||||
|
Stop the delta writer before the final merge and file swap.
|
||||||
|
|
||||||
## Export Training Dataset
|
## Export Training Dataset
|
||||||
Game saves now include a `dataset` section with labeled move samples.
|
Game saves now include a `dataset` section with labeled move samples.
|
||||||
|
|
||||||
|
|||||||
+36
-15
@@ -1,17 +1,38 @@
|
|||||||
services:
|
services:
|
||||||
battlesnake:
|
# battlesnake:
|
||||||
image: daniel156161/battlesnake
|
# image: daniel156161/battlesnake
|
||||||
container_name: battlesnake
|
# container_name: battlesnake
|
||||||
ports:
|
# ports:
|
||||||
- 8000:8000
|
# - 8000:8000
|
||||||
volumes:
|
# volumes:
|
||||||
- ${DOCKER_DATA_PATH}:/app/data
|
# - ${DOCKER_DATA_PATH}:/app/data
|
||||||
build:
|
# build:
|
||||||
context: ./
|
# context: ./
|
||||||
dockerfile: Dockerfile
|
# dockerfile: Dockerfile
|
||||||
#environment:
|
# #environment:
|
||||||
# - SNAKE_COLOR=blue
|
# # - SNAKE_COLOR=blue
|
||||||
# - SNAKE_HEAD=caffeine
|
# # - SNAKE_HEAD=caffeine
|
||||||
# - SNAKE_TAIL=mlh-gene
|
# # - SNAKE_TAIL=mlh-gene
|
||||||
# - STORE_GAME_HISTORY=True
|
# # - STORE_GAME_HISTORY=True
|
||||||
|
# restart: always
|
||||||
|
|
||||||
|
postgres:
|
||||||
restart: always
|
restart: always
|
||||||
|
image: postgres:18-alpine
|
||||||
|
container_name: postgres
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
|
||||||
|
start_period: 20s
|
||||||
|
interval: 30s
|
||||||
|
retries: 10
|
||||||
|
timeout: 5s
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
|
||||||
|
PUID: 1000
|
||||||
|
PGID: 1001
|
||||||
|
volumes:
|
||||||
|
- ${DOCKER_DATA_PATH}/postgres:/var/lib/postgresql
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
addopts = "--import-mode=importlib"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "snake-python"
|
name = "snake-python"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+1
-1
Submodule quart_common updated: 235ba7b8e9...d61514d755
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Benchmark snake move latency against sampled states from gameplay SQLite."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
from statistics import mean, median
|
||||||
|
import sys
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from server.GameBoard import GameBoard
|
||||||
|
from snakes import SnakeBuilder
|
||||||
|
|
||||||
|
def percentile(values: list[float], quantile: float) -> float:
|
||||||
|
ordered = sorted(values)
|
||||||
|
index = min(len(ordered) - 1, round((len(ordered) - 1) * quantile))
|
||||||
|
return ordered[index]
|
||||||
|
|
||||||
|
def load_states(db_path: str, samples: int, stride: int) -> list[tuple[dict, dict]]:
|
||||||
|
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)
|
||||||
|
if max_id == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 >= ?
|
||||||
|
ORDER BY t.id
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
connection.close()
|
||||||
|
return states
|
||||||
|
|
||||||
|
def benchmark(snake_name: str, states: list[tuple[dict, dict]], repeat: int) -> dict:
|
||||||
|
durations: list[float] = []
|
||||||
|
moves = 0
|
||||||
|
for pass_number in range(repeat):
|
||||||
|
for board_data, metadata in states:
|
||||||
|
snake = SnakeBuilder.build(snake_name)
|
||||||
|
game_id = f"benchmark-{pass_number}-{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,
|
||||||
|
)
|
||||||
|
state = {
|
||||||
|
"game": {
|
||||||
|
"id": game_id,
|
||||||
|
"ruleset": metadata["ruleset"],
|
||||||
|
"source": metadata["source"],
|
||||||
|
"map": metadata["map"],
|
||||||
|
"timeout": 500,
|
||||||
|
},
|
||||||
|
"turn": metadata["turn"],
|
||||||
|
"board": board_data,
|
||||||
|
"you": metadata["you"],
|
||||||
|
}
|
||||||
|
board.read_game_data(state)
|
||||||
|
started = perf_counter()
|
||||||
|
snake.choose_move(board)
|
||||||
|
durations.append((perf_counter() - started) * 1000)
|
||||||
|
moves += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"snake": snake_name,
|
||||||
|
"moves": moves,
|
||||||
|
"mean_ms": mean(durations),
|
||||||
|
"median_ms": median(durations),
|
||||||
|
"p95_ms": percentile(durations, 0.95),
|
||||||
|
"max_ms": max(durations),
|
||||||
|
}
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--database", required=True)
|
||||||
|
parser.add_argument("--snake", action="append", default=[])
|
||||||
|
parser.add_argument("--samples", type=int, default=100)
|
||||||
|
parser.add_argument("--stride", type=int, default=997)
|
||||||
|
parser.add_argument("--repeat", type=int, default=1)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
states = load_states(args.database, max(1, args.samples), max(1, args.stride))
|
||||||
|
if not states:
|
||||||
|
raise SystemExit("No gameplay states found")
|
||||||
|
|
||||||
|
snake_names = args.snake or ["ApexBattleSnake", "PrismBattleSnake_GPT_5_6_Sol"]
|
||||||
|
print(f"Loaded {len(states)} states from {args.database}")
|
||||||
|
for snake_name in snake_names:
|
||||||
|
result = benchmark(snake_name, states, max(1, args.repeat))
|
||||||
|
print(
|
||||||
|
f"{result['snake']}: {result['moves']} moves, "
|
||||||
|
f"mean={result['mean_ms']:.2f} ms, median={result['median_ms']:.2f} ms, "
|
||||||
|
f"p95={result['p95_ms']:.2f} ms, max={result['max_ms']:.2f} ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+271
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Safely merge a cleaned gameplay database with a temporary delta database.
|
||||||
|
|
||||||
|
The base and delta are opened read-only. A new destination is created, numeric
|
||||||
|
row IDs are regenerated, delta games are quality-rated, and all result rows are
|
||||||
|
kept even when their replay is excluded. The base can be replaced only after
|
||||||
|
verification and a timestamped backup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from scripts.migrate_gameplay_database import (
|
||||||
|
analyze_quality, column_or_null, create_destination, object_columns,
|
||||||
|
open_source, retained_game_ids,
|
||||||
|
)
|
||||||
|
from server.database.game_quality import quality_meets_minimum
|
||||||
|
|
||||||
|
GAME_COLUMNS = (
|
||||||
|
"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",
|
||||||
|
)
|
||||||
|
GAME_DEFAULTS = {
|
||||||
|
"winner_you": "0 AS winner_you", "final_turn": "0 AS final_turn",
|
||||||
|
"status": "'running' AS status", "has_replay": "1 AS has_replay",
|
||||||
|
"quality_status": "'retained' AS quality_status",
|
||||||
|
}
|
||||||
|
CORE_GAME_FIELDS = (
|
||||||
|
"game_id", "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",
|
||||||
|
)
|
||||||
|
|
||||||
|
def select_expression(columns:set[str], name:str) -> str:
|
||||||
|
if name in columns:
|
||||||
|
return name
|
||||||
|
return GAME_DEFAULTS.get(name, f"NULL AS {name}")
|
||||||
|
|
||||||
|
def read_games(connection:sqlite3.Connection) -> dict[str, sqlite3.Row]:
|
||||||
|
columns = object_columns(connection, "games")
|
||||||
|
selected = ", ".join(select_expression(columns, name) for name in GAME_COLUMNS)
|
||||||
|
return {
|
||||||
|
row["game_id"]: row
|
||||||
|
for row in connection.execute(f"SELECT {selected} FROM games")
|
||||||
|
}
|
||||||
|
|
||||||
|
def duplicate_ids(base_games:dict[str, sqlite3.Row], delta_games:dict[str, sqlite3.Row]) -> set[str]:
|
||||||
|
duplicates = set(base_games) & set(delta_games)
|
||||||
|
conflicts = []
|
||||||
|
for game_id in duplicates:
|
||||||
|
base_signature = tuple(base_games[game_id][name] for name in CORE_GAME_FIELDS)
|
||||||
|
delta_signature = tuple(delta_games[game_id][name] for name in CORE_GAME_FIELDS)
|
||||||
|
if base_signature != delta_signature:
|
||||||
|
conflicts.append(game_id)
|
||||||
|
if conflicts:
|
||||||
|
examples = ", ".join(sorted(conflicts)[:5])
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Conflicting duplicate game_id values ({len(conflicts)}): {examples}. "
|
||||||
|
"Nothing was merged."
|
||||||
|
)
|
||||||
|
return duplicates
|
||||||
|
|
||||||
|
def insert_games(
|
||||||
|
destination:sqlite3.Connection,
|
||||||
|
rows:dict[str, sqlite3.Row],
|
||||||
|
excluded:set[str],
|
||||||
|
quality:dict[str, object]|None=None,
|
||||||
|
minimum_quality:str="medium",
|
||||||
|
) -> tuple[int, set[str], Counter]:
|
||||||
|
placeholders = ",".join("?" for _ in GAME_COLUMNS)
|
||||||
|
sql = f"INSERT INTO games ({','.join(GAME_COLUMNS)}) VALUES ({placeholders})"
|
||||||
|
values = []
|
||||||
|
replay_ids:set[str] = set()
|
||||||
|
tiers:Counter = Counter()
|
||||||
|
|
||||||
|
for game_id, row in rows.items():
|
||||||
|
if game_id in excluded:
|
||||||
|
continue
|
||||||
|
output = [row[name] for name in GAME_COLUMNS]
|
||||||
|
if quality is not None:
|
||||||
|
result = quality[game_id]
|
||||||
|
keep_replay = quality_meets_minimum(result.tier, minimum_quality)
|
||||||
|
replacements = {
|
||||||
|
"has_replay": int(keep_replay),
|
||||||
|
"quality_status": "retained" if keep_replay else "low_quality",
|
||||||
|
"quality_score": result.score,
|
||||||
|
"quality_tier": result.tier,
|
||||||
|
"quality_reasons_json": json.dumps(result.reasons, separators=(",", ":")),
|
||||||
|
}
|
||||||
|
output = [replacements.get(name, row[name]) for name in GAME_COLUMNS]
|
||||||
|
tiers[result.tier] += 1
|
||||||
|
if keep_replay:
|
||||||
|
replay_ids.add(game_id)
|
||||||
|
elif bool(row["has_replay"]):
|
||||||
|
replay_ids.add(game_id)
|
||||||
|
values.append(tuple(output))
|
||||||
|
|
||||||
|
destination.executemany(sql, values)
|
||||||
|
return len(values), replay_ids, tiers
|
||||||
|
|
||||||
|
def copy_game_snakes(source:sqlite3.Connection, destination:sqlite3.Connection, allowed:set[str], batch_size:int) -> int:
|
||||||
|
has_table = source.execute(
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
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 (?,?,?,?)"
|
||||||
|
count = 0
|
||||||
|
while rows := cursor.fetchmany(batch_size):
|
||||||
|
values = [tuple(row) for row in rows if row[0] in allowed]
|
||||||
|
destination.executemany(sql, values)
|
||||||
|
count += len(values)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def copy_turns(source:sqlite3.Connection, destination:sqlite3.Connection, allowed:set[str], batch_size:int) -> int:
|
||||||
|
columns = object_columns(source, "turns")
|
||||||
|
names = (
|
||||||
|
"game_id", "turn", "observed_at", "my_move", "my_thinking_json",
|
||||||
|
"board_state_json", "snakes_json", "you_json", "food_json", "hazards_json",
|
||||||
|
)
|
||||||
|
defaults = {
|
||||||
|
"my_thinking_json": "NULL AS my_thinking_json", "board_state_json": "'{}' AS board_state_json",
|
||||||
|
"snakes_json": "'[]' AS snakes_json", "you_json": "'{}' AS you_json",
|
||||||
|
"food_json": "'[]' AS food_json", "hazards_json": "'[]' AS hazards_json",
|
||||||
|
}
|
||||||
|
selected = ",".join(name if name in columns else defaults[name] for name in names)
|
||||||
|
cursor = source.execute(f"SELECT {selected} FROM turns ORDER BY id")
|
||||||
|
sql = f"INSERT INTO turns ({','.join(names)}) VALUES ({','.join('?' for _ in names)})"
|
||||||
|
count = 0
|
||||||
|
while rows := cursor.fetchmany(batch_size):
|
||||||
|
values = [tuple(row) for row in rows if row[0] in allowed]
|
||||||
|
destination.executemany(sql, values)
|
||||||
|
count += len(values)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def copy_snake_turns(source:sqlite3.Connection, destination:sqlite3.Connection, allowed:set[str], batch_size:int) -> int:
|
||||||
|
columns = object_columns(source, "snake_turns")
|
||||||
|
names = (
|
||||||
|
"game_id", "turn", "snake_id", "snake_name", "health", "length",
|
||||||
|
"head_x", "head_y", "body_json", "is_you", "inferred_move", "latency",
|
||||||
|
)
|
||||||
|
selected = ",".join(column_or_null(columns, name) for name in names)
|
||||||
|
cursor = source.execute(f"SELECT {selected} FROM snake_turns ORDER BY id")
|
||||||
|
sql = f"INSERT INTO snake_turns ({','.join(names)}) VALUES ({','.join('?' for _ in names)})"
|
||||||
|
count = 0
|
||||||
|
while rows := cursor.fetchmany(batch_size):
|
||||||
|
values = [tuple(row) for row in rows if row[0] in allowed]
|
||||||
|
destination.executemany(sql, values)
|
||||||
|
count += len(values)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def copy_replays(source, destination, allowed:set[str], batch_size:int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"game_snakes": copy_game_snakes(source, destination, allowed, batch_size),
|
||||||
|
"turns": copy_turns(source, destination, allowed, batch_size),
|
||||||
|
"snake_turns": copy_snake_turns(source, destination, allowed, batch_size),
|
||||||
|
}
|
||||||
|
|
||||||
|
def verify(destination:sqlite3.Connection, expected:dict[str, int]) -> None:
|
||||||
|
for table, count in expected.items():
|
||||||
|
actual = int(destination.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
|
||||||
|
if actual != count:
|
||||||
|
raise RuntimeError(f"{table} count mismatch: expected {count}, got {actual}")
|
||||||
|
foreign_keys = destination.execute("PRAGMA foreign_key_check").fetchall()
|
||||||
|
if foreign_keys:
|
||||||
|
raise RuntimeError(f"Foreign-key verification failed with {len(foreign_keys)} errors")
|
||||||
|
integrity = destination.execute("PRAGMA integrity_check").fetchone()[0]
|
||||||
|
if integrity != "ok":
|
||||||
|
raise RuntimeError(f"Integrity check failed: {integrity}")
|
||||||
|
|
||||||
|
def replace_base(base_path:Path, destination_path:Path) -> Path:
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
backup = base_path.with_name(f"{base_path.name}.backup-{timestamp}")
|
||||||
|
base_path.rename(backup)
|
||||||
|
try:
|
||||||
|
destination_path.rename(base_path)
|
||||||
|
except Exception:
|
||||||
|
backup.rename(base_path)
|
||||||
|
raise
|
||||||
|
return backup
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--base", required=True, type=Path, help="Cleaned database")
|
||||||
|
parser.add_argument("--delta", required=True, type=Path, help="Database written during cleanup")
|
||||||
|
parser.add_argument("--destination", required=True, type=Path)
|
||||||
|
parser.add_argument("--minimum-quality", choices=("low", "medium", "high"), default="medium")
|
||||||
|
parser.add_argument("--batch-size", type=int, default=10_000)
|
||||||
|
parser.add_argument("--busy-timeout-ms", type=int, default=60_000)
|
||||||
|
parser.add_argument("--replace-base", action="store_true", help="Replace base after verification and keep a timestamped backup")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
base_path = args.base.expanduser().resolve()
|
||||||
|
delta_path = args.delta.expanduser().resolve()
|
||||||
|
destination_path = args.destination.expanduser().resolve()
|
||||||
|
if len({base_path, delta_path, destination_path}) != 3:
|
||||||
|
raise SystemExit("Base, delta, and destination must be different paths")
|
||||||
|
|
||||||
|
started = perf_counter()
|
||||||
|
base = open_source(base_path)
|
||||||
|
delta = open_source(delta_path)
|
||||||
|
destination = create_destination(destination_path, max(1000, args.busy_timeout_ms))
|
||||||
|
try:
|
||||||
|
base_games = read_games(base)
|
||||||
|
delta_games = read_games(delta)
|
||||||
|
duplicates = duplicate_ids(base_games, delta_games)
|
||||||
|
delta_quality = analyze_quality(delta)
|
||||||
|
|
||||||
|
base_count, base_replays, _ = insert_games(destination, base_games, set())
|
||||||
|
delta_count, delta_replays, tiers = insert_games(
|
||||||
|
destination, delta_games, duplicates, delta_quality, args.minimum_quality,
|
||||||
|
)
|
||||||
|
base_rows = copy_replays(base, destination, base_replays, max(1, args.batch_size))
|
||||||
|
delta_rows = copy_replays(delta, destination, delta_replays - duplicates, max(1, args.batch_size))
|
||||||
|
destination.commit()
|
||||||
|
destination.executescript("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_turns_game_turn ON turns(game_id, turn);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_games_status ON games(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snake_turns_game_turn ON snake_turns(game_id, turn);
|
||||||
|
""")
|
||||||
|
destination.execute("PRAGMA foreign_keys = ON")
|
||||||
|
expected = {
|
||||||
|
"games": base_count + delta_count,
|
||||||
|
"game_snakes": base_rows["game_snakes"] + delta_rows["game_snakes"],
|
||||||
|
"turns": base_rows["turns"] + delta_rows["turns"],
|
||||||
|
"snake_turns": base_rows["snake_turns"] + delta_rows["snake_turns"],
|
||||||
|
}
|
||||||
|
verify(destination, expected)
|
||||||
|
except Exception:
|
||||||
|
destination.close()
|
||||||
|
base.close()
|
||||||
|
delta.close()
|
||||||
|
for suffix in ("", "-wal", "-shm"):
|
||||||
|
Path(f"{destination_path}{suffix}").unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
destination.close()
|
||||||
|
base.close()
|
||||||
|
delta.close()
|
||||||
|
|
||||||
|
print(f"merged: base games={base_count:,}, delta games={delta_count:,}, identical duplicates skipped={len(duplicates):,}")
|
||||||
|
print(f"delta quality: {dict(tiers)}")
|
||||||
|
print(f"verified: {expected}; elapsed: {perf_counter() - started:.1f}s")
|
||||||
|
if args.replace_base:
|
||||||
|
backup = replace_base(base_path, destination_path)
|
||||||
|
print(f"base replaced; backup kept at: {backup}")
|
||||||
|
else:
|
||||||
|
print(f"merged database created at: {destination_path}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create a compact, normalized copy of a gameplay SQLite database.
|
||||||
|
|
||||||
|
The source is always opened read-only. The destination is written separately,
|
||||||
|
verified, and can optionally replace the source after a timestamped backup is
|
||||||
|
created. No in-place schema rewrite is performed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from server.database.backend.SqliteGameplayBackend import SqliteGameplayBackend
|
||||||
|
from server.database.game_quality import GameQualityInput, quality_meets_minimum, rate_game_quality
|
||||||
|
|
||||||
|
def open_source(path:Path) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=60)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA query_only = ON")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def object_columns(connection:sqlite3.Connection, name:str) -> set[str]:
|
||||||
|
return {row[1] for row in connection.execute(f"PRAGMA table_info({name})")}
|
||||||
|
|
||||||
|
def column_or_null(columns:set[str], name:str) -> str:
|
||||||
|
return name if name in columns else f"NULL AS {name}"
|
||||||
|
|
||||||
|
def create_destination(path:Path, busy_timeout_ms:int) -> sqlite3.Connection:
|
||||||
|
if path.exists():
|
||||||
|
raise FileExistsError(f"Destination already exists: {path}")
|
||||||
|
SqliteGameplayBackend(
|
||||||
|
str(path), busy_timeout_ms=busy_timeout_ms,
|
||||||
|
initialize_indexes=False, journal_mode="DELETE",
|
||||||
|
)
|
||||||
|
connection = sqlite3.connect(str(path), timeout=max(1, busy_timeout_ms // 1000))
|
||||||
|
connection.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
connection.execute("PRAGMA synchronous = OFF")
|
||||||
|
connection.execute("PRAGMA locking_mode = EXCLUSIVE")
|
||||||
|
connection.execute("PRAGMA temp_store = MEMORY")
|
||||||
|
connection.execute("PRAGMA cache_size = -262144")
|
||||||
|
connection.execute(f"PRAGMA busy_timeout = {busy_timeout_ms}")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def analyze_quality(source:sqlite3.Connection) -> dict[str, object]:
|
||||||
|
games = {
|
||||||
|
row["game_id"]: row
|
||||||
|
for row in source.execute("""
|
||||||
|
SELECT game_id, status, final_turn, winner_name
|
||||||
|
FROM games
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
aggregates = {
|
||||||
|
row["game_id"]: row
|
||||||
|
for row in source.execute("""
|
||||||
|
SELECT t.game_id,
|
||||||
|
COUNT(*) AS turn_rows,
|
||||||
|
MIN(t.turn) AS min_turn,
|
||||||
|
MAX(t.turn) AS max_turn,
|
||||||
|
SUM(CASE WHEN t.my_move IN ('up','down','left','right') THEN 1 ELSE 0 END) AS valid_moves,
|
||||||
|
SUM(CASE WHEN t.my_thinking_json IS NOT NULL AND t.my_thinking_json NOT IN ('', '{}', 'null') THEN 1 ELSE 0 END) AS thinking_rows,
|
||||||
|
COUNT(DISTINCT t.my_move) AS distinct_moves
|
||||||
|
FROM turns AS t
|
||||||
|
GROUP BY t.game_id
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
snake_counts = {
|
||||||
|
row["game_id"]: int(row["snake_turn_rows"])
|
||||||
|
for row in source.execute("""
|
||||||
|
SELECT game_id, COUNT(*) AS snake_turn_rows
|
||||||
|
FROM snake_turns GROUP BY game_id
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
quality = {}
|
||||||
|
for game_id, game in games.items():
|
||||||
|
aggregate = aggregates.get(game_id)
|
||||||
|
quality[game_id] = rate_game_quality(GameQualityInput(
|
||||||
|
status=game["status"],
|
||||||
|
final_turn=int(game["final_turn"] or 0),
|
||||||
|
turn_rows=int(aggregate["turn_rows"] if aggregate else 0),
|
||||||
|
min_turn=int(aggregate["min_turn"]) if aggregate and aggregate["min_turn"] is not None else None,
|
||||||
|
max_turn=int(aggregate["max_turn"]) if aggregate and aggregate["max_turn"] is not None else None,
|
||||||
|
valid_moves=int(aggregate["valid_moves"] if aggregate else 0),
|
||||||
|
thinking_rows=int(aggregate["thinking_rows"] if aggregate else 0),
|
||||||
|
distinct_moves=int(aggregate["distinct_moves"] if aggregate else 0),
|
||||||
|
snake_turn_rows=snake_counts.get(game_id, 0),
|
||||||
|
winner_name=game["winner_name"],
|
||||||
|
))
|
||||||
|
return quality
|
||||||
|
|
||||||
|
def copy_games(source:sqlite3.Connection, destination:sqlite3.Connection, batch_size:int, quality:dict[str, object], minimum_quality:str) -> tuple[int, int]:
|
||||||
|
columns = object_columns(source, "games")
|
||||||
|
selected = [
|
||||||
|
"game_id", "started_at", "ended_at", "width", "height", "source", "map_name",
|
||||||
|
"ruleset_name", "ruleset_version", "your_snake_id", "your_snake_name",
|
||||||
|
column_or_null(columns, "your_snake_type"), column_or_null(columns, "your_snake_version"),
|
||||||
|
column_or_null(columns, "game_type"), column_or_null(columns, "winner_name"),
|
||||||
|
"winner_you", "final_turn", "status",
|
||||||
|
]
|
||||||
|
cursor = source.execute(f"SELECT {', '.join(selected)} FROM games ORDER BY game_id")
|
||||||
|
sql = """
|
||||||
|
INSERT INTO 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_json
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
retained = 0
|
||||||
|
while rows := cursor.fetchmany(batch_size):
|
||||||
|
values = []
|
||||||
|
for row in rows:
|
||||||
|
game_quality = quality[row[0]]
|
||||||
|
keep_replay = quality_meets_minimum(game_quality.tier, minimum_quality)
|
||||||
|
values.append((
|
||||||
|
*tuple(row), 1 if keep_replay else 0,
|
||||||
|
"retained" if keep_replay else "low_quality",
|
||||||
|
game_quality.score, game_quality.tier,
|
||||||
|
json.dumps(game_quality.reasons, separators=(",", ":")),
|
||||||
|
))
|
||||||
|
retained += int(keep_replay)
|
||||||
|
destination.executemany(sql, values)
|
||||||
|
count += len(rows)
|
||||||
|
return count, retained
|
||||||
|
|
||||||
|
def retained_game_ids(quality:dict[str, object], minimum_quality:str) -> set[str]:
|
||||||
|
return {
|
||||||
|
game_id for game_id, result in quality.items()
|
||||||
|
if quality_meets_minimum(result.tier, minimum_quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
def copy_game_snakes(source:sqlite3.Connection, destination:sqlite3.Connection, batch_size:int, retained_ids:set[str]) -> int:
|
||||||
|
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 (?, ?, ?, ?)
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
while rows := cursor.fetchmany(batch_size):
|
||||||
|
retained_rows = [tuple(row) for row in rows if row[0] in retained_ids]
|
||||||
|
destination.executemany(sql, retained_rows)
|
||||||
|
count += len(retained_rows)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def decode_json(value:str|None, fallback):
|
||||||
|
if not value:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
def compact_board(row:sqlite3.Row) -> tuple[str, str, str]:
|
||||||
|
board = decode_json(row["board_state_json"], {})
|
||||||
|
food = board.get("food") if isinstance(board, dict) else None
|
||||||
|
hazards = board.get("hazards") if isinstance(board, dict) else None
|
||||||
|
if food is None:
|
||||||
|
food = decode_json(row["food_json"], [])
|
||||||
|
if hazards is None:
|
||||||
|
hazards = decode_json(row["hazards_json"], [])
|
||||||
|
compact = json.dumps({}, separators=(",", ":"))
|
||||||
|
return (
|
||||||
|
compact,
|
||||||
|
json.dumps(food or [], separators=(",", ":")),
|
||||||
|
json.dumps(hazards or [], separators=(",", ":")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def copy_turns(source:sqlite3.Connection, destination:sqlite3.Connection, batch_size:int, retained_ids:set[str]) -> int:
|
||||||
|
cursor = source.execute("""
|
||||||
|
SELECT t.id, t.game_id, t.turn, t.observed_at, t.my_move, t.my_thinking_json,
|
||||||
|
t.board_state_json, t.food_json, t.hazards_json
|
||||||
|
FROM turns AS t
|
||||||
|
ORDER BY t.id
|
||||||
|
""")
|
||||||
|
sql = """
|
||||||
|
INSERT INTO turns (
|
||||||
|
id, game_id, turn, observed_at, my_move, my_thinking_json,
|
||||||
|
board_state_json, snakes_json, you_json, food_json, hazards_json
|
||||||
|
) VALUES (?,?,?,?,?,?,?,'[]','{}',?,?)
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
while rows := cursor.fetchmany(batch_size):
|
||||||
|
values = []
|
||||||
|
for row in rows:
|
||||||
|
if row["game_id"] not in retained_ids:
|
||||||
|
continue
|
||||||
|
compact, food, hazards = compact_board(row)
|
||||||
|
values.append((
|
||||||
|
row["id"], row["game_id"], row["turn"], row["observed_at"],
|
||||||
|
row["my_move"], row["my_thinking_json"], compact, food, hazards,
|
||||||
|
))
|
||||||
|
destination.executemany(sql, values)
|
||||||
|
count += len(values)
|
||||||
|
if count % max(batch_size, 100_000) == 0:
|
||||||
|
print(f"turns: {count:,}", flush=True)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def copy_snake_turns(source:sqlite3.Connection, destination:sqlite3.Connection, batch_size:int, retained_ids:set[str]) -> int:
|
||||||
|
columns = object_columns(source, "snake_turns")
|
||||||
|
latency = column_or_null(columns, "latency")
|
||||||
|
cursor = source.execute(f"""
|
||||||
|
SELECT st.id, st.game_id, st.turn, st.snake_id, st.health, st.length,
|
||||||
|
st.head_x, st.head_y, st.body_json, st.inferred_move, st.{latency}
|
||||||
|
FROM snake_turns AS st
|
||||||
|
ORDER BY st.id
|
||||||
|
""")
|
||||||
|
sql = """
|
||||||
|
INSERT INTO snake_turns (
|
||||||
|
id, game_id, turn, snake_id, snake_name, health, length,
|
||||||
|
head_x, head_y, body_json, is_you, inferred_move, latency
|
||||||
|
) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
while rows := cursor.fetchmany(batch_size):
|
||||||
|
destination.executemany(sql, [
|
||||||
|
(
|
||||||
|
row["id"], row["game_id"], row["turn"], row["snake_id"],
|
||||||
|
row["health"], row["length"], row["head_x"], row["head_y"],
|
||||||
|
row["body_json"], row["inferred_move"], row["latency"],
|
||||||
|
)
|
||||||
|
for row in rows if row["game_id"] in retained_ids
|
||||||
|
])
|
||||||
|
count += sum(1 for row in rows if row["game_id"] in retained_ids)
|
||||||
|
if count % max(batch_size, 100_000) == 0:
|
||||||
|
print(f"snake_turns: {count:,}", flush=True)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def verify(source:sqlite3.Connection, destination:sqlite3.Connection, retained_ids:set[str]) -> dict[str, int]:
|
||||||
|
result = {}
|
||||||
|
retained_turns = sum(
|
||||||
|
1 for row in source.execute("SELECT game_id FROM turns")
|
||||||
|
if row["game_id"] in retained_ids
|
||||||
|
)
|
||||||
|
retained_snake_turns = sum(
|
||||||
|
1 for row in source.execute("SELECT game_id FROM snake_turns")
|
||||||
|
if row["game_id"] in retained_ids
|
||||||
|
)
|
||||||
|
expected = {
|
||||||
|
"games": int(source.execute("SELECT COUNT(*) FROM games").fetchone()[0]),
|
||||||
|
"turns": retained_turns,
|
||||||
|
"snake_turns": retained_snake_turns,
|
||||||
|
}
|
||||||
|
for table, source_count in expected.items():
|
||||||
|
destination_count = int(destination.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
|
||||||
|
if source_count != destination_count:
|
||||||
|
raise RuntimeError(f"{table} count mismatch: {source_count} != {destination_count}")
|
||||||
|
result[table] = destination_count
|
||||||
|
integrity = destination.execute("PRAGMA integrity_check").fetchone()[0]
|
||||||
|
if integrity != "ok":
|
||||||
|
raise RuntimeError(f"Destination integrity check failed: {integrity}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def replace_with_backup(source_path:Path, destination_path:Path) -> Path:
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
backup = source_path.with_name(f"{source_path.name}.backup-{timestamp}")
|
||||||
|
source_path.rename(backup)
|
||||||
|
try:
|
||||||
|
destination_path.rename(source_path)
|
||||||
|
except Exception:
|
||||||
|
backup.rename(source_path)
|
||||||
|
raise
|
||||||
|
return backup
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--source", required=True, type=Path)
|
||||||
|
parser.add_argument("--destination", type=Path)
|
||||||
|
parser.add_argument("--batch-size", type=int, default=10_000)
|
||||||
|
parser.add_argument("--busy-timeout-ms", type=int, default=60_000)
|
||||||
|
parser.add_argument("--minimum-quality", choices=("low", "medium", "high"), default="medium", help="Minimum quality tier whose replay rows are retained")
|
||||||
|
parser.add_argument("--replace", action="store_true", help="Backup source and replace it after verification")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
source_path = args.source.expanduser().resolve()
|
||||||
|
destination_path = (args.destination or source_path.with_name(f"{source_path.stem}.compact{source_path.suffix}")).expanduser().resolve()
|
||||||
|
if source_path == destination_path:
|
||||||
|
raise SystemExit("Source and destination must be different paths")
|
||||||
|
|
||||||
|
started = perf_counter()
|
||||||
|
source = open_source(source_path)
|
||||||
|
destination = create_destination(destination_path, max(1000, args.busy_timeout_ms))
|
||||||
|
try:
|
||||||
|
quality = analyze_quality(source)
|
||||||
|
retained_ids = retained_game_ids(quality, args.minimum_quality)
|
||||||
|
games, retained_games = copy_games(source, destination, max(1, args.batch_size), quality, args.minimum_quality)
|
||||||
|
game_snakes = copy_game_snakes(source, destination, max(1, args.batch_size), retained_ids)
|
||||||
|
turns = copy_turns(source, destination, max(1, args.batch_size), retained_ids)
|
||||||
|
snake_turns = copy_snake_turns(source, destination, max(1, args.batch_size), retained_ids)
|
||||||
|
destination.commit()
|
||||||
|
destination.executescript("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_turns_game_turn ON turns(game_id, turn);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_games_status ON games(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snake_turns_game_turn ON snake_turns(game_id, turn);
|
||||||
|
""")
|
||||||
|
destination.execute("PRAGMA foreign_keys = ON")
|
||||||
|
counts = verify(source, destination, retained_ids)
|
||||||
|
except Exception:
|
||||||
|
destination.close()
|
||||||
|
source.close()
|
||||||
|
for suffix in ("", "-wal", "-shm"):
|
||||||
|
Path(f"{destination_path}{suffix}").unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
destination.close()
|
||||||
|
source.close()
|
||||||
|
|
||||||
|
source_bytes = source_path.stat().st_size
|
||||||
|
destination_bytes = destination_path.stat().st_size
|
||||||
|
print(f"copied: game rates={games:,}, retained replays={retained_games:,}, game_snakes={game_snakes:,}, turns={turns:,}, snake_turns={snake_turns:,}")
|
||||||
|
print(f"verified: {counts}; size {source_bytes / 2**30:.2f} GiB -> {destination_bytes / 2**30:.2f} GiB")
|
||||||
|
print(f"elapsed: {perf_counter() - started:.1f}s")
|
||||||
|
|
||||||
|
if args.replace:
|
||||||
|
backup = replace_with_backup(source_path, destination_path)
|
||||||
|
print(f"source replaced; backup kept at: {backup}")
|
||||||
|
else:
|
||||||
|
print(f"compact database created at: {destination_path}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
|||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
from .Template import GameplayBackendTemplate
|
from .Template import GameplayBackendTemplate
|
||||||
|
from server.database.normalized_turn import compact_turn_json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
if not logger.handlers:
|
if not logger.handlers:
|
||||||
@@ -47,7 +48,12 @@ CREATE TABLE IF NOT EXISTS games (
|
|||||||
winner_name TEXT,
|
winner_name TEXT,
|
||||||
winner_you BOOLEAN NOT NULL DEFAULT FALSE,
|
winner_you BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
final_turn INTEGER NOT NULL DEFAULT 0,
|
final_turn INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT NOT NULL DEFAULT 'running'
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
has_replay BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
quality_status TEXT NOT NULL DEFAULT 'retained',
|
||||||
|
quality_score INTEGER,
|
||||||
|
quality_tier TEXT,
|
||||||
|
quality_reasons JSONB
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS turns (
|
CREATE TABLE IF NOT EXISTS turns (
|
||||||
@@ -65,6 +71,14 @@ CREATE TABLE IF NOT EXISTS turns (
|
|||||||
UNIQUE (game_id, turn)
|
UNIQUE (game_id, turn)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS game_snakes (
|
||||||
|
game_id TEXT NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
|
||||||
|
snake_id TEXT NOT NULL,
|
||||||
|
snake_name TEXT,
|
||||||
|
is_you BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
PRIMARY KEY (game_id, snake_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS snake_turns (
|
CREATE TABLE IF NOT EXISTS snake_turns (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
game_id TEXT NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
|
game_id TEXT NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
|
||||||
@@ -93,6 +107,11 @@ ALTER TABLE games ADD COLUMN IF NOT EXISTS game_type TEXT;
|
|||||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS your_snake_type TEXT;
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS your_snake_type TEXT;
|
||||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS your_snake_version TEXT;
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS your_snake_version TEXT;
|
||||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS winner_name TEXT;
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS winner_name TEXT;
|
||||||
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS has_replay BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_status TEXT NOT NULL DEFAULT 'retained';
|
||||||
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_score INTEGER;
|
||||||
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_tier TEXT;
|
||||||
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_reasons JSONB;
|
||||||
ALTER TABLE turns ADD COLUMN IF NOT EXISTS my_thinking JSONB;
|
ALTER TABLE turns ADD COLUMN IF NOT EXISTS my_thinking JSONB;
|
||||||
ALTER TABLE snake_turns ADD COLUMN IF NOT EXISTS latency TEXT;
|
ALTER TABLE snake_turns ADD COLUMN IF NOT EXISTS latency TEXT;
|
||||||
"""
|
"""
|
||||||
@@ -433,9 +452,23 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
|||||||
game_id = game.get("id")
|
game_id = game.get("id")
|
||||||
turn = int(game_state.get("turn", 0))
|
turn = int(game_state.get("turn", 0))
|
||||||
|
|
||||||
|
board_json, snakes_json, you_json, food_json, hazards_json = compact_turn_json(board)
|
||||||
|
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.transaction():
|
async with conn.transaction():
|
||||||
|
await conn.executemany("""
|
||||||
|
INSERT INTO game_snakes (game_id, snake_id, snake_name, is_you)
|
||||||
|
VALUES ($1,$2,$3,$4)
|
||||||
|
ON CONFLICT (game_id, snake_id) DO UPDATE SET
|
||||||
|
snake_name = EXCLUDED.snake_name,
|
||||||
|
is_you = EXCLUDED.is_you
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(game_id, snake.get("id"), snake.get("name"), snake.get("id") == you.get("id"))
|
||||||
|
for snake in snakes if snake.get("id") is not None
|
||||||
|
],
|
||||||
|
)
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
INSERT INTO turns (
|
INSERT INTO turns (
|
||||||
game_id, turn, observed_at, my_move, my_thinking,
|
game_id, turn, observed_at, my_move, my_thinking,
|
||||||
@@ -456,11 +489,11 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
|||||||
self._utc_now_ts(),
|
self._utc_now_ts(),
|
||||||
my_move,
|
my_move,
|
||||||
my_thinking,
|
my_thinking,
|
||||||
board,
|
board_json,
|
||||||
snakes,
|
snakes_json,
|
||||||
you,
|
you_json,
|
||||||
board.get("food", []),
|
food_json,
|
||||||
board.get("hazards", []),
|
hazards_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
previous_positions:dict[str, tuple[int, int]] = {}
|
previous_positions:dict[str, tuple[int, int]] = {}
|
||||||
@@ -498,8 +531,8 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
|||||||
inferred_move = EXCLUDED.inferred_move,
|
inferred_move = EXCLUDED.inferred_move,
|
||||||
latency = EXCLUDED.latency
|
latency = EXCLUDED.latency
|
||||||
""",
|
""",
|
||||||
p_game_id, p_turn, p_snake_id, p_name, p_health, p_length,
|
p_game_id, p_turn, p_snake_id, None, p_health, p_length,
|
||||||
p_head_x, p_head_y, p_body, p_is_you, p_inferred, p_latency,
|
p_head_x, p_head_y, p_body, False, p_inferred, p_latency,
|
||||||
)
|
)
|
||||||
|
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
@@ -561,10 +594,12 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
|||||||
final_turn = int(row["final_turn"] or 0)
|
final_turn = int(row["final_turn"] or 0)
|
||||||
|
|
||||||
snake_rows = await conn.fetch("""
|
snake_rows = await conn.fetch("""
|
||||||
SELECT snake_id, snake_name
|
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name) AS snake_name
|
||||||
FROM snake_turns
|
FROM snake_turns AS st
|
||||||
WHERE game_id = $1 AND turn = $2
|
LEFT JOIN game_snakes AS gs
|
||||||
ORDER BY is_you DESC, snake_name ASC
|
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 COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
|
||||||
""",
|
""",
|
||||||
game_id, final_turn,
|
game_id, final_turn,
|
||||||
)
|
)
|
||||||
@@ -578,10 +613,12 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
|||||||
if latest_row is not None and latest_row["latest_turn"] is not None:
|
if latest_row is not None and latest_row["latest_turn"] is not None:
|
||||||
final_turn = int(latest_row["latest_turn"])
|
final_turn = int(latest_row["latest_turn"])
|
||||||
snake_rows = await conn.fetch("""
|
snake_rows = await conn.fetch("""
|
||||||
SELECT snake_id, snake_name
|
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name) AS snake_name
|
||||||
FROM snake_turns
|
FROM snake_turns AS st
|
||||||
WHERE game_id = $1 AND turn = $2
|
LEFT JOIN game_snakes AS gs
|
||||||
ORDER BY is_you DESC, snake_name ASC
|
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 COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
|
||||||
""",
|
""",
|
||||||
game_id, final_turn,
|
game_id, final_turn,
|
||||||
)
|
)
|
||||||
@@ -640,6 +677,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
|||||||
SELECT game_id, started_at, ended_at, map_name, ruleset_name, game_type,
|
SELECT game_id, started_at, ended_at, map_name, ruleset_name, game_type,
|
||||||
your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status
|
your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status
|
||||||
FROM games
|
FROM games
|
||||||
|
WHERE has_replay
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
""",
|
""",
|
||||||
@@ -656,6 +694,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
|||||||
your_snake_name, your_snake_type, your_snake_version,
|
your_snake_name, your_snake_type, your_snake_version,
|
||||||
winner_you, winner_name, final_turn, status
|
winner_you, winner_name, final_turn, status
|
||||||
FROM games
|
FROM games
|
||||||
|
WHERE has_replay
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
""",
|
""",
|
||||||
@@ -673,7 +712,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
|||||||
your_snake_type, your_snake_version,
|
your_snake_type, your_snake_version,
|
||||||
winner_name, winner_you, final_turn, status
|
winner_name, winner_you, final_turn, status
|
||||||
FROM games
|
FROM games
|
||||||
WHERE game_id = $1
|
WHERE game_id = $1 AND has_replay
|
||||||
""",
|
""",
|
||||||
game_id,
|
game_id,
|
||||||
)
|
)
|
||||||
@@ -696,11 +735,16 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
|||||||
)
|
)
|
||||||
|
|
||||||
snake_rows = await conn.fetch("""
|
snake_rows = await conn.fetch("""
|
||||||
SELECT turn, snake_id, snake_name, health, length, head_x, head_y,
|
SELECT st.turn, st.snake_id,
|
||||||
body AS body_json, is_you, inferred_move, latency
|
COALESCE(gs.snake_name, st.snake_name) AS snake_name,
|
||||||
FROM snake_turns
|
st.health, st.length, st.head_x, st.head_y, st.body AS body_json,
|
||||||
WHERE game_id = $1
|
COALESCE(gs.is_you, st.is_you) AS is_you,
|
||||||
ORDER BY turn ASC, is_you DESC, snake_name ASC
|
st.inferred_move, st.latency
|
||||||
|
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
|
||||||
|
ORDER BY st.turn ASC, is_you DESC, snake_name ASC
|
||||||
""",
|
""",
|
||||||
game_id,
|
game_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from server.database.backend.Template import GameplayBackendTemplate
|
from server.database.backend.Template import GameplayBackendTemplate
|
||||||
|
from server.database.normalized_turn import compact_turn_json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
if not logger.handlers:
|
if not logger.handlers:
|
||||||
@@ -16,10 +17,12 @@ if not logger.handlers:
|
|||||||
_ZSTD_EXT = Path(os.environ.get("SQLITE_ZSTD_EXT", "/usr/local/lib/libsqlite_zstd.so")).expanduser().resolve()
|
_ZSTD_EXT = Path(os.environ.get("SQLITE_ZSTD_EXT", "/usr/local/lib/libsqlite_zstd.so")).expanduser().resolve()
|
||||||
|
|
||||||
class SqliteGameplayBackend(GameplayBackendTemplate):
|
class SqliteGameplayBackend(GameplayBackendTemplate):
|
||||||
def __init__(self, db_path:str, busy_timeout_ms:int=5000):
|
def __init__(self, db_path:str, busy_timeout_ms:int=5000, initialize_indexes:bool=True, journal_mode:str="WAL"):
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.busy_timeout_ms = max(1000, int(busy_timeout_ms))
|
self.busy_timeout_ms = max(1000, int(busy_timeout_ms))
|
||||||
self._zstd_available = False
|
self._zstd_available = False
|
||||||
|
self._initialize_indexes = initialize_indexes
|
||||||
|
self._journal_mode = journal_mode
|
||||||
self._initialize_database()
|
self._initialize_database()
|
||||||
|
|
||||||
# ── connection ─────────────────────────────────────────────────────────────
|
# ── connection ─────────────────────────────────────────────────────────────
|
||||||
@@ -43,7 +46,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
connection.enable_load_extension(False)
|
connection.enable_load_extension(False)
|
||||||
|
|
||||||
connection.execute("PRAGMA foreign_keys = ON")
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
connection.execute("PRAGMA journal_mode = WAL")
|
connection.execute(f"PRAGMA journal_mode = {self._journal_mode}")
|
||||||
connection.execute("PRAGMA synchronous = NORMAL")
|
connection.execute("PRAGMA synchronous = NORMAL")
|
||||||
connection.execute("PRAGMA temp_store = MEMORY")
|
connection.execute("PRAGMA temp_store = MEMORY")
|
||||||
connection.execute("PRAGMA journal_size_limit = 1048576")
|
connection.execute("PRAGMA journal_size_limit = 1048576")
|
||||||
@@ -80,7 +83,12 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
winner_name TEXT,
|
winner_name TEXT,
|
||||||
winner_you INTEGER NOT NULL DEFAULT 0,
|
winner_you INTEGER NOT NULL DEFAULT 0,
|
||||||
final_turn INTEGER NOT NULL DEFAULT 0,
|
final_turn INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT NOT NULL DEFAULT 'running'
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
has_replay INTEGER NOT NULL DEFAULT 1,
|
||||||
|
quality_status TEXT NOT NULL DEFAULT 'retained',
|
||||||
|
quality_score INTEGER,
|
||||||
|
quality_tier TEXT,
|
||||||
|
quality_reasons_json TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS turns (
|
CREATE TABLE IF NOT EXISTS turns (
|
||||||
@@ -99,6 +107,15 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
|
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS game_snakes (
|
||||||
|
game_id TEXT NOT NULL,
|
||||||
|
snake_id TEXT NOT NULL,
|
||||||
|
snake_name TEXT,
|
||||||
|
is_you INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (game_id, snake_id),
|
||||||
|
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS snake_turns (
|
CREATE TABLE IF NOT EXISTS snake_turns (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
game_id TEXT NOT NULL,
|
game_id TEXT NOT NULL,
|
||||||
@@ -116,6 +133,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
|
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
if self._initialize_indexes:
|
||||||
self._create_indexes_if_tables(connection)
|
self._create_indexes_if_tables(connection)
|
||||||
self._ensure_column_exists(connection, "turns", "my_thinking_json", "TEXT")
|
self._ensure_column_exists(connection, "turns", "my_thinking_json", "TEXT")
|
||||||
self._ensure_column_exists(connection, "games", "your_snake_type", "TEXT")
|
self._ensure_column_exists(connection, "games", "your_snake_type", "TEXT")
|
||||||
@@ -123,6 +141,11 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
self._ensure_column_exists(connection, "games", "game_type", "TEXT")
|
self._ensure_column_exists(connection, "games", "game_type", "TEXT")
|
||||||
self._ensure_column_exists(connection, "snake_turns", "latency", "TEXT")
|
self._ensure_column_exists(connection, "snake_turns", "latency", "TEXT")
|
||||||
self._ensure_column_exists(connection, "games", "winner_name", "TEXT")
|
self._ensure_column_exists(connection, "games", "winner_name", "TEXT")
|
||||||
|
self._ensure_column_exists(connection, "games", "has_replay", "INTEGER NOT NULL DEFAULT 1")
|
||||||
|
self._ensure_column_exists(connection, "games", "quality_status", "TEXT NOT NULL DEFAULT 'retained'")
|
||||||
|
self._ensure_column_exists(connection, "games", "quality_score", "INTEGER")
|
||||||
|
self._ensure_column_exists(connection, "games", "quality_tier", "TEXT")
|
||||||
|
self._ensure_column_exists(connection, "games", "quality_reasons_json", "TEXT")
|
||||||
if self._zstd_available:
|
if self._zstd_available:
|
||||||
self._enable_zstd_compression(connection)
|
self._enable_zstd_compression(connection)
|
||||||
connection.execute("PRAGMA optimize")
|
connection.execute("PRAGMA optimize")
|
||||||
@@ -239,7 +262,21 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
game_id = game.get("id")
|
game_id = game.get("id")
|
||||||
turn = int(game_state.get("turn", 0))
|
turn = int(game_state.get("turn", 0))
|
||||||
|
|
||||||
|
board_json, snakes_json, you_json, food_json, hazards_json = compact_turn_json(board)
|
||||||
|
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
|
connection.executemany("""
|
||||||
|
INSERT INTO game_snakes (game_id, snake_id, snake_name, is_you)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(game_id, snake_id) DO UPDATE SET
|
||||||
|
snake_name = excluded.snake_name,
|
||||||
|
is_you = excluded.is_you
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(game_id, snake.get("id"), snake.get("name"), 1 if snake.get("id") == you.get("id") else 0)
|
||||||
|
for snake in snakes if snake.get("id") is not None
|
||||||
|
],
|
||||||
|
)
|
||||||
connection.execute("""
|
connection.execute("""
|
||||||
INSERT INTO turns (
|
INSERT INTO turns (
|
||||||
game_id, turn, observed_at, my_move, my_thinking_json,
|
game_id, turn, observed_at, my_move, my_thinking_json,
|
||||||
@@ -261,11 +298,11 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
self._utc_now(),
|
self._utc_now(),
|
||||||
my_move,
|
my_move,
|
||||||
self._to_json(my_thinking) if my_thinking is not None else None,
|
self._to_json(my_thinking) if my_thinking is not None else None,
|
||||||
self._to_json(board),
|
self._to_json(board_json),
|
||||||
self._to_json(snakes),
|
self._to_json(snakes_json),
|
||||||
self._to_json(you),
|
self._to_json(you_json),
|
||||||
self._to_json(board.get("food", [])),
|
self._to_json(food_json),
|
||||||
self._to_json(board.get("hazards", [])),
|
self._to_json(hazards_json),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -305,9 +342,9 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
latency = excluded.latency
|
latency = excluded.latency
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
p_game_id, p_turn, p_snake_id, p_name, p_health, p_length,
|
p_game_id, p_turn, p_snake_id, None, p_health, p_length,
|
||||||
p_head_x, p_head_y, self._to_json(p_body),
|
p_head_x, p_head_y, self._to_json(p_body),
|
||||||
1 if p_is_you else 0,
|
0,
|
||||||
p_inferred, p_latency,
|
p_inferred, p_latency,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -368,10 +405,12 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
final_turn = int(row["final_turn"] or 0)
|
final_turn = int(row["final_turn"] or 0)
|
||||||
|
|
||||||
snake_rows = connection.execute("""
|
snake_rows = connection.execute("""
|
||||||
SELECT snake_id, snake_name
|
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name) AS snake_name
|
||||||
FROM snake_turns
|
FROM snake_turns AS st
|
||||||
WHERE game_id = ? AND turn = ?
|
LEFT JOIN game_snakes AS gs
|
||||||
ORDER BY is_you DESC, snake_name ASC
|
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
|
||||||
|
WHERE st.game_id = ? AND st.turn = ?
|
||||||
|
ORDER BY COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
|
||||||
""",
|
""",
|
||||||
(game_id, final_turn),
|
(game_id, final_turn),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -384,10 +423,12 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
if latest_row is not None and latest_row["latest_turn"] is not None:
|
if latest_row is not None and latest_row["latest_turn"] is not None:
|
||||||
final_turn = int(latest_row["latest_turn"])
|
final_turn = int(latest_row["latest_turn"])
|
||||||
snake_rows = connection.execute("""
|
snake_rows = connection.execute("""
|
||||||
SELECT snake_id, snake_name
|
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name) AS snake_name
|
||||||
FROM snake_turns
|
FROM snake_turns AS st
|
||||||
WHERE game_id = ? AND turn = ?
|
LEFT JOIN game_snakes AS gs
|
||||||
ORDER BY is_you DESC, snake_name ASC
|
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
|
||||||
|
WHERE st.game_id = ? AND st.turn = ?
|
||||||
|
ORDER BY COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
|
||||||
""",
|
""",
|
||||||
(game_id, final_turn),
|
(game_id, final_turn),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -448,6 +489,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
SELECT game_id, started_at, ended_at, map_name, ruleset_name, game_type,
|
SELECT game_id, started_at, ended_at, map_name, ruleset_name, game_type,
|
||||||
your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status
|
your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status
|
||||||
FROM games
|
FROM games
|
||||||
|
WHERE has_replay = 1
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
@@ -463,6 +505,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
your_snake_name, your_snake_type, your_snake_version,
|
your_snake_name, your_snake_type, your_snake_version,
|
||||||
winner_you, winner_name, final_turn, status
|
winner_you, winner_name, final_turn, status
|
||||||
FROM games
|
FROM games
|
||||||
|
WHERE has_replay = 1
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
@@ -479,7 +522,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
your_snake_type, your_snake_version,
|
your_snake_type, your_snake_version,
|
||||||
winner_name, winner_you, final_turn, status
|
winner_name, winner_you, final_turn, status
|
||||||
FROM games
|
FROM games
|
||||||
WHERE game_id = ?
|
WHERE game_id = ? AND has_replay = 1
|
||||||
""",
|
""",
|
||||||
(game_id,),
|
(game_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -498,11 +541,16 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
snake_rows = connection.execute("""
|
snake_rows = connection.execute("""
|
||||||
SELECT turn, snake_id, snake_name, health, length, head_x, head_y,
|
SELECT st.turn, st.snake_id,
|
||||||
body_json, is_you, inferred_move, latency
|
COALESCE(gs.snake_name, st.snake_name) AS snake_name,
|
||||||
FROM snake_turns
|
st.health, st.length, st.head_x, st.head_y, st.body_json,
|
||||||
WHERE game_id = ?
|
COALESCE(gs.is_you, st.is_you) AS is_you,
|
||||||
ORDER BY turn ASC, is_you DESC, snake_name ASC
|
st.inferred_move, st.latency
|
||||||
|
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 = ?
|
||||||
|
ORDER BY st.turn ASC, is_you DESC, snake_name ASC
|
||||||
""",
|
""",
|
||||||
(game_id,),
|
(game_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import json
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from server.database.normalized_turn import hydrate_replay_turns
|
||||||
|
|
||||||
class GameplayBackendTemplate:
|
class GameplayBackendTemplate:
|
||||||
"""Abstract base for gameplay database backends.
|
"""Abstract base for gameplay database backends.
|
||||||
|
|
||||||
@@ -164,19 +166,7 @@ class GameplayBackendTemplate:
|
|||||||
(SQLite) or already-decoded objects (PostgreSQL). Pass self._from_json for
|
(SQLite) or already-decoded objects (PostgreSQL). Pass self._from_json for
|
||||||
SQLite; pass (lambda x: x) for PostgreSQL.
|
SQLite; pass (lambda x: x) for PostgreSQL.
|
||||||
"""
|
"""
|
||||||
snakes_by_turn:dict[int, list[dict]] = {}
|
hydrated_turns = hydrate_replay_turns(game_row, turn_rows, snake_rows, decode_json)
|
||||||
for row in snake_rows:
|
|
||||||
snakes_by_turn.setdefault(int(row["turn"]), []).append({
|
|
||||||
"snake_id": row["snake_id"],
|
|
||||||
"snake_name": row["snake_name"],
|
|
||||||
"health": row["health"],
|
|
||||||
"length": row["length"],
|
|
||||||
"head": {"x": row["head_x"], "y": row["head_y"]},
|
|
||||||
"body": decode_json(row["body_json"]) or [],
|
|
||||||
"is_you": bool(row["is_you"]),
|
|
||||||
"inferred_move": row["inferred_move"],
|
|
||||||
"latency": row["latency"],
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"game": {
|
"game": {
|
||||||
@@ -200,18 +190,8 @@ class GameplayBackendTemplate:
|
|||||||
"status": game_row["status"],
|
"status": game_row["status"],
|
||||||
},
|
},
|
||||||
"turns": [
|
"turns": [
|
||||||
{
|
{**turn, "observed_at": self._ts_to_str(turn["observed_at"])}
|
||||||
"turn": int(row["turn"]),
|
for turn in hydrated_turns
|
||||||
"observed_at": self._ts_to_str(row["observed_at"]),
|
|
||||||
"my_move": row["my_move"],
|
|
||||||
"my_thinking": decode_json(row["my_thinking_json"]),
|
|
||||||
"board": decode_json(row["board_state_json"]),
|
|
||||||
"food": decode_json(row["food_json"]) or [],
|
|
||||||
"hazards": decode_json(row["hazards_json"]) or [],
|
|
||||||
"you": decode_json(row["you_json"]) or {},
|
|
||||||
"snakes": snakes_by_turn.get(int(row["turn"]), []),
|
|
||||||
}
|
|
||||||
for row in turn_rows
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Deterministic gameplay quality scoring.
|
||||||
|
|
||||||
|
Quality controls replay retention, never whether a game's result contributes to
|
||||||
|
historical rates. Structural failures produce ``invalid``; otherwise strategic
|
||||||
|
signals produce a 0-100 score and high/medium/low tier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
QUALITY_ORDER = {"invalid": 0, "low": 1, "medium": 2, "high": 3}
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GameQualityInput:
|
||||||
|
status:str
|
||||||
|
final_turn:int
|
||||||
|
turn_rows:int
|
||||||
|
min_turn:int|None
|
||||||
|
max_turn:int|None
|
||||||
|
valid_moves:int
|
||||||
|
thinking_rows:int
|
||||||
|
distinct_moves:int
|
||||||
|
snake_turn_rows:int
|
||||||
|
winner_name:str|None
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GameQuality:
|
||||||
|
score:int
|
||||||
|
tier:str
|
||||||
|
reasons:tuple[str, ...]
|
||||||
|
|
||||||
|
def rate_game_quality(data:GameQualityInput) -> GameQuality:
|
||||||
|
reasons:list[str] = []
|
||||||
|
expected_turns = max(1, data.final_turn)
|
||||||
|
coverage = min(1.0, data.turn_rows / expected_turns)
|
||||||
|
valid_ratio = data.valid_moves / data.turn_rows if data.turn_rows else 0.0
|
||||||
|
thinking_ratio = data.thinking_rows / data.turn_rows if data.turn_rows else 0.0
|
||||||
|
average_snakes = data.snake_turn_rows / data.turn_rows if data.turn_rows else 0.0
|
||||||
|
|
||||||
|
if data.status != "finished":
|
||||||
|
reasons.append("unfinished_game")
|
||||||
|
if data.turn_rows == 0:
|
||||||
|
reasons.append("missing_turns")
|
||||||
|
observed_span = (
|
||||||
|
data.max_turn - data.min_turn + 1
|
||||||
|
if data.min_turn is not None and data.max_turn is not None
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
if coverage < 0.8 or observed_span != data.turn_rows:
|
||||||
|
reasons.append("incomplete_turn_sequence")
|
||||||
|
if valid_ratio < 0.95:
|
||||||
|
reasons.append("invalid_or_missing_moves")
|
||||||
|
if reasons:
|
||||||
|
return GameQuality(score=0, tier="invalid", reasons=tuple(reasons))
|
||||||
|
|
||||||
|
score = 25.0 * coverage
|
||||||
|
score += 10.0 * valid_ratio
|
||||||
|
score += 20.0 * min(1.0, data.final_turn / 40.0)
|
||||||
|
score += 15.0 * thinking_ratio
|
||||||
|
score += 10.0 * min(1.0, data.distinct_moves / 3.0)
|
||||||
|
if average_snakes >= 3.0:
|
||||||
|
score += 15.0
|
||||||
|
elif average_snakes >= 1.8:
|
||||||
|
score += 10.0
|
||||||
|
elif average_snakes >= 1.0:
|
||||||
|
score += 3.0
|
||||||
|
if data.winner_name:
|
||||||
|
score += 5.0
|
||||||
|
|
||||||
|
if coverage >= 0.98:
|
||||||
|
reasons.append("complete_turn_sequence")
|
||||||
|
if valid_ratio == 1.0:
|
||||||
|
reasons.append("valid_moves")
|
||||||
|
if thinking_ratio >= 0.9:
|
||||||
|
reasons.append("complete_thinking_data")
|
||||||
|
elif thinking_ratio < 0.25:
|
||||||
|
reasons.append("sparse_thinking_data")
|
||||||
|
if average_snakes >= 1.8:
|
||||||
|
reasons.append("competitive_game")
|
||||||
|
else:
|
||||||
|
reasons.append("limited_opposition_data")
|
||||||
|
if data.final_turn < 3:
|
||||||
|
reasons.append("very_short_game")
|
||||||
|
elif data.final_turn < 10:
|
||||||
|
reasons.append("short_game")
|
||||||
|
else:
|
||||||
|
reasons.append("substantial_game_length")
|
||||||
|
if data.distinct_moves <= 1:
|
||||||
|
reasons.append("low_move_diversity")
|
||||||
|
|
||||||
|
rounded_score = max(0, min(100, round(score)))
|
||||||
|
if rounded_score >= 80 and data.final_turn >= 10:
|
||||||
|
tier = "high"
|
||||||
|
elif rounded_score >= 55:
|
||||||
|
tier = "medium"
|
||||||
|
else:
|
||||||
|
tier = "low"
|
||||||
|
return GameQuality(score=rounded_score, tier=tier, reasons=tuple(reasons))
|
||||||
|
|
||||||
|
def quality_meets_minimum(tier:str, minimum_tier:str) -> bool:
|
||||||
|
return QUALITY_ORDER.get(tier, 0) >= QUALITY_ORDER[minimum_tier]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Normalized gameplay turn storage and replay hydration.
|
||||||
|
|
||||||
|
A turn is split into one board row plus one snake row per participating snake.
|
||||||
|
Static snake identity belongs to ``game_snakes``. This avoids storing complete
|
||||||
|
snake payloads in the board, snakes, you, and snake-turn columns simultaneously.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
def compact_turn_json(board:dict) -> tuple[dict, list, dict, list, list]:
|
||||||
|
"""Return compatibility JSON plus canonical food and hazard values."""
|
||||||
|
return {}, [], {}, board.get("food", []), board.get("hazards", [])
|
||||||
|
|
||||||
|
def hydrate_replay_turns(game_row, turn_rows, snake_rows, decode_json:Callable) -> list[dict]:
|
||||||
|
rows_by_turn:dict[int, list] = {}
|
||||||
|
for row in snake_rows:
|
||||||
|
rows_by_turn.setdefault(int(row["turn"]), []).append(row)
|
||||||
|
|
||||||
|
turns = []
|
||||||
|
for row in turn_rows:
|
||||||
|
turn = int(row["turn"])
|
||||||
|
stored_board = decode_json(row["board_state_json"]) or {}
|
||||||
|
food = decode_json(row["food_json"])
|
||||||
|
hazards = decode_json(row["hazards_json"])
|
||||||
|
|
||||||
|
snakes = []
|
||||||
|
api_snakes = []
|
||||||
|
for snake_row in rows_by_turn.get(turn, []):
|
||||||
|
body = decode_json(snake_row["body_json"]) or []
|
||||||
|
api_snake = {
|
||||||
|
"id": snake_row["snake_id"],
|
||||||
|
"name": snake_row["snake_name"],
|
||||||
|
"health": snake_row["health"],
|
||||||
|
"length": snake_row["length"],
|
||||||
|
"head": {"x": snake_row["head_x"], "y": snake_row["head_y"]},
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
if snake_row["latency"] is not None:
|
||||||
|
api_snake["latency"] = snake_row["latency"]
|
||||||
|
api_snakes.append(api_snake)
|
||||||
|
snakes.append({
|
||||||
|
"snake_id": snake_row["snake_id"],
|
||||||
|
"snake_name": snake_row["snake_name"],
|
||||||
|
"health": snake_row["health"],
|
||||||
|
"length": snake_row["length"],
|
||||||
|
"head": api_snake["head"],
|
||||||
|
"body": body,
|
||||||
|
"is_you": bool(snake_row["is_you"]),
|
||||||
|
"inferred_move": snake_row["inferred_move"],
|
||||||
|
"latency": snake_row["latency"],
|
||||||
|
})
|
||||||
|
|
||||||
|
board = stored_board or {
|
||||||
|
"width": game_row["width"],
|
||||||
|
"height": game_row["height"],
|
||||||
|
"food": food or [],
|
||||||
|
"hazards": hazards or [],
|
||||||
|
"snakes": api_snakes,
|
||||||
|
}
|
||||||
|
if food is None:
|
||||||
|
food = board.get("food", [])
|
||||||
|
if hazards is None:
|
||||||
|
hazards = board.get("hazards", [])
|
||||||
|
|
||||||
|
you = decode_json(row["you_json"]) or {}
|
||||||
|
if not you:
|
||||||
|
you = next(
|
||||||
|
(snake for snake in api_snakes if snake["id"] == game_row["your_snake_id"]),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
turns.append({
|
||||||
|
"turn": turn,
|
||||||
|
"observed_at": row["observed_at"],
|
||||||
|
"my_move": row["my_move"],
|
||||||
|
"my_thinking": decode_json(row["my_thinking_json"]),
|
||||||
|
"board": board,
|
||||||
|
"food": food or [],
|
||||||
|
"hazards": hazards or [],
|
||||||
|
"you": you,
|
||||||
|
"snakes": snakes,
|
||||||
|
})
|
||||||
|
|
||||||
|
return turns
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Any
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
from snakes.ApexBattleSnake import ApexBattleSnake
|
||||||
|
from snakes.bitboard import BitBoard
|
||||||
|
from server.GameBoard import GameBoard
|
||||||
|
|
||||||
|
# Direction offsets for coord-dict → tuple conversion
|
||||||
|
_DIR_DELTAS = ((0, 1), (0, -1), (-1, 0), (1, 0))
|
||||||
|
_DIR_NAMES = ("up", "down", "left", "right")
|
||||||
|
|
||||||
|
class 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)
|
||||||
|
|
||||||
|
# ── 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
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
"""SupremeBattleSnake v1.0.0
|
||||||
|
|
||||||
|
Built on ApexBattleSnake v1.0.0. All strategic logic is inherited.
|
||||||
|
Performance improvement: all spatial primitives (flood fill, territory,
|
||||||
|
articulation detection, distance maps, pathfinding) replaced by a
|
||||||
|
bitboard engine that uses integer arithmetic instead of Python sets/deques.
|
||||||
|
|
||||||
|
Key speedups:
|
||||||
|
S1: Bitboard flood fill — replaces BFS deque+set with integer bit-expansion.
|
||||||
|
~60× faster per call, eliminates _neighbors() generator overhead.
|
||||||
|
S2: Bitboard territory — dual-BFS expansion on ints replaces per-cell
|
||||||
|
distance-map comparison loop.
|
||||||
|
S3: Bitboard articulation — partition sizes via bit-flood instead of
|
||||||
|
_bounded_bfs with sets.
|
||||||
|
S4: Bitboard distance map — BFS via bit-expansion + bit-extract.
|
||||||
|
S5: Bitboard path distance — early-exit BFS on ints.
|
||||||
|
S6: Bitboard nearest food — BFS food search on ints.
|
||||||
|
S7: Per-turn BitBoard instance cached for board dimensions.
|
||||||
|
S8: Blocked-set → bitboard conversion cached within a turn to avoid
|
||||||
|
redundant O(n) conversions for the same frozen set.
|
||||||
|
S9: Survival-tree uses bitboards natively — enemy body/attack bits
|
||||||
|
precomputed once at tree root, no per-node set/dict rebuilds.
|
||||||
|
S10: _legal_moves override uses bitboard neighbour mask instead of
|
||||||
|
per-direction Python loop + _in_bounds calls.
|
||||||
|
S11: _future_survival_tree inlines legal-move check with bitboard ops.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Any
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
from snakes.ApexBattleSnake import ApexBattleSnake
|
||||||
|
from snakes.bitboard import BitBoard
|
||||||
|
from server.GameBoard import GameBoard
|
||||||
|
|
||||||
|
# Direction offsets for coord-dict → tuple conversion
|
||||||
|
_DIR_DELTAS = ((0, 1), (0, -1), (-1, 0), (1, 0))
|
||||||
|
_DIR_NAMES = ("up", "down", "left", "right")
|
||||||
|
|
||||||
|
class SupremeBattleSnake_ClaudeOpus4_6(ApexBattleSnake):
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.name = "SupremeBattleSnake"
|
||||||
|
self.version = self.VERSION
|
||||||
|
|
||||||
|
# S7: cached BitBoard instance (reused while board dimensions stay the same)
|
||||||
|
self._bb: BitBoard | None = None
|
||||||
|
self._bb_w: int = 0
|
||||||
|
self._bb_h: int = 0
|
||||||
|
|
||||||
|
# S8: per-turn frozenset → bitboard conversion cache
|
||||||
|
self._bits_cache: dict[int, int] = {}
|
||||||
|
self._bits_cache_turn: int = -1
|
||||||
|
|
||||||
|
# S9: precomputed enemy state for survival tree (set per turn in choose_move)
|
||||||
|
self._enemy_body_bits: int = 0 # all enemy body cells as bitboard
|
||||||
|
self._enemy_tail_bits: int = 0 # enemy tails that will vacate
|
||||||
|
self._enemy_attack_danger: int = 0 # tiles where enemy len >= our len
|
||||||
|
self._enemy_attack_opportunity: int = 0 # tiles where enemy len < our len
|
||||||
|
|
||||||
|
# ── BitBoard accessor ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_bb(self, width: int, height: int) -> BitBoard:
|
||||||
|
"""Return (possibly cached) BitBoard for the current dimensions."""
|
||||||
|
if self._bb is None or width != self._bb_w or height != self._bb_h:
|
||||||
|
self._bb = BitBoard(width, height)
|
||||||
|
self._bb_w = width
|
||||||
|
self._bb_h = height
|
||||||
|
return self._bb
|
||||||
|
|
||||||
|
def _blocked_to_bits(self, blocked: set[tuple[int, int]], width: int, height: int) -> int:
|
||||||
|
"""Convert a blocked set to a bitboard, with per-turn caching."""
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
sid = id(blocked)
|
||||||
|
cached = self._bits_cache.get(sid)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
bits = bb.set_to_bits(blocked)
|
||||||
|
self._bits_cache[sid] = bits
|
||||||
|
return bits
|
||||||
|
|
||||||
|
# ── choose_move override: reset caches + precompute enemy bits ───────────
|
||||||
|
|
||||||
|
def choose_move(self, game_data: GameBoard) -> str:
|
||||||
|
turn = game_data.get_turn()
|
||||||
|
if turn != self._bits_cache_turn:
|
||||||
|
self._bits_cache = {}
|
||||||
|
self._bits_cache_turn = turn
|
||||||
|
|
||||||
|
bb = self._get_bb(game_data.get_width(), game_data.get_height())
|
||||||
|
|
||||||
|
# S9: precompute enemy body / tail / attack bitboards for survival tree
|
||||||
|
other_snakes = game_data.get_other_snakes()
|
||||||
|
my_snake = game_data.get_my_snake()
|
||||||
|
my_len = my_snake.get("length", len(my_snake["body"]))
|
||||||
|
food_set = {(f["x"], f["y"]) for f in game_data.get_food()}
|
||||||
|
game_type = game_data.get_type()
|
||||||
|
is_constrictor = game_type == "constrictor"
|
||||||
|
w = bb.width
|
||||||
|
|
||||||
|
enemy_body_bits = 0
|
||||||
|
enemy_tail_bits = 0
|
||||||
|
enemy_attack_danger = 0
|
||||||
|
enemy_attack_opportunity = 0
|
||||||
|
|
||||||
|
for snake in other_snakes:
|
||||||
|
for seg in snake["body"]:
|
||||||
|
enemy_body_bits |= 1 << (seg["y"] * w + seg["x"])
|
||||||
|
body = snake["body"]
|
||||||
|
# Check if tail will vacate
|
||||||
|
if not is_constrictor and len(body) >= 2:
|
||||||
|
tail_stacked = (body[-1]["x"] == body[-2]["x"] and body[-1]["y"] == body[-2]["y"])
|
||||||
|
if not tail_stacked:
|
||||||
|
can_grow = self._enemy_can_grow_this_turn(snake, food_set)
|
||||||
|
if not can_grow:
|
||||||
|
enemy_tail_bits |= 1 << (body[-1]["y"] * w + body[-1]["x"])
|
||||||
|
|
||||||
|
# Attack map: tiles enemy head can reach in 1 move
|
||||||
|
eh = snake["head"]
|
||||||
|
e_len = snake.get("length", len(body))
|
||||||
|
ehx, ehy = eh["x"], eh["y"]
|
||||||
|
for dx, dy in _DIR_DELTAS:
|
||||||
|
nx, ny = ehx + dx, ehy + dy
|
||||||
|
if 0 <= nx < w and 0 <= ny < bb.height:
|
||||||
|
bit = 1 << (ny * w + nx)
|
||||||
|
if e_len >= my_len:
|
||||||
|
enemy_attack_danger |= bit
|
||||||
|
else:
|
||||||
|
enemy_attack_opportunity |= bit
|
||||||
|
|
||||||
|
self._enemy_body_bits = enemy_body_bits
|
||||||
|
self._enemy_tail_bits = enemy_tail_bits
|
||||||
|
self._enemy_attack_danger = enemy_attack_danger
|
||||||
|
self._enemy_attack_opportunity = enemy_attack_opportunity
|
||||||
|
|
||||||
|
return super().choose_move(game_data)
|
||||||
|
|
||||||
|
# ── S1: Bitboard flood fill ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _flood_fill_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
|
start_idx = bb.idx(start[0], start[1])
|
||||||
|
|
||||||
|
# A7/E2: per-turn transposition cache (kept from Apex)
|
||||||
|
cache_key = (start, frozenset(blocked))
|
||||||
|
cached = self._bfs_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
result = bb.flood_count(start_idx, blocked_bits)
|
||||||
|
|
||||||
|
if len(self._bfs_cache) < self._bfs_cache_max:
|
||||||
|
self._bfs_cache[cache_key] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── S2: Bitboard territory ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _territory_fast(
|
||||||
|
self, my_pos: tuple, blocked: set, width: int, height: int,
|
||||||
|
deadline: float | None = None,
|
||||||
|
) -> int:
|
||||||
|
if not self._enemy_heads:
|
||||||
|
return 0
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
|
my_idx = bb.idx(my_pos[0], my_pos[1])
|
||||||
|
enemy_idxs = [bb.idx(eh[0], eh[1]) for eh in self._enemy_heads]
|
||||||
|
return bb.territory(my_idx, enemy_idxs, blocked_bits)
|
||||||
|
|
||||||
|
# ── S3: Bitboard articulation penalty ────────────────────────────────────
|
||||||
|
|
||||||
|
def _articulation_penalty(
|
||||||
|
self, point: tuple, blocked: set, width: int, height: int, required_space: int,
|
||||||
|
) -> float:
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
|
point_idx = bb.idx(point[0], point[1])
|
||||||
|
|
||||||
|
sizes = bb.partition_sizes(point_idx, blocked_bits)
|
||||||
|
if not sizes:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
min_size = min(sizes)
|
||||||
|
if min_size < required_space:
|
||||||
|
return 1500.0
|
||||||
|
elif min_size < required_space * 2:
|
||||||
|
return 400.0
|
||||||
|
else:
|
||||||
|
return 85.0
|
||||||
|
|
||||||
|
def _bounded_bfs(self, start: tuple, blocked: set, width: int, height: int, limit: int) -> set:
|
||||||
|
"""Bitboard-accelerated bounded BFS. Returns a set for API compatibility."""
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
|
start_idx = bb.idx(start[0], start[1])
|
||||||
|
reachable_bits = bb.flood_fill(start_idx, blocked_bits)
|
||||||
|
|
||||||
|
result: set[tuple[int, int]] = set()
|
||||||
|
temp = reachable_bits
|
||||||
|
w = bb.width
|
||||||
|
while temp:
|
||||||
|
bit = temp & (-temp)
|
||||||
|
idx = bit.bit_length() - 1
|
||||||
|
result.add((idx % w, idx // w))
|
||||||
|
temp ^= bit
|
||||||
|
if len(result) >= limit:
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── S4: Bitboard distance map ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _distance_map(self, start: tuple, blocked: set, width: int, height: int) -> dict:
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
|
start_idx = bb.idx(start[0], start[1])
|
||||||
|
idx_dmap = bb.distance_map(start_idx, blocked_bits)
|
||||||
|
w = bb.width
|
||||||
|
return {(idx % w, idx // w): d for idx, d in idx_dmap.items()}
|
||||||
|
|
||||||
|
# ── S5: Bitboard path distance ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _path_distance(
|
||||||
|
self, start: tuple, goal: tuple, blocked: set, width: int, height: int,
|
||||||
|
) -> int | None:
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
|
return bb.path_distance(
|
||||||
|
bb.idx(start[0], start[1]),
|
||||||
|
bb.idx(goal[0], goal[1]),
|
||||||
|
blocked_bits,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── S6: Bitboard nearest food ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _nearest_food_info(
|
||||||
|
self, start: tuple, food_set: set, blocked: set, width: int, height: int,
|
||||||
|
) -> tuple[int | None, tuple | None]:
|
||||||
|
if not food_set:
|
||||||
|
return None, None
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
|
food_bits = bb.set_to_bits(food_set)
|
||||||
|
start_idx = bb.idx(start[0], start[1])
|
||||||
|
dist, cell_idx = bb.nearest_food(start_idx, food_bits, blocked_bits)
|
||||||
|
if dist is None or cell_idx is None:
|
||||||
|
return None, None
|
||||||
|
return dist, bb.coord(cell_idx)
|
||||||
|
|
||||||
|
# ── Bitboard open-neighbour helpers ──────────────────────────────────────
|
||||||
|
|
||||||
|
def _open_neighbor_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
|
return bb.open_neighbor_count(bb.idx(start[0], start[1]), blocked_bits)
|
||||||
|
|
||||||
|
def _next_turn_options(self, head: dict, blocked: set, width: int, height: int) -> int:
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
|
return bb.open_neighbor_count(bb.idx(head["x"], head["y"]), blocked_bits)
|
||||||
|
|
||||||
|
# ── S9: Optimised survival tree (bitboard-native) ────────────────────────
|
||||||
|
|
||||||
|
def _future_position_score(
|
||||||
|
self, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool,
|
||||||
|
width: int, height: int, enemy_can_grow: dict, deadline: float | None,
|
||||||
|
) -> float:
|
||||||
|
"""S9: Bitboard-native position scoring for the survival tree.
|
||||||
|
|
||||||
|
Builds blocked bitboard directly from body lists (no intermediate set).
|
||||||
|
Uses precomputed enemy bits instead of rebuilding attack map per node.
|
||||||
|
"""
|
||||||
|
if deadline is not None and perf_counter() >= deadline:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
bb = self._bb # already initialised in choose_move
|
||||||
|
w = bb.width
|
||||||
|
head = my_body[0]
|
||||||
|
hx, hy = head["x"], head["y"]
|
||||||
|
head_idx = hy * w + hx
|
||||||
|
head_bit = 1 << head_idx
|
||||||
|
body_len = len(my_body)
|
||||||
|
|
||||||
|
# ── Build blocked bitboard directly (no set) ──────────────────────
|
||||||
|
my_bits = 0
|
||||||
|
for seg in my_body:
|
||||||
|
my_bits |= 1 << (seg["y"] * w + seg["x"])
|
||||||
|
|
||||||
|
# Own tail vacates unless stacked or constrictor
|
||||||
|
if not is_constrictor and body_len >= 2:
|
||||||
|
t, t2 = my_body[-1], my_body[-2]
|
||||||
|
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
|
||||||
|
my_bits &= ~(1 << (t["y"] * w + t["x"]))
|
||||||
|
|
||||||
|
# Enemy body (precomputed) minus vacating tails
|
||||||
|
en_bits = self._enemy_body_bits & ~self._enemy_tail_bits
|
||||||
|
|
||||||
|
blocked_bits = (my_bits | en_bits) & ~head_bit
|
||||||
|
|
||||||
|
# ── Reachable space ───────────────────────────────────────────────
|
||||||
|
reachable = bb.flood_count(head_idx, blocked_bits)
|
||||||
|
required = body_len + max(3, body_len // 6) if is_constrictor else body_len
|
||||||
|
if reachable < required:
|
||||||
|
return -5000.0
|
||||||
|
|
||||||
|
# ── Open neighbours (liberties) ───────────────────────────────────
|
||||||
|
nb_free = bb._neighbor_masks[head_idx] & ~blocked_bits & bb.board_mask
|
||||||
|
liberties = nb_free.bit_count()
|
||||||
|
if liberties == 0:
|
||||||
|
return -5000.0
|
||||||
|
|
||||||
|
# ── Safe next options (enemy-attack aware) ────────────────────────
|
||||||
|
# Remove tiles where an enemy of >= our length could head-to-head.
|
||||||
|
# The danger bitboard was precomputed; filter out tiles blocked by
|
||||||
|
# current body (enemy can't step there either).
|
||||||
|
danger_here = self._enemy_attack_danger & ~blocked_bits
|
||||||
|
safe_nb = nb_free & ~danger_here
|
||||||
|
en_safe = safe_nb.bit_count()
|
||||||
|
|
||||||
|
if en_safe == 0:
|
||||||
|
return -4000.0
|
||||||
|
|
||||||
|
sc = reachable * 1.9 + liberties * 14.0 + liberties * 11.0 + en_safe * 26.0
|
||||||
|
if en_safe == 1:
|
||||||
|
sc -= 420.0
|
||||||
|
return sc
|
||||||
|
|
||||||
|
def _future_survival_tree(
|
||||||
|
self, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool,
|
||||||
|
width: int, height: int, enemy_can_grow: dict,
|
||||||
|
depth: int, branch: int, deadline: float | None,
|
||||||
|
) -> float:
|
||||||
|
"""S9/S11: Bitboard-accelerated survival tree.
|
||||||
|
|
||||||
|
Inlines legal-move check with bitboard ops instead of per-direction
|
||||||
|
Python loops. Uses the bitboard-native _future_position_score.
|
||||||
|
"""
|
||||||
|
if depth <= 0 or (deadline is not None and perf_counter() >= deadline):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
bb = self._bb
|
||||||
|
w = bb.width
|
||||||
|
h = bb.height
|
||||||
|
head = my_body[0]
|
||||||
|
hx, hy = head["x"], head["y"]
|
||||||
|
head_idx = hy * w + hx
|
||||||
|
body_len = len(my_body)
|
||||||
|
|
||||||
|
# ── Build occupied bitboard for legal-move check ──────────────────
|
||||||
|
occupied_bits = 0
|
||||||
|
for seg in my_body:
|
||||||
|
occupied_bits |= 1 << (seg["y"] * w + seg["x"])
|
||||||
|
occupied_bits |= self._enemy_body_bits
|
||||||
|
|
||||||
|
# Own tail can be stepped on if not stacked/constrictor
|
||||||
|
passable = 0
|
||||||
|
if not is_constrictor and body_len >= 2:
|
||||||
|
t, t2 = my_body[-1], my_body[-2]
|
||||||
|
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
|
||||||
|
passable |= 1 << (t["y"] * w + t["x"])
|
||||||
|
|
||||||
|
# Enemy vacating tails are also steppable
|
||||||
|
passable |= self._enemy_tail_bits
|
||||||
|
|
||||||
|
# Legal moves: free neighbours OR passable tiles
|
||||||
|
legal_bits = bb._neighbor_masks[head_idx] & ((~occupied_bits & bb.board_mask) | passable)
|
||||||
|
|
||||||
|
if not legal_bits:
|
||||||
|
return -5000.0
|
||||||
|
|
||||||
|
# ── Precompute food bitboard once ─────────────────────────────────
|
||||||
|
food_bits_local = 0
|
||||||
|
for fx, fy in food_set:
|
||||||
|
food_bits_local |= 1 << (fy * w + fx)
|
||||||
|
|
||||||
|
# ── Score each legal move ─────────────────────────────────────────
|
||||||
|
scored: list[tuple[float, list]] = []
|
||||||
|
temp = legal_bits
|
||||||
|
while temp:
|
||||||
|
if deadline is not None and perf_counter() >= deadline:
|
||||||
|
break
|
||||||
|
bit = temp & (-temp)
|
||||||
|
temp ^= bit
|
||||||
|
idx = bit.bit_length() - 1
|
||||||
|
nx, ny = idx % w, idx // w
|
||||||
|
pos = {"x": nx, "y": ny}
|
||||||
|
ate = bool(bit & food_bits_local)
|
||||||
|
fb = self._future_body(my_body, pos, ate, is_constrictor)
|
||||||
|
sc = self._future_position_score(
|
||||||
|
fb, other_snakes, food_set, is_constrictor,
|
||||||
|
width, height, enemy_can_grow, deadline,
|
||||||
|
)
|
||||||
|
scored.append((sc, fb))
|
||||||
|
|
||||||
|
if not scored:
|
||||||
|
return -5000.0
|
||||||
|
|
||||||
|
DEATH = self._TREE_DEATH_THRESHOLD
|
||||||
|
viable = [(sc, fb) for sc, fb in scored if sc > DEATH]
|
||||||
|
if not viable:
|
||||||
|
return max(sc for sc, _ in scored)
|
||||||
|
|
||||||
|
viable.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
if depth == 1:
|
||||||
|
return viable[0][0]
|
||||||
|
|
||||||
|
best = viable[0][0]
|
||||||
|
for sc, fb in viable[:branch]:
|
||||||
|
if deadline is not None and perf_counter() >= deadline:
|
||||||
|
break
|
||||||
|
cont = self._future_survival_tree(
|
||||||
|
fb, other_snakes, food_set, is_constrictor,
|
||||||
|
width, height, enemy_can_grow, depth - 1, branch, deadline,
|
||||||
|
)
|
||||||
|
total = sc + cont * 0.72
|
||||||
|
if total > best:
|
||||||
|
best = total
|
||||||
|
return best
|
||||||
|
|
||||||
|
# ── S10: Bitboard legal moves ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _legal_moves(
|
||||||
|
self, my_head, my_body: list, other_snakes: list,
|
||||||
|
food_set: set, is_constrictor: bool, width: int, height: int,
|
||||||
|
enemy_can_grow: dict | None = None,
|
||||||
|
):
|
||||||
|
"""S10: Bitboard-accelerated legal move generation."""
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
w = bb.width
|
||||||
|
|
||||||
|
# Build occupied bitboard
|
||||||
|
occupied = 0
|
||||||
|
for seg in my_body:
|
||||||
|
occupied |= 1 << (seg["y"] * w + seg["x"])
|
||||||
|
for snake in other_snakes:
|
||||||
|
for seg in snake["body"]:
|
||||||
|
occupied |= 1 << (seg["y"] * w + seg["x"])
|
||||||
|
|
||||||
|
hx, hy = my_head["x"], my_head["y"]
|
||||||
|
head_idx = hy * w + hx
|
||||||
|
|
||||||
|
# Own tail can be stepped on
|
||||||
|
passable = 0
|
||||||
|
if not is_constrictor and len(my_body) >= 2:
|
||||||
|
t, t2 = my_body[-1], my_body[-2]
|
||||||
|
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
|
||||||
|
passable |= 1 << (t["y"] * w + t["x"])
|
||||||
|
|
||||||
|
# Enemy tails that will vacate
|
||||||
|
if not is_constrictor:
|
||||||
|
for snake in other_snakes:
|
||||||
|
sbody = snake["body"]
|
||||||
|
if len(sbody) < 2:
|
||||||
|
continue
|
||||||
|
st, st2 = sbody[-1], sbody[-2]
|
||||||
|
if st["x"] == st2["x"] and st["y"] == st2["y"]:
|
||||||
|
continue # stacked
|
||||||
|
sid = snake.get("id")
|
||||||
|
can_grow = None
|
||||||
|
if enemy_can_grow is not None and sid is not None:
|
||||||
|
can_grow = enemy_can_grow.get(sid)
|
||||||
|
if can_grow is None:
|
||||||
|
can_grow = self._enemy_can_grow_this_turn(snake, food_set)
|
||||||
|
if not can_grow:
|
||||||
|
passable |= 1 << (st["y"] * w + st["x"])
|
||||||
|
|
||||||
|
legal = bb._neighbor_masks[head_idx] & ((~occupied & bb.board_mask) | passable)
|
||||||
|
|
||||||
|
safe: dict[str, dict[str, int]] = {}
|
||||||
|
for name, (dx, dy) in self.DIRECTIONS.items():
|
||||||
|
nx, ny = hx + dx, hy + dy
|
||||||
|
if 0 <= nx < w and 0 <= ny < bb.height:
|
||||||
|
if (1 << (ny * w + nx)) & legal:
|
||||||
|
safe[name] = {"x": nx, "y": ny}
|
||||||
|
return safe
|
||||||
|
|
||||||
|
# ── Enemy confinement (uses bitboard flood) ──────────────────────────────
|
||||||
|
|
||||||
|
def _enemy_confinement_metrics(
|
||||||
|
self, enemy_head: tuple, blocked: set, width: int, height: int,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
|
eh_idx = bb.idx(enemy_head[0], enemy_head[1])
|
||||||
|
eb_bits = blocked_bits & ~(1 << eh_idx)
|
||||||
|
space = bb.flood_count(eh_idx, eb_bits)
|
||||||
|
options = bb.open_neighbor_count(eh_idx, eb_bits)
|
||||||
|
return space, options
|
||||||
|
|
||||||
|
def _enemy_constrictor_projection(
|
||||||
|
self, other_snakes: list, blocked: set, width: int, height: int,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
bb = self._get_bb(width, height)
|
||||||
|
blocked_bits = self._blocked_to_bits(blocked, width, height)
|
||||||
|
best_space = 0
|
||||||
|
total_opts = 0
|
||||||
|
for enemy in other_snakes:
|
||||||
|
eh = (enemy["head"]["x"], enemy["head"]["y"])
|
||||||
|
eh_idx = bb.idx(eh[0], eh[1])
|
||||||
|
nb = bb.neighbors_of(eh_idx) & ~blocked_bits & bb.board_mask
|
||||||
|
temp = nb
|
||||||
|
while temp:
|
||||||
|
total_opts += 1
|
||||||
|
bit = temp & (-temp)
|
||||||
|
n_idx = bit.bit_length() - 1
|
||||||
|
sp = bb.flood_count(n_idx, blocked_bits | bit)
|
||||||
|
if sp > best_space:
|
||||||
|
best_space = sp
|
||||||
|
temp ^= bit
|
||||||
|
return best_space, total_opts
|
||||||
@@ -10,6 +10,8 @@ SNAKE_REGISTRY = {
|
|||||||
"TrainedBattleSnake": "0.1.0",
|
"TrainedBattleSnake": "0.1.0",
|
||||||
"UltimateBattleSnake": "4.5.0",
|
"UltimateBattleSnake": "4.5.0",
|
||||||
"ApexBattleSnake": "1.0.0",
|
"ApexBattleSnake": "1.0.0",
|
||||||
|
"SupremeBattleSnake_ClaudeOpus4_6": "1.0.0",
|
||||||
|
"PrismBattleSnake_GPT_5_6_Sol": "1.0.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_SNAKE_CONFIG = {
|
DEFAULT_SNAKE_CONFIG = {
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""Bitboard engine for Battlesnake grid spatial operations.
|
||||||
|
|
||||||
|
Cell index = y * width + x. Bit *i* of a Python int represents cell *i*.
|
||||||
|
All heavy BFS / flood-fill / territory ops run on plain integer arithmetic —
|
||||||
|
no sets, deques, or per-cell Python objects.
|
||||||
|
|
||||||
|
Typical 11×11 board → 121-bit integers. Python big-int ops on these are
|
||||||
|
extremely fast (single C-level limb operations under the hood).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
class BitBoard:
|
||||||
|
"""Pre-computed masks and fast spatial primitives for a fixed grid size."""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"width", "height", "size", "board_mask",
|
||||||
|
"_not_rightcol", "_not_leftcol",
|
||||||
|
"_neighbor_masks",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, width: int, height: int) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.size = width * height
|
||||||
|
self.board_mask = (1 << self.size) - 1
|
||||||
|
|
||||||
|
# Column masks — prevent bit-shift wrap-around at row boundaries
|
||||||
|
rightcol = 0
|
||||||
|
leftcol = 0
|
||||||
|
for y in range(height):
|
||||||
|
rightcol |= 1 << (y * width + width - 1)
|
||||||
|
leftcol |= 1 << (y * width)
|
||||||
|
self._not_rightcol = self.board_mask & ~rightcol
|
||||||
|
self._not_leftcol = self.board_mask & ~leftcol
|
||||||
|
|
||||||
|
# Per-cell neighbour bitmask (4-connected)
|
||||||
|
nb = [0] * self.size
|
||||||
|
for idx in range(self.size):
|
||||||
|
x, y = idx % width, idx // width
|
||||||
|
mask = 0
|
||||||
|
if x > 0:
|
||||||
|
mask |= 1 << (idx - 1)
|
||||||
|
if x < width - 1:
|
||||||
|
mask |= 1 << (idx + 1)
|
||||||
|
if y > 0:
|
||||||
|
mask |= 1 << (idx - width)
|
||||||
|
if y < height - 1:
|
||||||
|
mask |= 1 << (idx + width)
|
||||||
|
nb[idx] = mask
|
||||||
|
self._neighbor_masks = nb
|
||||||
|
|
||||||
|
# ── Coordinate helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def idx(self, x: int, y: int) -> int:
|
||||||
|
"""(x, y) → flat index."""
|
||||||
|
return y * self.width + x
|
||||||
|
|
||||||
|
def coord(self, flat: int) -> tuple[int, int]:
|
||||||
|
"""Flat index → (x, y)."""
|
||||||
|
return flat % self.width, flat // self.width
|
||||||
|
|
||||||
|
def pt_bit(self, x: int, y: int) -> int:
|
||||||
|
"""Single-cell bitmask for (x, y)."""
|
||||||
|
return 1 << (y * self.width + x)
|
||||||
|
|
||||||
|
def set_to_bits(self, points: set[tuple[int, int]]) -> int:
|
||||||
|
"""Convert a set of (x, y) tuples to a bitmask."""
|
||||||
|
w = self.width
|
||||||
|
bits = 0
|
||||||
|
for x, y in points:
|
||||||
|
bits |= 1 << (y * w + x)
|
||||||
|
return bits
|
||||||
|
|
||||||
|
def in_bounds(self, x: int, y: int) -> bool:
|
||||||
|
return 0 <= x < self.width and 0 <= y < self.height
|
||||||
|
|
||||||
|
# ── Core spatial primitives ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def flood_fill(self, start_idx: int, blocked_bits: int) -> int:
|
||||||
|
"""Return bitmask of all cells reachable from *start_idx* (inclusive)."""
|
||||||
|
free = self.board_mask & ~blocked_bits
|
||||||
|
start_bit = 1 << start_idx
|
||||||
|
# If start is blocked, return just itself
|
||||||
|
if not (start_bit & free):
|
||||||
|
return start_bit
|
||||||
|
|
||||||
|
reachable = start_bit
|
||||||
|
frontier = start_bit
|
||||||
|
w = self.width
|
||||||
|
nrc = self._not_rightcol
|
||||||
|
nlc = self._not_leftcol
|
||||||
|
|
||||||
|
while frontier:
|
||||||
|
expanded = (
|
||||||
|
((frontier & nrc) << 1)
|
||||||
|
| ((frontier & nlc) >> 1)
|
||||||
|
| (frontier << w)
|
||||||
|
| (frontier >> w)
|
||||||
|
) & free & ~reachable
|
||||||
|
if not expanded:
|
||||||
|
break
|
||||||
|
reachable |= expanded
|
||||||
|
frontier = expanded
|
||||||
|
|
||||||
|
return reachable
|
||||||
|
|
||||||
|
def flood_count(self, start_idx: int, blocked_bits: int) -> int:
|
||||||
|
"""Count of cells reachable from *start_idx*."""
|
||||||
|
return self.flood_fill(start_idx, blocked_bits).bit_count()
|
||||||
|
|
||||||
|
def open_neighbor_count(self, cell_idx: int, blocked_bits: int) -> int:
|
||||||
|
"""Number of free neighbours of *cell_idx*."""
|
||||||
|
return (self._neighbor_masks[cell_idx] & ~blocked_bits & self.board_mask).bit_count()
|
||||||
|
|
||||||
|
def neighbors_of(self, cell_idx: int) -> int:
|
||||||
|
"""Bitmask of 4-connected neighbours (may include blocked cells)."""
|
||||||
|
return self._neighbor_masks[cell_idx]
|
||||||
|
|
||||||
|
# ── Territory (dual-BFS expansion) ────────────────────────────────────────
|
||||||
|
|
||||||
|
def territory(
|
||||||
|
self,
|
||||||
|
my_idx: int,
|
||||||
|
enemy_indices: list[int],
|
||||||
|
blocked_bits: int,
|
||||||
|
) -> int:
|
||||||
|
"""Simultaneous BFS from *my_idx* and all enemies.
|
||||||
|
|
||||||
|
Returns (my_cells − enemy_cells). Cells equidistant from both sides are
|
||||||
|
counted for neither (contested).
|
||||||
|
"""
|
||||||
|
if not enemy_indices:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
free = self.board_mask & ~blocked_bits
|
||||||
|
w = self.width
|
||||||
|
nrc = self._not_rightcol
|
||||||
|
nlc = self._not_leftcol
|
||||||
|
|
||||||
|
my_front = 1 << my_idx
|
||||||
|
my_terr = my_front
|
||||||
|
|
||||||
|
en_front = 0
|
||||||
|
for ei in enemy_indices:
|
||||||
|
en_front |= 1 << ei
|
||||||
|
en_terr = en_front
|
||||||
|
|
||||||
|
remaining = free & ~my_terr & ~en_terr
|
||||||
|
|
||||||
|
while (my_front or en_front) and remaining:
|
||||||
|
# Expand both sides simultaneously (same BFS depth → ties go to neither)
|
||||||
|
my_exp = 0
|
||||||
|
if my_front:
|
||||||
|
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)
|
||||||
|
|
||||||
|
my_front = my_exp
|
||||||
|
en_front = en_exp
|
||||||
|
|
||||||
|
return my_terr.bit_count() - en_terr.bit_count()
|
||||||
|
|
||||||
|
# ── Partition sizes (for articulation-point detection) ────────────────────
|
||||||
|
|
||||||
|
def partition_sizes(self, cut_idx: int, blocked_bits: int) -> list[int]:
|
||||||
|
"""Remove *cut_idx* from the free space and return sizes of each
|
||||||
|
resulting connected component among its neighbours.
|
||||||
|
|
||||||
|
Returns an empty list when the point is not a cut vertex (single component
|
||||||
|
or ≤1 free neighbour).
|
||||||
|
"""
|
||||||
|
test_blocked = blocked_bits | (1 << cut_idx)
|
||||||
|
free_nb = self._neighbor_masks[cut_idx] & ~test_blocked & self.board_mask
|
||||||
|
if free_nb.bit_count() <= 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
seen_all = 0
|
||||||
|
sizes: list[int] = []
|
||||||
|
|
||||||
|
temp = free_nb
|
||||||
|
while temp:
|
||||||
|
bit = temp & (-temp) # lowest set bit
|
||||||
|
temp ^= bit
|
||||||
|
if bit & seen_all:
|
||||||
|
continue
|
||||||
|
component = self.flood_fill(bit.bit_length() - 1, test_blocked)
|
||||||
|
seen_all |= component
|
||||||
|
sizes.append(component.bit_count())
|
||||||
|
|
||||||
|
return sizes if len(sizes) > 1 else []
|
||||||
|
|
||||||
|
# ── BFS distance map (indexed by cell idx) ────────────────────────────────
|
||||||
|
|
||||||
|
def distance_map(self, start_idx: int, blocked_bits: int) -> dict[int, int]:
|
||||||
|
"""BFS distance from *start_idx* to every reachable cell.
|
||||||
|
|
||||||
|
Returns ``{cell_idx: distance}`` — same semantics as the original
|
||||||
|
``_distance_map`` but using bitboard expansion internally.
|
||||||
|
"""
|
||||||
|
free = self.board_mask & ~blocked_bits
|
||||||
|
start_bit = 1 << start_idx
|
||||||
|
distances: dict[int, int] = {start_idx: 0}
|
||||||
|
frontier = start_bit
|
||||||
|
seen = frontier
|
||||||
|
dist = 0
|
||||||
|
w = self.width
|
||||||
|
nrc = self._not_rightcol
|
||||||
|
nlc = self._not_leftcol
|
||||||
|
|
||||||
|
while frontier:
|
||||||
|
dist += 1
|
||||||
|
expanded = (
|
||||||
|
((frontier & nrc) << 1)
|
||||||
|
| ((frontier & nlc) >> 1)
|
||||||
|
| (frontier << w)
|
||||||
|
| (frontier >> w)
|
||||||
|
) & free & ~seen
|
||||||
|
|
||||||
|
if not expanded:
|
||||||
|
break
|
||||||
|
|
||||||
|
seen |= expanded
|
||||||
|
# Extract individual bits
|
||||||
|
temp = expanded
|
||||||
|
while temp:
|
||||||
|
bit = temp & (-temp)
|
||||||
|
idx = bit.bit_length() - 1
|
||||||
|
distances[idx] = dist
|
||||||
|
temp ^= bit
|
||||||
|
|
||||||
|
frontier = expanded
|
||||||
|
|
||||||
|
return distances
|
||||||
|
|
||||||
|
# ── Path distance (BFS to single target) ──────────────────────────────────
|
||||||
|
|
||||||
|
def path_distance(
|
||||||
|
self,
|
||||||
|
start_idx: int,
|
||||||
|
goal_idx: int,
|
||||||
|
blocked_bits: int,
|
||||||
|
) -> int | None:
|
||||||
|
"""Shortest path length from *start_idx* to *goal_idx*, or ``None``."""
|
||||||
|
# Unblock the goal cell so BFS can reach it
|
||||||
|
free = (self.board_mask & ~blocked_bits) | (1 << goal_idx)
|
||||||
|
start_bit = 1 << start_idx
|
||||||
|
goal_bit = 1 << goal_idx
|
||||||
|
|
||||||
|
if start_idx == goal_idx:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
frontier = start_bit
|
||||||
|
seen = frontier
|
||||||
|
dist = 0
|
||||||
|
w = self.width
|
||||||
|
nrc = self._not_rightcol
|
||||||
|
nlc = self._not_leftcol
|
||||||
|
|
||||||
|
while frontier:
|
||||||
|
dist += 1
|
||||||
|
expanded = (
|
||||||
|
((frontier & nrc) << 1)
|
||||||
|
| ((frontier & nlc) >> 1)
|
||||||
|
| (frontier << w)
|
||||||
|
| (frontier >> w)
|
||||||
|
) & free & ~seen
|
||||||
|
|
||||||
|
if not expanded:
|
||||||
|
break
|
||||||
|
|
||||||
|
if expanded & goal_bit:
|
||||||
|
return dist
|
||||||
|
|
||||||
|
seen |= expanded
|
||||||
|
frontier = expanded
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Nearest-food BFS ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def nearest_food(
|
||||||
|
self,
|
||||||
|
start_idx: int,
|
||||||
|
food_bits: int,
|
||||||
|
blocked_bits: int,
|
||||||
|
) -> tuple[int | None, int | None]:
|
||||||
|
"""BFS from *start_idx* to nearest food cell.
|
||||||
|
|
||||||
|
Food cells are passable even if in *blocked_bits* (matching original
|
||||||
|
``_nearest_food_info`` semantics).
|
||||||
|
|
||||||
|
Returns ``(distance, cell_idx)`` or ``(None, None)``.
|
||||||
|
"""
|
||||||
|
if not food_bits:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Food tiles are always steppable
|
||||||
|
free = (self.board_mask & ~blocked_bits) | food_bits
|
||||||
|
start_bit = 1 << start_idx
|
||||||
|
|
||||||
|
# Check start
|
||||||
|
if start_bit & food_bits:
|
||||||
|
return 0, start_idx
|
||||||
|
|
||||||
|
frontier = start_bit
|
||||||
|
seen = frontier
|
||||||
|
dist = 0
|
||||||
|
w = self.width
|
||||||
|
nrc = self._not_rightcol
|
||||||
|
nlc = self._not_leftcol
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return None, None
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
class TestBitBoard(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_flood_fill_respects_walls(self):
|
||||||
|
board = BitBoard(3, 3)
|
||||||
|
blocked = board.set_to_bits({(1, 0), (1, 1), (1, 2)})
|
||||||
|
|
||||||
|
self.assertEqual(board.flood_count(board.idx(0, 1), blocked), 3)
|
||||||
|
self.assertEqual(board.path_distance(board.idx(0, 1), board.idx(2, 1), blocked), None)
|
||||||
|
|
||||||
|
def test_territory_counts_ties_for_neither_side(self):
|
||||||
|
board = BitBoard(5, 1)
|
||||||
|
|
||||||
|
self.assertEqual(board.territory(board.idx(0, 0), [board.idx(4, 0)], 0), 0)
|
||||||
|
|
||||||
|
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)))
|
||||||
|
|
||||||
|
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.assertIsInstance(SnakeBuilder.build("PrismBattleSnake_GPT_5_6_Sol"), PrismBattleSnake_GPT_5_6_Sol)
|
||||||
|
|
||||||
|
def test_bitboard_primitives_match_apex(self):
|
||||||
|
apex = ApexBattleSnake()
|
||||||
|
prism = PrismBattleSnake_GPT_5_6_Sol()
|
||||||
|
blocked = {(1, 0), (1, 1), (3, 2), (3, 3)}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
prism._flood_fill_count((0, 0), blocked, 5, 5),
|
||||||
|
apex._flood_fill_count((0, 0), blocked, 5, 5),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
prism._distance_map((0, 0), blocked, 5, 5),
|
||||||
|
apex._distance_map((0, 0), blocked, 5, 5),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
prism._path_distance((0, 0), (4, 4), blocked, 5, 5),
|
||||||
|
apex._path_distance((0, 0), (4, 4), blocked, 5, 5),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mutated_blocked_set_does_not_return_stale_result(self):
|
||||||
|
snake = PrismBattleSnake_GPT_5_6_Sol()
|
||||||
|
blocked: set[tuple[int, int]] = set()
|
||||||
|
|
||||||
|
open_count = snake._flood_fill_count((1, 1), blocked, 3, 3)
|
||||||
|
blocked.update({(0, 1), (1, 0), (2, 1), (1, 2)})
|
||||||
|
trapped_count = snake._flood_fill_count((1, 1), blocked, 3, 3)
|
||||||
|
|
||||||
|
self.assertEqual(open_count, 9)
|
||||||
|
self.assertEqual(trapped_count, 1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
"""Tests for SupremeBattleSnake.
|
||||||
|
|
||||||
|
Validates that the bitboard-accelerated snake produces correct results
|
||||||
|
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 server.GameBoard import GameBoard
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_board(game_state: dict) -> GameBoard:
|
||||||
|
snake = SupremeBattleSnake()
|
||||||
|
board = GameBoard(
|
||||||
|
game_id=game_state["game"]["id"],
|
||||||
|
width=game_state["board"]["width"],
|
||||||
|
height=game_state["board"]["height"],
|
||||||
|
ruleset=game_state["game"]["ruleset"],
|
||||||
|
source=game_state["game"].get("source", "custom"),
|
||||||
|
map=game_state["game"].get("map", "standard"),
|
||||||
|
snake_class=snake,
|
||||||
|
)
|
||||||
|
board.read_game_data(game_state)
|
||||||
|
return board
|
||||||
|
|
||||||
|
def move(game_state: dict) -> str:
|
||||||
|
return make_board(game_state).snake_neat_make_a_move()
|
||||||
|
|
||||||
|
def gs(
|
||||||
|
my_body: list[tuple],
|
||||||
|
other_bodies: list[list[tuple]] | None = None,
|
||||||
|
foods: list[tuple] | None = None,
|
||||||
|
hazards: list[tuple] | None = None,
|
||||||
|
my_health: int = 90,
|
||||||
|
my_id: str = "me",
|
||||||
|
enemy_health: int = 90,
|
||||||
|
game_type: str = "standard",
|
||||||
|
game_map: str = "standard",
|
||||||
|
hazard_damage: int = 14,
|
||||||
|
width: int = 11,
|
||||||
|
height: int = 11,
|
||||||
|
turn: int = 20,
|
||||||
|
game_id: str = "test-game",
|
||||||
|
) -> dict:
|
||||||
|
other_bodies = other_bodies or []
|
||||||
|
foods = foods or []
|
||||||
|
hazards = hazards or []
|
||||||
|
|
||||||
|
def body_dicts(coords):
|
||||||
|
return [{"x": x, "y": y} for x, y in coords]
|
||||||
|
|
||||||
|
my_snake = {
|
||||||
|
"id": my_id, "name": "SupremeBattleSnake", "health": my_health,
|
||||||
|
"body": body_dicts(my_body),
|
||||||
|
"head": {"x": my_body[0][0], "y": my_body[0][1]},
|
||||||
|
"length": len(my_body),
|
||||||
|
"latency": "50", "shout": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
snakes = [my_snake]
|
||||||
|
for i, body in enumerate(other_bodies):
|
||||||
|
snakes.append({
|
||||||
|
"id": f"enemy-{i}", "name": f"Enemy{i}", "health": enemy_health,
|
||||||
|
"body": body_dicts(body),
|
||||||
|
"head": {"x": body[0][0], "y": body[0][1]},
|
||||||
|
"length": len(body),
|
||||||
|
"latency": "60", "shout": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
ruleset = {
|
||||||
|
"name": game_type, "version": "v1.0.0",
|
||||||
|
"settings": {"hazardDamagePerTurn": hazard_damage},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"game": {"id": game_id, "ruleset": ruleset, "source": "custom", "map": game_map},
|
||||||
|
"turn": turn,
|
||||||
|
"board": {
|
||||||
|
"height": height, "width": width,
|
||||||
|
"food": body_dicts(foods),
|
||||||
|
"hazards": body_dicts(hazards),
|
||||||
|
"snakes": snakes,
|
||||||
|
},
|
||||||
|
"you": my_snake,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── BitBoard unit tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestBitBoard(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_flood_fill_open_board(self):
|
||||||
|
bb = BitBoard(5, 5)
|
||||||
|
count = bb.flood_count(bb.idx(2, 2), 0)
|
||||||
|
self.assertEqual(count, 25)
|
||||||
|
|
||||||
|
def test_flood_fill_blocked_center(self):
|
||||||
|
bb = BitBoard(5, 5)
|
||||||
|
# Block all 4 neighbours of (2,2)
|
||||||
|
blocked = (
|
||||||
|
bb.pt_bit(1, 2) | bb.pt_bit(3, 2)
|
||||||
|
| bb.pt_bit(2, 1) | bb.pt_bit(2, 3)
|
||||||
|
)
|
||||||
|
count = bb.flood_count(bb.idx(2, 2), blocked)
|
||||||
|
self.assertEqual(count, 1) # only the start cell
|
||||||
|
|
||||||
|
def test_flood_fill_row_wall(self):
|
||||||
|
bb = BitBoard(5, 5)
|
||||||
|
# Block entire row y=2, except (2,2) itself
|
||||||
|
blocked = 0
|
||||||
|
for x in range(5):
|
||||||
|
if x != 2:
|
||||||
|
blocked |= bb.pt_bit(x, 2)
|
||||||
|
# Start at (2,3) — should reach everything above the wall
|
||||||
|
count_above = bb.flood_count(bb.idx(2, 3), blocked)
|
||||||
|
self.assertGreater(count_above, 1)
|
||||||
|
self.assertLess(count_above, 25)
|
||||||
|
|
||||||
|
def test_territory_center_vs_corner(self):
|
||||||
|
bb = BitBoard(11, 11)
|
||||||
|
score = bb.territory(bb.idx(5, 5), [bb.idx(0, 0)], 0)
|
||||||
|
self.assertGreater(score, 0)
|
||||||
|
|
||||||
|
def test_territory_symmetric(self):
|
||||||
|
bb = BitBoard(11, 11)
|
||||||
|
score = bb.territory(bb.idx(0, 0), [bb.idx(10, 10)], 0)
|
||||||
|
self.assertEqual(score, 0) # symmetric → tied
|
||||||
|
|
||||||
|
def test_partition_sizes_no_cut(self):
|
||||||
|
bb = BitBoard(5, 5)
|
||||||
|
sizes = bb.partition_sizes(bb.idx(2, 2), 0)
|
||||||
|
# Open board — removing center doesn't split it (all neighbours connected)
|
||||||
|
self.assertEqual(sizes, [])
|
||||||
|
|
||||||
|
def test_partition_sizes_bridge(self):
|
||||||
|
bb = BitBoard(3, 3)
|
||||||
|
# Block corners so (1,1) becomes a bridge:
|
||||||
|
# . X .
|
||||||
|
# X . X
|
||||||
|
# . X .
|
||||||
|
blocked = (
|
||||||
|
bb.pt_bit(0, 0) | bb.pt_bit(2, 0)
|
||||||
|
| bb.pt_bit(0, 2) | bb.pt_bit(2, 2)
|
||||||
|
)
|
||||||
|
sizes = bb.partition_sizes(bb.idx(1, 1), blocked)
|
||||||
|
# Removing (1,1) from the cross → 4 isolated cells
|
||||||
|
self.assertEqual(len(sizes), 4)
|
||||||
|
self.assertTrue(all(s == 1 for s in sizes))
|
||||||
|
|
||||||
|
def test_distance_map_correctness(self):
|
||||||
|
bb = BitBoard(5, 5)
|
||||||
|
dmap = bb.distance_map(bb.idx(0, 0), 0)
|
||||||
|
self.assertEqual(dmap[bb.idx(0, 0)], 0)
|
||||||
|
self.assertEqual(dmap[bb.idx(1, 0)], 1)
|
||||||
|
self.assertEqual(dmap[bb.idx(4, 4)], 8)
|
||||||
|
|
||||||
|
def test_path_distance_blocked(self):
|
||||||
|
bb = BitBoard(5, 5)
|
||||||
|
# Block a wall separating left from right
|
||||||
|
blocked = 0
|
||||||
|
for y in range(5):
|
||||||
|
blocked |= bb.pt_bit(2, y)
|
||||||
|
result = bb.path_distance(bb.idx(0, 0), bb.idx(4, 4), blocked)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_path_distance_unblocked(self):
|
||||||
|
bb = BitBoard(5, 5)
|
||||||
|
result = bb.path_distance(bb.idx(0, 0), bb.idx(4, 4), 0)
|
||||||
|
self.assertEqual(result, 8)
|
||||||
|
|
||||||
|
def test_nearest_food_finds_closest(self):
|
||||||
|
bb = BitBoard(11, 11)
|
||||||
|
food = bb.pt_bit(5, 6) | bb.pt_bit(0, 0)
|
||||||
|
dist, idx = bb.nearest_food(bb.idx(5, 5), food, 0)
|
||||||
|
self.assertEqual(dist, 1)
|
||||||
|
self.assertEqual(idx, bb.idx(5, 6))
|
||||||
|
|
||||||
|
def test_nearest_food_none(self):
|
||||||
|
bb = BitBoard(5, 5)
|
||||||
|
dist, idx = bb.nearest_food(bb.idx(2, 2), 0, 0)
|
||||||
|
self.assertIsNone(dist)
|
||||||
|
|
||||||
|
def test_open_neighbor_count_center(self):
|
||||||
|
bb = BitBoard(5, 5)
|
||||||
|
self.assertEqual(bb.open_neighbor_count(bb.idx(2, 2), 0), 4)
|
||||||
|
|
||||||
|
def test_open_neighbor_count_corner(self):
|
||||||
|
bb = BitBoard(5, 5)
|
||||||
|
self.assertEqual(bb.open_neighbor_count(bb.idx(0, 0), 0), 2)
|
||||||
|
|
||||||
|
def test_set_to_bits_roundtrip(self):
|
||||||
|
bb = BitBoard(11, 11)
|
||||||
|
pts = {(3, 7), (0, 0), (10, 10), (5, 5)}
|
||||||
|
bits = bb.set_to_bits(pts)
|
||||||
|
for x, y in pts:
|
||||||
|
self.assertTrue(bits & bb.pt_bit(x, y))
|
||||||
|
self.assertEqual(bits.bit_count(), len(pts))
|
||||||
|
|
||||||
|
def test_no_row_wraparound(self):
|
||||||
|
"""Right-column expansion must not wrap to the next row's left column."""
|
||||||
|
bb = BitBoard(5, 5)
|
||||||
|
start = bb.idx(4, 0) # rightmost column, bottom row
|
||||||
|
# Block everything except start and (0,1) — if wrapping happened, (0,1) would be adjacent
|
||||||
|
blocked = bb.board_mask & ~(1 << start) & ~bb.pt_bit(0, 1)
|
||||||
|
reachable = bb.flood_fill(start, blocked)
|
||||||
|
self.assertEqual(reachable.bit_count(), 1) # only start itself
|
||||||
|
|
||||||
|
# ── Snake safety tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSupremeWallAndBodyAvoidance(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_avoids_left_wall(self):
|
||||||
|
result = move(gs(my_body=[(0, 5), (1, 5), (2, 5)],
|
||||||
|
other_bodies=[[(9, 9), (9, 8), (9, 7)]]))
|
||||||
|
self.assertNotEqual(result, "left")
|
||||||
|
|
||||||
|
def test_avoids_bottom_wall(self):
|
||||||
|
result = move(gs(my_body=[(5, 0), (5, 1), (5, 2)],
|
||||||
|
other_bodies=[[(9, 9), (9, 8), (9, 7)]]))
|
||||||
|
self.assertNotEqual(result, "down")
|
||||||
|
|
||||||
|
def test_avoids_own_body(self):
|
||||||
|
result = move(gs(my_body=[(5, 5), (6, 5), (7, 5), (8, 5)],
|
||||||
|
other_bodies=[[(1, 1), (1, 2), (1, 3)]],
|
||||||
|
foods=[(5, 9)]))
|
||||||
|
self.assertNotEqual(result, "right")
|
||||||
|
|
||||||
|
def test_avoids_enemy_body(self):
|
||||||
|
result = move(gs(my_body=[(5, 5), (5, 4), (5, 3)],
|
||||||
|
other_bodies=[[(6, 5), (7, 5), (8, 5), (9, 5), (9, 6), (9, 7)]]))
|
||||||
|
self.assertNotEqual(result, "right")
|
||||||
|
|
||||||
|
def test_only_one_safe_move_taken(self):
|
||||||
|
result = move(gs(my_body=[(1, 1), (1, 2), (2, 2), (2, 1)],
|
||||||
|
other_bodies=[], foods=[(5, 5)], width=7, height=7))
|
||||||
|
self.assertEqual(result, "right")
|
||||||
|
|
||||||
|
def test_no_safe_moves_returns_valid_direction(self):
|
||||||
|
result = move(gs(my_body=[(0, 0), (0, 1), (1, 1), (1, 0)], other_bodies=[]))
|
||||||
|
self.assertIn(result, ("up", "down", "left", "right"))
|
||||||
|
|
||||||
|
# ── Duel mode ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSupremeDuelMode(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_avoids_h2h_with_equal_length(self):
|
||||||
|
result = move(gs(my_body=[(5, 5), (5, 4), (5, 3)],
|
||||||
|
other_bodies=[[(7, 5), (7, 4), (7, 3)]],
|
||||||
|
foods=[(0, 0)]))
|
||||||
|
self.assertNotEqual(result, "right")
|
||||||
|
|
||||||
|
def test_head_hunts_smaller_enemy(self):
|
||||||
|
result = move(gs(
|
||||||
|
my_body=[(5, 5), (5, 4), (5, 3), (5, 2), (5, 1), (4, 1), (4, 2)],
|
||||||
|
other_bodies=[[(7, 5), (7, 4), (7, 3)]],
|
||||||
|
foods=[(0, 0)]))
|
||||||
|
self.assertEqual(result, "right")
|
||||||
|
|
||||||
|
def test_chases_food_when_low_health(self):
|
||||||
|
result = move(gs(my_body=[(5, 5), (5, 4), (5, 3)],
|
||||||
|
other_bodies=[[(9, 9), (9, 8), (9, 7)]],
|
||||||
|
foods=[(5, 6)], my_health=10))
|
||||||
|
self.assertEqual(result, "up")
|
||||||
|
|
||||||
|
# ── Constrictor mode ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSupremeConstrictorMode(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_returns_valid_move(self):
|
||||||
|
result = move(gs(my_body=[(5, 5), (5, 4), (5, 3)],
|
||||||
|
other_bodies=[[(3, 3), (3, 4), (3, 5)]],
|
||||||
|
game_type="constrictor"))
|
||||||
|
self.assertIn(result, ("up", "down", "left", "right"))
|
||||||
|
|
||||||
|
# ── Multi-snake mode ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSupremeMultiSnakeMode(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_returns_valid_move(self):
|
||||||
|
result = move(gs(my_body=[(5, 5), (5, 4), (5, 3)],
|
||||||
|
other_bodies=[[(2, 2), (2, 3), (2, 4)],
|
||||||
|
[(8, 8), (8, 7), (8, 6)]],
|
||||||
|
foods=[(3, 3), (7, 7)]))
|
||||||
|
self.assertIn(result, ("up", "down", "left", "right"))
|
||||||
|
|
||||||
|
# ── Hazard tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSupremeHazard(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_hazard_penalizes_score(self):
|
||||||
|
snake = SupremeBattleSnake()
|
||||||
|
snake._bb = BitBoard(11, 11)
|
||||||
|
snake._bb_w = 11
|
||||||
|
snake._bb_h = 11
|
||||||
|
snake._bits_cache = {}
|
||||||
|
snake._bits_cache_turn = 0
|
||||||
|
snake.game_board = make_board(gs(
|
||||||
|
my_body=[(5, 5), (5, 4), (5, 3)],
|
||||||
|
hazards=[(6, 5)], hazard_damage=14))
|
||||||
|
snake.previous_hazards = {(6, 5)}
|
||||||
|
snake._enemy_dmaps = []
|
||||||
|
snake._enemy_heads = []
|
||||||
|
snake._base_blocked = set()
|
||||||
|
|
||||||
|
score_right, _ = snake._score_move(
|
||||||
|
move="right", pos={"x": 6, "y": 5},
|
||||||
|
my_body=[{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}],
|
||||||
|
my_len=3, my_health=90,
|
||||||
|
other_snakes=[], food_set=set(),
|
||||||
|
hazard_set={(6, 5)}, hazard_damage=14, hazard_count={(6, 5): 1},
|
||||||
|
previous_hazard_set={(6, 5)},
|
||||||
|
is_constrictor=False, enemy_attack_map={},
|
||||||
|
enemy_can_grow={}, total_occupancy=0.05,
|
||||||
|
width=11, height=11, deadline=None)
|
||||||
|
score_up, _ = snake._score_move(
|
||||||
|
move="up", pos={"x": 5, "y": 6},
|
||||||
|
my_body=[{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}],
|
||||||
|
my_len=3, my_health=90,
|
||||||
|
other_snakes=[], food_set=set(),
|
||||||
|
hazard_set={(6, 5)}, hazard_damage=14, hazard_count={(6, 5): 1},
|
||||||
|
previous_hazard_set={(6, 5)},
|
||||||
|
is_constrictor=False, enemy_attack_map={},
|
||||||
|
enemy_can_grow={}, total_occupancy=0.05,
|
||||||
|
width=11, height=11, deadline=None)
|
||||||
|
self.assertGreater(score_up, score_right)
|
||||||
|
|
||||||
|
# ── Version ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSupremeVersion(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_version(self):
|
||||||
|
self.assertEqual(SupremeBattleSnake.VERSION, "1.0.0")
|
||||||
|
|
||||||
|
def test_class_name_contains_claude(self):
|
||||||
|
snake = SupremeBattleSnake()
|
||||||
|
self.assertIn("Claude", snake.__class__.__name__)
|
||||||
|
|
||||||
|
def test_builder(self):
|
||||||
|
from snakes import SnakeBuilder
|
||||||
|
snake = SnakeBuilder.build("SupremeBattleSnake_ClaudeOpus4_6")
|
||||||
|
self.assertIsInstance(snake, SupremeBattleSnake)
|
||||||
|
|
||||||
|
# ── Parity: Supreme makes same decisions as Apex on key scenarios ────────────
|
||||||
|
|
||||||
|
class TestParityWithApex(unittest.TestCase):
|
||||||
|
"""Ensure the bitboard optimisations don't change strategic behaviour."""
|
||||||
|
|
||||||
|
def test_trapped_corner(self):
|
||||||
|
"""Both snakes should survive a forced single-exit scenario."""
|
||||||
|
from snakes.ApexBattleSnake import ApexBattleSnake
|
||||||
|
|
||||||
|
state = gs(my_body=[(1, 1), (1, 2), (2, 2), (2, 1)],
|
||||||
|
other_bodies=[], foods=[(5, 5)], width=7, height=7)
|
||||||
|
|
||||||
|
apex_snake = ApexBattleSnake()
|
||||||
|
apex_board = GameBoard(game_id="parity", width=7, height=7,
|
||||||
|
ruleset=state["game"]["ruleset"],
|
||||||
|
source="custom", map="standard",
|
||||||
|
snake_class=apex_snake)
|
||||||
|
apex_board.read_game_data(state)
|
||||||
|
apex_move = apex_board.snake_neat_make_a_move()
|
||||||
|
|
||||||
|
supreme_move = move(state)
|
||||||
|
|
||||||
|
# Both must find the only safe exit
|
||||||
|
self.assertEqual(apex_move, "right")
|
||||||
|
self.assertEqual(supreme_move, "right")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from server.database.game_quality import GameQualityInput, quality_meets_minimum, rate_game_quality
|
||||||
|
|
||||||
|
class TestGameQuality(unittest.TestCase):
|
||||||
|
def test_complete_competitive_game_is_high_quality(self):
|
||||||
|
quality = rate_game_quality(GameQualityInput(
|
||||||
|
status="finished", final_turn=80, turn_rows=80, min_turn=1, max_turn=80,
|
||||||
|
valid_moves=80, thinking_rows=80, distinct_moves=4,
|
||||||
|
snake_turn_rows=160, winner_name="PrismBattleSnake",
|
||||||
|
))
|
||||||
|
|
||||||
|
self.assertEqual(quality.tier, "high")
|
||||||
|
self.assertGreaterEqual(quality.score, 80)
|
||||||
|
|
||||||
|
def test_short_but_valid_game_is_not_invalid(self):
|
||||||
|
quality = rate_game_quality(GameQualityInput(
|
||||||
|
status="finished", final_turn=5, turn_rows=5, min_turn=1, max_turn=5,
|
||||||
|
valid_moves=5, thinking_rows=5, distinct_moves=3,
|
||||||
|
snake_turn_rows=10, winner_name="PrismBattleSnake",
|
||||||
|
))
|
||||||
|
|
||||||
|
self.assertIn(quality.tier, ("low", "medium"))
|
||||||
|
self.assertIn("short_game", quality.reasons)
|
||||||
|
|
||||||
|
def test_incomplete_game_is_invalid(self):
|
||||||
|
quality = rate_game_quality(GameQualityInput(
|
||||||
|
status="finished", final_turn=100, turn_rows=10, min_turn=1, max_turn=10,
|
||||||
|
valid_moves=10, thinking_rows=10, distinct_moves=4,
|
||||||
|
snake_turn_rows=20, winner_name=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
self.assertEqual(quality.tier, "invalid")
|
||||||
|
self.assertIn("incomplete_turn_sequence", quality.reasons)
|
||||||
|
|
||||||
|
def test_minimum_tier_order(self):
|
||||||
|
self.assertTrue(quality_meets_minimum("high", "medium"))
|
||||||
|
self.assertTrue(quality_meets_minimum("medium", "medium"))
|
||||||
|
self.assertFalse(quality_meets_minimum("low", "medium"))
|
||||||
|
self.assertFalse(quality_meets_minimum("invalid", "low"))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -88,6 +88,27 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase):
|
|||||||
turns_count = connection.execute("SELECT COUNT(*) FROM turns WHERE game_id = ?", ("game-abc",)).fetchone()[0]
|
turns_count = connection.execute("SELECT COUNT(*) FROM turns WHERE game_id = ?", ("game-abc",)).fetchone()[0]
|
||||||
self.assertEqual(turns_count, 2)
|
self.assertEqual(turns_count, 2)
|
||||||
|
|
||||||
|
compact_turn = connection.execute("""
|
||||||
|
SELECT snakes_json, you_json, food_json, hazards_json
|
||||||
|
FROM turns WHERE game_id = ? AND turn = ?
|
||||||
|
""", ("game-abc", 2)).fetchone()
|
||||||
|
self.assertEqual(compact_turn, ("[]", "{}", '[{"x":2,"y":2}]', "[]"))
|
||||||
|
stored_body = connection.execute("""
|
||||||
|
SELECT body_json FROM snake_turns
|
||||||
|
WHERE game_id = ? AND turn = ? AND snake_id = ?
|
||||||
|
""", ("game-abc", 2, "me")).fetchone()[0]
|
||||||
|
self.assertNotEqual(stored_body, "[]")
|
||||||
|
identities = connection.execute("""
|
||||||
|
SELECT snake_id, snake_name, is_you FROM game_snakes
|
||||||
|
WHERE game_id = ? ORDER BY snake_id
|
||||||
|
""", ("game-abc",)).fetchall()
|
||||||
|
self.assertEqual(identities, [("enemy", "Enemy", 0), ("me", "Me", 1)])
|
||||||
|
repeated_identity = connection.execute("""
|
||||||
|
SELECT snake_name, is_you FROM snake_turns
|
||||||
|
WHERE game_id = ? AND turn = ? AND snake_id = ?
|
||||||
|
""", ("game-abc", 2, "me")).fetchone()
|
||||||
|
self.assertEqual(repeated_identity, (None, 0))
|
||||||
|
|
||||||
me_inferred = connection.execute("SELECT inferred_move FROM snake_turns WHERE game_id = ? AND turn = ? AND snake_id = ?", ("game-abc", 2, "me")).fetchone()[0]
|
me_inferred = connection.execute("SELECT inferred_move FROM snake_turns WHERE game_id = ? AND turn = ? AND snake_id = ?", ("game-abc", 2, "me")).fetchone()[0]
|
||||||
enemy_inferred = connection.execute("SELECT inferred_move FROM snake_turns WHERE game_id = ? AND turn = ? AND snake_id = ?", ("game-abc", 2, "enemy")).fetchone()[0]
|
enemy_inferred = connection.execute("SELECT inferred_move FROM snake_turns WHERE game_id = ? AND turn = ? AND snake_id = ?", ("game-abc", 2, "enemy")).fetchone()[0]
|
||||||
self.assertEqual(me_inferred, "up")
|
self.assertEqual(me_inferred, "up")
|
||||||
@@ -104,6 +125,9 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(len(replay["turns"]), 2)
|
self.assertEqual(len(replay["turns"]), 2)
|
||||||
self.assertEqual(replay["turns"][1]["my_move"], "up")
|
self.assertEqual(replay["turns"][1]["my_move"], "up")
|
||||||
self.assertEqual(replay["turns"][1]["my_thinking"]["reason"], "food")
|
self.assertEqual(replay["turns"][1]["my_thinking"]["reason"], "food")
|
||||||
|
self.assertEqual(replay["turns"][1]["food"], [{"x": 2, "y": 2}])
|
||||||
|
self.assertEqual(replay["turns"][1]["you"]["id"], "me")
|
||||||
|
self.assertEqual(len(replay["turns"][1]["snakes"][0]["body"]), 3)
|
||||||
|
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from server.database.backend.SqliteGameplayBackend import SqliteGameplayBackend
|
||||||
|
|
||||||
|
class TestMergeGameplayDatabases(unittest.TestCase):
|
||||||
|
def _create_game(self, path:Path, game_id:str, winner_you:int, cleaned:bool=False) -> None:
|
||||||
|
SqliteGameplayBackend(str(path))
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
connection.execute("""
|
||||||
|
INSERT INTO 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_json
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", (
|
||||||
|
game_id, "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z", 11, 11,
|
||||||
|
"league", "standard", "standard", "v1", "me", "PrismBattleSnake",
|
||||||
|
"PrismBattleSnake_GPT_5_6_Sol", "1.0.0", "duel", "PrismBattleSnake",
|
||||||
|
winner_you, 1, "finished", 1, "retained",
|
||||||
|
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 turns (
|
||||||
|
game_id,turn,observed_at,my_move,my_thinking_json,
|
||||||
|
board_state_json,snakes_json,you_json,food_json,hazards_json
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", (game_id, 1, "2026-01-01T00:00:01Z", "up", '{"score":1}', '{}', '[]', '{}', '[]', '[]'))
|
||||||
|
connection.execute("""
|
||||||
|
INSERT INTO snake_turns (
|
||||||
|
game_id,turn,snake_id,health,length,head_x,head_y,body_json,is_you,inferred_move
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", (game_id, 1, "me", 90, 3, 1, 1, '[{"x":1,"y":1}]', 0, "up"))
|
||||||
|
|
||||||
|
def test_merges_base_and_delta_and_regenerates_row_ids(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
base = root / "base.sqlite3"
|
||||||
|
delta = root / "delta.sqlite3"
|
||||||
|
merged = root / "merged.sqlite3"
|
||||||
|
self._create_game(base, "base-game", 1, cleaned=True)
|
||||||
|
self._create_game(delta, "delta-game", 0)
|
||||||
|
|
||||||
|
result = subprocess.run([
|
||||||
|
sys.executable, "scripts/merge_gameplay_databases.py",
|
||||||
|
"--base", str(base), "--delta", str(delta),
|
||||||
|
"--destination", str(merged), "--minimum-quality", "medium",
|
||||||
|
], cwd=Path(__file__).resolve().parents[1], text=True, capture_output=True)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
with sqlite3.connect(merged) as connection:
|
||||||
|
games = connection.execute(
|
||||||
|
"SELECT game_id, winner_you, has_replay, quality_tier FROM games ORDER BY game_id"
|
||||||
|
).fetchall()
|
||||||
|
self.assertEqual(games, [
|
||||||
|
("base-game", 1, 1, "high"),
|
||||||
|
("delta-game", 0, 1, "medium"),
|
||||||
|
])
|
||||||
|
self.assertEqual(connection.execute("SELECT COUNT(*) FROM turns").fetchone()[0], 2)
|
||||||
|
self.assertEqual(connection.execute("SELECT COUNT(*) FROM snake_turns").fetchone()[0], 2)
|
||||||
|
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
|
||||||
|
|
||||||
|
def test_conflicting_duplicate_aborts_without_destination(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
base = root / "base.sqlite3"
|
||||||
|
delta = root / "delta.sqlite3"
|
||||||
|
merged = root / "merged.sqlite3"
|
||||||
|
self._create_game(base, "same-game", 1, cleaned=True)
|
||||||
|
self._create_game(delta, "same-game", 0)
|
||||||
|
|
||||||
|
result = subprocess.run([
|
||||||
|
sys.executable, "scripts/merge_gameplay_databases.py",
|
||||||
|
"--base", str(base), "--delta", str(delta), "--destination", str(merged),
|
||||||
|
], cwd=Path(__file__).resolve().parents[1], text=True, capture_output=True)
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("Conflicting duplicate game_id", result.stderr)
|
||||||
|
self.assertFalse(merged.exists())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user