Compare commits

..

2 Commits

2 changed files with 258 additions and 39 deletions
+47 -9
View File
@@ -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"],
+210 -29
View File
@@ -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;
@@ -375,14 +390,21 @@
}
.hazard {
background-color: var(--hazard);
filter: grayscale(0.35) brightness(0.62);
}
.hazard::after {
content: "";
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(
135deg,
rgba(255, 255, 255, 0.18) 0,
rgba(255, 255, 255, 0.18) 2px,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, 0) 6px
rgba(106, 90, 155, 0.55) 0,
rgba(106, 90, 155, 0.55) 2px,
transparent 2px,
transparent 6px
);
filter: grayscale(0.35) brightness(0.62);
z-index: 2;
pointer-events: none;
}
.snake-you { background: var(--you); }
.snake-enemy { background: var(--enemy); }
@@ -457,6 +479,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 +567,8 @@
.snake-row {
background: var(--snake-row-bg, transparent);
cursor: pointer;
transition: opacity 0.15s, filter 0.15s;
}
.snake-row td {
@@ -546,6 +580,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 +645,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 +744,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 +765,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 +815,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 +860,7 @@
const colorById = buildSnakeColorById(turn);
if (snakes.length === 0) {
return "<tr><td colspan=\"6\">No snake data available</td></tr>";
return "<tr><td colspan=\"7\">No snake data available</td></tr>";
}
return snakes.map((snake, idx) => (
(() => {
@@ -831,10 +876,58 @@
const diedTurn = Number(snake._last_seen_turn ?? currentTurn) + 1;
const moveText = snake._is_dead ? `dead @ ${diedTurn}` : safeString(snake.inferred_move);
const deadClass = snake._is_dead ? " dead-row" : "";
const deadLabel = snake._is_dead ? " (dead)" : "";
const causeLabel = (() => {
if (!snake._is_dead) return "";
const lastTurn = turns.find((t) => t && t.turn === snake._last_seen_turn);
if (!lastTurn) return "";
const lastSelf = (lastTurn.snakes || []).find((s) => (s.snake_id || s.id) === snake._snake_id);
if (!lastSelf) return "";
// Starvation: health ≤ 1 at last seen turn
if (Number(lastSelf.health) <= 1) return " · starved";
// Project head to next position based on body direction
const body = Array.isArray(lastSelf.body) ? lastSelf.body : [];
const h = lastSelf.head || (body[0] || null);
const neck = body[1] || null;
const projX = h && neck ? h.x + (h.x - neck.x) : null;
const projY = h && neck ? h.y + (h.y - neck.y) : null;
const nextTurn = turns.find((t) => t && t.turn === snake._last_seen_turn + 1);
if (projX !== null) {
const game = replay && replay.game ? replay.game : {};
const bw = Number(game.width || 0);
const bh = Number(game.height || 0);
// Wall collision: projected head out of bounds
if (bw > 0 && bh > 0 && (projX < 0 || projX >= bw || projY < 0 || projY >= bh)) {
return " · wall";
}
if (nextTurn) {
// Head-to-head: another alive snake's head at projected position
for (const other of (nextTurn.snakes || [])) {
const otherId = other.snake_id || other.id;
if (otherId === snake._snake_id) continue;
if (other.head && other.head.x === projX && other.head.y === projY) {
return ` · head-to-head ${safeString(other.snake_name)}`;
}
}
// Body collision: projected head inside another snake's body
for (const other of (nextTurn.snakes || [])) {
const otherId = other.snake_id || other.id;
if (otherId === snake._snake_id) continue;
const otherBody = Array.isArray(other.body) ? other.body : [];
const hitBody = otherBody.some((seg, i) => i > 0 && seg.x === projX && seg.y === projY);
if (hitBody) return ` · hit ${safeString(other.snake_name)}`;
}
// Hazard: projected position is in hazard list
const hazards = nextTurn.hazards || [];
if (hazards.some((hz) => hz.x === projX && hz.y === projY)) return " · Hazard";
}
}
return "";
})();
const deadLabel = snake._is_dead ? ` (dead${causeLabel})` : "";
return `
<tr class="snake-row${deadClass}" ${rowStyle}>
<tr class="snake-row${deadClass}" data-snake-id="${snakeId}" ${rowStyle}>
<td class="name-cell">${safeString(snake.snake_name)}${snake.is_you ? " (you)" : ""}${deadLabel}</td>
<td class="num-cell">${safeString(snake.latency)}</td>
<td class="num-cell">${moveText}</td>
<td class="num-cell">${healthCell}</td>
<td class="num-cell">${safeString(snake.length)}</td>
@@ -943,12 +1036,56 @@
return `/dashboard/customizations/${kind}/${raw}.svg`;
}
async function loadSvg(url) {
if (svgCache.has(url)) return svgCache.get(url);
try {
const res = await fetch(url);
const text = res.ok ? await res.text() : null;
svgCache.set(url, text);
return text;
} catch {
svgCache.set(url, null);
return null;
}
}
async function preloadReplaySvgs() {
if (!replay || !Array.isArray(replay.turns)) return;
const urls = new Set();
for (const turn of replay.turns) {
const snakes = turn && turn.board && Array.isArray(turn.board.snakes) ? turn.board.snakes : [];
for (const snake of snakes) {
const custom = snake && (snake.customizations || {});
const headUrl = buildCustomizationIconUrl("heads", custom.head);
const tailUrl = buildCustomizationIconUrl("tails", custom.tail);
if (headUrl) urls.add(headUrl);
if (tailUrl) urls.add(tailUrl);
}
}
await Promise.all([...urls].map(loadSvg));
}
function createIconLayer(iconUrl, color, transformValue, type) {
const layer = document.createElement("div");
layer.className = type === "head" ? "icon-layer icon-layer--head" : "icon-layer icon-layer--tail";
layer.style.setProperty("--icon-transform", transformValue || "rotate(0deg)");
if (type === "head") {
const svgMarkup = svgCache.get(iconUrl);
if (svgMarkup) {
layer.innerHTML = svgMarkup;
const svgEl = layer.querySelector("svg");
if (svgEl) {
svgEl.style.width = "100%";
svgEl.style.height = "100%";
svgEl.style.fill = color || "currentColor";
svgEl.removeAttribute("width");
svgEl.removeAttribute("height");
}
}
} else {
layer.style.setProperty("--icon-url", `url(${iconUrl})`);
layer.style.setProperty("--icon-color", color || "var(--you)");
layer.style.setProperty("--icon-transform", transformValue || "rotate(0deg)");
}
return layer;
}
@@ -959,6 +1096,14 @@
return "rotate(0deg)";
}
function directionToTailTransform(direction) {
if (direction === "right") return "scaleX(-1)";
if (direction === "left") return "rotate(0deg)";
if (direction === "up") return "rotate(270deg) scaleX(-1)";
if (direction === "down") return "rotate(90deg) scaleX(-1)";
return "scaleX(-1)";
}
function directionToAngle(direction) {
if (direction === "right") return 0;
if (direction === "down") return 90;
@@ -1124,14 +1269,15 @@
<p class="section-title">Snake State This Turn</p>
<table class="score-table">
<colgroup>
<col style="width:34%">
<col style="width:32%">
<col style="width:10%">
<col style="width:18%">
<col style="width:8%">
<col style="width:15%">
<col style="width:15%">
<col style="width:10%">
<col style="width:20%">
<col style="width:6%">
<col style="width:7%">
<col style="width:7%">
</colgroup>
<thead><tr><th>Snake</th><th>Move</th><th>Health</th><th>Length</th><th>Head</th><th>Tail</th></tr></thead>
<thead><tr><th>Snake</th><th>Latency</th><th>Move</th><th>Health</th><th>Length</th><th>Head</th><th>Tail</th></tr></thead>
<tbody>${buildSnakesRows(turn)}</tbody>
</table>
</section>
@@ -1141,6 +1287,14 @@
<pre class="mono">${JSON.stringify(reasoning, null, 2)}</pre>
</section>
`;
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 +1314,9 @@
gamesBodyEl.innerHTML = games.map((g) => `
<tr data-game-id="${g.game_id}">
<td><code>${shortId(g.game_id)}</code><br><small>${safeString(g.map)}</small></td>
<td><code>${shortId(g.game_id)}</code><br><small>${safeString((g.map && g.map.trim() && g.map.trim() !== "empty") ? g.map.trim() : g.game_type)}</small></td>
<td>${toTitle(g.status)}</td>
<td>${g.winner_you ? "Win" : "Loss"}</td>
<td>${g.status === "running" ? "-" : g.winner_you ? "Win" : "Loss"}</td>
<td>${safeString(g.final_turn)}</td>
</tr>
`).join("");
@@ -1221,8 +1375,9 @@
const headIconByCell = new Map();
const tailIconByCell = new Map();
const headTransformByCell = new Map();
const tailAngleByCell = new Map();
const tailTransformByCell = new Map();
const snakeColorByCell = new Map();
const snakeIdByCell = new Map();
(turnData.snakes || []).forEach((snake, idx) => {
if (!snake) return;
const snakeId = snake.snake_id || snake.id || `${safeString(snake.snake_name)}-${idx}`;
@@ -1233,9 +1388,10 @@
const headIcon = buildCustomizationIconUrl("heads", custom.head);
const tailIcon = buildCustomizationIconUrl("tails", custom.tail);
const headTransform = directionToHeadTransform(inferHeadDirection(snake));
const tailAngle = (directionToAngle(inferTailDirection(snake)) + 180) % 360;
const tailTransform = directionToTailTransform(inferTailDirection(snake));
for (const part of (snake.body || [])) {
snakeBody.set(cellKey(part.x, part.y), bodyColor);
snakeIdByCell.set(cellKey(part.x, part.y), snakeId);
}
if (snake.head) {
const headKey = cellKey(snake.head.x, snake.head.y);
@@ -1252,7 +1408,7 @@
const tailKey = cellKey(tail.x, tail.y);
snakeTail.set(tailKey, snake.is_you ? "snake-tail-you" : "snake-tail-enemy");
tailVariantByCell.set(tailKey, tailVariant);
tailAngleByCell.set(tailKey, tailAngle);
tailTransformByCell.set(tailKey, tailTransform);
snakeColorByCell.set(tailKey, bodyColor);
if (tailIcon) {
tailIconByCell.set(tailKey, tailIcon);
@@ -1275,6 +1431,9 @@
} else {
cell.style.background = snakeBody.get(key);
}
if (selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
cell.style.opacity = "0.2";
}
}
if (snakeTail.has(key)) {
cell.classList.add(snakeTail.get(key));
@@ -1286,7 +1445,7 @@
const layer = createIconLayer(
tailIcon,
snakeColorByCell.get(key) || "var(--you)",
`rotate(${tailAngleByCell.get(key) || 0}deg)`,
tailTransformByCell.get(key) || "scaleX(-1)",
"tail",
);
cell.appendChild(layer);
@@ -1340,6 +1499,7 @@
return;
}
replay = await response.json();
await preloadReplaySvgs();
turnIndex = 0;
const count = Array.isArray(replay.turns) ? replay.turns.length : 0;
sliderEl.max = String(Math.max(0, count - 1));
@@ -1418,6 +1578,27 @@
syncMonoOffset();
}
thinkingEl.addEventListener("click", (event) => {
const row = event.target.closest(".snake-row");
if (!row) return;
const section = row.closest(".snakes-section");
if (!section) return;
const clickedId = row.dataset.snakeId || null;
if (row.classList.contains("highlighted")) {
row.classList.remove("highlighted");
section.classList.remove("has-highlight");
selectedSnakeId = null;
} else {
section.querySelectorAll(".snake-row.highlighted").forEach((r) => r.classList.remove("highlighted"));
row.classList.add("highlighted");
section.classList.add("has-highlight");
selectedSnakeId = clickedId;
}
const game = replay && replay.game ? replay.game : {};
const turns = replay && Array.isArray(replay.turns) ? replay.turns : [];
if (turns[turnIndex]) paintBoard(turns[turnIndex], game.width, game.height);
});
window.addEventListener("resize", () => {
syncMonoOffset();
});