2078 lines
67 KiB
HTML
2078 lines
67 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Snake Dashboard</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
--bg-1: #f2eee6;
|
|
--bg-2: #e7dcc8;
|
|
--panel: #fffcf6;
|
|
--line: #d9ccb6;
|
|
--ink: #252119;
|
|
--muted: #6f6657;
|
|
--accent: #146a4b;
|
|
--accent-soft: #e5f2ed;
|
|
--danger: #b0492a;
|
|
--surface: #ffffff;
|
|
--surface-soft: #fffdf8;
|
|
--row-hover: #fdf4e7;
|
|
--row-active: #edf8f3;
|
|
--shadow: rgba(41, 29, 11, 0.08);
|
|
--you: #1a7a56;
|
|
--enemy: #bf5b33;
|
|
--snake-1: #bf5b33;
|
|
--snake-2: #2f6fdd;
|
|
--snake-3: #8d4ad6;
|
|
--snake-4: #cc7a11;
|
|
--snake-5: #0f8f84;
|
|
--snake-6: #be3f70;
|
|
--snake-7: #6b8a12;
|
|
--snake-8: #9a4a2f;
|
|
--snake-9: #2e8698;
|
|
--snake-10: #7f5fdd;
|
|
--food: #cca100;
|
|
--hazard: #6a5a9b;
|
|
--grid: #e6dbc8;
|
|
--cell: #ffffff;
|
|
--head-ring: #111111;
|
|
--mono-bg: #1d1b18;
|
|
--mono-ink: #ecdfcb;
|
|
--mono-vh-offset: 430px;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg-1: #151819;
|
|
--bg-2: #1b2022;
|
|
--panel: #1f2527;
|
|
--line: #374144;
|
|
--ink: #e6e8e9;
|
|
--muted: #a8b1b3;
|
|
--accent: #4ec894;
|
|
--accent-soft: #233e35;
|
|
--danger: #d1734f;
|
|
--surface: #232b2e;
|
|
--surface-soft: #273134;
|
|
--row-hover: #2b3538;
|
|
--row-active: #224338;
|
|
--shadow: rgba(0, 0, 0, 0.35);
|
|
--you: #4ec894;
|
|
--enemy: #e2815a;
|
|
--snake-1: #e2815a;
|
|
--snake-2: #7ea8ff;
|
|
--snake-3: #c198ff;
|
|
--snake-4: #f2b857;
|
|
--snake-5: #67d2c8;
|
|
--snake-6: #ea86ad;
|
|
--snake-7: #b8d86b;
|
|
--snake-8: #e29d83;
|
|
--snake-9: #75c9da;
|
|
--snake-10: #b7a0ff;
|
|
--food: #ebc14b;
|
|
--hazard: #9b86d8;
|
|
--grid: #3b464a;
|
|
--cell: #1a2022;
|
|
--head-ring: #f3f5f6;
|
|
--mono-bg: #101416;
|
|
--mono-ink: #dce7e9;
|
|
}
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
html, body {
|
|
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 {
|
|
height: 100vh;
|
|
display: grid;
|
|
grid-template-rows: auto 1fr;
|
|
gap: 12px;
|
|
padding: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.topbar {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 12px;
|
|
align-items: center;
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 12px;
|
|
padding: 10px 12px;
|
|
box-shadow: 0 8px 28px var(--shadow);
|
|
}
|
|
|
|
.title {
|
|
margin: 0;
|
|
font-size: 1.12rem;
|
|
letter-spacing: 0.01em;
|
|
}
|
|
|
|
.subtitle {
|
|
margin: 4px 0 0;
|
|
color: var(--muted);
|
|
font-size: 0.86rem;
|
|
}
|
|
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(6, minmax(90px, 1fr));
|
|
gap: 8px;
|
|
min-width: 520px;
|
|
}
|
|
|
|
.stat {
|
|
border: 1px solid #eadfcd;
|
|
border-radius: 8px;
|
|
background: var(--surface);
|
|
padding: 8px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat .k {
|
|
font-size: 0.72rem;
|
|
color: var(--muted);
|
|
display: block;
|
|
}
|
|
|
|
.stat .v {
|
|
font-size: 1.05rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.main {
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-columns: 330px 1fr;
|
|
gap: 12px;
|
|
}
|
|
|
|
.panel {
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 12px;
|
|
box-shadow: 0 8px 28px var(--shadow);
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.panel-header {
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid #eadfcd;
|
|
}
|
|
|
|
.panel-header h2 {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.panel-header p {
|
|
margin: 4px 0 0;
|
|
font-size: 0.8rem;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.games {
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.88rem;
|
|
}
|
|
|
|
thead th {
|
|
position: sticky;
|
|
top: 0;
|
|
background: var(--bg);
|
|
z-index: 1;
|
|
}
|
|
|
|
th, td {
|
|
text-align: left;
|
|
vertical-align: top;
|
|
padding: 8px 10px;
|
|
border-bottom: 1px solid #efe5d5;
|
|
}
|
|
|
|
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;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: nowrap;
|
|
align-items: center;
|
|
padding: 2px 8px 1px;
|
|
border-bottom: 1px solid #eadfcd;
|
|
overflow-x: auto;
|
|
scrollbar-width: thin;
|
|
line-height: 1;
|
|
min-height: 0;
|
|
height: 34px;
|
|
margin: 0;
|
|
}
|
|
|
|
.controls > * {
|
|
margin: 0;
|
|
align-self: center;
|
|
}
|
|
|
|
button {
|
|
border: 1px solid #d2c3ab;
|
|
background: var(--surface);
|
|
color: var(--ink);
|
|
border-radius: 6px;
|
|
padding: 2px 7px;
|
|
font-weight: 600;
|
|
font-size: 0.8rem;
|
|
line-height: 1.2;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.controls label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 0.8rem;
|
|
white-space: nowrap;
|
|
margin: 0;
|
|
line-height: 1;
|
|
}
|
|
|
|
.controls label span {
|
|
display: inline-block;
|
|
line-height: 1;
|
|
}
|
|
|
|
.controls select {
|
|
height: 24px;
|
|
font-size: 0.8rem;
|
|
padding: 0 4px;
|
|
}
|
|
|
|
.controls input[type="range"] {
|
|
width: 150px;
|
|
min-width: 130px;
|
|
margin: 0;
|
|
height: 12px;
|
|
padding: 0;
|
|
}
|
|
|
|
button.primary {
|
|
background: var(--accent);
|
|
border-color: #0f5a3f;
|
|
color: #fff;
|
|
}
|
|
|
|
#prev-btn,
|
|
#next-btn {
|
|
width: 52px;
|
|
min-width: 52px;
|
|
text-align: center;
|
|
}
|
|
|
|
#play-btn {
|
|
width: 62px;
|
|
min-width: 62px;
|
|
text-align: center;
|
|
}
|
|
|
|
select,
|
|
input[type="range"] {
|
|
accent-color: var(--accent);
|
|
}
|
|
|
|
.turn-badge {
|
|
margin-left: auto;
|
|
font-size: 0.76rem;
|
|
font-weight: 700;
|
|
color: var(--accent);
|
|
white-space: nowrap;
|
|
line-height: 1;
|
|
}
|
|
|
|
.content {
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-columns: minmax(320px, 42%) 1fr;
|
|
gap: 12px;
|
|
padding: 8px 12px 12px;
|
|
align-items: stretch;
|
|
margin: 0;
|
|
flex: 1 1 auto;
|
|
}
|
|
|
|
.board-wrap {
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-rows: auto 1fr;
|
|
gap: 8px;
|
|
min-height: 520px;
|
|
}
|
|
|
|
.legend {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: nowrap;
|
|
overflow-x: auto;
|
|
white-space: nowrap;
|
|
scrollbar-width: thin;
|
|
font-size: 0.74rem;
|
|
color: var(--muted);
|
|
padding-top: 5px;
|
|
}
|
|
|
|
.dot {
|
|
display: inline-block;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 2px;
|
|
margin-right: 5px;
|
|
vertical-align: baseline;
|
|
}
|
|
|
|
.board {
|
|
min-height: 0;
|
|
height: auto;
|
|
width: 100%;
|
|
display: grid;
|
|
gap: 1px;
|
|
background: var(--grid);
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
padding: 1px;
|
|
align-content: start;
|
|
}
|
|
|
|
.cell {
|
|
background: var(--cell);
|
|
width: 100%;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
aspect-ratio: 1 / 1;
|
|
position: relative;
|
|
border-radius: 0;
|
|
}
|
|
|
|
.snake-turn-cell::after {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
background: var(--turn-color, transparent);
|
|
z-index: 0;
|
|
pointer-events: none;
|
|
}
|
|
.snake-turn-cell.snake-turn-ur::after { border-top-right-radius: 42%; }
|
|
.snake-turn-cell.snake-turn-ul::after { border-top-left-radius: 42%; }
|
|
.snake-turn-cell.snake-turn-dr::after { border-bottom-right-radius: 42%; }
|
|
.snake-turn-cell.snake-turn-dl::after { border-bottom-left-radius: 42%; }
|
|
|
|
.food {
|
|
background-image: radial-gradient(circle at center, #d73a31 0 45%, transparent 48%);
|
|
background-repeat: no-repeat;
|
|
background-position: center;
|
|
background-size: 78% 78%;
|
|
}
|
|
.hazard {
|
|
background-color: var(--hazard);
|
|
}
|
|
.hazard::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
background: rgba(20, 10, 50, 0.38) repeating-linear-gradient(
|
|
135deg,
|
|
rgba(80, 60, 140, 0.6) 0,
|
|
rgba(80, 60, 140, 0.6) 2px,
|
|
transparent 2px,
|
|
transparent 6px
|
|
);
|
|
z-index: 4;
|
|
pointer-events: none;
|
|
border-radius: inherit;
|
|
}
|
|
.snake-you { background: var(--you); }
|
|
.snake-enemy { background: var(--enemy); }
|
|
.snake-head { outline: 2px solid var(--head-ring); outline-offset: -2px; }
|
|
.snake-head::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: 30%;
|
|
top: 30%;
|
|
width: 40%;
|
|
height: 40%;
|
|
border-radius: 50%;
|
|
background: var(--head-ring);
|
|
opacity: 0.9;
|
|
z-index: 2;
|
|
}
|
|
.snake-head.head-style-1::after { border-radius: 50%; }
|
|
.snake-head.head-style-2::after { border-radius: 2px; transform: rotate(45deg); }
|
|
.snake-head.head-style-3::after { width: 52%; height: 28%; top: 36%; left: 24%; border-radius: 999px; }
|
|
.snake-head.head-style-4::after { width: 24%; height: 56%; top: 22%; left: 38%; border-radius: 999px; }
|
|
.snake-head.head-style-5::after { width: 46%; height: 46%; top: 27%; left: 27%; clip-path: polygon(50% 0, 100% 100%, 0 100%); border-radius: 0; }
|
|
.snake-head.has-head-icon::after { display: none; }
|
|
.snake-head.has-head-icon { outline: none; }
|
|
.snake-tail-you::after,
|
|
.snake-tail-enemy::after {
|
|
content: "";
|
|
position: absolute;
|
|
right: 6%;
|
|
top: 32%;
|
|
width: 38%;
|
|
height: 36%;
|
|
border-radius: 3px;
|
|
background: rgba(255, 255, 255, 0.55);
|
|
z-index: 2;
|
|
}
|
|
.snake-tail-you.tail-style-1::after,
|
|
.snake-tail-enemy.tail-style-1::after { width: 38%; height: 36%; }
|
|
.snake-tail-you.tail-style-2::after,
|
|
.snake-tail-enemy.tail-style-2::after { width: 24%; height: 56%; right: 10%; top: 22%; border-radius: 999px; }
|
|
.snake-tail-you.tail-style-3::after,
|
|
.snake-tail-enemy.tail-style-3::after { width: 44%; height: 24%; right: 8%; top: 38%; border-radius: 999px; }
|
|
.snake-tail-you.tail-style-4::after,
|
|
.snake-tail-enemy.tail-style-4::after { width: 34%; height: 34%; right: 10%; top: 32%; transform: rotate(45deg); border-radius: 1px; }
|
|
.snake-tail-you.tail-style-5::after,
|
|
.snake-tail-enemy.tail-style-5::after { width: 42%; height: 42%; right: 8%; top: 29%; clip-path: polygon(100% 50%, 0 0, 0 100%); border-radius: 0; }
|
|
.snake-tail-you.has-tail-icon::after,
|
|
.snake-tail-enemy.has-tail-icon::after { display: none; }
|
|
.snake-tail-you.has-tail-icon,
|
|
.snake-tail-enemy.has-tail-icon { box-shadow: none; }
|
|
|
|
.icon-layer {
|
|
position: absolute;
|
|
inset: 2%;
|
|
background: var(--icon-color, currentColor);
|
|
-webkit-mask-image: var(--icon-url);
|
|
-webkit-mask-repeat: no-repeat;
|
|
-webkit-mask-position: center;
|
|
-webkit-mask-size: contain;
|
|
mask-image: var(--icon-url);
|
|
mask-repeat: no-repeat;
|
|
mask-position: center;
|
|
mask-size: contain;
|
|
transform: var(--icon-transform, rotate(0deg));
|
|
transform-origin: center;
|
|
pointer-events: none;
|
|
z-index: 2;
|
|
}
|
|
|
|
.icon-layer--tail {
|
|
z-index: 2;
|
|
opacity: 0.92;
|
|
}
|
|
|
|
.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 {
|
|
min-height: 0;
|
|
overflow: auto;
|
|
border: 1px solid #e8dcc8;
|
|
border-radius: 10px;
|
|
background: var(--surface);
|
|
padding: 10px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
min-height: 420px;
|
|
}
|
|
|
|
.raw-block {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
min-height: 0;
|
|
flex: 1 1 auto;
|
|
}
|
|
|
|
.think-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.chip {
|
|
border: 1px solid #ebdfcb;
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
background: var(--surface-soft);
|
|
}
|
|
|
|
.chip .k {
|
|
display: block;
|
|
font-size: 0.72rem;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.chip .v {
|
|
font-size: 0.95rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.section-title {
|
|
margin: 0;
|
|
font-size: 0.86rem;
|
|
color: var(--muted);
|
|
font-weight: 700;
|
|
letter-spacing: 0.01em;
|
|
}
|
|
|
|
.reason-list {
|
|
margin: 0;
|
|
padding-left: 18px;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.score-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.84rem;
|
|
table-layout: fixed;
|
|
}
|
|
|
|
.score-table td,
|
|
.score-table th {
|
|
border-bottom: 1px solid #f0e7d7;
|
|
padding: 6px;
|
|
white-space: normal;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.snake-row {
|
|
background: var(--snake-row-bg, transparent);
|
|
cursor: pointer;
|
|
transition: opacity 0.15s, filter 0.15s;
|
|
}
|
|
|
|
.snake-row td {
|
|
color: var(--ink);
|
|
}
|
|
|
|
.snake-row td:first-child {
|
|
border-left: 4px solid var(--snake-row-color, transparent);
|
|
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;
|
|
}
|
|
|
|
.name-cell {
|
|
word-break: break-word;
|
|
}
|
|
|
|
.num-cell {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.health-wrap {
|
|
display: inline-block;
|
|
width: 120px;
|
|
height: 10px;
|
|
border-radius: 999px;
|
|
background: rgba(120, 120, 120, 0.18);
|
|
overflow: hidden;
|
|
position: relative;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.health-fill {
|
|
display: block;
|
|
height: 100%;
|
|
border-radius: inherit;
|
|
transition: width 120ms linear;
|
|
}
|
|
|
|
.health-text {
|
|
margin-left: 6px;
|
|
font-size: 0.74rem;
|
|
color: inherit;
|
|
opacity: 0.82;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.mono {
|
|
margin: 0;
|
|
font-family: "IBM Plex Mono", "Consolas", monospace;
|
|
font-size: 0.75rem;
|
|
background: var(--mono-bg);
|
|
color: var(--mono-ink);
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
overflow: auto;
|
|
width: -webkit-fill-available;
|
|
height: -webkit-fill-available;
|
|
min-height: 0;
|
|
flex: 1 1 auto;
|
|
resize: none;
|
|
max-height: calc(100vh - var(--mono-vh-offset));
|
|
}
|
|
|
|
@media (max-width: 1100px) {
|
|
.topbar {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.stats {
|
|
min-width: 0;
|
|
grid-template-columns: repeat(6, minmax(80px, 1fr));
|
|
}
|
|
.main {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.games {
|
|
min-height: 200px;
|
|
}
|
|
.content {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.board-wrap {
|
|
min-height: 360px;
|
|
}
|
|
.turn-badge {
|
|
margin-left: 0;
|
|
}
|
|
.controls {
|
|
flex-wrap: wrap;
|
|
}
|
|
.think-grid {
|
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 700px) {
|
|
.think-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
<header class="topbar">
|
|
<div>
|
|
<h1 class="title">Battlesnake Replay Dashboard</h1>
|
|
<p class="subtitle">Full-screen replay with turn-by-turn move reasoning and snake state.</p>
|
|
</div>
|
|
<div class="stats" id="stats"></div>
|
|
</header>
|
|
|
|
<main class="main">
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<h2>Games</h2>
|
|
<p>Pick a game to inspect the match, move timeline, and why your snake chose each turn.</p>
|
|
</div>
|
|
<div class="games">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Game</th>
|
|
<th>Status</th>
|
|
<th>W/L</th>
|
|
<th>Turns</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="games-body"></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel right">
|
|
<div class="controls">
|
|
<button id="prev-btn">Prev</button>
|
|
<button id="play-btn" class="primary" title="Play" aria-label="Play">▶</button>
|
|
<button id="next-btn">Next</button>
|
|
<label>
|
|
<span>Speed</span>
|
|
<select id="speed">
|
|
<option value="900">0.75x</option>
|
|
<option value="650" selected>1x</option>
|
|
<option value="400">1.5x</option>
|
|
<option value="250">2x</option>
|
|
</select>
|
|
</label>
|
|
<input type="range" min="0" max="0" step="1" id="turn-slider" value="0">
|
|
<strong class="turn-badge" id="turn-label">Turn -</strong>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<div class="board-wrap">
|
|
<div class="board" id="board"></div>
|
|
</div>
|
|
|
|
<div class="thinking" id="thinking"></div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
const initialGameId = {{ initial_game_id|tojson }};
|
|
const initialSummary = {{ initial_summary|tojson }};
|
|
const initialGamesPayload = {{ initial_games|tojson }};
|
|
let dashboardSummary = (initialSummary && typeof initialSummary === "object") ? initialSummary : {};
|
|
let dashboardGamesPayload = (initialGamesPayload && typeof initialGamesPayload === "object") ? initialGamesPayload : { games: [] };
|
|
let replay = null;
|
|
let turnIndex = 0;
|
|
let timer = null;
|
|
let activeGameId = String(initialGameId || "");
|
|
let gamesWebSocket = null;
|
|
let gamesWebSocketReconnectTimer = null;
|
|
let gamesWebSocketServerShuttingDown = false;
|
|
let hasLoadedReplayOnce = false;
|
|
let replayRequestSeq = 0;
|
|
const pendingReplayRequests = new Map();
|
|
let selectedSnakeId = null;
|
|
const svgCache = new Map();
|
|
|
|
const statsEl = document.getElementById("stats");
|
|
const gamesBodyEl = document.getElementById("games-body");
|
|
const boardEl = document.getElementById("board");
|
|
const legendEl = document.getElementById("legend");
|
|
const thinkingEl = document.getElementById("thinking");
|
|
const turnLabelEl = document.getElementById("turn-label");
|
|
const sliderEl = document.getElementById("turn-slider");
|
|
|
|
function toTitle(value) {
|
|
if (String(value || "").toLowerCase() === "finished") return "Done";
|
|
return String(value || "").replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
|
|
}
|
|
|
|
function safeString(value) {
|
|
if (value === null || value === undefined || value === "") return "-";
|
|
return String(value);
|
|
}
|
|
|
|
function formatObservedAtLocal(value) {
|
|
if (value === null || value === undefined || value === "") return "-";
|
|
const raw = String(value).trim();
|
|
const parsed = new Date(raw);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return safeString(raw).slice(11, 19);
|
|
}
|
|
return parsed.toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
}
|
|
|
|
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", finished],
|
|
["Wins", wins],
|
|
["Losses", summary.losses || 0],
|
|
["Win Rate", winRate],
|
|
["Avg Turns", summary.avg_turns_finished || 0],
|
|
];
|
|
statsEl.innerHTML = items.map(([k, v]) => (
|
|
`<div class="stat"><span class="k">${k}</span><span class="v">${v}</span></div>`
|
|
)).join("");
|
|
}
|
|
|
|
function loadSummary() {
|
|
renderStats(dashboardSummary);
|
|
}
|
|
|
|
function renderGamesTable(games) {
|
|
gamesBodyEl.innerHTML = games.map((g) => `
|
|
<tr data-game-id="${g.game_id}">
|
|
<td><code>${shortId(g.game_id)}</code><br><small>${safeString(displayGameTypeOrMap(g))}</small></td>
|
|
<td>${toTitle(g.status)}</td>
|
|
<td>${g.status === "running" ? "-" : g.winner_you ? "Win" : "Loss"}</td>
|
|
<td>${safeString(g.final_turn)}</td>
|
|
</tr>
|
|
`).join("");
|
|
|
|
for (const row of gamesBodyEl.querySelectorAll("tr")) {
|
|
row.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
const gameId = row.getAttribute("data-game-id");
|
|
if (gameId) {
|
|
activeGameId = gameId;
|
|
loadReplay(gameId);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (activeGameId) {
|
|
setActiveGame(activeGameId);
|
|
}
|
|
}
|
|
|
|
function shortId(gameId) {
|
|
return String(gameId || "-").slice(0, 8);
|
|
}
|
|
|
|
function displayGameTypeOrMap(game) {
|
|
const mapName = String((game && game.map) || "").trim();
|
|
const gameType = String((game && game.game_type) || "").trim();
|
|
if (gameType.toLowerCase() === "duel" && mapName.toLowerCase() === "standard") {
|
|
return "duel";
|
|
}
|
|
if (mapName && mapName.toLowerCase() !== "empty") {
|
|
return mapName;
|
|
}
|
|
return gameType || "-";
|
|
}
|
|
|
|
function extractReasoningList(reasoning) {
|
|
const parts = [];
|
|
if (!reasoning || typeof reasoning !== "object") {
|
|
return ["No reasoning recorded by this snake implementation."];
|
|
}
|
|
if (reasoning.reason) parts.push(`Reason: ${reasoning.reason}`);
|
|
if (reasoning.mode) parts.push(`Mode: ${reasoning.mode}`);
|
|
if (reasoning.health !== undefined) parts.push(`Health: ${reasoning.health}`);
|
|
if (reasoning.length !== undefined) parts.push(`Length: ${reasoning.length}`);
|
|
if (reasoning.occupancy !== undefined) parts.push(`Occupancy: ${reasoning.occupancy}`);
|
|
if (reasoning.ms_remaining !== undefined) parts.push(`Time left: ${reasoning.ms_remaining}ms`);
|
|
if (parts.length === 0) parts.push("Structured reasoning not provided; showing raw payload below.");
|
|
return parts;
|
|
}
|
|
|
|
function buildScoresRows(reasoning) {
|
|
const scores = reasoning && typeof reasoning === "object" ? reasoning.scores : null;
|
|
const moveOrder = ["up", "down", "left", "right"];
|
|
return moveOrder.map((move) => {
|
|
const hasScore = scores && typeof scores === "object" && !Array.isArray(scores) && Object.prototype.hasOwnProperty.call(scores, move);
|
|
const value = hasScore ? scores[move] : "-";
|
|
return `<tr><td>${move}</td><td>${safeString(value)}</td></tr>`;
|
|
}).join("");
|
|
}
|
|
|
|
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();
|
|
const lastSeenTurnById = new Map();
|
|
const aliveById = new Map();
|
|
|
|
for (const historyTurn of turns) {
|
|
const historyTurnNumber = Number(historyTurn && historyTurn.turn !== undefined ? historyTurn.turn : 0);
|
|
if (historyTurnNumber > currentTurn) {
|
|
continue;
|
|
}
|
|
for (const snake of (historyTurn.snakes || [])) {
|
|
if (!snake) continue;
|
|
const snakeId = snake.snake_id || snake.id || `${safeString(snake.snake_name)}-${historyTurnNumber}`;
|
|
lastSeenById.set(snakeId, snake);
|
|
lastSeenTurnById.set(snakeId, historyTurnNumber);
|
|
if (historyTurnNumber === currentTurn) {
|
|
aliveById.set(snakeId, snake);
|
|
}
|
|
}
|
|
}
|
|
|
|
const snakes = [];
|
|
for (const [snakeId, snake] of lastSeenById.entries()) {
|
|
const aliveSnake = aliveById.get(snakeId);
|
|
snakes.push({
|
|
...(aliveSnake || snake),
|
|
_snake_id: snakeId,
|
|
_is_dead: !aliveSnake,
|
|
_last_seen_turn: Number(lastSeenTurnById.get(snakeId) ?? currentTurn),
|
|
});
|
|
}
|
|
|
|
snakes.sort((a, b) => {
|
|
const youDelta = Number(Boolean(b.is_you)) - Number(Boolean(a.is_you));
|
|
if (youDelta !== 0) return youDelta;
|
|
const deadDelta = Number(Boolean(a._is_dead)) - Number(Boolean(b._is_dead));
|
|
if (deadDelta !== 0) return deadDelta;
|
|
return safeString(a.snake_name).localeCompare(safeString(b.snake_name));
|
|
});
|
|
|
|
const colorById = buildSnakeColorById(turn);
|
|
if (snakes.length === 0) {
|
|
return "<tr><td colspan=\"7\">No snake data available</td></tr>";
|
|
}
|
|
return snakes.map((snake, idx) => (
|
|
(() => {
|
|
const healthValue = Number(snake.health ?? 0);
|
|
const healthClamped = snake._is_dead ? 0 : Math.max(0, Math.min(100, healthValue));
|
|
const healthColor = healthClamped > 60 ? "#28a264" : (healthClamped > 30 ? "#d39a1c" : "#c34939");
|
|
const healthText = snake._is_dead ? "dead" : safeString(snake.health);
|
|
const healthCell = `<span class=\"health-wrap\"><span class=\"health-fill\" style=\"width:${healthClamped}%;background:${healthColor};\"></span></span><span class=\"health-text\">${healthText}</span>`;
|
|
const snakeId = snake._snake_id || snake.snake_id || snake.id || `${safeString(snake.snake_name)}-${idx}`;
|
|
const rowColor = resolveSnakeColor(snakeId, snake.is_you, colorById);
|
|
const rowBg = snakeRowBackground(rowColor);
|
|
const rowStyle = `style=\"--snake-row-color:${rowColor};--snake-row-bg:${rowBg};\"`;
|
|
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 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}" 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>
|
|
<td class="num-cell">${snake.head ? `${snake.head.x},${snake.head.y}` : "-"}</td>
|
|
<td class="num-cell">${Array.isArray(snake.body) && snake.body.length > 0 ? `${snake.body[snake.body.length - 1].x},${snake.body[snake.body.length - 1].y}` : "-"}</td>
|
|
</tr>`;
|
|
})()
|
|
)).join("");
|
|
}
|
|
|
|
function snakeColor(index) {
|
|
return `var(--snake-${((index % 10) + 1)})`;
|
|
}
|
|
|
|
function stableColorIndexFromId(snakeId) {
|
|
const raw = String(snakeId || "");
|
|
let hash = 0;
|
|
for (let i = 0; i < raw.length; i += 1) {
|
|
hash = ((hash * 31) + raw.charCodeAt(i)) >>> 0;
|
|
}
|
|
return hash % 10;
|
|
}
|
|
|
|
function resolveSnakeColor(snakeId, isYou, colorById) {
|
|
if (snakeId && colorById && colorById.has(snakeId)) {
|
|
return colorById.get(snakeId);
|
|
}
|
|
if (isYou) {
|
|
return "var(--you)";
|
|
}
|
|
return snakeColor(stableColorIndexFromId(snakeId));
|
|
}
|
|
|
|
function extractSnakeColor(rawSnake) {
|
|
if (!rawSnake || typeof rawSnake !== "object") return null;
|
|
const direct = rawSnake.color;
|
|
const custom = rawSnake.customizations && rawSnake.customizations.color;
|
|
const appearance = rawSnake.appearance && rawSnake.appearance.color;
|
|
const color = direct || custom || appearance;
|
|
if (!color) return null;
|
|
return String(color).trim();
|
|
}
|
|
|
|
function buildSnakeColorById(turnData) {
|
|
const colorById = new Map();
|
|
const replayTurns = replay && Array.isArray(replay.turns) ? replay.turns : [];
|
|
const boardSnakes = turnData && turnData.board && Array.isArray(turnData.board.snakes)
|
|
? turnData.board.snakes
|
|
: [];
|
|
const replaySnakes = Array.isArray(turnData && turnData.snakes) ? turnData.snakes : [];
|
|
|
|
const historicalSnakes = [];
|
|
for (const replayTurn of replayTurns) {
|
|
if (replayTurn && Array.isArray(replayTurn.snakes)) {
|
|
historicalSnakes.push(...replayTurn.snakes);
|
|
}
|
|
if (replayTurn && replayTurn.board && Array.isArray(replayTurn.board.snakes)) {
|
|
historicalSnakes.push(...replayTurn.board.snakes);
|
|
}
|
|
}
|
|
|
|
for (const snake of [...historicalSnakes, ...boardSnakes, ...replaySnakes]) {
|
|
if (!snake) continue;
|
|
const snakeId = snake.id || snake.snake_id;
|
|
if (!snakeId) continue;
|
|
const color = extractSnakeColor(snake);
|
|
if (color) colorById.set(snakeId, color);
|
|
}
|
|
return colorById;
|
|
}
|
|
|
|
function buildSnakeCustomizationById(turnData) {
|
|
const customById = new Map();
|
|
const replayTurns = replay && Array.isArray(replay.turns) ? replay.turns : [];
|
|
const sources = [];
|
|
|
|
for (const replayTurn of replayTurns) {
|
|
if (replayTurn && replayTurn.board && Array.isArray(replayTurn.board.snakes)) {
|
|
sources.push(...replayTurn.board.snakes);
|
|
}
|
|
}
|
|
|
|
if (turnData && turnData.board && Array.isArray(turnData.board.snakes)) {
|
|
sources.push(...turnData.board.snakes);
|
|
}
|
|
|
|
for (const snake of sources) {
|
|
if (!snake) continue;
|
|
const snakeId = snake.id || snake.snake_id;
|
|
if (!snakeId) continue;
|
|
const custom = snake.customizations || {};
|
|
const head = custom.head || null;
|
|
const tail = custom.tail || null;
|
|
if (head || tail) {
|
|
customById.set(snakeId, { head, tail });
|
|
}
|
|
}
|
|
|
|
return customById;
|
|
}
|
|
|
|
function buildCustomizationIconUrl(kind, value) {
|
|
const raw = String(value || "").trim().toLowerCase();
|
|
if (!raw) return null;
|
|
if (!/^[a-z0-9-]+$/.test(raw)) return null;
|
|
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;
|
|
}
|
|
}
|
|
|
|
function parseViewBox(svgEl) {
|
|
const raw = String(svgEl.getAttribute("viewBox") || "").trim();
|
|
const parts = raw.split(/\s+/).map((item) => Number(item));
|
|
if (parts.length !== 4 || parts.some((v) => Number.isNaN(v))) {
|
|
return { minX: 0, minY: 0, width: 100, height: 100 };
|
|
}
|
|
return { minX: parts[0], minY: parts[1], width: parts[2], height: parts[3] };
|
|
}
|
|
|
|
function groupLooksOffCanvas(groupEl, viewBox) {
|
|
const attrNames = new Set(["x", "y", "cx", "cy", "x1", "y1", "x2", "y2", "d", "points"]);
|
|
const allElements = [groupEl, ...groupEl.querySelectorAll("*")];
|
|
let farOutsideCount = 0;
|
|
let numericCount = 0;
|
|
const minAllowedX = viewBox.minX - Math.max(40, viewBox.width * 0.8);
|
|
const minAllowedY = viewBox.minY - Math.max(40, viewBox.height * 0.8);
|
|
const maxAllowedX = viewBox.minX + viewBox.width + Math.max(40, viewBox.width * 0.8);
|
|
const maxAllowedY = viewBox.minY + viewBox.height + Math.max(40, viewBox.height * 0.8);
|
|
|
|
for (const node of allElements) {
|
|
for (const attr of node.getAttributeNames()) {
|
|
if (!attrNames.has(attr)) continue;
|
|
const value = node.getAttribute(attr);
|
|
if (!value) continue;
|
|
const matches = value.match(/-?\d*\.?\d+/g);
|
|
if (!matches) continue;
|
|
|
|
for (let idx = 0; idx < matches.length; idx += 1) {
|
|
const num = Number(matches[idx]);
|
|
if (Number.isNaN(num)) continue;
|
|
numericCount += 1;
|
|
const isXCoord = idx % 2 === 0;
|
|
if (isXCoord) {
|
|
if (num < minAllowedX || num > maxAllowedX) farOutsideCount += 1;
|
|
} else {
|
|
if (num < minAllowedY || num > maxAllowedY) farOutsideCount += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (numericCount < 10) return false;
|
|
return farOutsideCount / numericCount > 0.55;
|
|
}
|
|
|
|
function maxNestedGroupDepth(groupEl) {
|
|
let maxDepth = 1;
|
|
const stack = [{ node: groupEl, depth: 1 }];
|
|
while (stack.length > 0) {
|
|
const entry = stack.pop();
|
|
if (!entry) continue;
|
|
maxDepth = Math.max(maxDepth, entry.depth);
|
|
for (const child of Array.from(entry.node.children)) {
|
|
if (!child.tagName || child.tagName.toLowerCase() !== "g") continue;
|
|
stack.push({ node: child, depth: entry.depth + 1 });
|
|
}
|
|
}
|
|
return maxDepth;
|
|
}
|
|
|
|
function normalizeHeadSvgMarkup(svgMarkup) {
|
|
if (!svgMarkup) return null;
|
|
try {
|
|
const parser = new DOMParser();
|
|
const parsed = parser.parseFromString(svgMarkup, "image/svg+xml");
|
|
const svgEl = parsed.querySelector("svg");
|
|
if (!svgEl) return svgMarkup;
|
|
|
|
const topLevelGroups = Array.from(svgEl.children).filter((el) => el.tagName && el.tagName.toLowerCase() === "g");
|
|
if (topLevelGroups.length > 1) {
|
|
const viewBox = parseViewBox(svgEl);
|
|
const firstGroup = topLevelGroups[0];
|
|
const deeplyNestedGroup = maxNestedGroupDepth(firstGroup) >= 3;
|
|
if (groupLooksOffCanvas(firstGroup, viewBox) || deeplyNestedGroup) {
|
|
firstGroup.remove();
|
|
}
|
|
}
|
|
|
|
return new XMLSerializer().serializeToString(svgEl);
|
|
} catch {
|
|
return svgMarkup;
|
|
}
|
|
}
|
|
|
|
async function preloadReplaySvgs() {
|
|
if (!replay || !Array.isArray(replay.turns)) return;
|
|
const urls = new Set();
|
|
for (const turn of replay.turns) {
|
|
const snakes = turn && turn.board && Array.isArray(turn.board.snakes) ? turn.board.snakes : [];
|
|
for (const snake of snakes) {
|
|
const custom = snake && (snake.customizations || {});
|
|
const headUrl = buildCustomizationIconUrl("heads", custom.head);
|
|
const tailUrl = buildCustomizationIconUrl("tails", custom.tail);
|
|
if (headUrl) urls.add(headUrl);
|
|
if (tailUrl) urls.add(tailUrl);
|
|
}
|
|
}
|
|
await Promise.all([...urls].map(loadSvg));
|
|
}
|
|
|
|
function createIconLayer(iconUrl, color, transformValue, type) {
|
|
const layer = document.createElement("div");
|
|
layer.className = type === "head" ? "icon-layer icon-layer--head" : "icon-layer icon-layer--tail";
|
|
layer.style.setProperty("--icon-transform", transformValue || "rotate(0deg)");
|
|
if (type === "head") {
|
|
const svgMarkup = svgCache.get(iconUrl);
|
|
if (svgMarkup) {
|
|
layer.innerHTML = normalizeHeadSvgMarkup(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;
|
|
}
|
|
|
|
function directionToHeadTransform(direction) {
|
|
if (direction === "left") return "scaleX(-1)";
|
|
if (direction === "up") return "rotate(270deg)";
|
|
if (direction === "down") return "rotate(90deg)";
|
|
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;
|
|
if (direction === "left") return 180;
|
|
if (direction === "up") return 270;
|
|
return 0;
|
|
}
|
|
|
|
function inferHeadDirection(snake) {
|
|
const body = Array.isArray(snake && snake.body) ? snake.body : [];
|
|
if (body.length >= 2) {
|
|
const head = body[0];
|
|
const neck = body[1];
|
|
if (head && neck) {
|
|
const dx = Number(head.x) - Number(neck.x);
|
|
const dy = Number(head.y) - Number(neck.y);
|
|
if (dx > 0) return "right";
|
|
if (dx < 0) return "left";
|
|
if (dy > 0) return "up";
|
|
if (dy < 0) return "down";
|
|
}
|
|
}
|
|
|
|
const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase();
|
|
if (["up", "down", "left", "right"].includes(inferred)) {
|
|
return inferred;
|
|
}
|
|
|
|
if (body.length < 2) return "right";
|
|
const head = body[0];
|
|
const neck = body[1];
|
|
if (!head || !neck) return "right";
|
|
const dx = Number(head.x) - Number(neck.x);
|
|
const dy = Number(head.y) - Number(neck.y);
|
|
if (dx > 0) return "right";
|
|
if (dx < 0) return "left";
|
|
if (dy > 0) return "up";
|
|
if (dy < 0) return "down";
|
|
return "right";
|
|
}
|
|
|
|
function inferTailDirection(snake) {
|
|
const body = Array.isArray(snake && snake.body) ? snake.body : [];
|
|
if (body.length < 2) return "right";
|
|
const tail = body[body.length - 1];
|
|
if (!tail) return "right";
|
|
|
|
let beforeTail = null;
|
|
for (let idx = body.length - 2; idx >= 0; idx -= 1) {
|
|
const candidate = body[idx];
|
|
if (!candidate) continue;
|
|
if (Number(candidate.x) !== Number(tail.x) || Number(candidate.y) !== Number(tail.y)) {
|
|
beforeTail = candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!beforeTail) {
|
|
const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase();
|
|
if (["up", "down", "left", "right"].includes(inferred)) {
|
|
return inferred;
|
|
}
|
|
return "right";
|
|
}
|
|
|
|
const dx = Number(beforeTail.x) - Number(tail.x);
|
|
const dy = Number(beforeTail.y) - Number(tail.y);
|
|
if (dx > 0) return "right";
|
|
if (dx < 0) return "left";
|
|
if (dy > 0) return "up";
|
|
if (dy < 0) return "down";
|
|
return "right";
|
|
}
|
|
|
|
function stableVariantFromString(value) {
|
|
const raw = String(value || "");
|
|
if (!raw) return 1;
|
|
let hash = 0;
|
|
for (let i = 0; i < raw.length; i += 1) {
|
|
hash = ((hash * 33) + raw.charCodeAt(i)) >>> 0;
|
|
}
|
|
return (hash % 5) + 1;
|
|
}
|
|
|
|
function parseSnakeColor(color) {
|
|
if (!color) return null;
|
|
const value = String(color).trim();
|
|
if (value.startsWith("#")) {
|
|
const hex = value.slice(1);
|
|
if (hex.length === 3) {
|
|
const r = parseInt(hex[0] + hex[0], 16);
|
|
const g = parseInt(hex[1] + hex[1], 16);
|
|
const b = parseInt(hex[2] + hex[2], 16);
|
|
return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b };
|
|
}
|
|
if (hex.length === 6) {
|
|
const r = parseInt(hex.slice(0, 2), 16);
|
|
const g = parseInt(hex.slice(2, 4), 16);
|
|
const b = parseInt(hex.slice(4, 6), 16);
|
|
return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b };
|
|
}
|
|
}
|
|
const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
|
if (rgb) {
|
|
return {
|
|
r: Math.max(0, Math.min(255, Number(rgb[1]))),
|
|
g: Math.max(0, Math.min(255, Number(rgb[2]))),
|
|
b: Math.max(0, Math.min(255, Number(rgb[3]))),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function snakeRowBackground(color) {
|
|
const parsed = parseSnakeColor(color);
|
|
if (!parsed) return "transparent";
|
|
const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
const alpha = isDark ? 0.26 : 0.16;
|
|
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})`;
|
|
}
|
|
|
|
function renderThinking(turn) {
|
|
if (!turn) {
|
|
thinkingEl.innerHTML = "<p class=\"section-title\">Select a game to inspect reasoning.</p>";
|
|
syncMonoOffset();
|
|
return;
|
|
}
|
|
|
|
const reasoning = turn.my_thinking;
|
|
const reasons = extractReasoningList(reasoning);
|
|
const reasonList = reasons.map((item) => `<li>${item}</li>`).join("");
|
|
const gameMeta = replay && replay.game ? replay.game : {};
|
|
const snakeType = safeString(gameMeta.your_snake_type);
|
|
const snakeVersion = safeString(gameMeta.your_snake_version);
|
|
const maxTurn = (replay && Array.isArray(replay.turns) && replay.turns.length > 0)
|
|
? replay.turns[replay.turns.length - 1].turn
|
|
: turn.turn;
|
|
thinkingEl.innerHTML = `
|
|
<div class="think-grid">
|
|
<div class="chip"><span class="k">Chosen Move</span><span class="v">${safeString(turn.my_move)}</span></div>
|
|
<div class="chip"><span class="k">Snake Type</span><span class="v">${snakeType}</span></div>
|
|
<div class="chip"><span class="k">Snake Version</span><span class="v">${snakeVersion}</span></div>
|
|
<div class="chip"><span class="k">Observed At</span><span class="v">${formatObservedAtLocal(turn.observed_at)}</span></div>
|
|
<div class="chip"><span class="k">Food Count</span><span class="v">${Array.isArray(turn.food) ? turn.food.length : 0}</span></div>
|
|
<div class="chip"><span class="k">Hazard Count</span><span class="v">${Array.isArray(turn.hazards) ? turn.hazards.length : 0}</span></div>
|
|
</div>
|
|
|
|
<section class="snakes-section">
|
|
<table class="score-table">
|
|
<colgroup>
|
|
<col style="width:32%">
|
|
<col style="width:10%">
|
|
<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>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>
|
|
|
|
<section class="scores-section">
|
|
<p class="section-title">Move Scores</p>
|
|
<table class="score-table">
|
|
<colgroup><col style="width:50%"><col style="width:50%"></colgroup>
|
|
<thead><tr><th>Move</th><th>Score</th></tr></thead>
|
|
<tbody>${buildScoresRows(reasoning)}</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<section>
|
|
<p class="section-title">Decision Summary</p>
|
|
<ul class="reason-list">${reasonList}</ul>
|
|
</section>
|
|
|
|
<section class="raw-block">
|
|
<p class="section-title">Raw Reasoning Payload</p>
|
|
<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();
|
|
}
|
|
|
|
function syncMonoOffset() {
|
|
const mono = thinkingEl.querySelector(".mono");
|
|
if (!mono) return;
|
|
const rect = mono.getBoundingClientRect();
|
|
const bottomPaddingPx = 36;
|
|
const offset = Math.max(120, Math.round(rect.top + bottomPaddingPx));
|
|
document.documentElement.style.setProperty("--mono-vh-offset", `${offset}px`);
|
|
}
|
|
|
|
async function loadGames() {
|
|
const games = (dashboardGamesPayload && Array.isArray(dashboardGamesPayload.games))
|
|
? dashboardGamesPayload.games
|
|
: [];
|
|
|
|
renderGamesTable(games);
|
|
|
|
if (activeGameId) {
|
|
await loadReplay(activeGameId);
|
|
} else if (games.length > 0) {
|
|
activeGameId = games[0].game_id;
|
|
await loadReplay(activeGameId);
|
|
}
|
|
}
|
|
|
|
function dashboardGamesWebSocketUrl() {
|
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
return `${protocol}://${window.location.host}/dashboard/ws/games`;
|
|
}
|
|
|
|
function scheduleDashboardGamesWebSocketReconnect() {
|
|
if (gamesWebSocketServerShuttingDown) return;
|
|
if (gamesWebSocketReconnectTimer) return;
|
|
gamesWebSocketReconnectTimer = window.setTimeout(() => {
|
|
gamesWebSocketReconnectTimer = null;
|
|
connectDashboardGamesWebSocket();
|
|
}, 1500);
|
|
}
|
|
|
|
function rejectPendingReplayRequests(message) {
|
|
for (const pending of pendingReplayRequests.values()) {
|
|
window.clearTimeout(pending.timeoutId);
|
|
pending.reject(new Error(message));
|
|
}
|
|
pendingReplayRequests.clear();
|
|
}
|
|
|
|
function applyDashboardGamesUpdate(payload) {
|
|
const nextSummary = payload && payload.summary && typeof payload.summary === "object"
|
|
? payload.summary
|
|
: null;
|
|
const nextGames = payload && payload.games && typeof payload.games === "object"
|
|
? payload.games
|
|
: null;
|
|
|
|
if (nextSummary) {
|
|
dashboardSummary = nextSummary;
|
|
renderStats(dashboardSummary);
|
|
}
|
|
|
|
if (nextGames) {
|
|
dashboardGamesPayload = nextGames;
|
|
const games = Array.isArray(dashboardGamesPayload.games) ? dashboardGamesPayload.games : [];
|
|
renderGamesTable(games);
|
|
|
|
const gameIds = new Set(games.map((g) => g.game_id));
|
|
if (activeGameId && !gameIds.has(activeGameId)) {
|
|
activeGameId = "";
|
|
replay = null;
|
|
turnIndex = 0;
|
|
renderTurn();
|
|
clearActiveGame();
|
|
}
|
|
|
|
if (activeGameId && gameIds.has(activeGameId) && payload && payload.trigger === "game_saved") {
|
|
loadReplay(activeGameId);
|
|
return;
|
|
}
|
|
|
|
if (!activeGameId && games.length > 0) {
|
|
activeGameId = games[0].game_id;
|
|
loadReplay(activeGameId);
|
|
}
|
|
}
|
|
}
|
|
|
|
function connectDashboardGamesWebSocket() {
|
|
if (gamesWebSocketServerShuttingDown) return;
|
|
if (gamesWebSocket && (gamesWebSocket.readyState === WebSocket.OPEN || gamesWebSocket.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
const wsUrl = dashboardGamesWebSocketUrl();
|
|
try {
|
|
gamesWebSocket = new WebSocket(wsUrl);
|
|
} catch {
|
|
scheduleDashboardGamesWebSocketReconnect();
|
|
return;
|
|
}
|
|
|
|
gamesWebSocket.addEventListener("message", (event) => {
|
|
let payload = null;
|
|
try {
|
|
payload = JSON.parse(event.data);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!payload || !payload.type) return;
|
|
|
|
if (payload.type === "dashboard_ws_shutdown") {
|
|
gamesWebSocketServerShuttingDown = true;
|
|
if (gamesWebSocketReconnectTimer) {
|
|
clearTimeout(gamesWebSocketReconnectTimer);
|
|
gamesWebSocketReconnectTimer = null;
|
|
}
|
|
rejectPendingReplayRequests("Server shutting down");
|
|
if (gamesWebSocket) {
|
|
gamesWebSocket.close();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "dashboard_game_replay") {
|
|
const requestId = String(payload.request_id || "");
|
|
if (!requestId) return;
|
|
const pending = pendingReplayRequests.get(requestId);
|
|
if (!pending) return;
|
|
pendingReplayRequests.delete(requestId);
|
|
window.clearTimeout(pending.timeoutId);
|
|
if (payload.error) {
|
|
pending.reject(new Error(String(payload.error)));
|
|
return;
|
|
}
|
|
pending.resolve(payload.replay || null);
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "dashboard_games_update") {
|
|
applyDashboardGamesUpdate(payload);
|
|
}
|
|
});
|
|
|
|
gamesWebSocket.addEventListener("close", () => {
|
|
gamesWebSocket = null;
|
|
rejectPendingReplayRequests("Dashboard websocket disconnected");
|
|
if (!gamesWebSocketServerShuttingDown) {
|
|
scheduleDashboardGamesWebSocketReconnect();
|
|
}
|
|
});
|
|
|
|
gamesWebSocket.addEventListener("error", () => {
|
|
if (gamesWebSocket) {
|
|
gamesWebSocket.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
function waitForDashboardGamesWebSocketOpen(timeoutMs = 4000) {
|
|
if (gamesWebSocketServerShuttingDown) {
|
|
return Promise.resolve(false);
|
|
}
|
|
|
|
if (!gamesWebSocket || gamesWebSocket.readyState === WebSocket.CLOSED) {
|
|
connectDashboardGamesWebSocket();
|
|
}
|
|
|
|
if (gamesWebSocket && gamesWebSocket.readyState === WebSocket.OPEN) {
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
if (!gamesWebSocket) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
const socketRef = gamesWebSocket;
|
|
let settled = false;
|
|
const cleanup = () => {
|
|
if (!socketRef) return;
|
|
socketRef.removeEventListener("open", onOpen);
|
|
socketRef.removeEventListener("close", onClose);
|
|
socketRef.removeEventListener("error", onError);
|
|
window.clearTimeout(timeoutId);
|
|
};
|
|
const finish = (value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
resolve(value);
|
|
};
|
|
const onOpen = () => finish(true);
|
|
const onClose = () => finish(false);
|
|
const onError = () => finish(false);
|
|
const timeoutId = window.setTimeout(() => finish(false), timeoutMs);
|
|
|
|
socketRef.addEventListener("open", onOpen);
|
|
socketRef.addEventListener("close", onClose);
|
|
socketRef.addEventListener("error", onError);
|
|
});
|
|
}
|
|
|
|
async function requestReplayOverWebSocket(gameId) {
|
|
const isOpen = await waitForDashboardGamesWebSocketOpen();
|
|
if (!isOpen || !gamesWebSocket || gamesWebSocket.readyState !== WebSocket.OPEN) {
|
|
throw new Error("Dashboard websocket unavailable");
|
|
}
|
|
|
|
const requestId = `replay-${Date.now()}-${replayRequestSeq++}`;
|
|
return await new Promise((resolve, reject) => {
|
|
const timeoutId = window.setTimeout(() => {
|
|
pendingReplayRequests.delete(requestId);
|
|
reject(new Error(`Replay websocket timeout for ${gameId}`));
|
|
}, 4000);
|
|
|
|
pendingReplayRequests.set(requestId, { resolve, reject, timeoutId });
|
|
try {
|
|
gamesWebSocket.send(JSON.stringify({
|
|
type: "dashboard_game_replay_request",
|
|
request_id: requestId,
|
|
game_id: gameId,
|
|
}));
|
|
} catch (error) {
|
|
window.clearTimeout(timeoutId);
|
|
pendingReplayRequests.delete(requestId);
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearActiveGame() {
|
|
for (const row of gamesBodyEl.querySelectorAll("tr")) {
|
|
row.classList.remove("active");
|
|
}
|
|
}
|
|
|
|
function setActiveGame(gameId) {
|
|
clearActiveGame();
|
|
const active = gamesBodyEl.querySelector(`tr[data-game-id="${gameId}"]`);
|
|
if (active) active.classList.add("active");
|
|
}
|
|
|
|
function clearBoard() {
|
|
boardEl.innerHTML = "";
|
|
boardEl.style.gridTemplateColumns = "none";
|
|
}
|
|
|
|
function cellKey(x, y) {
|
|
return `${x}:${y}`;
|
|
}
|
|
|
|
function paintBoard(turnData, width, height) {
|
|
clearBoard();
|
|
if (!turnData || !width || !height) return;
|
|
const colorById = buildSnakeColorById(turnData);
|
|
const customById = buildSnakeCustomizationById(turnData);
|
|
|
|
boardEl.style.gridTemplateColumns = `repeat(${width}, 1fr)`;
|
|
const foods = new Set((turnData.food || []).map((p) => cellKey(p.x, p.y)));
|
|
const hazards = new Set((turnData.hazards || []).map((p) => cellKey(p.x, p.y)));
|
|
|
|
const snakeBody = new Map();
|
|
const snakeHead = new Set();
|
|
const snakeTail = new Map();
|
|
const headVariantByCell = new Map();
|
|
const tailVariantByCell = new Map();
|
|
const headIconByCell = new Map();
|
|
const tailIconByCell = new Map();
|
|
const headTransformByCell = 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}`;
|
|
const bodyColor = resolveSnakeColor(snakeId, snake.is_you, colorById);
|
|
const custom = customById.get(snakeId) || {};
|
|
const headVariant = stableVariantFromString(custom.head);
|
|
const tailVariant = stableVariantFromString(custom.tail);
|
|
const headIcon = buildCustomizationIconUrl("heads", custom.head);
|
|
const tailIcon = buildCustomizationIconUrl("tails", custom.tail);
|
|
const headTransform = directionToHeadTransform(inferHeadDirection(snake));
|
|
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);
|
|
snakeHead.add(headKey);
|
|
headVariantByCell.set(headKey, headVariant);
|
|
headTransformByCell.set(headKey, headTransform);
|
|
snakeColorByCell.set(headKey, bodyColor);
|
|
if (headIcon) {
|
|
headIconByCell.set(headKey, headIcon);
|
|
}
|
|
}
|
|
if (Array.isArray(snake.body) && snake.body.length > 0) {
|
|
const tail = snake.body[snake.body.length - 1];
|
|
const tailKey = cellKey(tail.x, tail.y);
|
|
snakeTail.set(tailKey, snake.is_you ? "snake-tail-you" : "snake-tail-enemy");
|
|
tailVariantByCell.set(tailKey, tailVariant);
|
|
tailTransformByCell.set(tailKey, tailTransform);
|
|
snakeColorByCell.set(tailKey, bodyColor);
|
|
if (tailIcon) {
|
|
tailIconByCell.set(tailKey, tailIcon);
|
|
}
|
|
}
|
|
});
|
|
|
|
for (let y = height - 1; y >= 0; y--) {
|
|
for (let x = 0; x < width; x++) {
|
|
const key = cellKey(x, y);
|
|
const cell = document.createElement("div");
|
|
cell.className = "cell";
|
|
if (hazards.has(key)) cell.classList.add("hazard");
|
|
if (foods.has(key)) cell.classList.add("food");
|
|
if (snakeBody.has(key)) {
|
|
const hasHeadIcon = headIconByCell.has(key);
|
|
const hasTailIcon = tailIconByCell.has(key);
|
|
if (hasHeadIcon || hasTailIcon) {
|
|
//cell.style.background = "var(--cell)";
|
|
} else {
|
|
cell.style.background = snakeBody.get(key);
|
|
}
|
|
if (selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
|
|
cell.style.opacity = "0.2";
|
|
}
|
|
|
|
if (!snakeHead.has(key) && !snakeTail.has(key)) {
|
|
const snakeId = snakeIdByCell.get(key);
|
|
if (snakeId) {
|
|
const up = snakeIdByCell.get(cellKey(x, y + 1)) === snakeId;
|
|
const down = snakeIdByCell.get(cellKey(x, y - 1)) === snakeId;
|
|
const left = snakeIdByCell.get(cellKey(x - 1, y)) === snakeId;
|
|
const right = snakeIdByCell.get(cellKey(x + 1, y)) === snakeId;
|
|
|
|
if (up && right && !down && !left) {
|
|
cell.classList.add("snake-turn-cell");
|
|
cell.classList.add("snake-turn-dl");
|
|
cell.style.setProperty("--turn-color", snakeBody.get(key));
|
|
cell.style.background = "var(--cell)";
|
|
} else if (up && left && !down && !right) {
|
|
cell.classList.add("snake-turn-cell");
|
|
cell.classList.add("snake-turn-dr");
|
|
cell.style.setProperty("--turn-color", snakeBody.get(key));
|
|
cell.style.background = "var(--cell)";
|
|
} else if (down && right && !up && !left) {
|
|
cell.classList.add("snake-turn-cell");
|
|
cell.classList.add("snake-turn-ul");
|
|
cell.style.setProperty("--turn-color", snakeBody.get(key));
|
|
cell.style.background = "var(--cell)";
|
|
} else if (down && left && !up && !right) {
|
|
cell.classList.add("snake-turn-cell");
|
|
cell.classList.add("snake-turn-ur");
|
|
cell.style.setProperty("--turn-color", snakeBody.get(key));
|
|
cell.style.background = "var(--cell)";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (snakeTail.has(key)) {
|
|
cell.classList.add(snakeTail.get(key));
|
|
cell.classList.add(`tail-style-${tailVariantByCell.get(key) || 1}`);
|
|
const tailIcon = tailIconByCell.get(key);
|
|
if (tailIcon && !snakeHead.has(key)) {
|
|
cell.classList.add("has-tail-icon");
|
|
cell.classList.add("icon-tail");
|
|
const layer = createIconLayer(
|
|
tailIcon,
|
|
snakeColorByCell.get(key) || "var(--you)",
|
|
tailTransformByCell.get(key) || "scaleX(-1)",
|
|
"tail",
|
|
);
|
|
cell.appendChild(layer);
|
|
}
|
|
}
|
|
if (snakeHead.has(key)) {
|
|
cell.classList.add("snake-head");
|
|
cell.classList.add(`head-style-${headVariantByCell.get(key) || 1}`);
|
|
const headIcon = headIconByCell.get(key);
|
|
if (headIcon) {
|
|
cell.classList.add("has-head-icon");
|
|
cell.classList.add("icon-head");
|
|
const layer = createIconLayer(
|
|
headIcon,
|
|
snakeColorByCell.get(key) || "var(--you)",
|
|
headTransformByCell.get(key) || "rotate(0deg)",
|
|
"head",
|
|
);
|
|
cell.appendChild(layer);
|
|
}
|
|
}
|
|
boardEl.appendChild(cell);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderTurn() {
|
|
if (!replay || !Array.isArray(replay.turns) || replay.turns.length === 0) {
|
|
turnLabelEl.textContent = "Turn -";
|
|
clearBoard();
|
|
if (legendEl) {
|
|
legendEl.innerHTML = "";
|
|
}
|
|
renderThinking(null);
|
|
return;
|
|
}
|
|
|
|
const game = replay.game || {};
|
|
const turns = replay.turns;
|
|
const turn = turns[turnIndex];
|
|
turnLabelEl.textContent = `Turn ${turn.turn} / ${turns[turns.length - 1].turn}`;
|
|
sliderEl.value = String(turnIndex);
|
|
paintBoard(turn, game.width, game.height);
|
|
renderThinking(turn);
|
|
}
|
|
|
|
async function loadReplay(gameId) {
|
|
let nextReplay = null;
|
|
try {
|
|
nextReplay = await requestReplayOverWebSocket(gameId);
|
|
} catch {
|
|
if (!hasLoadedReplayOnce) {
|
|
renderThinking({ my_move: "-", my_thinking: { error: `Replay websocket unavailable for ${gameId}` } });
|
|
return;
|
|
}
|
|
const response = await fetch(`/dashboard/game/${gameId}`);
|
|
if (!response.ok) {
|
|
renderThinking({ my_move: "-", my_thinking: { error: `Replay load failed for ${gameId}` } });
|
|
return;
|
|
}
|
|
nextReplay = await response.json();
|
|
}
|
|
|
|
replay = nextReplay;
|
|
hasLoadedReplayOnce = true;
|
|
activeGameId = String(gameId || "");
|
|
await preloadReplaySvgs();
|
|
turnIndex = 0;
|
|
const count = Array.isArray(replay.turns) ? replay.turns.length : 0;
|
|
sliderEl.max = String(Math.max(0, count - 1));
|
|
sliderEl.value = "0";
|
|
setActiveGame(gameId);
|
|
renderTurn();
|
|
// Keep URL stable; replay selection stays in-memory only.
|
|
}
|
|
|
|
function stopPlayback() {
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
const playBtn = document.getElementById("play-btn");
|
|
playBtn.textContent = "▶";
|
|
playBtn.setAttribute("title", "Play");
|
|
playBtn.setAttribute("aria-label", "Play");
|
|
}
|
|
|
|
function stepTurnBackward() {
|
|
stopPlayback();
|
|
if (!replay || turnIndex <= 0) return;
|
|
turnIndex -= 1;
|
|
renderTurn();
|
|
}
|
|
|
|
function stepTurnForward() {
|
|
stopPlayback();
|
|
if (!replay || !Array.isArray(replay.turns) || turnIndex >= replay.turns.length - 1) return;
|
|
turnIndex += 1;
|
|
renderTurn();
|
|
}
|
|
|
|
function adjustPlaybackSpeed(direction) {
|
|
const speedEl = document.getElementById("speed");
|
|
const optionCount = speedEl.options.length;
|
|
if (optionCount <= 1) return;
|
|
|
|
const currentIndex = speedEl.selectedIndex;
|
|
const nextIndex = Math.max(0, Math.min(optionCount - 1, currentIndex + direction));
|
|
if (nextIndex === currentIndex) return;
|
|
|
|
speedEl.selectedIndex = nextIndex;
|
|
speedEl.dispatchEvent(new Event("change"));
|
|
}
|
|
|
|
function startPlayback() {
|
|
if (!replay || !Array.isArray(replay.turns) || replay.turns.length < 2) return;
|
|
if (turnIndex >= replay.turns.length - 1) {
|
|
turnIndex = 0;
|
|
renderTurn();
|
|
}
|
|
stopPlayback();
|
|
const interval = Number(document.getElementById("speed").value || 650);
|
|
timer = setInterval(() => {
|
|
if (!replay || turnIndex >= replay.turns.length - 1) {
|
|
stopPlayback();
|
|
return;
|
|
}
|
|
turnIndex += 1;
|
|
renderTurn();
|
|
}, interval);
|
|
const playBtn = document.getElementById("play-btn");
|
|
playBtn.textContent = "❚❚";
|
|
playBtn.setAttribute("title", "Pause");
|
|
playBtn.setAttribute("aria-label", "Pause");
|
|
}
|
|
|
|
document.getElementById("play-btn").addEventListener("click", () => {
|
|
if (timer) stopPlayback();
|
|
else startPlayback();
|
|
});
|
|
|
|
document.getElementById("prev-btn").addEventListener("click", () => {
|
|
stepTurnBackward();
|
|
});
|
|
|
|
document.getElementById("next-btn").addEventListener("click", () => {
|
|
stepTurnForward();
|
|
});
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
const target = event.target;
|
|
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowLeft") {
|
|
event.preventDefault();
|
|
stepTurnBackward();
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowRight") {
|
|
event.preventDefault();
|
|
stepTurnForward();
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
adjustPlaybackSpeed(1);
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
adjustPlaybackSpeed(-1);
|
|
return;
|
|
}
|
|
|
|
if (event.key === " " || event.key === "Spacebar") {
|
|
event.preventDefault();
|
|
if (timer) stopPlayback();
|
|
else startPlayback();
|
|
}
|
|
});
|
|
|
|
sliderEl.addEventListener("input", () => {
|
|
stopPlayback();
|
|
turnIndex = Number(sliderEl.value || 0);
|
|
renderTurn();
|
|
});
|
|
|
|
document.getElementById("speed").addEventListener("change", () => {
|
|
if (timer) startPlayback();
|
|
});
|
|
|
|
async function boot() {
|
|
renderThinking(null);
|
|
loadSummary();
|
|
connectDashboardGamesWebSocket();
|
|
await waitForDashboardGamesWebSocketOpen(2500);
|
|
await loadGames();
|
|
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();
|
|
});
|
|
|
|
window.addEventListener("beforeunload", () => {
|
|
gamesWebSocketServerShuttingDown = true;
|
|
if (gamesWebSocketReconnectTimer) {
|
|
clearTimeout(gamesWebSocketReconnectTimer);
|
|
gamesWebSocketReconnectTimer = null;
|
|
}
|
|
rejectPendingReplayRequests("Dashboard unloading");
|
|
if (gamesWebSocket) {
|
|
gamesWebSocket.close();
|
|
gamesWebSocket = null;
|
|
}
|
|
});
|
|
|
|
boot();
|
|
</script>
|
|
</body>
|
|
</html>
|