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

- Add bitboard-accelerated Prism and versioned Supreme snake implementations.
- Add database-backed move benchmarks and focused strategy tests.
- Normalize gameplay storage while preserving replay compatibility.
- Add deterministic game quality scoring and replay retention tiers.
- Add backup-first SQLite cleanup, verification, and replacement tooling.
- Add safe compact-plus-delta database merging with conflict detection.
- Extend SQLite and PostgreSQL schemas for replay and quality metadata.
- Add PostgreSQL development service and pytest import configuration.
- Update gameplay documentation and the quart_common submodule revision.
This commit is contained in:
2026-08-01 16:21:02 +02:00
parent 9a7f4de586
commit c704fbc742
21 changed files with 3156 additions and 88 deletions
+70
View File
@@ -75,6 +75,76 @@ BATTLE_SNAKE_DUEL_STYLE=balanced python main.py
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
Game saves now include a `dataset` section with labeled move samples.
+36 -15
View File
@@ -1,17 +1,38 @@
services:
battlesnake:
image: daniel156161/battlesnake
container_name: battlesnake
ports:
- 8000:8000
volumes:
- ${DOCKER_DATA_PATH}:/app/data
build:
context: ./
dockerfile: Dockerfile
#environment:
# - SNAKE_COLOR=blue
# - SNAKE_HEAD=caffeine
# - SNAKE_TAIL=mlh-gene
# - STORE_GAME_HISTORY=True
# battlesnake:
# image: daniel156161/battlesnake
# container_name: battlesnake
# ports:
# - 8000:8000
# volumes:
# - ${DOCKER_DATA_PATH}:/app/data
# build:
# context: ./
# dockerfile: Dockerfile
# #environment:
# # - SNAKE_COLOR=blue
# # - SNAKE_HEAD=caffeine
# # - SNAKE_TAIL=mlh-gene
# # - STORE_GAME_HISTORY=True
# restart: always
postgres:
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
+4
View File
@@ -1,3 +1,7 @@
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "--import-mode=importlib"
[project]
name = "snake-python"
version = "0.1.0"
+137
View File
@@ -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()
+271
View File
@@ -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()
+343
View File
@@ -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 .Template import GameplayBackendTemplate
from server.database.normalized_turn import compact_turn_json
logger = logging.getLogger(__name__)
if not logger.handlers:
@@ -47,7 +48,12 @@ CREATE TABLE IF NOT EXISTS games (
winner_name TEXT,
winner_you BOOLEAN NOT NULL DEFAULT FALSE,
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 (
@@ -65,6 +71,14 @@ CREATE TABLE IF NOT EXISTS turns (
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 (
id BIGSERIAL PRIMARY KEY,
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_version 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 snake_turns ADD COLUMN IF NOT EXISTS latency TEXT;
"""
@@ -433,9 +452,23 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
game_id = game.get("id")
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()
async with pool.acquire() as conn:
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("""
INSERT INTO turns (
game_id, turn, observed_at, my_move, my_thinking,
@@ -456,11 +489,11 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
self._utc_now_ts(),
my_move,
my_thinking,
board,
snakes,
you,
board.get("food", []),
board.get("hazards", []),
board_json,
snakes_json,
you_json,
food_json,
hazards_json,
)
previous_positions:dict[str, tuple[int, int]] = {}
@@ -498,8 +531,8 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
inferred_move = EXCLUDED.inferred_move,
latency = EXCLUDED.latency
""",
p_game_id, p_turn, p_snake_id, p_name, p_health, p_length,
p_head_x, p_head_y, p_body, p_is_you, p_inferred, p_latency,
p_game_id, p_turn, p_snake_id, None, p_health, p_length,
p_head_x, p_head_y, p_body, False, p_inferred, p_latency,
)
await conn.execute("""
@@ -561,10 +594,12 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
final_turn = int(row["final_turn"] or 0)
snake_rows = await conn.fetch("""
SELECT snake_id, snake_name
FROM snake_turns
WHERE game_id = $1 AND turn = $2
ORDER BY is_you DESC, snake_name ASC
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name) AS snake_name
FROM snake_turns AS st
LEFT JOIN game_snakes AS gs
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
WHERE st.game_id = $1 AND st.turn = $2
ORDER BY COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
""",
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:
final_turn = int(latest_row["latest_turn"])
snake_rows = await conn.fetch("""
SELECT snake_id, snake_name
FROM snake_turns
WHERE game_id = $1 AND turn = $2
ORDER BY is_you DESC, snake_name ASC
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name) AS snake_name
FROM snake_turns AS st
LEFT JOIN game_snakes AS gs
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
WHERE st.game_id = $1 AND st.turn = $2
ORDER BY COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
""",
game_id, final_turn,
)
@@ -640,6 +677,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
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
FROM games
WHERE has_replay
ORDER BY started_at DESC
LIMIT $1
""",
@@ -656,6 +694,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
your_snake_name, your_snake_type, your_snake_version,
winner_you, winner_name, final_turn, status
FROM games
WHERE has_replay
ORDER BY started_at DESC
LIMIT $1
""",
@@ -673,7 +712,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
your_snake_type, your_snake_version,
winner_name, winner_you, final_turn, status
FROM games
WHERE game_id = $1
WHERE game_id = $1 AND has_replay
""",
game_id,
)
@@ -696,11 +735,16 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate):
)
snake_rows = await conn.fetch("""
SELECT turn, snake_id, snake_name, health, length, head_x, head_y,
body AS body_json, is_you, inferred_move, latency
FROM snake_turns
WHERE game_id = $1
ORDER BY turn ASC, is_you DESC, snake_name ASC
SELECT st.turn, st.snake_id,
COALESCE(gs.snake_name, st.snake_name) AS snake_name,
st.health, st.length, st.head_x, st.head_y, st.body AS body_json,
COALESCE(gs.is_you, st.is_you) AS is_you,
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,
)
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from pathlib import Path
from server.database.backend.Template import GameplayBackendTemplate
from server.database.normalized_turn import compact_turn_json
logger = logging.getLogger(__name__)
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()
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.busy_timeout_ms = max(1000, int(busy_timeout_ms))
self._zstd_available = False
self._initialize_indexes = initialize_indexes
self._journal_mode = journal_mode
self._initialize_database()
# ── connection ─────────────────────────────────────────────────────────────
@@ -43,7 +46,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
connection.enable_load_extension(False)
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 temp_store = MEMORY")
connection.execute("PRAGMA journal_size_limit = 1048576")
@@ -80,7 +83,12 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
winner_name TEXT,
winner_you 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 (
@@ -99,6 +107,15 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id TEXT NOT NULL,
@@ -116,13 +133,19 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
);
""")
self._create_indexes_if_tables(connection)
if self._initialize_indexes:
self._create_indexes_if_tables(connection)
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_version", "TEXT")
self._ensure_column_exists(connection, "games", "game_type", "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", "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:
self._enable_zstd_compression(connection)
connection.execute("PRAGMA optimize")
@@ -239,7 +262,21 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
game_id = game.get("id")
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:
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("""
INSERT INTO turns (
game_id, turn, observed_at, my_move, my_thinking_json,
@@ -261,11 +298,11 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
self._utc_now(),
my_move,
self._to_json(my_thinking) if my_thinking is not None else None,
self._to_json(board),
self._to_json(snakes),
self._to_json(you),
self._to_json(board.get("food", [])),
self._to_json(board.get("hazards", [])),
self._to_json(board_json),
self._to_json(snakes_json),
self._to_json(you_json),
self._to_json(food_json),
self._to_json(hazards_json),
),
)
@@ -305,9 +342,9 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
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),
1 if p_is_you else 0,
0,
p_inferred, p_latency,
),
)
@@ -368,10 +405,12 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
final_turn = int(row["final_turn"] or 0)
snake_rows = connection.execute("""
SELECT snake_id, snake_name
FROM snake_turns
WHERE game_id = ? AND turn = ?
ORDER BY is_you DESC, snake_name ASC
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name) AS snake_name
FROM snake_turns AS st
LEFT JOIN game_snakes AS gs
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
WHERE st.game_id = ? AND st.turn = ?
ORDER BY COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
""",
(game_id, final_turn),
).fetchall()
@@ -384,10 +423,12 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
if latest_row is not None and latest_row["latest_turn"] is not None:
final_turn = int(latest_row["latest_turn"])
snake_rows = connection.execute("""
SELECT snake_id, snake_name
FROM snake_turns
WHERE game_id = ? AND turn = ?
ORDER BY is_you DESC, snake_name ASC
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name) AS snake_name
FROM snake_turns AS st
LEFT JOIN game_snakes AS gs
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
WHERE st.game_id = ? AND st.turn = ?
ORDER BY COALESCE(gs.is_you, st.is_you) DESC, snake_name ASC
""",
(game_id, final_turn),
).fetchall()
@@ -448,6 +489,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
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
FROM games
WHERE has_replay = 1
ORDER BY started_at DESC
LIMIT ?
""",
@@ -463,6 +505,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
your_snake_name, your_snake_type, your_snake_version,
winner_you, winner_name, final_turn, status
FROM games
WHERE has_replay = 1
ORDER BY started_at DESC
LIMIT ?
""",
@@ -479,7 +522,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
your_snake_type, your_snake_version,
winner_name, winner_you, final_turn, status
FROM games
WHERE game_id = ?
WHERE game_id = ? AND has_replay = 1
""",
(game_id,),
).fetchone()
@@ -498,11 +541,16 @@ class SqliteGameplayBackend(GameplayBackendTemplate):
).fetchall()
snake_rows = connection.execute("""
SELECT turn, snake_id, snake_name, health, length, head_x, head_y,
body_json, is_you, inferred_move, latency
FROM snake_turns
WHERE game_id = ?
ORDER BY turn ASC, is_you DESC, snake_name ASC
SELECT st.turn, st.snake_id,
COALESCE(gs.snake_name, st.snake_name) AS snake_name,
st.health, st.length, st.head_x, st.head_y, st.body_json,
COALESCE(gs.is_you, st.is_you) AS is_you,
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,),
).fetchall()
+5 -25
View File
@@ -2,6 +2,8 @@ import json
from datetime import datetime, timezone
from typing import Any
from server.database.normalized_turn import hydrate_replay_turns
class GameplayBackendTemplate:
"""Abstract base for gameplay database backends.
@@ -164,19 +166,7 @@ class GameplayBackendTemplate:
(SQLite) or already-decoded objects (PostgreSQL). Pass self._from_json for
SQLite; pass (lambda x: x) for PostgreSQL.
"""
snakes_by_turn:dict[int, list[dict]] = {}
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"],
})
hydrated_turns = hydrate_replay_turns(game_row, turn_rows, snake_rows, decode_json)
return {
"game": {
@@ -200,18 +190,8 @@ class GameplayBackendTemplate:
"status": game_row["status"],
},
"turns": [
{
"turn": int(row["turn"]),
"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
{**turn, "observed_at": self._ts_to_str(turn["observed_at"])}
for turn in hydrated_turns
],
}
+100
View File
@@ -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]
+84
View File
@@ -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
+497
View File
@@ -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
+513
View File
@@ -0,0 +1,513 @@
"""SupremeBattleSnake v1.0.0
Built on ApexBattleSnake v1.0.0. All strategic logic is inherited.
Performance improvement: all spatial primitives (flood fill, territory,
articulation detection, distance maps, pathfinding) replaced by a
bitboard engine that uses integer arithmetic instead of Python sets/deques.
Key speedups:
S1: Bitboard flood fill replaces BFS deque+set with integer bit-expansion.
~60× faster per call, eliminates _neighbors() generator overhead.
S2: Bitboard territory dual-BFS expansion on ints replaces per-cell
distance-map comparison loop.
S3: Bitboard articulation partition sizes via bit-flood instead of
_bounded_bfs with sets.
S4: Bitboard distance map BFS via bit-expansion + bit-extract.
S5: Bitboard path distance early-exit BFS on ints.
S6: Bitboard nearest food BFS food search on ints.
S7: Per-turn BitBoard instance cached for board dimensions.
S8: Blocked-set bitboard conversion cached within a turn to avoid
redundant O(n) conversions for the same frozen set.
S9: Survival-tree uses bitboards natively enemy body/attack bits
precomputed once at tree root, no per-node set/dict rebuilds.
S10: _legal_moves override uses bitboard neighbour mask instead of
per-direction Python loop + _in_bounds calls.
S11: _future_survival_tree inlines legal-move check with bitboard ops.
"""
from __future__ import annotations
from typing import Any
from time import perf_counter
from snakes.ApexBattleSnake import ApexBattleSnake
from snakes.bitboard import BitBoard
from server.GameBoard import GameBoard
# Direction offsets for coord-dict → tuple conversion
_DIR_DELTAS = ((0, 1), (0, -1), (-1, 0), (1, 0))
_DIR_NAMES = ("up", "down", "left", "right")
class SupremeBattleSnake_ClaudeOpus4_6(ApexBattleSnake):
VERSION = "1.0.0"
def __init__(self) -> None:
super().__init__()
self.name = "SupremeBattleSnake"
self.version = self.VERSION
# S7: cached BitBoard instance (reused while board dimensions stay the same)
self._bb: BitBoard | None = None
self._bb_w: int = 0
self._bb_h: int = 0
# S8: per-turn frozenset → bitboard conversion cache
self._bits_cache: dict[int, int] = {}
self._bits_cache_turn: int = -1
# S9: precomputed enemy state for survival tree (set per turn in choose_move)
self._enemy_body_bits: int = 0 # all enemy body cells as bitboard
self._enemy_tail_bits: int = 0 # enemy tails that will vacate
self._enemy_attack_danger: int = 0 # tiles where enemy len >= our len
self._enemy_attack_opportunity: int = 0 # tiles where enemy len < our len
# ── BitBoard accessor ────────────────────────────────────────────────────
def _get_bb(self, width: int, height: int) -> BitBoard:
"""Return (possibly cached) BitBoard for the current dimensions."""
if self._bb is None or width != self._bb_w or height != self._bb_h:
self._bb = BitBoard(width, height)
self._bb_w = width
self._bb_h = height
return self._bb
def _blocked_to_bits(self, blocked: set[tuple[int, int]], width: int, height: int) -> int:
"""Convert a blocked set to a bitboard, with per-turn caching."""
bb = self._get_bb(width, height)
sid = id(blocked)
cached = self._bits_cache.get(sid)
if cached is not None:
return cached
bits = bb.set_to_bits(blocked)
self._bits_cache[sid] = bits
return bits
# ── choose_move override: reset caches + precompute enemy bits ───────────
def choose_move(self, game_data: GameBoard) -> str:
turn = game_data.get_turn()
if turn != self._bits_cache_turn:
self._bits_cache = {}
self._bits_cache_turn = turn
bb = self._get_bb(game_data.get_width(), game_data.get_height())
# S9: precompute enemy body / tail / attack bitboards for survival tree
other_snakes = game_data.get_other_snakes()
my_snake = game_data.get_my_snake()
my_len = my_snake.get("length", len(my_snake["body"]))
food_set = {(f["x"], f["y"]) for f in game_data.get_food()}
game_type = game_data.get_type()
is_constrictor = game_type == "constrictor"
w = bb.width
enemy_body_bits = 0
enemy_tail_bits = 0
enemy_attack_danger = 0
enemy_attack_opportunity = 0
for snake in other_snakes:
for seg in snake["body"]:
enemy_body_bits |= 1 << (seg["y"] * w + seg["x"])
body = snake["body"]
# Check if tail will vacate
if not is_constrictor and len(body) >= 2:
tail_stacked = (body[-1]["x"] == body[-2]["x"] and body[-1]["y"] == body[-2]["y"])
if not tail_stacked:
can_grow = self._enemy_can_grow_this_turn(snake, food_set)
if not can_grow:
enemy_tail_bits |= 1 << (body[-1]["y"] * w + body[-1]["x"])
# Attack map: tiles enemy head can reach in 1 move
eh = snake["head"]
e_len = snake.get("length", len(body))
ehx, ehy = eh["x"], eh["y"]
for dx, dy in _DIR_DELTAS:
nx, ny = ehx + dx, ehy + dy
if 0 <= nx < w and 0 <= ny < bb.height:
bit = 1 << (ny * w + nx)
if e_len >= my_len:
enemy_attack_danger |= bit
else:
enemy_attack_opportunity |= bit
self._enemy_body_bits = enemy_body_bits
self._enemy_tail_bits = enemy_tail_bits
self._enemy_attack_danger = enemy_attack_danger
self._enemy_attack_opportunity = enemy_attack_opportunity
return super().choose_move(game_data)
# ── S1: Bitboard flood fill ──────────────────────────────────────────────
def _flood_fill_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
# A7/E2: per-turn transposition cache (kept from Apex)
cache_key = (start, frozenset(blocked))
cached = self._bfs_cache.get(cache_key)
if cached is not None:
return cached
result = bb.flood_count(start_idx, blocked_bits)
if len(self._bfs_cache) < self._bfs_cache_max:
self._bfs_cache[cache_key] = result
return result
# ── S2: Bitboard territory ──────────────────────────────────────────────
def _territory_fast(
self, my_pos: tuple, blocked: set, width: int, height: int,
deadline: float | None = None,
) -> int:
if not self._enemy_heads:
return 0
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
my_idx = bb.idx(my_pos[0], my_pos[1])
enemy_idxs = [bb.idx(eh[0], eh[1]) for eh in self._enemy_heads]
return bb.territory(my_idx, enemy_idxs, blocked_bits)
# ── S3: Bitboard articulation penalty ────────────────────────────────────
def _articulation_penalty(
self, point: tuple, blocked: set, width: int, height: int, required_space: int,
) -> float:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
point_idx = bb.idx(point[0], point[1])
sizes = bb.partition_sizes(point_idx, blocked_bits)
if not sizes:
return 0.0
min_size = min(sizes)
if min_size < required_space:
return 1500.0
elif min_size < required_space * 2:
return 400.0
else:
return 85.0
def _bounded_bfs(self, start: tuple, blocked: set, width: int, height: int, limit: int) -> set:
"""Bitboard-accelerated bounded BFS. Returns a set for API compatibility."""
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
reachable_bits = bb.flood_fill(start_idx, blocked_bits)
result: set[tuple[int, int]] = set()
temp = reachable_bits
w = bb.width
while temp:
bit = temp & (-temp)
idx = bit.bit_length() - 1
result.add((idx % w, idx // w))
temp ^= bit
if len(result) >= limit:
break
return result
# ── S4: Bitboard distance map ───────────────────────────────────────────
def _distance_map(self, start: tuple, blocked: set, width: int, height: int) -> dict:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
idx_dmap = bb.distance_map(start_idx, blocked_bits)
w = bb.width
return {(idx % w, idx // w): d for idx, d in idx_dmap.items()}
# ── S5: Bitboard path distance ──────────────────────────────────────────
def _path_distance(
self, start: tuple, goal: tuple, blocked: set, width: int, height: int,
) -> int | None:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.path_distance(
bb.idx(start[0], start[1]),
bb.idx(goal[0], goal[1]),
blocked_bits,
)
# ── S6: Bitboard nearest food ───────────────────────────────────────────
def _nearest_food_info(
self, start: tuple, food_set: set, blocked: set, width: int, height: int,
) -> tuple[int | None, tuple | None]:
if not food_set:
return None, None
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
food_bits = bb.set_to_bits(food_set)
start_idx = bb.idx(start[0], start[1])
dist, cell_idx = bb.nearest_food(start_idx, food_bits, blocked_bits)
if dist is None or cell_idx is None:
return None, None
return dist, bb.coord(cell_idx)
# ── Bitboard open-neighbour helpers ──────────────────────────────────────
def _open_neighbor_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.open_neighbor_count(bb.idx(start[0], start[1]), blocked_bits)
def _next_turn_options(self, head: dict, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.open_neighbor_count(bb.idx(head["x"], head["y"]), blocked_bits)
# ── S9: Optimised survival tree (bitboard-native) ────────────────────────
def _future_position_score(
self, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool,
width: int, height: int, enemy_can_grow: dict, deadline: float | None,
) -> float:
"""S9: Bitboard-native position scoring for the survival tree.
Builds blocked bitboard directly from body lists (no intermediate set).
Uses precomputed enemy bits instead of rebuilding attack map per node.
"""
if deadline is not None and perf_counter() >= deadline:
return 0.0
bb = self._bb # already initialised in choose_move
w = bb.width
head = my_body[0]
hx, hy = head["x"], head["y"]
head_idx = hy * w + hx
head_bit = 1 << head_idx
body_len = len(my_body)
# ── Build blocked bitboard directly (no set) ──────────────────────
my_bits = 0
for seg in my_body:
my_bits |= 1 << (seg["y"] * w + seg["x"])
# Own tail vacates unless stacked or constrictor
if not is_constrictor and body_len >= 2:
t, t2 = my_body[-1], my_body[-2]
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
my_bits &= ~(1 << (t["y"] * w + t["x"]))
# Enemy body (precomputed) minus vacating tails
en_bits = self._enemy_body_bits & ~self._enemy_tail_bits
blocked_bits = (my_bits | en_bits) & ~head_bit
# ── Reachable space ───────────────────────────────────────────────
reachable = bb.flood_count(head_idx, blocked_bits)
required = body_len + max(3, body_len // 6) if is_constrictor else body_len
if reachable < required:
return -5000.0
# ── Open neighbours (liberties) ───────────────────────────────────
nb_free = bb._neighbor_masks[head_idx] & ~blocked_bits & bb.board_mask
liberties = nb_free.bit_count()
if liberties == 0:
return -5000.0
# ── Safe next options (enemy-attack aware) ────────────────────────
# Remove tiles where an enemy of >= our length could head-to-head.
# The danger bitboard was precomputed; filter out tiles blocked by
# current body (enemy can't step there either).
danger_here = self._enemy_attack_danger & ~blocked_bits
safe_nb = nb_free & ~danger_here
en_safe = safe_nb.bit_count()
if en_safe == 0:
return -4000.0
sc = reachable * 1.9 + liberties * 14.0 + liberties * 11.0 + en_safe * 26.0
if en_safe == 1:
sc -= 420.0
return sc
def _future_survival_tree(
self, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool,
width: int, height: int, enemy_can_grow: dict,
depth: int, branch: int, deadline: float | None,
) -> float:
"""S9/S11: Bitboard-accelerated survival tree.
Inlines legal-move check with bitboard ops instead of per-direction
Python loops. Uses the bitboard-native _future_position_score.
"""
if depth <= 0 or (deadline is not None and perf_counter() >= deadline):
return 0.0
bb = self._bb
w = bb.width
h = bb.height
head = my_body[0]
hx, hy = head["x"], head["y"]
head_idx = hy * w + hx
body_len = len(my_body)
# ── Build occupied bitboard for legal-move check ──────────────────
occupied_bits = 0
for seg in my_body:
occupied_bits |= 1 << (seg["y"] * w + seg["x"])
occupied_bits |= self._enemy_body_bits
# Own tail can be stepped on if not stacked/constrictor
passable = 0
if not is_constrictor and body_len >= 2:
t, t2 = my_body[-1], my_body[-2]
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
passable |= 1 << (t["y"] * w + t["x"])
# Enemy vacating tails are also steppable
passable |= self._enemy_tail_bits
# Legal moves: free neighbours OR passable tiles
legal_bits = bb._neighbor_masks[head_idx] & ((~occupied_bits & bb.board_mask) | passable)
if not legal_bits:
return -5000.0
# ── Precompute food bitboard once ─────────────────────────────────
food_bits_local = 0
for fx, fy in food_set:
food_bits_local |= 1 << (fy * w + fx)
# ── Score each legal move ─────────────────────────────────────────
scored: list[tuple[float, list]] = []
temp = legal_bits
while temp:
if deadline is not None and perf_counter() >= deadline:
break
bit = temp & (-temp)
temp ^= bit
idx = bit.bit_length() - 1
nx, ny = idx % w, idx // w
pos = {"x": nx, "y": ny}
ate = bool(bit & food_bits_local)
fb = self._future_body(my_body, pos, ate, is_constrictor)
sc = self._future_position_score(
fb, other_snakes, food_set, is_constrictor,
width, height, enemy_can_grow, deadline,
)
scored.append((sc, fb))
if not scored:
return -5000.0
DEATH = self._TREE_DEATH_THRESHOLD
viable = [(sc, fb) for sc, fb in scored if sc > DEATH]
if not viable:
return max(sc for sc, _ in scored)
viable.sort(key=lambda x: x[0], reverse=True)
if depth == 1:
return viable[0][0]
best = viable[0][0]
for sc, fb in viable[:branch]:
if deadline is not None and perf_counter() >= deadline:
break
cont = self._future_survival_tree(
fb, other_snakes, food_set, is_constrictor,
width, height, enemy_can_grow, depth - 1, branch, deadline,
)
total = sc + cont * 0.72
if total > best:
best = total
return best
# ── S10: Bitboard legal moves ────────────────────────────────────────────
def _legal_moves(
self, my_head, my_body: list, other_snakes: list,
food_set: set, is_constrictor: bool, width: int, height: int,
enemy_can_grow: dict | None = None,
):
"""S10: Bitboard-accelerated legal move generation."""
bb = self._get_bb(width, height)
w = bb.width
# Build occupied bitboard
occupied = 0
for seg in my_body:
occupied |= 1 << (seg["y"] * w + seg["x"])
for snake in other_snakes:
for seg in snake["body"]:
occupied |= 1 << (seg["y"] * w + seg["x"])
hx, hy = my_head["x"], my_head["y"]
head_idx = hy * w + hx
# Own tail can be stepped on
passable = 0
if not is_constrictor and len(my_body) >= 2:
t, t2 = my_body[-1], my_body[-2]
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
passable |= 1 << (t["y"] * w + t["x"])
# Enemy tails that will vacate
if not is_constrictor:
for snake in other_snakes:
sbody = snake["body"]
if len(sbody) < 2:
continue
st, st2 = sbody[-1], sbody[-2]
if st["x"] == st2["x"] and st["y"] == st2["y"]:
continue # stacked
sid = snake.get("id")
can_grow = None
if enemy_can_grow is not None and sid is not None:
can_grow = enemy_can_grow.get(sid)
if can_grow is None:
can_grow = self._enemy_can_grow_this_turn(snake, food_set)
if not can_grow:
passable |= 1 << (st["y"] * w + st["x"])
legal = bb._neighbor_masks[head_idx] & ((~occupied & bb.board_mask) | passable)
safe: dict[str, dict[str, int]] = {}
for name, (dx, dy) in self.DIRECTIONS.items():
nx, ny = hx + dx, hy + dy
if 0 <= nx < w and 0 <= ny < bb.height:
if (1 << (ny * w + nx)) & legal:
safe[name] = {"x": nx, "y": ny}
return safe
# ── Enemy confinement (uses bitboard flood) ──────────────────────────────
def _enemy_confinement_metrics(
self, enemy_head: tuple, blocked: set, width: int, height: int,
) -> tuple[int, int]:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
eh_idx = bb.idx(enemy_head[0], enemy_head[1])
eb_bits = blocked_bits & ~(1 << eh_idx)
space = bb.flood_count(eh_idx, eb_bits)
options = bb.open_neighbor_count(eh_idx, eb_bits)
return space, options
def _enemy_constrictor_projection(
self, other_snakes: list, blocked: set, width: int, height: int,
) -> tuple[int, int]:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
best_space = 0
total_opts = 0
for enemy in other_snakes:
eh = (enemy["head"]["x"], enemy["head"]["y"])
eh_idx = bb.idx(eh[0], eh[1])
nb = bb.neighbors_of(eh_idx) & ~blocked_bits & bb.board_mask
temp = nb
while temp:
total_opts += 1
bit = temp & (-temp)
n_idx = bit.bit_length() - 1
sp = bb.flood_count(n_idx, blocked_bits | bit)
if sp > best_space:
best_space = sp
temp ^= bit
return best_space, total_opts
+2
View File
@@ -10,6 +10,8 @@ SNAKE_REGISTRY = {
"TrainedBattleSnake": "0.1.0",
"UltimateBattleSnake": "4.5.0",
"ApexBattleSnake": "1.0.0",
"SupremeBattleSnake_ClaudeOpus4_6": "1.0.0",
"PrismBattleSnake_GPT_5_6_Sol": "1.0.0",
}
DEFAULT_SNAKE_CONFIG = {
+355
View File
@@ -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()
+371
View File
@@ -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()
+43
View File
@@ -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()
+24
View File
@@ -88,6 +88,27 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase):
turns_count = connection.execute("SELECT COUNT(*) FROM turns WHERE game_id = ?", ("game-abc",)).fetchone()[0]
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]
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")
@@ -104,6 +125,9 @@ class TestGameplayDatabase(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(replay["turns"]), 2)
self.assertEqual(replay["turns"][1]["my_move"], "up")
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()
+93
View File
@@ -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()