From 1b55d4dc51471a5a7db86269c4f9790aa9aef434 Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 18:08:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(voting):=20add=20map=20voting=20UI=20widge?= =?UTF-8?q?t=20to=20replay=20viewer=20(=C2=A714.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MapID field to engine Config struct for inclusion in replay JSON - Add map_id to TypeScript Config interface - Add map voting panel to replay viewer sidebar with: - Map metadata display (dimensions, wall density, energy node count) - Thumbs up/down vote buttons wired to POST /api/vote/map - One vote per visitor enforcement (disables after voting) - Net vote count display with positive/negative coloring - Graceful fallback when map_id unavailable (local replays) Co-Authored-By: Claude Opus 4.7 --- engine/types.go | 1 + web/src/pages/replay.ts | 149 +++++++++++++++++++++++++++++++++++++++- web/src/types.ts | 7 ++ 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/engine/types.go b/engine/types.go index 447ef0d..c2ae4c0 100644 --- a/engine/types.go +++ b/engine/types.go @@ -143,6 +143,7 @@ type Config struct { SpawnCost int `json:"spawn_cost"` // energy cost to spawn a bot EnergyInterval int `json:"energy_interval"` // turns between energy spawns CoresPerPlayer int `json:"cores_per_player"` // starting cores per player + MapID string `json:"map_id,omitempty"` SeasonID string `json:"season_id,omitempty"` RulesVersion string `json:"rules_version,omitempty"` } diff --git a/web/src/pages/replay.ts b/web/src/pages/replay.ts index 5790b7d..e63bef9 100644 --- a/web/src/pages/replay.ts +++ b/web/src/pages/replay.ts @@ -1,6 +1,6 @@ // Standalone replay viewer page - lazy loaded from app.ts import type { Replay, GameEvent, DebugInfo, Position, ViewMode } from '../types'; -import { fetchCommentary } from '../api-types'; +import { fetchCommentary, submitMapVote, fetchMapVotes } from '../api-types'; import { AnnotationOverlay, createAnnotationForm, @@ -233,6 +233,41 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): + + + +
+ +
+
+
Load a replay to view transcript
+
+
+
+

Events This Turn

@@ -254,6 +289,7 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): 1-6 Follow Bot 0/Esc Exit Follow F Theater Mode + R Transcript
@@ -338,6 +374,19 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): .commentary-text.type-denouement { color: #94a3b8; font-style: italic; } .commentary-toggle { flex-shrink: 0; opacity: 0.6; transition: opacity 0.2s; } .commentary-toggle.active { opacity: 1; background-color: var(--accent); color: white; } + /* Map voting widget */ + .map-vote-section { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--bg-tertiary); } + .map-vote-label { display: block; color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; } + .map-vote-controls { display: flex; align-items: center; gap: 12px; justify-content: center; } + .map-vote-btn { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-primary); font-size: 1.25rem; width: 44px; height: 44px; border-radius: 8px; cursor: pointer; transition: all 0.15s ease; display: flex; align-items: center; justify-content: center; } + .map-vote-btn:hover:not(:disabled) { background: var(--accent); color: white; border-color: var(--accent); } + .map-vote-btn:disabled { opacity: 0.4; cursor: not-allowed; } + .map-vote-btn.voted-up { background: #22c55e; color: white; border-color: #22c55e; opacity: 1; } + .map-vote-btn.voted-down { background: #ef4444; color: white; border-color: #ef4444; opacity: 1; } + .map-vote-count { font-size: 1.1rem; font-weight: 600; min-width: 32px; text-align: center; color: var(--text-primary); font-family: monospace; } + .map-vote-count.positive { color: #22c55e; } + .map-vote-count.negative { color: #ef4444; } + .map-vote-status { text-align: center; color: var(--text-muted); font-size: 0.7rem; margin-top: 6px; min-height: 1em; } @media (max-width: 900px) { .replay-layout { flex-direction: column; } .replay-sidebar { width: 100%; } @@ -363,6 +412,22 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): .debug-target-item { font-size: 0.75rem; font-family: monospace; color: var(--text-muted); display: flex; align-items: center; gap: 6px; } .debug-target-priority { opacity: 0.7; } .no-debug-data { color: var(--text-muted); font-size: 0.8rem; font-style: italic; } + /* Transcript panel (§15.3) */ + .transcript-panel { padding: 0; overflow: hidden; } + .transcript-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 15px; cursor: pointer; user-select: none; } + .transcript-panel-header:hover { background-color: var(--bg-tertiary); } + .transcript-panel-header h2 { font-size: 1rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } + .transcript-chevron { color: var(--text-muted); font-size: 1rem; transition: transform 0.2s; } + .transcript-panel.expanded .transcript-chevron { transform: rotate(180deg); } + .transcript-panel-body { display: none; padding: 0 15px 15px; } + .transcript-panel.expanded .transcript-panel-body { display: block; } + .transcript-content { max-height: 400px; overflow-y: auto; font-size: 0.8rem; line-height: 1.6; } + .transcript-entry { padding: 8px 0; border-bottom: 1px solid var(--bg-tertiary); } + .transcript-entry:last-child { border-bottom: none; } + .transcript-entry.turn-current { background-color: var(--accent); color: white; padding: 8px; border-radius: 4px; margin: 4px 0; } + .transcript-turn-number { font-weight: 600; color: var(--text-muted); margin-right: 8px; } + .transcript-entry.turn-current .transcript-turn-number { color: rgba(255,255,255,0.7); } + .no-transcript { color: var(--text-muted); font-size: 0.8rem; font-style: italic; } /* Mobile: bottom sheet */ @media (max-width: 900px) { .debug-panel { @@ -669,6 +734,7 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { loadCommentary(replay.match_id); initDebugPanel(replay); initAnnotations(replay); + initMapVote(replay); // §16.13: Register active replay for PIP support const pipCanvasWrapper = document.querySelector('.canvas-wrapper') as HTMLElement; @@ -723,6 +789,87 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { } } + // ── Map voting widget (§14.6) ────────────────────────────────────────────────── + + const mapVotePanel = document.getElementById('map-vote-panel') as HTMLDivElement; + const mapInfoDimensions = document.getElementById('map-info-dimensions') as HTMLElement; + const mapInfoWallDensity = document.getElementById('map-info-wall-density') as HTMLElement; + const mapInfoEnergyCount = document.getElementById('map-info-energy-count') as HTMLElement; + const mapVoteSection = document.getElementById('map-vote-section') as HTMLDivElement; + const mapVoteUp = document.getElementById('map-vote-up') as HTMLButtonElement; + const mapVoteDown = document.getElementById('map-vote-down') as HTMLButtonElement; + const mapVoteCount = document.getElementById('map-vote-count') as HTMLSpanElement; + const mapVoteStatus = document.getElementById('map-vote-status') as HTMLDivElement; + + function initMapVote(replay: Replay): void { + // Always show map metadata + const totalTiles = replay.map.rows * replay.map.cols; + const wallDensity = totalTiles > 0 ? (replay.map.walls.length / totalTiles * 100).toFixed(1) : '0'; + mapInfoDimensions.textContent = `${replay.map.cols} x ${replay.map.rows}`; + mapInfoWallDensity.textContent = `${wallDensity}% (${replay.map.walls.length} tiles)`; + mapInfoEnergyCount.textContent = String(replay.map.energy_nodes.length); + mapVotePanel.style.display = ''; + + // Voting requires a map_id (available in newer replays via config) + const mapId = replay.config.map_id; + if (!mapId) { + mapVoteSection.innerHTML = '
Voting unavailable (no map ID)
'; + return; + } + + // Enable buttons + mapVoteUp.disabled = false; + mapVoteDown.disabled = false; + + // Load existing votes + fetchMapVotes(mapId).then(data => { + updateMapVoteUI(data.net_votes, data.my_vote ?? null); + }).catch(() => { + mapVoteCount.textContent = '0'; + }); + + // Wire up vote buttons + mapVoteUp.onclick = () => submitVote(mapId, 1); + mapVoteDown.onclick = () => submitVote(mapId, -1); + } + + async function submitVote(mapId: string, vote: 1 | -1): Promise { + mapVoteUp.disabled = true; + mapVoteDown.disabled = true; + mapVoteStatus.textContent = 'Submitting...'; + + try { + const result = await submitMapVote(mapId, vote); + updateMapVoteUI(result.net_votes, vote); + mapVoteStatus.textContent = vote === 1 ? 'You upvoted this map' : 'You downvoted this map'; + } catch (err) { + mapVoteStatus.textContent = String(err).replace(/^Error:\s*/, ''); + mapVoteUp.disabled = false; + mapVoteDown.disabled = false; + } + } + + function updateMapVoteUI(netVotes: number, myVote: number | null): void { + mapVoteCount.textContent = String(netVotes); + mapVoteCount.className = 'map-vote-count' + (netVotes > 0 ? ' positive' : netVotes < 0 ? ' negative' : ''); + + // Clear previous vote state + mapVoteUp.classList.remove('voted-up'); + mapVoteDown.classList.remove('voted-down'); + + if (myVote === 1) { + mapVoteUp.classList.add('voted-up'); + mapVoteUp.disabled = true; + mapVoteDown.disabled = true; + mapVoteStatus.textContent = 'You upvoted this map'; + } else if (myVote === -1) { + mapVoteDown.classList.add('voted-down'); + mapVoteUp.disabled = true; + mapVoteDown.disabled = true; + mapVoteStatus.textContent = 'You downvoted this map'; + } + } + function initDebugPanel(replay: Replay): void { const playerColors = [ '#332288', '#88ccee', '#44aa99', '#117733', '#999933', '#ddcc77', diff --git a/web/src/types.ts b/web/src/types.ts index f5ad56c..dc2c0ba 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -13,6 +13,7 @@ export interface Config { attack_radius2: number; spawn_cost: number; energy_interval: number; + map_id?: string; season_id?: string; rules_version?: string; } @@ -167,6 +168,12 @@ export interface EnrichedCommentary { entries: CommentaryEntry[]; } +// Screen reader transcript types (§15.3) +export interface TranscriptEntry { + turn: number; + text: string; +} + // Series types export interface SeriesGame { match_id: string;