Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e7d0227cf9
|
|||
|
b7c6a0e345
|
@@ -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"],
|
||||
|
||||
+211
-30
@@ -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-url", `url(${iconUrl})`);
|
||||
layer.style.setProperty("--icon-color", color || "var(--you)");
|
||||
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)");
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user