feat(replay): add debug telemetry panel and fix test build
Add debug telemetry UI to replay viewer with player toggles, priority-based target markers, and stacked reasoning boxes. Fix undefined generateTestImage in main_test.go. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
80040fa501
commit
80af92b022
4 changed files with 248 additions and 11 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug telemetry bottom sheet (mobile) / sidebar panel (desktop) -->
|
||||
<div id="debug-panel" class="panel debug-panel" style="display:none">
|
||||
<div class="debug-panel-header" id="debug-panel-toggle-btn" role="button" tabindex="0"
|
||||
aria-expanded="false" aria-controls="debug-panel-body">
|
||||
<h2 style="margin:0">Debug Telemetry</h2>
|
||||
<span id="debug-panel-chevron" class="debug-chevron" aria-hidden="true">▾</span>
|
||||
</div>
|
||||
<div id="debug-panel-body" class="debug-panel-body">
|
||||
<div id="debug-player-toggles" class="debug-player-toggles"></div>
|
||||
<div id="debug-info-display" class="debug-info-display">
|
||||
<div class="no-debug-data">No debug data for this turn</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.replay-page .page-title { margin-bottom: 20px; }
|
||||
.replay-layout { display: flex; gap: 20px; }
|
||||
|
|
@ -220,6 +235,59 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
|
|||
.replay-layout { flex-direction: column; }
|
||||
.replay-sidebar { width: 100%; }
|
||||
}
|
||||
/* Debug telemetry panel */
|
||||
.debug-panel { padding: 0; overflow: hidden; }
|
||||
.debug-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 15px; cursor: pointer; user-select: none; }
|
||||
.debug-panel-header:hover { background-color: var(--bg-tertiary); }
|
||||
.debug-panel-header h2 { font-size: 1rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.debug-chevron { color: var(--text-muted); font-size: 1rem; transition: transform 0.2s; }
|
||||
.debug-panel.expanded .debug-chevron { transform: rotate(180deg); }
|
||||
.debug-panel-body { display: none; padding: 0 15px 15px; }
|
||||
.debug-panel.expanded .debug-panel-body { display: block; }
|
||||
.debug-player-toggles { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.debug-player-toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; color: var(--text-muted); font-size: 0.875rem; }
|
||||
.debug-player-toggle input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
|
||||
.debug-player-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.debug-info-display { display: flex; flex-direction: column; gap: 10px; }
|
||||
.debug-player-info { background-color: var(--bg-tertiary); border-radius: 6px; padding: 10px; }
|
||||
.debug-player-name { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; margin-bottom: 6px; font-weight: 600; }
|
||||
.debug-reasoning { color: var(--text-secondary); font-size: 0.8rem; line-height: 1.5; margin-bottom: 8px; }
|
||||
.debug-targets { display: flex; flex-direction: column; gap: 4px; }
|
||||
.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; }
|
||||
/* Mobile: bottom sheet */
|
||||
@media (max-width: 900px) {
|
||||
.debug-panel {
|
||||
position: fixed !important;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
border-radius: 12px 12px 0 0 !important;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
transform: translateY(calc(100% - 52px));
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.debug-panel.expanded {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.debug-panel-header::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--border, #374151);
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.debug-panel-header { position: relative; padding-top: 20px; }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
|
|
@ -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<void> {
|
||||
|
|
@ -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 = `
|
||||
<input type="checkbox" id="debug-toggle-p${idx}" checked>
|
||||
<span class="debug-player-dot" style="background:${color}"></span>
|
||||
${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<number, DebugInfo> | null): void {
|
||||
if (!debug || Object.keys(debug).length === 0) {
|
||||
debugInfoDisplay.innerHTML = '<div class="no-debug-data">No debug data for this turn</div>';
|
||||
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 += `<div class="debug-player-info">
|
||||
<div class="debug-player-name" style="color:${color}">${playerName}</div>`;
|
||||
|
||||
if (info.reasoning) {
|
||||
html += `<div class="debug-reasoning">${escapeHtml(info.reasoning)}</div>`;
|
||||
}
|
||||
|
||||
if (info.targets && info.targets.length > 0) {
|
||||
html += '<div class="debug-targets">';
|
||||
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 += `<div class="debug-target-item">
|
||||
<span style="color:${t.color || color}">●</span>
|
||||
${label}
|
||||
<span class="debug-target-priority">${pct}%</span>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
debugInfoDisplay.innerHTML = html || '<div class="no-debug-data">No debug data for this turn</div>';
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').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<number, DebugInfo> | 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) {
|
||||
|
|
|
|||
|
|
@ -383,6 +383,7 @@ export class ReplayViewer {
|
|||
private accessibility: AccessibilitySettings;
|
||||
private viewMode: ViewMode;
|
||||
private showDebug: boolean;
|
||||
private debugPlayerEnabled: Map<number, boolean> = 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<number, DebugInfo> | 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<number, DebugInfo> | 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<number, DebugInfo>, 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue