Files
snake-python/server/database/backend/Template.py
T
daniel156161 c704fbc742
Build and Push Docker Container / build-and-push (push) Successful in 8m3s
feat: add Prism snake and gameplay database lifecycle
- 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.
2026-08-01 16:21:02 +02:00

262 lines
9.3 KiB
Python

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.
Subclasses must override every method that raises NotImplementedError.
Shared pure-Python helpers (_utc_now, _to_json, etc.) live here so they
are available to both SQLite and PostgreSQL implementations.
"""
# ── public async interface ─────────────────────────────────────────────────
async def initialize(self) -> None:
"""Called once on server startup. Backends that need eager connection
(pool creation, schema init, migration) should override this."""
return None
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
raise NotImplementedError
async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None:
raise NotImplementedError
async def record_game_end(self, game_state:dict) -> None:
raise NotImplementedError
async def get_summary(self, recent_limit:int=15) -> dict:
raise NotImplementedError
async def list_games(self, limit:int=50) -> list[dict]:
raise NotImplementedError
async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
raise NotImplementedError
async def get_game_replay(self, game_id:str) -> dict|None:
raise NotImplementedError
async def close(self) -> None:
return None
# ── shared pure-python helpers ─────────────────────────────────────────────
def _utc_now(self) -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_utc_timestamp(self, value:str|None) -> datetime|None:
if not value:
return None
normalized = value.strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _to_json(self, payload:object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def _from_json(self, payload:str|None) -> Any:
if payload is None or payload == "":
return None
try:
return json.loads(payload)
except (json.JSONDecodeError, TypeError):
return None
def _extract_snakes(self, game_state:dict) -> list[dict]:
return list(game_state.get("board", {}).get("snakes", []))
def _extract_you(self, game_state:dict) -> dict:
return dict(game_state.get("you", {}))
def _infer_direction(self, old_head:tuple[int, int]|None, new_head:tuple[int, int]|None) -> str|None:
if old_head is None or new_head is None:
return None
dx = new_head[0] - old_head[0]
dy = new_head[1] - old_head[1]
if dx == 1 and dy == 0:
return "right"
if dx == -1 and dy == 0:
return "left"
if dx == 0 and dy == 1:
return "up"
if dx == 0 and dy == -1:
return "down"
return None
def _derive_game_type(self, board:dict, ruleset:dict) -> str:
if len(board.get("snakes", [])) == 2:
return "duel"
return ruleset.get("name") or "standard"
# ── shared output builders ─────────────────────────────────────────────────
def _ts_to_str(self, value) -> str|None:
"""Normalize a timestamp: pass str through, call .isoformat() on datetime."""
if value is None:
return None
if isinstance(value, str):
return value
return value.isoformat()
def _build_summary_output(self, totals, by_type_rows, recent_rows) -> dict:
return {
"total_games": int(totals["total_games"] or 0),
"running_games": int(totals["running_games"] or 0),
"finished_games": int(totals["finished_games"] or 0),
"wins": int(totals["wins"] or 0),
"losses": int(totals["losses"] or 0),
"avg_turns_finished": round(float(totals["avg_turns"] or 0.0), 2),
"by_game_type": [{
"game_type": row["type_label"],
"total": int(row["total"]),
"wins": int(row["wins"]),
"losses": int(row["losses"]),
} for row in by_type_rows],
"recent_games": [{
"game_id": row["game_id"],
"started_at": self._ts_to_str(row["started_at"]),
"ended_at": self._ts_to_str(row["ended_at"]),
"map": row["map_name"],
"ruleset": row["ruleset_name"],
"game_type": row["game_type"],
"snake": row["your_snake_name"],
"snake_type": row["your_snake_type"],
"snake_version": row["your_snake_version"],
"winner_you": bool(row["winner_you"]),
"final_turn": int(row["final_turn"] or 0),
"status": row["status"],
} for row in recent_rows],
}
def _build_game_list_output(self, rows) -> list[dict]:
return [{
"game_id": row["game_id"],
"started_at": self._ts_to_str(row["started_at"]),
"ended_at": self._ts_to_str(row["ended_at"]),
"map": row["map_name"],
"source": row["source"],
"ruleset": row["ruleset_name"],
"game_type": row["game_type"],
"snake": row["your_snake_name"],
"snake_type": row["your_snake_type"],
"snake_version": row["your_snake_version"],
"winner_you": bool(row["winner_you"]),
"winner_name": row["winner_name"],
"final_turn": int(row["final_turn"] or 0),
"status": row["status"],
} for row in rows]
def _build_game_replay_output(self, game_row, turn_rows, snake_rows, decode_json) -> dict:
"""Build the full replay dict.
decode_json: callable applied to raw column values that may be JSON strings
(SQLite) or already-decoded objects (PostgreSQL). Pass self._from_json for
SQLite; pass (lambda x: x) for PostgreSQL.
"""
hydrated_turns = hydrate_replay_turns(game_row, turn_rows, snake_rows, decode_json)
return {
"game": {
"game_id": game_row["game_id"],
"started_at": self._ts_to_str(game_row["started_at"]),
"ended_at": self._ts_to_str(game_row["ended_at"]),
"width": game_row["width"],
"height": game_row["height"],
"source": game_row["source"],
"map": game_row["map_name"],
"ruleset_name": game_row["ruleset_name"],
"ruleset_version": game_row["ruleset_version"],
"game_type": game_row["game_type"],
"your_snake_id": game_row["your_snake_id"],
"your_snake_name": game_row["your_snake_name"],
"your_snake_type": game_row["your_snake_type"],
"your_snake_version": game_row["your_snake_version"],
"winner_name": game_row["winner_name"],
"winner_you": bool(game_row["winner_you"]),
"final_turn": int(game_row["final_turn"] or 0),
"status": game_row["status"],
},
"turns": [
{**turn, "observed_at": self._ts_to_str(turn["observed_at"])}
for turn in hydrated_turns
],
}
# ── shared write-path helpers ──────────────────────────────────────────────
def _build_snake_turn_params(
self,
snakes:list[dict],
you_id:str|None,
game_id:str|None,
turn:int,
previous_positions:dict,
) -> list[tuple]:
"""Return one parameter tuple per snake for a snake_turns INSERT."""
result = []
for snake in snakes:
snake_id = snake.get("id")
if snake_id is None:
continue
head = snake.get("head", {})
head_x = head.get("x")
head_y = head.get("y")
new_head = (
(int(head_x), int(head_y))
if head_x is not None and head_y is not None
else None
)
inferred = self._infer_direction(previous_positions.get(snake_id), new_head)
result.append((
game_id,
turn,
snake_id,
snake.get("name"),
snake.get("health"),
snake.get("length"),
head_x,
head_y,
snake.get("body", []),
snake_id == you_id,
inferred,
snake.get("latency"),
))
return result
def _extract_game_end_params(self, game_state:dict) -> tuple:
"""Return (game_id, winner_name, winner_you, turn) from a game_end payload."""
game = game_state.get("game", {})
game_id = game.get("id")
board = game_state.get("board", {})
snakes = list(board.get("snakes", []))
you = self._extract_you(game_state)
winner_name = next((s.get("name") for s in snakes if s.get("name")), None)
you_id = you.get("id")
winner_you = any(s.get("id") == you_id for s in snakes)
turn = int(game_state.get("turn", 0))
return game_id, winner_name, winner_you, turn
def _calculate_survivor(self, snake_rows, your_snake_id:str|None) -> tuple[bool, str|None]:
"""Return (winner_you, survivor_name) from a list of snake_turns rows."""
survivor_ids = [s["snake_id"] for s in snake_rows if s["snake_id"]]
winner_you = bool(
your_snake_id
and your_snake_id in survivor_ids
and len(survivor_ids) == 1
)
survivor_name = next((s["snake_name"] for s in snake_rows if s["snake_name"]), None)
return winner_you, survivor_name