fix svg color problem show dead resions when very sure and allow to highlight snakes when clicking into them store timout and calcumate Snake Win Rate Overall, fix to use full page of games-body, get win lloss or unknown
This commit is contained in:
@@ -88,6 +88,8 @@ class GameplayDatabase:
|
|||||||
self._ensure_column_exists(connection, "turns", "my_thinking_json", "TEXT")
|
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_type", "TEXT")
|
||||||
self._ensure_column_exists(connection, "games", "your_snake_version", "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")
|
connection.execute("PRAGMA optimize")
|
||||||
|
|
||||||
def _ensure_column_exists(self, connection:sqlite3.Connection, table_name:str, column_name:str, column_type:str) -> None:
|
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 "down"
|
||||||
return None
|
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:
|
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", {})
|
game = game_state.get("game", {})
|
||||||
board = game_state.get("board", {})
|
board = game_state.get("board", {})
|
||||||
you = self._extract_you(game_state)
|
you = self._extract_you(game_state)
|
||||||
ruleset = game.get("ruleset", {})
|
ruleset = game.get("ruleset", {})
|
||||||
|
game_type = self._derive_game_type(board, ruleset)
|
||||||
|
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute("""
|
connection.execute("""
|
||||||
INSERT INTO games (
|
INSERT INTO games (
|
||||||
game_id, started_at, width, height, source, map_name,
|
game_id, started_at, width, height, source, map_name,
|
||||||
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
|
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
|
||||||
your_snake_type, your_snake_version, status
|
your_snake_type, your_snake_version, game_type, status
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running')
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running')
|
||||||
ON CONFLICT(game_id) DO UPDATE SET
|
ON CONFLICT(game_id) DO UPDATE SET
|
||||||
width = excluded.width,
|
width = excluded.width,
|
||||||
height = excluded.height,
|
height = excluded.height,
|
||||||
@@ -157,6 +166,7 @@ class GameplayDatabase:
|
|||||||
your_snake_name = excluded.your_snake_name,
|
your_snake_name = excluded.your_snake_name,
|
||||||
your_snake_type = excluded.your_snake_type,
|
your_snake_type = excluded.your_snake_type,
|
||||||
your_snake_version = excluded.your_snake_version,
|
your_snake_version = excluded.your_snake_version,
|
||||||
|
game_type = excluded.game_type,
|
||||||
status = 'running'
|
status = 'running'
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
@@ -172,6 +182,7 @@ class GameplayDatabase:
|
|||||||
you.get("name"),
|
you.get("name"),
|
||||||
snake_type,
|
snake_type,
|
||||||
snake_version,
|
snake_version,
|
||||||
|
game_type,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
connection.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
connection.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
||||||
@@ -253,8 +264,8 @@ class GameplayDatabase:
|
|||||||
connection.execute("""
|
connection.execute("""
|
||||||
INSERT INTO snake_turns (
|
INSERT INTO snake_turns (
|
||||||
game_id, turn, snake_id, snake_name, health, length,
|
game_id, turn, snake_id, snake_name, health, length,
|
||||||
head_x, head_y, body_json, is_you, inferred_move
|
head_x, head_y, body_json, is_you, inferred_move, latency
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(game_id, turn, snake_id) DO UPDATE SET
|
ON CONFLICT(game_id, turn, snake_id) DO UPDATE SET
|
||||||
snake_name = excluded.snake_name,
|
snake_name = excluded.snake_name,
|
||||||
health = excluded.health,
|
health = excluded.health,
|
||||||
@@ -263,7 +274,8 @@ class GameplayDatabase:
|
|||||||
head_y = excluded.head_y,
|
head_y = excluded.head_y,
|
||||||
body_json = excluded.body_json,
|
body_json = excluded.body_json,
|
||||||
is_you = excluded.is_you,
|
is_you = excluded.is_you,
|
||||||
inferred_move = excluded.inferred_move
|
inferred_move = excluded.inferred_move,
|
||||||
|
latency = excluded.latency
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
game_id,
|
game_id,
|
||||||
@@ -277,6 +289,7 @@ class GameplayDatabase:
|
|||||||
self._to_json(snake.get("body", [])),
|
self._to_json(snake.get("body", [])),
|
||||||
1 if snake_id == you_id else 0,
|
1 if snake_id == you_id else 0,
|
||||||
inferred,
|
inferred,
|
||||||
|
snake.get("latency"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -332,8 +345,22 @@ class GameplayDatabase:
|
|||||||
"""
|
"""
|
||||||
).fetchone()
|
).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("""
|
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
|
FROM games
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -349,11 +376,19 @@ class GameplayDatabase:
|
|||||||
"wins": int(totals["wins"] or 0),
|
"wins": int(totals["wins"] or 0),
|
||||||
"losses": int(totals["losses"] or 0),
|
"losses": int(totals["losses"] or 0),
|
||||||
"avg_turns_finished": round(float(totals["avg_turns"] or 0.0), 2),
|
"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": [{
|
"recent_games": [{
|
||||||
"game_id": row["game_id"],
|
"game_id": row["game_id"],
|
||||||
"started_at": row["started_at"],
|
"started_at": row["started_at"],
|
||||||
"ended_at": row["ended_at"],
|
"ended_at": row["ended_at"],
|
||||||
"map": row["map_name"],
|
"map": row["map_name"],
|
||||||
|
"ruleset": row["ruleset_name"],
|
||||||
|
"game_type": row["game_type"],
|
||||||
"snake": row["your_snake_name"],
|
"snake": row["your_snake_name"],
|
||||||
"snake_type": row["your_snake_type"],
|
"snake_type": row["your_snake_type"],
|
||||||
"snake_version": row["your_snake_version"],
|
"snake_version": row["your_snake_version"],
|
||||||
@@ -366,7 +401,7 @@ class GameplayDatabase:
|
|||||||
def _list_games_sync(self, limit:int=50) -> list[dict]:
|
def _list_games_sync(self, limit:int=50) -> list[dict]:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
rows = connection.execute("""
|
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,
|
your_snake_name, your_snake_type, your_snake_version,
|
||||||
winner_you, winner_names_json, final_turn, status
|
winner_you, winner_names_json, final_turn, status
|
||||||
FROM games
|
FROM games
|
||||||
@@ -383,6 +418,7 @@ class GameplayDatabase:
|
|||||||
"map": row["map_name"],
|
"map": row["map_name"],
|
||||||
"source": row["source"],
|
"source": row["source"],
|
||||||
"ruleset": row["ruleset_name"],
|
"ruleset": row["ruleset_name"],
|
||||||
|
"game_type": row["game_type"],
|
||||||
"snake": row["your_snake_name"],
|
"snake": row["your_snake_name"],
|
||||||
"snake_type": row["your_snake_type"],
|
"snake_type": row["your_snake_type"],
|
||||||
"snake_version": row["your_snake_version"],
|
"snake_version": row["your_snake_version"],
|
||||||
@@ -396,7 +432,7 @@ class GameplayDatabase:
|
|||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
game_row = connection.execute("""
|
game_row = connection.execute("""
|
||||||
SELECT game_id, started_at, ended_at, width, height, source, map_name,
|
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,
|
your_snake_type, your_snake_version,
|
||||||
winner_names_json, winner_you, final_turn, status
|
winner_names_json, winner_you, final_turn, status
|
||||||
FROM games
|
FROM games
|
||||||
@@ -420,7 +456,7 @@ class GameplayDatabase:
|
|||||||
|
|
||||||
snake_rows = connection.execute("""
|
snake_rows = connection.execute("""
|
||||||
SELECT turn, snake_id, snake_name, health, length, head_x, head_y,
|
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
|
FROM snake_turns
|
||||||
WHERE game_id = ?
|
WHERE game_id = ?
|
||||||
ORDER BY turn ASC, is_you DESC, snake_name ASC
|
ORDER BY turn ASC, is_you DESC, snake_name ASC
|
||||||
@@ -440,6 +476,7 @@ class GameplayDatabase:
|
|||||||
"body": self._from_json(row["body_json"]) or [],
|
"body": self._from_json(row["body_json"]) or [],
|
||||||
"is_you": bool(row["is_you"]),
|
"is_you": bool(row["is_you"]),
|
||||||
"inferred_move": row["inferred_move"],
|
"inferred_move": row["inferred_move"],
|
||||||
|
"latency": row["latency"],
|
||||||
})
|
})
|
||||||
|
|
||||||
replay_turns = []
|
replay_turns = []
|
||||||
@@ -468,6 +505,7 @@ class GameplayDatabase:
|
|||||||
"map": game_row["map_name"],
|
"map": game_row["map_name"],
|
||||||
"ruleset_name": game_row["ruleset_name"],
|
"ruleset_name": game_row["ruleset_name"],
|
||||||
"ruleset_version": game_row["ruleset_version"],
|
"ruleset_version": game_row["ruleset_version"],
|
||||||
|
"game_type": game_row["game_type"],
|
||||||
"your_snake_id": game_row["your_snake_id"],
|
"your_snake_id": game_row["your_snake_id"],
|
||||||
"your_snake_name": game_row["your_snake_name"],
|
"your_snake_name": game_row["your_snake_name"],
|
||||||
"your_snake_type": game_row["your_snake_type"],
|
"your_snake_type": game_row["your_snake_type"],
|
||||||
|
|||||||
+199
-25
@@ -88,17 +88,19 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
background: linear-gradient(180deg, var(--bg-1), var(--bg-2));
|
background: linear-gradient(180deg, var(--bg-1), var(--bg-2));
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
@@ -127,7 +129,7 @@
|
|||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(90px, 1fr));
|
grid-template-columns: repeat(6, minmax(90px, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 520px;
|
min-width: 520px;
|
||||||
}
|
}
|
||||||
@@ -164,6 +166,9 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 28px var(--shadow);
|
box-shadow: 0 8px 28px var(--shadow);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -183,8 +188,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.games {
|
.games {
|
||||||
overflow: auto;
|
overflow-y: auto;
|
||||||
height: clamp(280px, 62vh, 760px);
|
overflow-x: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@@ -193,6 +200,13 @@
|
|||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
@@ -203,6 +217,7 @@
|
|||||||
tbody tr { cursor: pointer; }
|
tbody tr { cursor: pointer; }
|
||||||
tbody tr:hover { background: var(--row-hover); }
|
tbody tr:hover { background: var(--row-hover); }
|
||||||
tbody tr.active { background: var(--row-active); }
|
tbody tr.active { background: var(--row-active); }
|
||||||
|
#games-body tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
.right {
|
.right {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -457,6 +472,16 @@
|
|||||||
.icon-layer--head {
|
.icon-layer--head {
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
background: none;
|
||||||
|
-webkit-mask-image: none;
|
||||||
|
mask-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-layer--head > svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thinking {
|
.thinking {
|
||||||
@@ -535,6 +560,8 @@
|
|||||||
|
|
||||||
.snake-row {
|
.snake-row {
|
||||||
background: var(--snake-row-bg, transparent);
|
background: var(--snake-row-bg, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, filter 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snake-row td {
|
.snake-row td {
|
||||||
@@ -546,6 +573,9 @@
|
|||||||
padding-left: 8px;
|
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 {
|
.snake-row.dead-row {
|
||||||
filter: grayscale(0.55);
|
filter: grayscale(0.55);
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
@@ -608,13 +638,13 @@
|
|||||||
}
|
}
|
||||||
.stats {
|
.stats {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
grid-template-columns: repeat(5, minmax(80px, 1fr));
|
grid-template-columns: repeat(6, minmax(80px, 1fr));
|
||||||
}
|
}
|
||||||
.main {
|
.main {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.games {
|
.games {
|
||||||
height: 320px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -707,6 +737,8 @@
|
|||||||
let replay = null;
|
let replay = null;
|
||||||
let turnIndex = 0;
|
let turnIndex = 0;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
let selectedSnakeId = null;
|
||||||
|
const svgCache = new Map();
|
||||||
|
|
||||||
const statsEl = document.getElementById("stats");
|
const statsEl = document.getElementById("stats");
|
||||||
const gamesBodyEl = document.getElementById("games-body");
|
const gamesBodyEl = document.getElementById("games-body");
|
||||||
@@ -726,11 +758,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderStats(summary) {
|
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 = [
|
const items = [
|
||||||
["Games", summary.total_games || 0],
|
["Games", summary.total_games || 0],
|
||||||
["Finished", summary.finished_games || 0],
|
["Finished", finished],
|
||||||
["Wins", summary.wins || 0],
|
["Wins", wins],
|
||||||
["Losses", summary.losses || 0],
|
["Losses", summary.losses || 0],
|
||||||
|
["Win Rate", winRate],
|
||||||
["Avg Turns", summary.avg_turns_finished || 0],
|
["Avg Turns", summary.avg_turns_finished || 0],
|
||||||
];
|
];
|
||||||
statsEl.innerHTML = items.map(([k, v]) => (
|
statsEl.innerHTML = items.map(([k, v]) => (
|
||||||
@@ -772,6 +808,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSnakesRows(turn) {
|
function buildSnakesRows(turn) {
|
||||||
|
console.log(turn);
|
||||||
|
|
||||||
const currentTurn = Number(turn && turn.turn !== undefined ? turn.turn : 0);
|
const currentTurn = Number(turn && turn.turn !== undefined ? turn.turn : 0);
|
||||||
const turns = replay && Array.isArray(replay.turns) ? replay.turns : [];
|
const turns = replay && Array.isArray(replay.turns) ? replay.turns : [];
|
||||||
const lastSeenById = new Map();
|
const lastSeenById = new Map();
|
||||||
@@ -815,7 +853,7 @@
|
|||||||
|
|
||||||
const colorById = buildSnakeColorById(turn);
|
const colorById = buildSnakeColorById(turn);
|
||||||
if (snakes.length === 0) {
|
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) => (
|
return snakes.map((snake, idx) => (
|
||||||
(() => {
|
(() => {
|
||||||
@@ -831,10 +869,58 @@
|
|||||||
const diedTurn = Number(snake._last_seen_turn ?? currentTurn) + 1;
|
const diedTurn = Number(snake._last_seen_turn ?? currentTurn) + 1;
|
||||||
const moveText = snake._is_dead ? `dead @ ${diedTurn}` : safeString(snake.inferred_move);
|
const moveText = snake._is_dead ? `dead @ ${diedTurn}` : safeString(snake.inferred_move);
|
||||||
const deadClass = snake._is_dead ? " dead-row" : "";
|
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 `
|
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="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">${moveText}</td>
|
||||||
<td class="num-cell">${healthCell}</td>
|
<td class="num-cell">${healthCell}</td>
|
||||||
<td class="num-cell">${safeString(snake.length)}</td>
|
<td class="num-cell">${safeString(snake.length)}</td>
|
||||||
@@ -943,12 +1029,56 @@
|
|||||||
return `/dashboard/customizations/${kind}/${raw}.svg`;
|
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) {
|
function createIconLayer(iconUrl, color, transformValue, type) {
|
||||||
const layer = document.createElement("div");
|
const layer = document.createElement("div");
|
||||||
layer.className = type === "head" ? "icon-layer icon-layer--head" : "icon-layer icon-layer--tail";
|
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)");
|
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;
|
return layer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,6 +1089,14 @@
|
|||||||
return "rotate(0deg)";
|
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) {
|
function directionToAngle(direction) {
|
||||||
if (direction === "right") return 0;
|
if (direction === "right") return 0;
|
||||||
if (direction === "down") return 90;
|
if (direction === "down") return 90;
|
||||||
@@ -1124,14 +1262,15 @@
|
|||||||
<p class="section-title">Snake State This Turn</p>
|
<p class="section-title">Snake State This Turn</p>
|
||||||
<table class="score-table">
|
<table class="score-table">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style="width:34%">
|
<col style="width:32%">
|
||||||
<col style="width:10%">
|
<col style="width:10%">
|
||||||
<col style="width:18%">
|
<col style="width:10%">
|
||||||
<col style="width:8%">
|
<col style="width:20%">
|
||||||
<col style="width:15%">
|
<col style="width:6%">
|
||||||
<col style="width:15%">
|
<col style="width:7%">
|
||||||
|
<col style="width:7%">
|
||||||
</colgroup>
|
</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>
|
<tbody>${buildSnakesRows(turn)}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
@@ -1141,6 +1280,14 @@
|
|||||||
<pre class="mono">${JSON.stringify(reasoning, null, 2)}</pre>
|
<pre class="mono">${JSON.stringify(reasoning, null, 2)}</pre>
|
||||||
</section>
|
</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();
|
syncMonoOffset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,9 +1307,9 @@
|
|||||||
|
|
||||||
gamesBodyEl.innerHTML = games.map((g) => `
|
gamesBodyEl.innerHTML = games.map((g) => `
|
||||||
<tr data-game-id="${g.game_id}">
|
<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>${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>
|
<td>${safeString(g.final_turn)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join("");
|
`).join("");
|
||||||
@@ -1221,8 +1368,9 @@
|
|||||||
const headIconByCell = new Map();
|
const headIconByCell = new Map();
|
||||||
const tailIconByCell = new Map();
|
const tailIconByCell = new Map();
|
||||||
const headTransformByCell = new Map();
|
const headTransformByCell = new Map();
|
||||||
const tailAngleByCell = new Map();
|
const tailTransformByCell = new Map();
|
||||||
const snakeColorByCell = new Map();
|
const snakeColorByCell = new Map();
|
||||||
|
const snakeIdByCell = new Map();
|
||||||
(turnData.snakes || []).forEach((snake, idx) => {
|
(turnData.snakes || []).forEach((snake, idx) => {
|
||||||
if (!snake) return;
|
if (!snake) return;
|
||||||
const snakeId = snake.snake_id || snake.id || `${safeString(snake.snake_name)}-${idx}`;
|
const snakeId = snake.snake_id || snake.id || `${safeString(snake.snake_name)}-${idx}`;
|
||||||
@@ -1233,9 +1381,10 @@
|
|||||||
const headIcon = buildCustomizationIconUrl("heads", custom.head);
|
const headIcon = buildCustomizationIconUrl("heads", custom.head);
|
||||||
const tailIcon = buildCustomizationIconUrl("tails", custom.tail);
|
const tailIcon = buildCustomizationIconUrl("tails", custom.tail);
|
||||||
const headTransform = directionToHeadTransform(inferHeadDirection(snake));
|
const headTransform = directionToHeadTransform(inferHeadDirection(snake));
|
||||||
const tailAngle = (directionToAngle(inferTailDirection(snake)) + 180) % 360;
|
const tailTransform = directionToTailTransform(inferTailDirection(snake));
|
||||||
for (const part of (snake.body || [])) {
|
for (const part of (snake.body || [])) {
|
||||||
snakeBody.set(cellKey(part.x, part.y), bodyColor);
|
snakeBody.set(cellKey(part.x, part.y), bodyColor);
|
||||||
|
snakeIdByCell.set(cellKey(part.x, part.y), snakeId);
|
||||||
}
|
}
|
||||||
if (snake.head) {
|
if (snake.head) {
|
||||||
const headKey = cellKey(snake.head.x, snake.head.y);
|
const headKey = cellKey(snake.head.x, snake.head.y);
|
||||||
@@ -1252,7 +1401,7 @@
|
|||||||
const tailKey = cellKey(tail.x, tail.y);
|
const tailKey = cellKey(tail.x, tail.y);
|
||||||
snakeTail.set(tailKey, snake.is_you ? "snake-tail-you" : "snake-tail-enemy");
|
snakeTail.set(tailKey, snake.is_you ? "snake-tail-you" : "snake-tail-enemy");
|
||||||
tailVariantByCell.set(tailKey, tailVariant);
|
tailVariantByCell.set(tailKey, tailVariant);
|
||||||
tailAngleByCell.set(tailKey, tailAngle);
|
tailTransformByCell.set(tailKey, tailTransform);
|
||||||
snakeColorByCell.set(tailKey, bodyColor);
|
snakeColorByCell.set(tailKey, bodyColor);
|
||||||
if (tailIcon) {
|
if (tailIcon) {
|
||||||
tailIconByCell.set(tailKey, tailIcon);
|
tailIconByCell.set(tailKey, tailIcon);
|
||||||
@@ -1275,6 +1424,9 @@
|
|||||||
} else {
|
} else {
|
||||||
cell.style.background = snakeBody.get(key);
|
cell.style.background = snakeBody.get(key);
|
||||||
}
|
}
|
||||||
|
if (selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
|
||||||
|
cell.style.opacity = "0.2";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (snakeTail.has(key)) {
|
if (snakeTail.has(key)) {
|
||||||
cell.classList.add(snakeTail.get(key));
|
cell.classList.add(snakeTail.get(key));
|
||||||
@@ -1286,7 +1438,7 @@
|
|||||||
const layer = createIconLayer(
|
const layer = createIconLayer(
|
||||||
tailIcon,
|
tailIcon,
|
||||||
snakeColorByCell.get(key) || "var(--you)",
|
snakeColorByCell.get(key) || "var(--you)",
|
||||||
`rotate(${tailAngleByCell.get(key) || 0}deg)`,
|
tailTransformByCell.get(key) || "scaleX(-1)",
|
||||||
"tail",
|
"tail",
|
||||||
);
|
);
|
||||||
cell.appendChild(layer);
|
cell.appendChild(layer);
|
||||||
@@ -1340,6 +1492,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
replay = await response.json();
|
replay = await response.json();
|
||||||
|
await preloadReplaySvgs();
|
||||||
turnIndex = 0;
|
turnIndex = 0;
|
||||||
const count = Array.isArray(replay.turns) ? replay.turns.length : 0;
|
const count = Array.isArray(replay.turns) ? replay.turns.length : 0;
|
||||||
sliderEl.max = String(Math.max(0, count - 1));
|
sliderEl.max = String(Math.max(0, count - 1));
|
||||||
@@ -1418,6 +1571,27 @@
|
|||||||
syncMonoOffset();
|
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", () => {
|
window.addEventListener("resize", () => {
|
||||||
syncMonoOffset();
|
syncMonoOffset();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user