From ce3f9f1d82eebdbcc3ca188f21131cac5cba4ad5 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Wed, 8 Apr 2026 18:22:08 +0200 Subject: [PATCH] rework that the sqlite and postgresql backend uses the same code to build the data for the dashboard --- .../backend/PostgresqlGameplayBackend.py | 157 ++------------- .../database/backend/SqliteGameplayBackend.py | 150 ++------------- server/database/backend/Template.py | 181 ++++++++++++++++++ 3 files changed, 213 insertions(+), 275 deletions(-) diff --git a/server/database/backend/PostgresqlGameplayBackend.py b/server/database/backend/PostgresqlGameplayBackend.py index 8a2af76..028ed1d 100644 --- a/server/database/backend/PostgresqlGameplayBackend.py +++ b/server/database/backend/PostgresqlGameplayBackend.py @@ -479,20 +479,9 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): } you_id = you.get("id") - for snake in snakes: - snake_id = snake.get("id") - head = snake.get("head", {}) - head_x = head.get("x") - head_y = head.get("y") - if snake_id is None: - continue - - 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) + for params in self._build_snake_turn_params(snakes, you_id, game_id, turn, previous_positions): + (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) = params await conn.execute(""" INSERT INTO snake_turns ( game_id, turn, snake_id, snake_name, health, length, @@ -509,18 +498,8 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): inferred_move = EXCLUDED.inferred_move, latency = EXCLUDED.latency """, - 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"), + 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, ) await conn.execute(""" @@ -532,14 +511,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): ) async def record_game_end(self, game_state:dict) -> None: - 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) + game_id, winner_name, winner_you, turn = self._extract_game_end_params(game_state) pool = await self._get_pool() async with pool.acquire() as conn: @@ -555,7 +527,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): self._utc_now_ts(), winner_name, winner_you, - int(game_state.get("turn", 0)), + turn, game_id, ) @@ -614,13 +586,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): game_id, final_turn, ) - 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) + winner_you, survivor_name = self._calculate_survivor(snake_rows, your_snake_id) tag = await conn.execute(""" UPDATE games @@ -680,34 +646,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): max(1, int(recent_limit)), ) - 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], - "recent_games": [{ - "game_id": row["game_id"], - "started_at": row["started_at"].isoformat() if row["started_at"] else None, - "ended_at": row["ended_at"].isoformat() if row["ended_at"] else None, - "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], - } + return self._build_summary_output(totals, by_type, recent) async def list_games(self, limit:int=50) -> list[dict]: pool = await self._get_pool() @@ -723,22 +662,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): max(1, int(limit)), ) - return [{ - "game_id": row["game_id"], - "started_at": row["started_at"].isoformat() if row["started_at"] else None, - "ended_at": row["ended_at"].isoformat() if row["ended_at"] else None, - "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] + return self._build_game_list_output(rows) async def get_game_replay(self, game_id:str) -> dict|None: pool = await self._get_pool() @@ -758,8 +682,12 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): return None turn_rows = await conn.fetch(""" - SELECT turn, observed_at, my_move, my_thinking, - board_state, food, hazards, you + SELECT turn, observed_at, my_move, + my_thinking AS my_thinking_json, + board_state AS board_state_json, + food AS food_json, + hazards AS hazards_json, + you AS you_json FROM turns WHERE game_id = $1 ORDER BY turn ASC @@ -769,7 +697,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): snake_rows = await conn.fetch(""" SELECT turn, snake_id, snake_name, health, length, head_x, head_y, - body, is_you, inferred_move, latency + 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 @@ -777,56 +705,7 @@ class PostgresqlGameplayBackend(GameplayBackendTemplate): game_id, ) - 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": row["body"] or [], - "is_you": bool(row["is_you"]), - "inferred_move": row["inferred_move"], - "latency": row["latency"], - }) - - return { - "game": { - "game_id": game_row["game_id"], - "started_at": game_row["started_at"].isoformat() if game_row["started_at"] else None, - "ended_at": game_row["ended_at"].isoformat() if game_row["ended_at"] else None, - "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": int(row["turn"]), - "observed_at": row["observed_at"].isoformat() if row["observed_at"] else None, - "my_move": row["my_move"], - "my_thinking": row["my_thinking"], - "board": row["board_state"], - "food": row["food"] or [], - "hazards": row["hazards"] or [], - "you": row["you"] or {}, - "snakes": snakes_by_turn.get(int(row["turn"]), []), - } - for row in turn_rows - ], - } + return self._build_game_replay_output(game_row, turn_rows, snake_rows, decode_json=lambda x: x) # ── lifecycle ────────────────────────────────────────────────────────────── diff --git a/server/database/backend/SqliteGameplayBackend.py b/server/database/backend/SqliteGameplayBackend.py index ef0f20a..1c60a6e 100644 --- a/server/database/backend/SqliteGameplayBackend.py +++ b/server/database/backend/SqliteGameplayBackend.py @@ -285,19 +285,9 @@ class SqliteGameplayBackend(GameplayBackendTemplate): } you_id = you.get("id") - for snake in snakes: - snake_id = snake.get("id") - head = snake.get("head", {}) - head_x = head.get("x") - head_y = head.get("y") - if snake_id is None: - continue - 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) + for params in self._build_snake_turn_params(snakes, you_id, game_id, turn, previous_positions): + (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) = params connection.execute(""" INSERT INTO snake_turns ( game_id, turn, snake_id, snake_name, health, length, @@ -315,18 +305,10 @@ class SqliteGameplayBackend(GameplayBackendTemplate): latency = excluded.latency """, ( - game_id, - turn, - snake_id, - snake.get("name"), - snake.get("health"), - snake.get("length"), - head_x, - head_y, - self._to_json(snake.get("body", [])), - 1 if snake_id == you_id else 0, - inferred, - snake.get("latency"), + p_game_id, p_turn, p_snake_id, p_name, p_health, p_length, + p_head_x, p_head_y, self._to_json(p_body), + 1 if p_is_you else 0, + p_inferred, p_latency, ), ) @@ -339,14 +321,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate): ) def _record_game_end_sync(self, game_state:dict) -> None: - 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((snake.get("name") for snake in snakes if snake.get("name")), None) - you_id = you.get("id") - winner_you = any(snake.get("id") == you_id for snake in snakes) + game_id, winner_name, winner_you, turn = self._extract_game_end_params(game_state) with self._connect() as connection: connection.execute(""" @@ -362,8 +337,8 @@ class SqliteGameplayBackend(GameplayBackendTemplate): self._utc_now(), winner_name, 1 if winner_you else 0, - int(game_state.get("turn", 0)), - int(game_state.get("turn", 0)), + turn, + turn, game_id, ), ) @@ -417,13 +392,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate): (game_id, final_turn), ).fetchall() - 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) + winner_you, survivor_name = self._calculate_survivor(snake_rows, your_snake_id) result = connection.execute(""" UPDATE games @@ -485,34 +454,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate): (max(1, int(recent_limit)),), ).fetchall() - 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], - "recent_games": [{ - "game_id": row["game_id"], - "started_at": row["started_at"], - "ended_at": 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], - } + return self._build_summary_output(totals, by_type, recent) def _list_games_sync(self, limit:int=50) -> list[dict]: with self._connect() as connection: @@ -527,22 +469,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate): (max(1, int(limit)),), ).fetchall() - return [{ - "game_id": row["game_id"], - "started_at": row["started_at"], - "ended_at": 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] + return self._build_game_list_output(rows) def _get_game_replay_sync(self, game_id:str) -> dict|None: with self._connect() as connection: @@ -580,56 +507,7 @@ class SqliteGameplayBackend(GameplayBackendTemplate): (game_id,), ).fetchall() - 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": self._from_json(row["body_json"]) or [], - "is_you": bool(row["is_you"]), - "inferred_move": row["inferred_move"], - "latency": row["latency"], - }) - - return { - "game": { - "game_id": game_row["game_id"], - "started_at": game_row["started_at"], - "ended_at": 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": int(row["turn"]), - "observed_at": row["observed_at"], - "my_move": row["my_move"], - "my_thinking": self._from_json(row["my_thinking_json"]), - "board": self._from_json(row["board_state_json"]), - "food": self._from_json(row["food_json"]) or [], - "hazards": self._from_json(row["hazards_json"]) or [], - "you": self._from_json(row["you_json"]) or {}, - "snakes": snakes_by_turn.get(int(row["turn"]), []), - } - for row in turn_rows - ], - } + return self._build_game_replay_output(game_row, turn_rows, snake_rows, decode_json=self._from_json) # ── public async interface ───────────────────────────────────────────────── diff --git a/server/database/backend/Template.py b/server/database/backend/Template.py index 2dee660..05e1540 100644 --- a/server/database/backend/Template.py +++ b/server/database/backend/Template.py @@ -98,3 +98,184 @@ class GameplayBackendTemplate: 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. + """ + 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"], + }) + + 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": 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 + ], + } + + # ── 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