diff --git a/server/database/GameplayDatabase.py b/server/database/GameplayDatabase.py index 2c413d7..cfb7d5b 100644 --- a/server/database/GameplayDatabase.py +++ b/server/database/GameplayDatabase.py @@ -88,6 +88,8 @@ class GameplayDatabase: 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") connection.execute("PRAGMA optimize") def _ensure_column_exists(self, connection:sqlite3.Connection, table_name:str, column_name:str, column_type:str) -> None: @@ -133,19 +135,26 @@ class GameplayDatabase: return "down" return None + def _derive_game_type(self, board:dict, ruleset:dict) -> str: + initial_snake_count = len(board.get("snakes", [])) + if initial_snake_count == 2: + return "duel" + return ruleset.get("name") or "standard" + def _record_game_start_sync(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None: game = game_state.get("game", {}) board = game_state.get("board", {}) you = self._extract_you(game_state) ruleset = game.get("ruleset", {}) + game_type = self._derive_game_type(board, ruleset) with self._connect() as connection: connection.execute(""" INSERT INTO games ( game_id, started_at, width, height, source, map_name, ruleset_name, ruleset_version, your_snake_id, your_snake_name, - your_snake_type, your_snake_version, status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running') + your_snake_type, your_snake_version, game_type, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running') ON CONFLICT(game_id) DO UPDATE SET width = excluded.width, height = excluded.height, @@ -157,6 +166,7 @@ class GameplayDatabase: your_snake_name = excluded.your_snake_name, your_snake_type = excluded.your_snake_type, your_snake_version = excluded.your_snake_version, + game_type = excluded.game_type, status = 'running' """, ( @@ -172,6 +182,7 @@ class GameplayDatabase: you.get("name"), snake_type, snake_version, + game_type, ), ) connection.execute("PRAGMA wal_checkpoint(PASSIVE)") @@ -253,8 +264,8 @@ class GameplayDatabase: connection.execute(""" INSERT INTO snake_turns ( game_id, turn, snake_id, snake_name, health, length, - head_x, head_y, body_json, is_you, inferred_move - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + head_x, head_y, body_json, is_you, inferred_move, latency + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(game_id, turn, snake_id) DO UPDATE SET snake_name = excluded.snake_name, health = excluded.health, @@ -263,7 +274,8 @@ class GameplayDatabase: head_y = excluded.head_y, body_json = excluded.body_json, is_you = excluded.is_you, - inferred_move = excluded.inferred_move + inferred_move = excluded.inferred_move, + latency = excluded.latency """, ( game_id, @@ -277,6 +289,7 @@ class GameplayDatabase: self._to_json(snake.get("body", [])), 1 if snake_id == you_id else 0, inferred, + snake.get("latency"), ), ) @@ -332,8 +345,22 @@ class GameplayDatabase: """ ).fetchone() + by_type = connection.execute(""" + SELECT + COALESCE(game_type, ruleset_name, 'unknown') AS type_label, + COUNT(*) AS total, + SUM(CASE WHEN status = 'finished' AND winner_you = 1 THEN 1 ELSE 0 END) AS wins, + SUM(CASE WHEN status = 'finished' AND winner_you = 0 THEN 1 ELSE 0 END) AS losses + FROM games + WHERE status = 'finished' + GROUP BY type_label + ORDER BY total DESC + """ + ).fetchall() + recent = connection.execute(""" - SELECT game_id, started_at, ended_at, map_name, your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status + 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 ORDER BY started_at DESC LIMIT ? @@ -349,11 +376,19 @@ class GameplayDatabase: "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"], @@ -366,7 +401,7 @@ class GameplayDatabase: def _list_games_sync(self, limit:int=50) -> list[dict]: with self._connect() as connection: rows = connection.execute(""" - SELECT game_id, started_at, ended_at, map_name, source, ruleset_name, + SELECT game_id, started_at, ended_at, map_name, source, ruleset_name, game_type, your_snake_name, your_snake_type, your_snake_version, winner_you, winner_names_json, final_turn, status FROM games @@ -383,6 +418,7 @@ class GameplayDatabase: "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"], @@ -396,7 +432,7 @@ class GameplayDatabase: with self._connect() as connection: game_row = connection.execute(""" SELECT game_id, started_at, ended_at, width, height, source, map_name, - ruleset_name, ruleset_version, your_snake_id, your_snake_name, + ruleset_name, ruleset_version, game_type, your_snake_id, your_snake_name, your_snake_type, your_snake_version, winner_names_json, winner_you, final_turn, status FROM games @@ -420,7 +456,7 @@ class GameplayDatabase: snake_rows = connection.execute(""" SELECT turn, snake_id, snake_name, health, length, head_x, head_y, - body_json, is_you, inferred_move + body_json, is_you, inferred_move, latency FROM snake_turns WHERE game_id = ? ORDER BY turn ASC, is_you DESC, snake_name ASC @@ -440,6 +476,7 @@ class GameplayDatabase: "body": self._from_json(row["body_json"]) or [], "is_you": bool(row["is_you"]), "inferred_move": row["inferred_move"], + "latency": row["latency"], }) replay_turns = [] @@ -468,6 +505,7 @@ class GameplayDatabase: "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"], diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html index 5fdeea2..bc4576d 100644 --- a/server/templates/dashboard.html +++ b/server/templates/dashboard.html @@ -88,17 +88,19 @@ margin: 0; padding: 0; height: 100%; + overflow: hidden; color: var(--ink); font-family: "IBM Plex Sans", "Segoe UI", sans-serif; background: linear-gradient(180deg, var(--bg-1), var(--bg-2)); } .page { - min-height: 100vh; + height: 100vh; display: grid; grid-template-rows: auto 1fr; gap: 12px; padding: 12px; + overflow: hidden; } .topbar { @@ -127,7 +129,7 @@ .stats { display: grid; - grid-template-columns: repeat(5, minmax(90px, 1fr)); + grid-template-columns: repeat(6, minmax(90px, 1fr)); gap: 8px; min-width: 520px; } @@ -164,6 +166,9 @@ border-radius: 12px; box-shadow: 0 8px 28px var(--shadow); min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; } .panel-header { @@ -183,8 +188,10 @@ } .games { - overflow: auto; - height: clamp(280px, 62vh, 760px); + overflow-y: auto; + overflow-x: hidden; + flex: 1; + min-height: 0; } table { @@ -193,6 +200,13 @@ font-size: 0.88rem; } + thead th { + position: sticky; + top: 0; + background: var(--bg); + z-index: 1; + } + th, td { text-align: left; vertical-align: top; @@ -203,6 +217,7 @@ tbody tr { cursor: pointer; } tbody tr:hover { background: var(--row-hover); } tbody tr.active { background: var(--row-active); } + #games-body tr:last-child td { border-bottom: none; } .right { min-height: 0; @@ -457,6 +472,16 @@ .icon-layer--head { z-index: 3; opacity: 1; + background: none; + -webkit-mask-image: none; + mask-image: none; + } + + .icon-layer--head > svg { + width: 100%; + height: 100%; + display: block; + overflow: visible; } .thinking { @@ -535,6 +560,8 @@ .snake-row { background: var(--snake-row-bg, transparent); + cursor: pointer; + transition: opacity 0.15s, filter 0.15s; } .snake-row td { @@ -546,6 +573,9 @@ padding-left: 8px; } + .snake-row.highlighted { outline: 2px solid var(--snake-row-color, var(--line)); outline-offset: -1px; } + .snakes-section.has-highlight .snake-row:not(.highlighted) { opacity: 0.25; filter: grayscale(0.6); } + .snake-row.dead-row { filter: grayscale(0.55); opacity: 0.78; @@ -608,13 +638,13 @@ } .stats { min-width: 0; - grid-template-columns: repeat(5, minmax(80px, 1fr)); + grid-template-columns: repeat(6, minmax(80px, 1fr)); } .main { grid-template-columns: 1fr; } .games { - height: 320px; + min-height: 200px; } .content { grid-template-columns: 1fr; @@ -707,6 +737,8 @@ let replay = null; let turnIndex = 0; let timer = null; + let selectedSnakeId = null; + const svgCache = new Map(); const statsEl = document.getElementById("stats"); const gamesBodyEl = document.getElementById("games-body"); @@ -726,11 +758,15 @@ } function renderStats(summary) { + const finished = summary.finished_games || 0; + const wins = summary.wins || 0; + const winRate = finished > 0 ? ((wins / finished) * 100).toFixed(1) + "%" : "-"; const items = [ ["Games", summary.total_games || 0], - ["Finished", summary.finished_games || 0], - ["Wins", summary.wins || 0], + ["Finished", finished], + ["Wins", wins], ["Losses", summary.losses || 0], + ["Win Rate", winRate], ["Avg Turns", summary.avg_turns_finished || 0], ]; statsEl.innerHTML = items.map(([k, v]) => ( @@ -772,6 +808,8 @@ } function buildSnakesRows(turn) { + console.log(turn); + const currentTurn = Number(turn && turn.turn !== undefined ? turn.turn : 0); const turns = replay && Array.isArray(replay.turns) ? replay.turns : []; const lastSeenById = new Map(); @@ -815,7 +853,7 @@ const colorById = buildSnakeColorById(turn); if (snakes.length === 0) { - return "
Snake State This Turn
| Snake | Move | Health | Length | Head | Tail | |
|---|---|---|---|---|---|---|
| Snake | Latency | Move | Health | Length | Head | Tail |
${JSON.stringify(reasoning, null, 2)}
`;
+ if (selectedSnakeId) {
+ const section = thinkingEl.querySelector(".snakes-section");
+ const row = section && section.querySelector(`[data-snake-id="${CSS.escape(selectedSnakeId)}"]`);
+ if (row) {
+ row.classList.add("highlighted");
+ section.classList.add("has-highlight");
+ }
+ }
syncMonoOffset();
}
@@ -1160,9 +1307,9 @@
gamesBodyEl.innerHTML = games.map((g) => `
${shortId(g.game_id)}${shortId(g.game_id)}