feat(playlists/replay): n-player win prob, annotations, evolver metrics

Playlist curation per §10 is fully implemented in the index builder:
- generatePlaylists() writes /data/playlists/index.json and {slug}.json
- curateWeeklyHighlights() selects best-of-week by upsets, elite
  clashes, marathon turns, and closest finishes (last 7 days)
- persistGeneratedPlaylists() upserts to playlists/playlist_matches DB tables
- /data/playlists/ stub files seeded for all 12 curated collections

Replay viewer improvements shipped alongside:
- WinProbPoint refactored from {p0,p1} to {probs: number[]} for N players
- renderWinProbSparkline draws one line per player with matching colors
- replay.ts updated to build probs[] from replay.win_prob arrays
- Dynamic legend generated from replay.players instead of hardcoded P0/P1

New annotation overlay component (§16.8):
- AnnotationOverlay: timeline track, per-turn list, canvas markers
- createAnnotationForm: type selector, author, body, localStorage + API
- ANNOTATION_OVERLAY_STYLES: self-contained CSS for the overlay

Evolver: add mutations_per_hour metric to Totals (live.json §14)
Types: consolidate evolution types into types.ts, re-export from api-types.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-21 17:15:31 -04:00
parent fd9ffbc048
commit d8812b98ee
8 changed files with 780 additions and 204 deletions

View file

@ -1 +1 @@
aa1c78c9d7901ce17ff0f28b5a2b89961c31d403
fd9ffbc0487ebcf54f04ac13d3eae2f0f96157cb

View file

@ -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

View file

@ -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<string, number>;
island_best_fitness: Record<string, number>;
}
// Evolution live data (plan §14 format)
export interface EvolutionLiveData {
updated_at: string;
cycle?: CycleInfo;
recent_activity?: ActivityEntry[];
islands: Record<string, IslandStat>;
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 {

View file

@ -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<Annotation[]> {
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<boolean> {
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 = '<div class="ann-overlay-empty">No annotations yet</div>';
return;
}
const turnMarkers = this.renderTimelineMarkers();
this.container.innerHTML = `
<div class="ann-overlay-header">
<span class="ann-overlay-title">Annotations</span>
<span class="ann-overlay-count">${this.annotations.length}</span>
</div>
<div class="ann-overlay-track">
${turnMarkers}
</div>
<div class="ann-overlay-list">
${this.renderCurrentTurnAnnotations()}
</div>
`;
this.wireClickHandlers();
this.updateHighlight();
}
private renderTimelineMarkers(): string {
const grouped = new Map<number, Annotation[]>();
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 ? `<span class="ann-marker-count">${anns.length}</span>` : '';
return `<div class="ann-marker" data-turn="${turn}" style="left:${pct.toFixed(1)}%;--ann-color:${color}">
<span class="ann-marker-dot"></span>${count}
</div>`;
}).join('');
}
private renderCurrentTurnAnnotations(): string {
const current = this.getAnnotationsForTurn(this.currentTurn);
if (current.length === 0) {
return '<div class="ann-no-current">No annotations at this turn</div>';
}
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 `<div class="ann-item" data-ann-id="${ann.id}">
<div class="ann-item-header">
<span class="ann-item-type" style="color:${color}">${icon} ${escapeHtml(label)}</span>
<span class="ann-item-author">${escapeHtml(ann.author)}</span>
</div>
<div class="ann-item-body">${escapeHtml(ann.body)}</div>
${pos ? `<div class="ann-item-pos">@ ${pos}</div>` : ''}
<div class="ann-item-meta">
<span class="ann-item-upvotes">${ann.upvotes} upvotes</span>
<span class="ann-item-time">${formatTime(ann.created_at)}</span>
</div>
</div>`;
}).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<number, Annotation[]>();
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 = `
<div class="ann-form">
<div class="ann-form-types">
${FEEDBACK_TYPES.map(ft =>
`<button class="ann-type-btn" data-type="${ft.type}" style="--ann-color:${ft.color}" title="${ft.label}">
<span class="ann-type-icon">${ft.icon}</span>
<span class="ann-type-label">${ft.label}</span>
</button>`,
).join('')}
</div>
<div class="ann-form-fields">
<input type="text" class="ann-author-input" placeholder="Your name" value="${escapeHtml(savedAuthor)}" maxlength="64">
<textarea class="ann-body-input" placeholder="What happened here? (max 500 chars)" maxlength="500" rows="2"></textarea>
<span class="ann-char-count">0 / 500</span>
</div>
<button class="btn primary ann-submit-btn" disabled>Add Annotation</button>
</div>
`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 '';
}
}

View file

@ -654,6 +654,10 @@ function renderStatistics(container: HTMLElement, totals: Totals): void {
<div class="stat-label">Evolved in Top 10</div>
<div class="stat-value">${totals.evolved_in_top_10}</div>
</div>
<div class="stat-card">
<div class="stat-label">Mutations / Hour</div>
<div class="stat-value">${totals.mutations_per_hour ?? 0}</div>
</div>
</div>
`;
}

View file

@ -60,16 +60,13 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
<div class="win-prob-header">
<span class="win-prob-title">Win Probability</span>
<div class="critical-moment-nav">
<button id="prev-critical-btn" class="btn" title="Previous critical moment" disabled>&#9664; Prev</button>
<button id="prev-critical-btn" class="btn" title="Previous critical moment ([)" disabled>&#9664; Prev</button>
<span id="critical-moment-info" class="critical-moment-info">&#8212;</span>
<button id="next-critical-btn" class="btn" title="Next critical moment" disabled>Next &#9654;</button>
<button id="next-critical-btn" class="btn" title="Next critical moment (])" disabled>Next &#9654;</button>
</div>
</div>
<div id="win-prob-container" class="win-prob-container"></div>
<div class="win-prob-legend">
<span id="wp-p0-label" class="wp-legend-p0">&#8212; Player 0</span>
<span id="wp-p1-label" class="wp-legend-p1">-- Player 1</span>
</div>
<div id="win-prob-legend" class="win-prob-legend"></div>
</div>
</div>
@ -176,6 +173,7 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
<div class="keyboard-shortcuts">
<kbd>Space</kbd> Play/Pause
<kbd></kbd><kbd></kbd> Step
<kbd>[</kbd><kbd>]</kbd> Prev/Next Critical
<kbd>Home</kbd><kbd>End</kbd> First/Last
</div>
</div>
@ -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 ? '&#8212;' : '--';
return `<span style="color:${color}">${dash} ${player.name}</span>`;
}).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<number, DebugInfo> | 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;
}
});

View file

@ -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.01.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,
});
}

View file

@ -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<string, number>;
island_best_fitness: Record<string, number>;
}
export interface LiveJSON {
updated_at: string;
cycle?: EvolutionCycleInfo;
recent_activity?: EvolutionActivityEntry[];
islands: Record<string, EvolutionIslandStat>;
totals: EvolutionTotals;
// Legacy fields for backward compatibility
total_programs?: number;
promoted_count?: number;
generation_log?: EvolutionGenerationEntry[];
lineage?: EvolutionLineageNode[];
meta_snapshots?: EvolutionMetaSnapshot[];
}