feat: add live replays and PostgreSQL maintenance tools
Build and Push Docker Container / build-and-push (push) Successful in 7m53s

- Stream compact live replay updates across local and clustered dashboards.
- Render responsive snake bodies as SVG paths with aligned custom icons.
- Add cache-busted assets, replay fallback routes, and live-follow playback.
- Support PostgreSQL benchmark sampling and idempotent SQLite migration.
- Add dry-run cleanup for old low-quality PostgreSQL replay payloads.
- Reward safe perimeter lanes and bump Prism to version 1.5.0.
- Add backend, migration, dashboard, and perimeter regression coverage.
This commit is contained in:
2026-08-02 00:50:46 +02:00
parent 9b99b526e4
commit f14d780f29
29 changed files with 1574 additions and 310 deletions
+76 -70
View File
@@ -262,11 +262,12 @@ input[type="range"] {
}
.board-wrap {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
gap: 8px;
min-height: 520px;
display: flex;
align-items: flex-start;
justify-content: center;
overflow: hidden;
}
.legend {
@@ -291,54 +292,38 @@ input[type="range"] {
}
.board {
position: relative;
min-width: 0;
min-height: 0;
height: auto;
width: 100%;
display: grid;
gap: 2px;
flex: none;
background: var(--grid);
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px;
align-content: start;
overflow: hidden;
}
/* Snake bodies are drawn as one rounded polyline per snake on top of the grid,
so bends and cell gaps need no per-cell patching. */
.snake-layer {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.cell {
background: var(--cell);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
aspect-ratio: 1 / 1;
position: relative;
border-radius: 2px;
}
.snake-turn-cell::after {
content: "";
position: absolute;
inset: 0;
background: var(--turn-color, transparent);
z-index: 0;
pointer-events: none;
}
/* 50% = quarter-circle at the inner corner of the bend */
.snake-turn-cell.snake-turn-ur::after {
border-top-right-radius: 50%;
}
.snake-turn-cell.snake-turn-ul::after {
border-top-left-radius: 50%;
}
.snake-turn-cell.snake-turn-dr::after {
border-bottom-right-radius: 50%;
}
.snake-turn-cell.snake-turn-dl::after {
border-bottom-left-radius: 50%;
}
.food {
background-image: radial-gradient(circle at center, #d73a31 0 45%, transparent 48%);
background-repeat: no-repeat;
@@ -350,6 +335,12 @@ input[type="range"] {
background-color: var(--hazard);
}
/* The hatch overlay (::before, z-index 4) still paints above the body stroke,
but the opaque fill would hide it. */
.hazard.hazard-over-snake {
background-color: transparent;
}
.hazard::before {
content: "";
position: absolute;
@@ -364,17 +355,11 @@ input[type="range"] {
border-radius: inherit;
}
.snake-you {
background: var(--you);
}
.snake-enemy {
background: var(--enemy);
}
/* Head/tail markers and icon layers use z-index >= 2 so they paint above
.snake-layer. The cells themselves stay unstacked, keeping their background
below the body stroke. */
.snake-head {
outline: 2px solid var(--head-ring);
outline-offset: -2px;
outline: none;
}
.snake-head::after {
@@ -494,43 +479,41 @@ input[type="range"] {
display: none;
}
.snake-tail-you.has-tail-icon,
.snake-tail-enemy.has-tail-icon {
box-shadow: none;
}
/* Spans the full cell along the travel axis and the body stroke width across
it, so the icon is exactly as thick as the body and its leading edge lands on
the cell border where the stroke ends. */
.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;
left: 0;
right: 0;
top: var(--icon-cross-inset, 7%);
bottom: var(--icon-cross-inset, 7%);
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 {
/* Fallback for artwork that has not been fetched yet. An SVG used as a mask
image keeps its own preserveAspectRatio and letterboxes inside this
non-square box, so the inlined variant above is preferred. */
.icon-layer--masked {
background: var(--icon-color, currentColor);
-webkit-mask-image: var(--icon-url);
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: 100% 100%;
mask-image: var(--icon-url);
mask-repeat: no-repeat;
mask-position: center;
mask-size: 100% 100%;
}
.icon-layer>svg {
width: 100%;
height: 100%;
display: block;
@@ -693,6 +676,19 @@ input[type="range"] {
}
@media (max-width: 1100px) {
html,
body {
height: auto;
min-height: 100%;
overflow: auto;
}
.page {
height: auto;
min-height: 100vh;
overflow: visible;
}
.topbar {
grid-template-columns: 1fr;
}
@@ -704,6 +700,12 @@ input[type="range"] {
.main {
grid-template-columns: 1fr;
overflow: visible;
}
.panel,
.right {
overflow: visible;
}
.games {
@@ -714,8 +716,12 @@ input[type="range"] {
grid-template-columns: 1fr;
}
.thinking {
overflow: visible;
}
.board-wrap {
min-height: 360px;
min-height: min(70vw, 520px);
}
.turn-badge {