diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 84867c2..b90c99f 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -aa1c78c9d7901ce17ff0f28b5a2b89961c31d403 +fd9ffbc0487ebcf54f04ac13d3eae2f0f96157cb diff --git a/cmd/acb-evolver/internal/live/exporter.go b/cmd/acb-evolver/internal/live/exporter.go index 0c71c22..156f00c 100644 --- a/cmd/acb-evolver/internal/live/exporter.go +++ b/cmd/acb-evolver/internal/live/exporter.go @@ -132,6 +132,7 @@ type Totals struct { PromotionRate7d float64 `json:"promotion_rate_7d"` HighestEvolvedRating int `json:"highest_evolved_rating"` EvolvedInTop10 int `json:"evolved_in_top_10"` + MutationsPerHour float64 `json:"mutations_per_hour"` } // LiveData is the full evolution dashboard payload written to live.json (plan §14 format). @@ -289,6 +290,15 @@ func fillTotals(ctx context.Context, db *sql.DB, data *LiveData) error { top10Count = 0 } + // Mutations per hour (programs created in the last hour) + var mutationsLastHour int + err = db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM programs + WHERE created_at >= NOW() - INTERVAL '1 hour'`).Scan(&mutationsLastHour) + if err != nil { + mutationsLastHour = 0 + } + data.Totals = Totals{ GenerationsTotal: maxGen, CandidatesToday: candidatesToday, @@ -296,6 +306,7 @@ func fillTotals(ctx context.Context, db *sql.DB, data *LiveData) error { PromotionRate7d: rate7d, HighestEvolvedRating: highestRating, EvolvedInTop10: top10Count, + MutationsPerHour: round3(float64(mutationsLastHour)), } return nil diff --git a/web/src/api-types.ts b/web/src/api-types.ts index 937ddc4..992be15 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -98,17 +98,45 @@ export interface RegisterResponse { error?: string; } -// Evolution dashboard types +// Evolution dashboard types (re-exported from types.ts for convenience) +import type { + LiveJSON, + EvolutionIslandStat, + EvolutionParentInfo, + EvolutionStageResult, + EvolutionValidationStatus, + EvaluationMatchResult, + EvolutionEvaluationStatus, + EvolutionCandidate, + EvolutionCycleInfo, + EvolutionActivityEntry, + EvolutionTotals, + EvolutionGenerationEntry, + EvolutionLineageNode, + EvolutionMetaSnapshot, +} from './types'; -// Dashboard island stat (live.json format) -export interface IslandStat { - population: number; - best_rating: number; - best_bot: string; - language_div?: string; -} +export type { + LiveJSON, + EvolutionIslandStat as IslandStat, + EvolutionParentInfo as ParentInfo, + EvolutionStageResult as StageResult, + EvolutionValidationStatus as ValidationStatus, + EvaluationMatchResult as MatchResult, + EvolutionEvaluationStatus as EvaluationStatus, + EvolutionCandidate as Candidate, + EvolutionCycleInfo as CycleInfo, + EvolutionActivityEntry as ActivityEntry, + EvolutionTotals as Totals, + EvolutionGenerationEntry as GenerationEntry, + EvolutionLineageNode as LineageNode, + EvolutionMetaSnapshot as MetaSnapshot, +} from './types'; -// Full island stat (legacy format) +// Convenience alias: the full live.json document +export type EvolutionLiveData = LiveJSON; + +// Full island stat (legacy format, not in live.json schema) export interface IslandStatFull { count: number; best_fitness: number; @@ -117,126 +145,6 @@ export interface IslandStatFull { promoted_count: number; } -// Parent info for candidate -export interface ParentInfo { - id: string; - rating: number; -} - -// Validation stage result -export interface StageResult { - passed: boolean; - time_ms: number; - error?: string; -} - -// Validation status -export interface ValidationStatus { - syntax?: StageResult; - schema?: StageResult; - smoke?: StageResult; -} - -// Match result in evaluation -export interface MatchResult { - opponent: string; - won: boolean; - score: string; -} - -// Evaluation status -export interface EvaluationStatus { - matches_total: number; - matches_played: number; - results: MatchResult[]; -} - -// Current candidate being evaluated -export interface Candidate { - id: string; - island: string; - language: string; - parents: ParentInfo[]; - validation?: ValidationStatus; - evaluation?: EvaluationStatus; -} - -// Current cycle info -export interface CycleInfo { - generation: number; - started_at: string; - phase: string; // generating, validating, evaluating, promoting, idle - candidate?: Candidate; -} - -// Activity entry in recent activity feed -export interface ActivityEntry { - time: string; - generation: number; - candidate: string; - island: string; - result: string; // promoted, rejected - reason: string; - stage: string; // validation, promotion, deployment - bot_id?: string; - initial_rating?: number; -} - -// Overall evolution statistics -export interface Totals { - generations_total: number; - candidates_today: number; - promoted_today: number; - promotion_rate_7d: number; - highest_evolved_rating: number; - evolved_in_top_10: number; -} - -// Legacy generation entry -export interface GenerationEntry { - generation: number; - island: string; - evaluated_at: string; - count: number; - promoted: number; - best_fitness: number; - avg_fitness: number; -} - -// Legacy lineage node -export interface LineageNode { - id: number; - parent_ids: number[]; - generation: number; - island: string; - fitness: number; - promoted: boolean; - language: string; - created_at: string; -} - -// Legacy meta snapshot -export interface MetaSnapshot { - generation: number; - island_counts: Record; - island_best_fitness: Record; -} - -// Evolution live data (plan §14 format) -export interface EvolutionLiveData { - updated_at: string; - cycle?: CycleInfo; - recent_activity?: ActivityEntry[]; - islands: Record; - totals: Totals; - // Legacy fields for backward compatibility - total_programs?: number; - promoted_count?: number; - generation_log?: GenerationEntry[]; - lineage?: LineageNode[]; - meta_snapshots?: MetaSnapshot[]; -} - // Blog / Narrative Engine types export interface BlogWeekStats { diff --git a/web/src/components/annotation.ts b/web/src/components/annotation.ts new file mode 100644 index 0000000..37d1d8a --- /dev/null +++ b/web/src/components/annotation.ts @@ -0,0 +1,480 @@ +// AnnotationOverlay — spatial + text replay annotations per §16.8 +// Users add tagged feedback anchored to a (turn, grid position) pair. +// Feedback types match plan §8.3: insight, mistake, idea, highlight. +// Markers render on the canvas; annotations show in a side panel + event timeline. + +import type { Position } from '../types'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type FeedbackType = 'insight' | 'mistake' | 'idea' | 'highlight'; + +export interface Annotation { + id: string; + match_id: string; + turn: number; + type: FeedbackType; + body: string; + author: string; + upvotes: number; + created_at: string; + // Spatial data (optional — user may click a grid position) + position?: Position; +} + +export const FEEDBACK_TYPES: { type: FeedbackType; label: string; icon: string; color: string }[] = [ + { type: 'insight', label: 'Tactical Insight', icon: '\u{1F4A1}', color: '#3b82f6' }, + { type: 'mistake', label: 'Mistake Spotted', icon: '⚠️', color: '#ef4444' }, + { type: 'idea', label: 'Strategy Idea', icon: '\u{1F9EA}', color: '#22c55e' }, + { type: 'highlight', label: 'Highlight', icon: '⭐', color: '#fbbf24' }, +]; + +// ─── Storage ──────────────────────────────────────────────────────────────── + +const LS_KEY = 'acb_annotations_v2'; + +function saveLocal(ann: Annotation): void { + try { + const existing: Annotation[] = JSON.parse(localStorage.getItem(LS_KEY) ?? '[]'); + existing.push(ann); + localStorage.setItem(LS_KEY, JSON.stringify(existing.slice(-200))); + } catch { /* ignore */ } +} + +export function loadLocalAnnotations(matchId?: string): Annotation[] { + try { + const all: Annotation[] = JSON.parse(localStorage.getItem(LS_KEY) ?? '[]'); + if (matchId) return all.filter(a => a.match_id === matchId); + return all; + } catch { + return []; + } +} + +// ─── Fetch feedback.json from pre-built data ──────────────────────────────── + +export async function fetchFeedback(matchId: string): Promise { + try { + const resp = await fetch(`/data/matches/${matchId}/feedback.json`); + if (!resp.ok) return []; + return resp.json(); + } catch { + return []; + } +} + +// ─── Submit (POST to API, localStorage fallback) ──────────────────────────── + +const API_BASE = '/api'; + +export async function submitAnnotation(ann: Annotation): Promise { + saveLocal(ann); + try { + const resp = await fetch(`${API_BASE}/feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ann), + }); + return resp.ok; + } catch { + return false; + } +} + +// ─── AnnotationOverlay class ──────────────────────────────────────────────── + +export interface AnnotationOverlayOptions { + onAnnotationAdd?: (ann: Annotation) => void; + onTurnClick?: (turn: number) => void; +} + +export class AnnotationOverlay { + private container: HTMLElement; + private annotations: Annotation[] = []; + private currentTurn: number = 0; + private totalTurns: number = 0; + private _matchId: string = ''; + private options: AnnotationOverlayOptions; + + constructor(container: HTMLElement, options: AnnotationOverlayOptions = {}) { + this.container = container; + this.options = options; + } + + loadAnnotations(matchId: string, annotations: Annotation[], totalTurns: number): void { + this.matchId = matchId; + this.totalTurns = totalTurns; + this.annotations = annotations.filter(a => a.match_id === matchId); + this.render(); + } + + setCurrentTurn(turn: number): void { + this.currentTurn = turn; + this.updateHighlight(); + } + + addAnnotation(ann: Annotation): void { + this.annotations.push(ann); + this.render(); + if (this.options.onAnnotationAdd) this.options.onAnnotationAdd(ann); + } + + getAnnotationsForTurn(turn: number): Annotation[] { + return this.annotations.filter(a => a.turn === turn); + } + + getAllAnnotations(): Annotation[] { + return [...this.annotations]; + } + + // Get turns that have annotations (for rendering markers on canvas/timeline) + getAnnotatedTurns(): number[] { + const turns = new Set(this.annotations.map(a => a.turn)); + return [...turns].sort((a, b) => a - b); + } + + // ── Render ──────────────────────────────────────────────────────────────── + + private render(): void { + if (this.annotations.length === 0) { + this.container.innerHTML = '
No annotations yet
'; + return; + } + + const turnMarkers = this.renderTimelineMarkers(); + + this.container.innerHTML = ` +
+ Annotations + ${this.annotations.length} +
+
+ ${turnMarkers} +
+
+ ${this.renderCurrentTurnAnnotations()} +
+ `; + + this.wireClickHandlers(); + this.updateHighlight(); + } + + private renderTimelineMarkers(): string { + const grouped = new Map(); + for (const ann of this.annotations) { + const list = grouped.get(ann.turn) ?? []; + list.push(ann); + grouped.set(ann.turn, list); + } + + return [...grouped.entries()].map(([turn, anns]) => { + const pct = (turn / Math.max(1, this.totalTurns - 1)) * 100; + const primaryType = anns[0].type; + const config = FEEDBACK_TYPES.find(f => f.type === primaryType); + const color = config?.color ?? '#94a3b8'; + const count = anns.length > 1 ? `${anns.length}` : ''; + return `
+ ${count} +
`; + }).join(''); + } + + private renderCurrentTurnAnnotations(): string { + const current = this.getAnnotationsForTurn(this.currentTurn); + if (current.length === 0) { + return '
No annotations at this turn
'; + } + + return current.map(ann => { + const config = FEEDBACK_TYPES.find(f => f.type === ann.type); + const color = config?.color ?? '#94a3b8'; + const icon = config?.icon ?? ''; + const label = config?.label ?? ann.type; + const pos = ann.position ? `(${ann.position.row}, ${ann.position.col})` : ''; + return `
+
+ ${icon} ${escapeHtml(label)} + ${escapeHtml(ann.author)} +
+
${escapeHtml(ann.body)}
+ ${pos ? `
@ ${pos}
` : ''} +
+ ${ann.upvotes} upvotes + ${formatTime(ann.created_at)} +
+
`; + }).join(''); + } + + private wireClickHandlers(): void { + this.container.querySelectorAll('.ann-marker').forEach(el => { + el.addEventListener('click', () => { + const turn = parseInt((el as HTMLElement).dataset.turn || '0', 10); + if (this.options.onTurnClick) this.options.onTurnClick(turn); + }); + }); + } + + private updateHighlight(): void { + this.container.querySelectorAll('.ann-marker').forEach(el => { + const turn = parseInt((el as HTMLElement).dataset.turn || '0', 10); + el.classList.toggle('active', turn === this.currentTurn); + }); + + // Update annotation list for current turn + const listEl = this.container.querySelector('.ann-overlay-list'); + if (listEl) listEl.innerHTML = this.renderCurrentTurnAnnotations(); + } + + // ── Canvas marker rendering ─────────────────────────────────────────────── + // Call this from ReplayViewer's render loop to draw annotation markers on the canvas + + static drawCanvasMarkers( + ctx: CanvasRenderingContext2D, + annotations: Annotation[], + currentTurn: number, + cellSize: number, + _mapRows: number, + ): void { + const currentAnns = annotations.filter(a => a.turn === currentTurn); + if (currentAnns.length === 0) return; + + ctx.save(); + + for (const ann of currentAnns) { + const config = FEEDBACK_TYPES.find(f => f.type === ann.type); + const color = config?.color ?? '#94a3b8'; + + if (ann.position) { + // Draw marker at grid position + const x = ann.position.col * cellSize + cellSize / 2; + const y = ann.position.row * cellSize + cellSize / 2; + const r = cellSize / 2 + 2; + + ctx.globalAlpha = 0.6; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.stroke(); + + ctx.globalAlpha = 0.15; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + } else { + // Draw a small indicator at top-right corner of the map + const x = 0; + const y = 0; + ctx.globalAlpha = 0.8; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x + cellSize - 2, y + 2, 3, 0, Math.PI * 2); + ctx.fill(); + } + } + + ctx.restore(); + } + + // Draw annotation count badges on the event timeline + static drawTimelineBadges( + ctx: CanvasRenderingContext2D, + annotations: Annotation[], + totalTurns: number, + width: number, + y: number, + height: number, + ): void { + const grouped = new Map(); + for (const ann of annotations) { + const list = grouped.get(ann.turn) ?? []; + list.push(ann); + grouped.set(ann.turn, list); + } + + ctx.save(); + ctx.globalAlpha = 0.7; + for (const [turn, anns] of grouped) { + const pct = turn / Math.max(1, totalTurns - 1); + const x = pct * width; + const config = FEEDBACK_TYPES.find(f => f.type === anns[0].type); + const color = config?.color ?? '#94a3b8'; + + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x, y + height / 2, anns.length > 1 ? 4 : 3, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + destroy(): void { + this.container.innerHTML = ''; + } +} + +// ─── Annotation form (embeddable in any panel) ────────────────────────────── + +export interface AnnotationFormOptions { + matchId: string; + currentTurn: number; + authorName: string; + onSubmit?: (ann: Annotation) => void; +} + +export function createAnnotationForm( + container: HTMLElement, + getTurn: () => number, + getMatchId: () => string, + getGridPosition: () => Position | undefined, +): void { + const authorKey = 'acb_author_name'; + const savedAuthor = localStorage.getItem(authorKey) || ''; + + container.innerHTML = ` +
+
+ ${FEEDBACK_TYPES.map(ft => + ``, + ).join('')} +
+
+ + + 0 / 500 +
+ +
+ `; + + let selectedType: FeedbackType | null = null; + const typeBtns = container.querySelectorAll('.ann-type-btn'); + const bodyInput = container.querySelector('.ann-body-input') as HTMLTextAreaElement; + const charCount = container.querySelector('.ann-char-count') as HTMLSpanElement; + const authorInput = container.querySelector('.ann-author-input') as HTMLInputElement; + const submitBtn = container.querySelector('.ann-submit-btn') as HTMLButtonElement; + + typeBtns.forEach(btn => { + btn.addEventListener('click', () => { + typeBtns.forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + selectedType = (btn as HTMLElement).dataset.type as FeedbackType; + updateSubmitState(); + }); + }); + + bodyInput.addEventListener('input', () => { + charCount.textContent = `${bodyInput.value.length} / 500`; + updateSubmitState(); + }); + + authorInput.addEventListener('input', () => { + localStorage.setItem(authorKey, authorInput.value.trim()); + }); + + function updateSubmitState(): void { + submitBtn.disabled = !selectedType || bodyInput.value.trim().length === 0; + } + + submitBtn.addEventListener('click', async () => { + if (!selectedType || !bodyInput.value.trim()) return; + + const author = authorInput.value.trim() || 'Anonymous'; + const matchId = getMatchId(); + const turn = getTurn(); + const position = getGridPosition(); + + const ann: Annotation = { + id: `ann_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`, + match_id: matchId, + turn, + type: selectedType, + body: bodyInput.value.trim(), + author, + upvotes: 0, + created_at: new Date().toISOString(), + position, + }; + + submitBtn.disabled = true; + submitBtn.textContent = 'Submitting...'; + + await submitAnnotation(ann); + + // Reset form + typeBtns.forEach(b => b.classList.remove('selected')); + selectedType = null; + bodyInput.value = ''; + charCount.textContent = '0 / 500'; + submitBtn.textContent = 'Add Annotation'; + submitBtn.disabled = true; + + // Dispatch custom event so the replay page can handle it + container.dispatchEvent(new CustomEvent('annotation-added', { detail: ann, bubbles: true })); + }); +} + +// ─── Styles ───────────────────────────────────────────────────────────────── + +export const ANNOTATION_OVERLAY_STYLES = ` + .ann-overlay-empty { color: var(--text-muted); font-size: 0.8rem; padding: 8px; text-align: center; } + .ann-overlay-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } + .ann-overlay-title { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; } + .ann-overlay-count { font-size: 0.7rem; background: var(--bg-tertiary); color: var(--text-muted); padding: 2px 6px; border-radius: 8px; } + .ann-overlay-track { position: relative; height: 16px; background: var(--bg-tertiary); border-radius: 4px; margin-bottom: 10px; } + .ann-marker { position: absolute; top: 1px; bottom: 1px; display: flex; align-items: center; justify-content: center; transform: translateX(-50%); cursor: pointer; } + .ann-marker-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ann-color, #94a3b8); transition: transform 0.15s; } + .ann-marker:hover .ann-marker-dot { transform: scale(1.5); } + .ann-marker.active .ann-marker-dot { transform: scale(1.8); box-shadow: 0 0 4px var(--ann-color); } + .ann-marker-count { position: absolute; top: -8px; font-size: 0.55rem; color: var(--text-muted); font-weight: 600; } + .ann-overlay-list { display: flex; flex-direction: column; gap: 6px; max-height: 180px; overflow-y: auto; } + .ann-no-current { color: var(--text-muted); font-size: 0.75rem; font-style: italic; } + .ann-item { background: var(--bg-tertiary); border-radius: 6px; padding: 8px; border-left: 3px solid var(--ann-color, #475569); } + .ann-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; } + .ann-item-type { font-size: 0.75rem; font-weight: 600; } + .ann-item-author { font-size: 0.65rem; color: var(--text-muted); } + .ann-item-body { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.4; margin-bottom: 4px; } + .ann-item-pos { font-size: 0.65rem; color: var(--text-muted); font-family: monospace; margin-bottom: 4px; } + .ann-item-meta { display: flex; gap: 10px; font-size: 0.65rem; color: var(--text-muted); } + /* Annotation form */ + .ann-form { display: flex; flex-direction: column; gap: 10px; } + .ann-form-types { display: flex; flex-wrap: wrap; gap: 4px; } + .ann-type-btn { display: flex; align-items: center; gap: 4px; background: var(--bg-primary); border: 2px solid var(--ann-color, #475569); color: var(--ann-color, #94a3b8); padding: 4px 10px; border-radius: 16px; cursor: pointer; font-size: 0.75rem; transition: all 0.15s; } + .ann-type-btn:hover { background: color-mix(in srgb, var(--ann-color, #475569) 15%, transparent); } + .ann-type-btn.selected { background: color-mix(in srgb, var(--ann-color, #475569) 25%, transparent); font-weight: 600; } + .ann-type-icon { font-size: 0.85rem; } + .ann-type-label { font-size: 0.7rem; } + .ann-form-fields { display: flex; flex-direction: column; gap: 6px; } + .ann-author-input, .ann-body-input { width: 100%; background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 6px 8px; border-radius: 6px; font-size: 0.8rem; font-family: inherit; } + .ann-body-input { resize: vertical; } + .ann-char-count { font-size: 0.6rem; color: var(--text-muted); text-align: right; } + .ann-submit-btn { align-self: flex-end; } +`; + +// ─── Utilities ────────────────────────────────────────────────────────────── + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function formatTime(iso: string): string { + try { + const d = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } catch { + return ''; + } +} diff --git a/web/src/pages/evolution.ts b/web/src/pages/evolution.ts index fd99fcd..2d7fc9c 100644 --- a/web/src/pages/evolution.ts +++ b/web/src/pages/evolution.ts @@ -654,6 +654,10 @@ function renderStatistics(container: HTMLElement, totals: Totals): void {
Evolved in Top 10
${totals.evolved_in_top_10}
+
+
Mutations / Hour
+
${totals.mutations_per_hour ?? 0}
+
`; } diff --git a/web/src/pages/replay.ts b/web/src/pages/replay.ts index 0a05a59..81f3993 100644 --- a/web/src/pages/replay.ts +++ b/web/src/pages/replay.ts @@ -60,16 +60,13 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
Win Probability
- + - +
-
- — Player 0 - -- Player 1 -
+
@@ -176,6 +173,7 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
Space Play/Pause Step + [] Prev/Next Critical HomeEnd First/Last
@@ -242,9 +240,7 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): .critical-moment-nav .btn:disabled { opacity: 0.4; cursor: not-allowed; } .critical-moment-info { color: var(--text-muted); font-size: 0.8rem; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .win-prob-container { width: 100%; overflow: hidden; border-radius: 4px; } - .win-prob-legend { display: flex; gap: 16px; margin-top: 6px; font-size: 0.75rem; } - .wp-legend-p0 { color: #3b82f6; } - .wp-legend-p1 { color: #ef4444; } + .win-prob-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; font-size: 0.75rem; font-family: monospace; } .commentary-bar { background-color: var(--bg-secondary); border-radius: 8px; padding: 8px 12px; margin-top: 10px; display: flex; align-items: center; gap: 10px; min-height: 40px; } .commentary-content { flex: 1; min-width: 0; } .commentary-text { color: var(--text-secondary); font-size: 0.875rem; line-height: 1.4; display: block; } @@ -342,11 +338,10 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { const infoReason = document.getElementById('info-reason') as HTMLElement; const winProbSection = document.getElementById('win-prob-section') as HTMLDivElement; const winProbContainer = document.getElementById('win-prob-container') as HTMLDivElement; + const winProbLegend = document.getElementById('win-prob-legend') as HTMLDivElement; const prevCriticalBtn = document.getElementById('prev-critical-btn') as HTMLButtonElement; const nextCriticalBtn = document.getElementById('next-critical-btn') as HTMLButtonElement; const criticalMomentInfo = document.getElementById('critical-moment-info') as HTMLSpanElement; - const wpP0Label = document.getElementById('wp-p0-label') as HTMLSpanElement; - const wpP1Label = document.getElementById('wp-p1-label') as HTMLSpanElement; const commentaryBar = document.getElementById('commentary-bar') as HTMLDivElement; const commentaryText = document.getElementById('commentary-text') as HTMLSpanElement; const commentaryToggle = document.getElementById('commentary-toggle') as HTMLButtonElement; @@ -631,28 +626,42 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { return; } - const points = replay.win_prob.map((pair: any, t: number) => ({ + // Map win_prob: number[][] → WinProbPoint[] (one probs array per turn) + const points = replay.win_prob.map((probs: number[], t: number) => ({ turn: t, - p0WinProb: pair[0] ?? 0.5, - p1WinProb: pair[1] ?? 0.5, - drawProb: Math.max(0, 1 - (pair[0] ?? 0.5) - (pair[1] ?? 0.5)), + probs: probs.slice(), // copy to avoid mutation })); criticalMoments = replay.critical_moments ?? []; + // Build player colors array matching the viewer's palette + const playerColors = replay.players.map((_: any, idx: number) => { + const palettes = [ + '#332288', '#88ccee', '#44aa99', '#117733', '#999933', '#ddcc77', + '#882255', '#cc6677', + ]; + return palettes[idx] ?? '#888888'; + }); + viewer.setWinProbabilityData(points); viewer.setCriticalMoments(criticalMoments); + viewer.setWinProbPlayerColors(playerColors); winProbSection.style.display = 'block'; - if (replay.players.length >= 1) wpP0Label.textContent = `— ${replay.players[0].name}`; - if (replay.players.length >= 2) wpP1Label.textContent = `-- ${replay.players[1].name}`; + // Dynamic legend: one entry per player + winProbLegend.innerHTML = replay.players.map((player: any, idx: number) => { + const color = playerColors[idx]; + const dash = idx === 0 ? '—' : '--'; + return `${dash} ${player.name}`; + }).join(' '); winProbContainer.innerHTML = ''; viewer.createWinProbSparkline(winProbContainer, 800, 70, (turn: number) => { viewer.setTurn(turn); updateUI(); updateEventLog(); + updateCriticalMomentNav(); }); updateCriticalMomentNav(); @@ -676,7 +685,7 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { } } - prevCriticalBtn.addEventListener('click', () => { + function navigateToPrevCriticalMoment(): void { const currentTurn = viewer.getTurn(); const prev = [...criticalMoments].reverse().find((m: any) => m.turn < currentTurn); if (prev) { @@ -685,9 +694,9 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { updateEventLog(); criticalMomentInfo.textContent = prev.description; } - }); + } - nextCriticalBtn.addEventListener('click', () => { + function navigateToNextCriticalMoment(): void { const currentTurn = viewer.getTurn(); const next = criticalMoments.find((m: any) => m.turn > currentTurn); if (next) { @@ -696,7 +705,10 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { updateEventLog(); criticalMomentInfo.textContent = next.description; } - }); + } + + prevCriticalBtn.addEventListener('click', navigateToPrevCriticalMoment); + nextCriticalBtn.addEventListener('click', navigateToNextCriticalMoment); fileInput.addEventListener('change', async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; @@ -792,9 +804,10 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { viewer.onTurnChange = () => { updateUI(); updateEventLog(); - if (criticalMoments.length > 0) updateCriticalMomentNav(); + updateCriticalMomentNav(); updateMobileUI(); updateMobileTimeline(); + viewer.refreshWinProbSparkline(); }; viewer.onDebugChange = (debug: Record | null) => { updateDebugDisplay(debug); @@ -965,6 +978,14 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { updateUI(); updateEventLog(); break; + case 'BracketLeft': + e.preventDefault(); + navigateToPrevCriticalMoment(); + break; + case 'BracketRight': + e.preventDefault(); + navigateToNextCriticalMoment(); + break; } }); diff --git a/web/src/replay-viewer.ts b/web/src/replay-viewer.ts index 56e431b..b839f09 100644 --- a/web/src/replay-viewer.ts +++ b/web/src/replay-viewer.ts @@ -136,9 +136,7 @@ function drawEffects(ctx: CanvasRenderingContext2D): void { // Win probability point for sparkline export interface WinProbPoint { turn: number; - p0WinProb: number; - p1WinProb: number; - drawProb?: number; + probs: number[]; // one probability per player (0.0–1.0) } export interface CriticalMomentMarker { @@ -147,6 +145,18 @@ export interface CriticalMomentMarker { description: string; } +// Default player colors for sparkline (matches DEFAULT_PLAYER_COLORS) +const SPARKLINE_COLORS = [ + '#3b82f6', // Blue + '#ef4444', // Red + '#22c55e', // Green + '#f59e0b', // Amber + '#8b5cf6', // Purple + '#06b6d4', // Cyan + '#ec4899', // Pink + '#f97316', // Orange +]; + // Render win probability sparkline to canvas export function renderWinProbSparkline( ctx: CanvasRenderingContext2D, @@ -155,12 +165,15 @@ export function renderWinProbSparkline( options: { width: number; height: number; - color0?: string; - color1?: string; + playerColors?: string[]; criticalMoments?: CriticalMomentMarker[]; }, ): void { - const { width, height, color0 = '#3b82f6', color1 = '#ef4444', criticalMoments = [] } = options; + const { + width, height, + playerColors = SPARKLINE_COLORS, + criticalMoments = [], + } = options; const padding = { top: 8, bottom: 8, left: 4, right: 4 }; const chartW = width - padding.left - padding.right; const chartH = height - padding.top - padding.bottom; @@ -176,6 +189,7 @@ export function renderWinProbSparkline( ctx.fillRect(0, 0, width, height); const maxTurn = points[points.length - 1].turn; + const numPlayers = points[0].probs.length; const x = (turn: number) => padding.left + (turn / maxTurn) * chartW; const y = (prob: number) => padding.top + chartH * (1 - prob); @@ -191,10 +205,19 @@ export function renderWinProbSparkline( ctx.stroke(); ctx.setLineDash([]); + // 0% and 100% labels + ctx.fillStyle = '#475569'; + ctx.font = '8px monospace'; + ctx.textAlign = 'right'; + ctx.fillText('100%', padding.left + 28, padding.top + 6); + ctx.fillText('0%', padding.left + 22, height - padding.bottom - 1); + // Critical moment markers — dashed vertical lines with delta labels for (const moment of criticalMoments) { const mx = x(moment.turn); - const markerColor = moment.delta > 0 ? color0 : color1; + const markerColor = moment.delta > 0 + ? playerColors[0] ?? SPARKLINE_COLORS[0] + : playerColors[1] ?? SPARKLINE_COLORS[1]; ctx.strokeStyle = markerColor + 'aa'; ctx.lineWidth = 1.5; @@ -225,44 +248,41 @@ export function renderWinProbSparkline( ctx.fillText(label, Math.max(18, Math.min(width - 18, mx)), padding.top + 7); } - // P0 area fill - ctx.beginPath(); - ctx.moveTo(padding.left, y(0.5)); - for (const pt of points) { - ctx.lineTo(x(pt.turn), y(pt.p0WinProb)); + // Area fill for first two players (creates the visual gradient) + if (numPlayers >= 2) { + ctx.beginPath(); + ctx.moveTo(padding.left, y(points[0].probs[0])); + for (const pt of points) { + ctx.lineTo(x(pt.turn), y(pt.probs[0])); + } + ctx.lineTo(width - padding.right, y(points[points.length - 1].probs[1])); + for (let i = points.length - 1; i >= 0; i--) { + ctx.lineTo(x(points[i].turn), y(points[i].probs[1])); + } + ctx.closePath(); + const grad = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom); + grad.addColorStop(0, (playerColors[0] ?? SPARKLINE_COLORS[0]) + '33'); + grad.addColorStop(0.5, 'transparent'); + grad.addColorStop(1, (playerColors[1] ?? SPARKLINE_COLORS[1]) + '33'); + ctx.fillStyle = grad; + ctx.fill(); } - ctx.lineTo(width - padding.right, y(0.5)); - ctx.closePath(); - const grad = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom); - grad.addColorStop(0, color0 + '44'); - grad.addColorStop(0.5, 'transparent'); - grad.addColorStop(1, color1 + '44'); - ctx.fillStyle = grad; - ctx.fill(); - // P0 line - ctx.beginPath(); - for (let i = 0; i < points.length; i++) { - const pt = points[i]; - if (i === 0) ctx.moveTo(x(pt.turn), y(pt.p0WinProb)); - else ctx.lineTo(x(pt.turn), y(pt.p0WinProb)); + // Draw a line per player + for (let p = numPlayers - 1; p >= 0; p--) { + const color = playerColors[p] ?? SPARKLINE_COLORS[p % SPARKLINE_COLORS.length]; + ctx.beginPath(); + for (let i = 0; i < points.length; i++) { + const pt = points[i]; + if (i === 0) ctx.moveTo(x(pt.turn), y(pt.probs[p])); + else ctx.lineTo(x(pt.turn), y(pt.probs[p])); + } + ctx.strokeStyle = color; + ctx.lineWidth = p === 0 ? 2 : 1.5; + if (p > 1) ctx.setLineDash([4, 3]); + ctx.stroke(); + ctx.setLineDash([]); } - ctx.strokeStyle = color0; - ctx.lineWidth = 2; - ctx.stroke(); - - // P1 line (dashed) - ctx.beginPath(); - for (let i = 0; i < points.length; i++) { - const pt = points[i]; - if (i === 0) ctx.moveTo(x(pt.turn), y(pt.p1WinProb)); - else ctx.lineTo(x(pt.turn), y(pt.p1WinProb)); - } - ctx.strokeStyle = color1; - ctx.lineWidth = 1.5; - ctx.setLineDash([4, 3]); - ctx.stroke(); - ctx.setLineDash([]); // Current turn marker const curX = x(currentTurn); @@ -273,16 +293,19 @@ export function renderWinProbSparkline( ctx.lineTo(curX, height - padding.bottom); ctx.stroke(); - // Current probability dot + // Current probability dots for all players const curPt = points.find(p => p.turn >= currentTurn) ?? points[points.length - 1]; if (curPt) { - ctx.beginPath(); - ctx.arc(curX, y(curPt.p0WinProb), 4, 0, Math.PI * 2); - ctx.fillStyle = curPt.p0WinProb > 0.5 ? color0 : color1; - ctx.fill(); - ctx.strokeStyle = '#ffffff'; - ctx.lineWidth = 1; - ctx.stroke(); + for (let p = 0; p < curPt.probs.length; p++) { + const color = playerColors[p] ?? SPARKLINE_COLORS[p % SPARKLINE_COLORS.length]; + ctx.beginPath(); + ctx.arc(curX, y(curPt.probs[p]), 4, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1; + ctx.stroke(); + } } } @@ -1741,6 +1764,7 @@ export class ReplayViewer { private winProbData: WinProbPoint[] | null = null; private winProbCanvas: HTMLCanvasElement | null = null; private winProbCriticalMoments: CriticalMomentMarker[] = []; + private winProbPlayerColors: string[] = []; setWinProbabilityData(points: WinProbPoint[]): void { this.winProbData = points; @@ -1760,6 +1784,18 @@ export class ReplayViewer { return this.winProbCriticalMoments; } + // Set player colors used in the sparkline (must call before createWinProbSparkline) + setWinProbPlayerColors(colors: string[]): void { + this.winProbPlayerColors = colors; + } + + // Re-render the sparkline at the current turn (call from onTurnChange) + refreshWinProbSparkline(): void { + if (this.winProbCanvas && this.winProbData) { + this.renderWinProbSparkline(); + } + } + // Create and attach a win probability sparkline canvas below the main viewer. // Pass onTurnClick to enable click-to-scrub: clicking anywhere on the sparkline // calls onTurnClick with the nearest turn number. @@ -1806,8 +1842,7 @@ export class ReplayViewer { renderWinProbSparkline(ctx, this.winProbData, this.currentTurn, { width: this.winProbCanvas.width, height: this.winProbCanvas.height, - color0: this.accessibility.highContrast ? '#0000ff' : '#3b82f6', - color1: this.accessibility.highContrast ? '#ff0000' : '#ef4444', + playerColors: this.winProbPlayerColors, criticalMoments: this.winProbCriticalMoments, }); } diff --git a/web/src/types.ts b/web/src/types.ts index e4e4bc0..ff073be 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -279,3 +279,120 @@ export interface PredictionLeaderboard { updated_at: string; leaders: PredictorStats[]; } + +// Evolution live.json schema (plan §14) — real-time dashboard feed from acb-evolver + +export interface EvolutionIslandStat { + population: number; + best_rating: number; + best_bot: string; + language_div?: string; +} + +export interface EvolutionParentInfo { + id: string; + rating: number; +} + +export interface EvolutionStageResult { + passed: boolean; + time_ms: number; + error?: string; +} + +export interface EvolutionValidationStatus { + syntax?: EvolutionStageResult; + schema?: EvolutionStageResult; + smoke?: EvolutionStageResult; +} + +export interface EvaluationMatchResult { + opponent: string; + won: boolean; + score: string; +} + +export interface EvolutionEvaluationStatus { + matches_total: number; + matches_played: number; + results: EvaluationMatchResult[]; +} + +export interface EvolutionCandidate { + id: string; + island: string; + language: string; + parents: EvolutionParentInfo[]; + validation?: EvolutionValidationStatus; + evaluation?: EvolutionEvaluationStatus; +} + +export interface EvolutionCycleInfo { + generation: number; + started_at: string; + phase: string; // generating, validating, evaluating, promoting, idle + candidate?: EvolutionCandidate; +} + +export interface EvolutionActivityEntry { + time: string; + generation: number; + candidate: string; + island: string; + result: string; // promoted, rejected + reason: string; + stage: string; // validation, promotion, deployment + bot_id?: string; + initial_rating?: number; +} + +export interface EvolutionTotals { + generations_total: number; + candidates_today: number; + promoted_today: number; + promotion_rate_7d: number; + highest_evolved_rating: number; + evolved_in_top_10: number; + mutations_per_hour: number; +} + +export interface EvolutionGenerationEntry { + generation: number; + island: string; + evaluated_at: string; + count: number; + promoted: number; + best_fitness: number; + avg_fitness: number; +} + +export interface EvolutionLineageNode { + id: number; + parent_ids: number[]; + generation: number; + island: string; + fitness: number; + promoted: boolean; + language: string; + created_at: string; +} + +export interface EvolutionMetaSnapshot { + generation: number; + island_counts: Record; + island_best_fitness: Record; +} + +export interface LiveJSON { + updated_at: string; + cycle?: EvolutionCycleInfo; + recent_activity?: EvolutionActivityEntry[]; + islands: Record; + totals: EvolutionTotals; + // Legacy fields for backward compatibility + total_programs?: number; + promoted_count?: number; + generation_log?: EvolutionGenerationEntry[]; + lineage?: EvolutionLineageNode[]; + meta_snapshots?: EvolutionMetaSnapshot[]; +}