Compare commits
2 Commits
050dd1083c
...
e7d0227cf9
| 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, "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"],
|
||||||
|
|||||||
+210
-29
@@ -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;
|
||||||
@@ -375,14 +390,21 @@
|
|||||||
}
|
}
|
||||||
.hazard {
|
.hazard {
|
||||||
background-color: var(--hazard);
|
background-color: var(--hazard);
|
||||||
|
filter: grayscale(0.35) brightness(0.62);
|
||||||
|
}
|
||||||
|
.hazard::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(255, 255, 255, 0.18) 0,
|
rgba(106, 90, 155, 0.55) 0,
|
||||||
rgba(255, 255, 255, 0.18) 2px,
|
rgba(106, 90, 155, 0.55) 2px,
|
||||||
rgba(0, 0, 0, 0) 2px,
|
transparent 2px,
|
||||||
rgba(0, 0, 0, 0) 6px
|
transparent 6px
|
||||||
);
|
);
|
||||||
filter: grayscale(0.35) brightness(0.62);
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.snake-you { background: var(--you); }
|
.snake-you { background: var(--you); }
|
||||||
.snake-enemy { background: var(--enemy); }
|
.snake-enemy { background: var(--enemy); }
|
||||||
@@ -457,6 +479,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 +567,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 +580,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 +645,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 +744,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 +765,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 +815,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 +860,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 +876,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 +1036,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-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-url", `url(${iconUrl})`);
|
||||||
layer.style.setProperty("--icon-color", color || "var(--you)");
|
layer.style.setProperty("--icon-color", color || "var(--you)");
|
||||||
layer.style.setProperty("--icon-transform", transformValue || "rotate(0deg)");
|
}
|
||||||
return layer;
|
return layer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,6 +1096,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 +1269,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 +1287,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 +1314,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 +1375,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 +1388,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 +1408,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 +1431,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 +1445,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 +1499,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 +1578,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