6643eb35af
- Resolve selected moves and enemy replies on the same simulated turn. - Add an Apex candidate hook and bump the Prism snake to version 1.1.0. - Rebuild benchmark states from normalized turn data when snapshots are empty. - Synthesize missing game snake identities during legacy database migration. - Add regression coverage for duel timing and partial legacy schemas.
359 lines
14 KiB
Python
359 lines
14 KiB
Python
#!/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
|
|
sql = """
|
|
INSERT OR IGNORE INTO game_snakes (
|
|
game_id, snake_id, snake_name, is_you, customizations_json
|
|
) VALUES (?, ?, ?, ?, ?)
|
|
"""
|
|
count = 0
|
|
if has_game_snakes:
|
|
columns = object_columns(source, "game_snakes")
|
|
customizations = (
|
|
"COALESCE(customizations_json, '{}') AS customizations_json"
|
|
if "customizations_json" in columns else "'{}' AS customizations_json"
|
|
)
|
|
cursor = source.execute(f"""
|
|
SELECT game_id, snake_id, snake_name, is_you, {customizations}
|
|
FROM game_snakes ORDER BY game_id, snake_id
|
|
""")
|
|
while rows := cursor.fetchmany(batch_size):
|
|
retained_rows = [tuple(row) for row in rows if row[0] in retained_ids]
|
|
before = destination.total_changes
|
|
destination.executemany(sql, retained_rows)
|
|
count += destination.total_changes - before
|
|
|
|
# Older databases can contain an empty or only partially populated
|
|
# game_snakes table. Always synthesize missing identities from snake_turns.
|
|
cursor = source.execute("""
|
|
SELECT game_id, snake_id, MAX(snake_name), MAX(is_you),
|
|
'{}' AS customizations_json
|
|
FROM snake_turns
|
|
GROUP BY game_id, snake_id
|
|
ORDER BY game_id, snake_id
|
|
""")
|
|
while rows := cursor.fetchmany(batch_size):
|
|
retained_rows = [tuple(row) for row in rows if row[0] in retained_ids]
|
|
before = destination.total_changes
|
|
destination.executemany(sql, retained_rows)
|
|
count += destination.total_changes - before
|
|
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()
|