diff --git a/cmd/acb-index-builder/main_test.go b/cmd/acb-index-builder/main_test.go index b721a33..95b2592 100644 --- a/cmd/acb-index-builder/main_test.go +++ b/cmd/acb-index-builder/main_test.go @@ -11,6 +11,16 @@ import ( "time" ) +func generateTestImage(w, h int) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: 100, G: 100, B: 100, A: 255}) + } + } + return img +} + func TestLoadConfig(t *testing.T) { // Set test environment variables t.Setenv("ACB_POSTGRES_HOST", "testhost") diff --git a/web/src/pages/replay.ts b/web/src/pages/replay.ts index 5f11b56..0095629 100644 --- a/web/src/pages/replay.ts +++ b/web/src/pages/replay.ts @@ -1,5 +1,5 @@ // Standalone replay viewer page - lazy loaded from app.ts -import type { Replay, GameEvent } from '../types'; +import type { Replay, GameEvent, DebugInfo } from '../types'; import { fetchCommentary } from '../api-types'; const loadReplayViewer = () => import('../replay-viewer'); @@ -162,6 +162,21 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): + + + `; @@ -258,10 +326,16 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { const commentaryBar = document.getElementById('commentary-bar') as HTMLDivElement; const commentaryText = document.getElementById('commentary-text') as HTMLSpanElement; const commentaryToggle = document.getElementById('commentary-toggle') as HTMLButtonElement; + const debugPanel = document.getElementById('debug-panel') as HTMLDivElement; + const debugPanelToggleBtn = document.getElementById('debug-panel-toggle-btn') as HTMLDivElement; + const debugPanelChevron = document.getElementById('debug-panel-chevron') as HTMLSpanElement; + const debugPlayerToggles = document.getElementById('debug-player-toggles') as HTMLDivElement; + const debugInfoDisplay = document.getElementById('debug-info-display') as HTMLDivElement; let viewer = new ReplayViewerClass(canvas, { cellSize: 10 }); let criticalMoments: Array<{turn: number; delta: number; description: string}> = []; let commentaryEnabled = true; + let debugPanelExpanded = false; function enableControls(): void { playBtn.disabled = false; @@ -325,6 +399,24 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { updateEventLog(); initWinProb(replay); loadCommentary(replay.match_id); + initDebugPanel(replay); + + const hasAnyDebug = replay.turns.some(t => t.debug && Object.keys(t.debug).length > 0); + if (hasAnyDebug) { + debugPanel.style.display = ''; + // On mobile, default collapsed; on desktop, default expanded + if (window.innerWidth > 900) { + debugPanelExpanded = true; + debugPanel.classList.add('expanded'); + debugPanelToggleBtn.setAttribute('aria-expanded', 'true'); + } else { + debugPanelExpanded = false; + debugPanel.classList.remove('expanded'); + debugPanelToggleBtn.setAttribute('aria-expanded', 'false'); + } + } else { + debugPanel.style.display = 'none'; + } } async function loadCommentary(matchId: string): Promise { @@ -340,6 +432,90 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { } } + function initDebugPanel(replay: Replay): void { + const playerColors = [ + '#332288', '#88ccee', '#44aa99', '#117733', '#999933', '#ddcc77', + ]; + + debugPlayerToggles.innerHTML = ''; + replay.players.forEach((player, idx) => { + const color = playerColors[idx] || '#888'; + const label = document.createElement('label'); + label.className = 'debug-player-toggle'; + label.innerHTML = ` + + + ${player.name} + `; + const checkbox = label.querySelector('input') as HTMLInputElement; + checkbox.addEventListener('change', () => { + viewer.setShowDebug(true); + viewer.setDebugPlayerEnabled(idx, checkbox.checked); + updateDebugDisplay(viewer.getDebugForCurrentTurn?.() ?? null); + }); + debugPlayerToggles.appendChild(label); + }); + + viewer.setShowDebug(true); + } + + function updateDebugDisplay(debug: Record | null): void { + if (!debug || Object.keys(debug).length === 0) { + debugInfoDisplay.innerHTML = '
No debug data for this turn
'; + return; + } + + const playerColors = [ + '#332288', '#88ccee', '#44aa99', '#117733', '#999933', '#ddcc77', + ]; + const replay = viewer.getReplay() as Replay | null; + + let html = ''; + for (const [playerId, info] of Object.entries(debug)) { + const idx = parseInt(playerId, 10); + if (viewer.getDebugPlayerEnabled(idx) === false) continue; + const color = playerColors[idx] || '#888'; + const playerName = replay?.players[idx]?.name ?? `Player ${idx}`; + const hasData = !!(info.reasoning || (info.targets && info.targets.length > 0)); + if (!hasData) continue; + + html += `
+
${playerName}
`; + + if (info.reasoning) { + html += `
${escapeHtml(info.reasoning)}
`; + } + + if (info.targets && info.targets.length > 0) { + html += '
'; + for (const t of info.targets) { + const pct = t.priority !== undefined ? Math.round(t.priority * 100) : 100; + const label = t.label ? escapeHtml(t.label) : `(${t.position.row},${t.position.col})`; + html += `
+ + ${label} + ${pct}% +
`; + } + html += '
'; + } + + html += '
'; + } + + debugInfoDisplay.innerHTML = html || '
No debug data for this turn
'; + } + + function escapeHtml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>'); + } + + function toggleDebugPanel(): void { + debugPanelExpanded = !debugPanelExpanded; + debugPanel.classList.toggle('expanded', debugPanelExpanded); + debugPanelToggleBtn.setAttribute('aria-expanded', String(debugPanelExpanded)); + } + function initWinProb(replay: Replay): void { if (!replay.win_prob || replay.win_prob.length === 0) { winProbSection.style.display = 'none'; @@ -499,11 +675,19 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { updateAccessibility(); } + debugPanelToggleBtn.addEventListener('click', toggleDebugPanel); + debugPanelToggleBtn.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleDebugPanel(); } + }); + viewer.onTurnChange = () => { updateUI(); updateEventLog(); if (criticalMoments.length > 0) updateCriticalMomentNav(); }; + viewer.onDebugChange = (debug: Record | null) => { + updateDebugDisplay(debug); + }; viewer.onPlayStateChange = (playing: boolean) => { playBtn.textContent = playing ? 'Pause' : 'Play'; }; viewer.onCommentaryChange = (entry: { turn: number; text: string; type: string } | null) => { if (!entry || !commentaryEnabled) { diff --git a/web/src/replay-viewer.ts b/web/src/replay-viewer.ts index 89d3071..9313cac 100644 --- a/web/src/replay-viewer.ts +++ b/web/src/replay-viewer.ts @@ -383,6 +383,7 @@ export class ReplayViewer { private accessibility: AccessibilitySettings; private viewMode: ViewMode; private showDebug: boolean; + private debugPlayerEnabled: Map = new Map(); private screenReaderRegion: HTMLElement | null = null; // Animation state @@ -403,6 +404,7 @@ export class ReplayViewer { public onPlayStateChange?: (playing: boolean) => void; public onReplayLoad?: (replay: Replay) => void; public onCommentaryChange?: (entry: { turn: number; text: string; type: string } | null) => void; + public onDebugChange?: (debug: Record | null) => void; // Enriched commentary state (§13.3) private commentary: EnrichedCommentary | null = null; @@ -475,6 +477,7 @@ export class ReplayViewer { this.startRenderLoop(); if (this.onReplayLoad) this.onReplayLoad(replay); + this.fireDebugForTurn(0); } private resizeCanvas(): void { @@ -582,6 +585,19 @@ export class ReplayViewer { return this.showDebug; } + setDebugPlayerEnabled(player: number, enabled: boolean): void { + this.debugPlayerEnabled.set(player, enabled); + this.render(); + } + + getDebugPlayerEnabled(player: number): boolean { + return this.debugPlayerEnabled.get(player) ?? true; + } + + getDebugForCurrentTurn(): Record | null { + return this.replay?.turns[this.currentTurn]?.debug ?? null; + } + // ── Enriched Commentary Controls (§13.3) ────────────────────────────────────── setCommentary(commentary: EnrichedCommentary | null): void { @@ -623,6 +639,13 @@ export class ReplayViewer { } } + private fireDebugForTurn(turn: number): void { + if (this.onDebugChange) { + const turnData = this.replay?.turns[turn]; + this.onDebugChange(turnData?.debug ?? null); + } + } + destroy(): void { this.stopRenderLoop(); this.isPlaying = false; @@ -854,6 +877,7 @@ export class ReplayViewer { if (this.onTurnChange) this.onTurnChange(this.currentTurn); this.fireCommentaryForTurn(this.currentTurn); + this.fireDebugForTurn(this.currentTurn); } private fireTurnAnimations(turnData: ReplayTurn): void { @@ -1393,53 +1417,71 @@ export class ReplayViewer { // Render debug telemetry overlay private renderDebugOverlay(debug: Record, colors: string[]): void { const { ctx, cellSize } = this; + let reasoningRow = 0; for (const [playerId, info] of Object.entries(debug)) { const playerIdx = parseInt(playerId, 10); + + // Skip if this player's overlay is explicitly disabled + if (this.debugPlayerEnabled.get(playerIdx) === false) continue; + const color = colors[playerIdx] || '#ffffff'; - // Draw debug targets + // Draw debug targets with priority-based opacity if (info.targets) { for (const target of info.targets) { const x = target.position.col * cellSize + cellSize / 2; const y = target.position.row * cellSize + cellSize / 2; + const alpha = target.priority !== undefined ? Math.max(0.1, target.priority) : 1.0; + const markerColor = target.color || color; - // Draw target marker - ctx.strokeStyle = target.color || color; + ctx.globalAlpha = alpha; + ctx.strokeStyle = markerColor; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, cellSize / 2, 0, Math.PI * 2); ctx.stroke(); - // Draw label if provided if (target.label) { - ctx.fillStyle = color; + ctx.fillStyle = markerColor; ctx.font = '10px monospace'; ctx.textAlign = 'center'; - ctx.fillText(target.label, x, y - cellSize / 2 - 4); + ctx.textBaseline = 'bottom'; + ctx.fillText(target.label, x, y - cellSize / 2 - 2); } + ctx.globalAlpha = 1.0; } } - // Draw reasoning text + // Draw reasoning text — stack boxes from the canvas bottom if (info.reasoning) { const padding = 10; const maxWidth = 200; const lineHeight = 14; + const boxH = 54; + const yTop = this.canvas.height - boxH - padding - reasoningRow * (boxH + 4); - ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; - ctx.fillRect(padding, this.canvas.height - 60 - padding, maxWidth + padding * 2, 50); + ctx.globalAlpha = 1.0; + ctx.fillStyle = 'rgba(0, 0, 0, 0.82)'; + ctx.fillRect(padding, yTop, maxWidth + padding * 2, boxH); ctx.fillStyle = color; ctx.font = '11px monospace'; ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; const lines = this.wrapText(info.reasoning, maxWidth); lines.forEach((line, i) => { - ctx.fillText(line, padding * 2, this.canvas.height - 60 + i * lineHeight); + ctx.fillText(line, padding * 2, yTop + padding / 2 + i * lineHeight); }); + + reasoningRow++; } } + + // Reset canvas state + ctx.globalAlpha = 1.0; + ctx.textBaseline = 'alphabetic'; } // Wrap text to fit within max width diff --git a/web/src/types.ts b/web/src/types.ts index 200196a..e4e4bc0 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -135,6 +135,7 @@ export interface DebugTarget { position: Position; label?: string; color?: string; + priority?: number; // 0.0–1.0; controls marker opacity (1 = fully opaque) } export interface DebugInfo {