ai-code-battle/web/src/win-probability.ts
jedarden f5d7553f98 Add Phase 7-9 features: evolution dashboard, WASM sandbox, enhanced replay
Phase 7 Evolution:
- Add live-export subcommand to acb-evolver for dashboard JSON generation
- Export programs, stats, and generation log to live.json

Phase 8 Enhanced Features:
- Add WASM game engine build (cmd/acb-wasm/) with JS bindings
- Add in-browser sandbox page with Monaco editor (web/src/pages/sandbox.ts)
- Add win probability computation (web/src/win-probability.ts)
- Add replay commentary generator (web/src/commentary.ts)
- Add clip maker for GIF/MP4 export (web/src/pages/clip-maker.ts)
- Add rivalry detection and pages (web/src/pages/rivalries.ts)
- Add replay feedback system (web/src/pages/feedback.ts)
- Add evolution dashboard page (web/src/pages/evolution.ts)

Phase 9 Platform Depth:
- Add predictions API (cmd/acb-api/predictions.go)
- Add series management API (cmd/acb-api/series.go)
- Add seasons API (cmd/acb-api/seasons.go)
- Add narrative generator for rivalries (cmd/acb-indexer/src/narrative.ts)

Engine Updates:
- Add debug field to move response schema
- Add match event timeline extraction
- Add replay enrichment fields

Web Updates:
- Update app.html navigation for new pages
- Add API client methods for predictions, series, seasons
- Export engine types for browser use

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 01:13:23 -04:00

282 lines
10 KiB
TypeScript

// Win probability via Monte Carlo rollout from a replay snapshot.
//
// Usage:
// const wp = new WinProbabilityEngine(replay);
// const probs = await wp.computeAll(50); // run 50 simulations per turn
// const sparkline = wp.getSparkline(); // [{turn, p0, p1}]
// const critical = wp.getCriticalMoments();
import {
type Config, type Replay, type ReplayTurn,
type Move, type GameState, type Bot, type Core, type EnergyNode,
type Player, type MatchResult,
randomStrategy,
getVisibleState, executeTurn, posKey,
} from './engine';
export interface WinProbPoint {
turn: number;
p0WinProb: number;
p1WinProb: number;
drawProb: number;
}
export interface CriticalMoment {
turn: number;
description: string;
deltaP0: number; // change in p0 win probability (positive = improved for p0)
type: 'swing' | 'kill' | 'capture' | 'energy' | 'milestone';
}
export class WinProbabilityEngine {
private replay: Replay;
private points: WinProbPoint[] = [];
constructor(replay: Replay) {
this.replay = replay;
}
/**
* Compute win probability at every Nth turn using Monte Carlo rollouts.
* @param simulations number of random playouts per sampled turn
* @param step sample every N turns (default 5)
*/
async computeAll(simulations = 50, step = 5): Promise<WinProbPoint[]> {
const turns = this.replay.turns;
this.points = [];
const cfg = this.replay.config;
const maxTurn = turns.length - 1;
for (let t = 0; t <= maxTurn; t += step) {
// Allow the browser to stay responsive
if (t % (step * 10) === 0) {
await yieldToUI();
}
const prob = this.rollout(turns[t], cfg, simulations);
this.points.push({ turn: t, ...prob });
}
// Always include the last turn
if (this.points[this.points.length - 1]?.turn !== maxTurn) {
const prob = this.rollout(turns[maxTurn], cfg, simulations);
this.points.push({ turn: maxTurn, ...prob });
}
return this.points;
}
getSparkline(): WinProbPoint[] {
return this.points;
}
/**
* Returns turns where the win probability swung by >= 15 percentage points.
*/
getCriticalMoments(): CriticalMoment[] {
if (this.points.length < 2) return [];
const moments: CriticalMoment[] = [];
for (let i = 1; i < this.points.length; i++) {
const prev = this.points[i - 1];
const curr = this.points[i];
const delta = curr.p0WinProb - prev.p0WinProb;
if (Math.abs(delta) >= 0.15) {
const turnData = this.replay.turns[curr.turn];
const description = this.describeMoment(turnData, delta);
moments.push({
turn: curr.turn,
description,
deltaP0: delta,
type: classifyMoment(turnData, delta),
});
}
}
return moments;
}
// ── Private helpers ──────────────────────────────────────────────────────
private rollout(
snapshot: ReplayTurn,
cfg: Config,
simulations: number,
): { p0WinProb: number; p1WinProb: number; drawProb: number } {
let p0Wins = 0, p1Wins = 0, draws = 0;
for (let i = 0; i < simulations; i++) {
const winner = simulateFromSnapshot(snapshot, cfg, this.replay);
if (winner === 0) p0Wins++;
else if (winner === 1) p1Wins++;
else draws++;
}
return {
p0WinProb: p0Wins / simulations,
p1WinProb: p1Wins / simulations,
drawProb: draws / simulations,
};
}
private describeMoment(turn: ReplayTurn | undefined, delta: number): string {
if (!turn) return 'Probability shift';
const events = turn.events ?? [];
const kills = events.filter(e => e.type === 'bot_died').length;
const captures = events.filter(e => e.type === 'core_captured').length;
if (captures > 0) return `Core captured (${delta > 0 ? '+' : ''}${(delta * 100).toFixed(0)}% shift)`;
if (kills > 2) return `${kills} bots eliminated (${delta > 0 ? '+' : ''}${(delta * 100).toFixed(0)}% shift)`;
return `Probability swung ${delta > 0 ? '+' : ''}${(delta * 100).toFixed(0)}%`;
}
}
// ────────────────────────────────────────────────────────────────────────────
// Single-game rollout from a snapshot
// ────────────────────────────────────────────────────────────────────────────
function simulateFromSnapshot(snapshot: ReplayTurn, cfg: Config, replay: Replay): number {
// Reconstruct a lightweight game state from the snapshot
const gs = replaySnapshotToGameState(snapshot, cfg, replay);
// Run the rest of the match with random strategies
const s0 = randomStrategy;
const s1 = randomStrategy;
let result: MatchResult | null = null;
let safety = cfg.max_turns - snapshot.turn + 1;
while (!result && safety-- > 0) {
const allMoves = new Map<number, Move[]>();
for (const p of gs.players) {
const visible = getVisibleState(gs, p.id);
try {
allMoves.set(p.id, p.id === 0 ? s0(visible) : s1(visible));
} catch {
allMoves.set(p.id, []);
}
}
result = executeTurn(gs, allMoves);
}
return result?.winner ?? -1;
}
function replaySnapshotToGameState(snap: ReplayTurn, cfg: Config, replay: Replay): GameState {
const walls = new Set<string>(
(replay.map?.walls ?? []).map((p: any) => posKey(p)),
);
const bots: Bot[] = (snap.bots ?? []).map(b => ({ ...b }));
const cores: Core[] = (snap.cores ?? []).map(c => ({ ...c }));
// Reconstruct energy nodes from map + current state
const energyOnTile = new Set<string>((snap.energy ?? []).map(posKey));
const energy: EnergyNode[] = (replay.map?.energy_nodes ?? []).map((p: any) => ({
position: p,
hasEnergy: energyOnTile.has(posKey(p)),
tick: 0,
}));
const players: Player[] = (snap.scores ?? [0, 0]).map((score: number, id: number) => ({
id,
energy: snap.energy_held?.[id] ?? 0,
score,
botCount: bots.filter(b => b.alive && b.owner === id).length,
}));
return {
config: cfg,
bots,
cores,
energy,
players,
turn: snap.turn,
matchId: replay.match_id,
walls,
events: [],
dominance: new Map(),
};
}
function classifyMoment(turn: ReplayTurn | undefined, _delta: number): CriticalMoment['type'] {
if (!turn) return 'swing';
const events = turn.events ?? [];
if (events.some((e: any) => e.type === 'core_captured')) return 'capture';
if (events.filter((e: any) => e.type === 'bot_died').length > 2) return 'kill';
if (events.some((e: any) => e.type === 'energy_collected')) return 'energy';
return 'swing';
}
function yieldToUI(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0));
}
// ────────────────────────────────────────────────────────────────────────────
// SVG sparkline renderer
// ────────────────────────────────────────────────────────────────────────────
export function renderWinProbSparkline(
container: HTMLElement,
points: WinProbPoint[],
options: { width?: number; height?: number; showLegend?: boolean } = {},
): void {
const W = (options.width ?? container.clientWidth) || 400;
const H = options.height ?? 80;
const showLegend = options.showLegend ?? true;
if (points.length < 2) {
container.innerHTML = '<div style="color:var(--text-muted);font-size:0.75rem;text-align:center;padding:10px">Not enough data</div>';
return;
}
const maxTurn = points[points.length - 1].turn;
function x(turn: number): number { return (turn / maxTurn) * W; }
function y(prob: number): number { return H - prob * H; }
// Build SVG paths
const p0Path = points.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${x(pt.turn).toFixed(1)} ${y(pt.p0WinProb).toFixed(1)}`).join(' ');
const p1Path = points.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${x(pt.turn).toFixed(1)} ${y(pt.p1WinProb).toFixed(1)}`).join(' ');
// 50% line
const midY = y(0.5).toFixed(1);
const svg = `
<svg width="${W}" height="${H + (showLegend ? 20 : 0)}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="p0grad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.3"/>
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="${W}" height="${H}" fill="transparent"/>
<!-- 50% line -->
<line x1="0" y1="${midY}" x2="${W}" y2="${midY}" stroke="#475569" stroke-width="1" stroke-dasharray="4,4"/>
<!-- P0 fill -->
<path d="${p0Path} L ${W} ${H} L 0 ${H} Z" fill="url(#p0grad)"/>
<!-- P0 line -->
<path d="${p0Path}" fill="none" stroke="#3b82f6" stroke-width="2"/>
<!-- P1 line -->
<path d="${p1Path}" fill="none" stroke="#ef4444" stroke-width="2"/>
${showLegend ? `
<circle cx="12" cy="${H + 12}" r="5" fill="#3b82f6"/>
<text x="22" y="${H + 16}" fill="#94a3b8" font-size="11">Player 0</text>
<circle cx="90" cy="${H + 12}" r="5" fill="#ef4444"/>
<text x="100" y="${H + 16}" fill="#94a3b8" font-size="11">Player 1</text>
` : ''}
</svg>
`;
container.innerHTML = svg;
}
// ────────────────────────────────────────────────────────────────────────────
// Exported re-type for Replay (mirrors types.ts shape)
// ────────────────────────────────────────────────────────────────────────────
// Re-export the Replay type from engine for consumers that only import
// from win-probability
export type { Replay } from './engine';