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>
283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
// Replay enrichment: template-based AI commentary for featured matches.
|
|
//
|
|
// Commentary is generated from replay event data using a curated set of
|
|
// narrative templates. For production, these can be enhanced with an LLM
|
|
// by POST-ing the context to /api/commentary.
|
|
|
|
import type { WinProbPoint, CriticalMoment } from './win-probability';
|
|
|
|
export interface CommentaryLine {
|
|
turn: number;
|
|
text: string;
|
|
importance: 'low' | 'medium' | 'high';
|
|
type: 'action' | 'analysis' | 'color' | 'milestonecomment';
|
|
}
|
|
|
|
export interface MatchCommentary {
|
|
matchId: string;
|
|
intro: string;
|
|
lines: CommentaryLine[];
|
|
outro: string;
|
|
generatedAt: string;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Commentary generator
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function generateCommentary(
|
|
replay: any,
|
|
winProb: WinProbPoint[],
|
|
criticalMoments: CriticalMoment[],
|
|
playerNames?: string[],
|
|
): MatchCommentary {
|
|
const p0 = playerNames?.[0] ?? replay.players?.[0]?.name ?? 'Player 0';
|
|
const p1 = playerNames?.[1] ?? replay.players?.[1]?.name ?? 'Player 1';
|
|
const totalTurns = replay.result?.turns ?? replay.turns?.length ?? 0;
|
|
const winner = replay.result?.winner ?? -1;
|
|
const reason = replay.result?.reason ?? 'unknown';
|
|
|
|
const lines: CommentaryLine[] = [];
|
|
|
|
// Intro
|
|
const intro = pickTemplate(INTROS, { p0, p1, turns: totalTurns, reason });
|
|
|
|
// Scan turns for notable events
|
|
const turns = replay.turns ?? [];
|
|
let prevP0Prob = 0.5;
|
|
|
|
for (const turn of turns) {
|
|
const t = turn.turn;
|
|
const events: any[] = turn.events ?? [];
|
|
|
|
for (const ev of events) {
|
|
switch (ev.type) {
|
|
case 'bot_died':
|
|
if (events.filter((e: any) => e.type === 'bot_died').length >= 3 && lines.every(l => l.turn !== t)) {
|
|
lines.push({
|
|
turn: t,
|
|
text: pickTemplate(MASS_KILL_TEMPLATES, { p0, p1, count: events.filter((e: any) => e.type === 'bot_died').length }),
|
|
importance: 'medium',
|
|
type: 'action',
|
|
});
|
|
}
|
|
break;
|
|
case 'core_captured':
|
|
lines.push({
|
|
turn: t,
|
|
text: pickTemplate(CORE_CAPTURE_TEMPLATES, {
|
|
p0, p1,
|
|
capturer: ev.details?.captureOwner === 0 ? p0 : p1,
|
|
victim: ev.details?.coreOwner === 0 ? p0 : p1,
|
|
}),
|
|
importance: 'high',
|
|
type: 'action',
|
|
});
|
|
break;
|
|
case 'bot_spawned':
|
|
if (t % 20 === 0) { // Only comment on spawns occasionally
|
|
lines.push({
|
|
turn: t,
|
|
text: pickTemplate(SPAWN_TEMPLATES, {
|
|
player: ev.details?.owner === 0 ? p0 : p1,
|
|
}),
|
|
importance: 'low',
|
|
type: 'color',
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Probability-based commentary
|
|
const probPoint = winProb.find(wp => wp.turn === t);
|
|
if (probPoint) {
|
|
const delta = probPoint.p0WinProb - prevP0Prob;
|
|
if (Math.abs(delta) >= 0.2) {
|
|
lines.push({
|
|
turn: t,
|
|
text: pickTemplate(PROB_SWING_TEMPLATES, {
|
|
p0, p1,
|
|
leading: delta > 0 ? p0 : p1,
|
|
trailing: delta > 0 ? p1 : p0,
|
|
prob: Math.round(Math.max(probPoint.p0WinProb, probPoint.p1WinProb) * 100),
|
|
}),
|
|
importance: 'medium',
|
|
type: 'analysis',
|
|
});
|
|
}
|
|
prevP0Prob = probPoint.p0WinProb;
|
|
}
|
|
|
|
// Milestone turns
|
|
if (t === Math.floor(totalTurns * 0.25)) {
|
|
const p0Bots = turn.bots?.filter((b: any) => b.alive && b.owner === 0).length ?? 0;
|
|
const p1Bots = turn.bots?.filter((b: any) => b.alive && b.owner === 1).length ?? 0;
|
|
lines.push({
|
|
turn: t,
|
|
text: pickTemplate(QUARTER_TEMPLATES, { p0, p1, p0Bots, p1Bots }),
|
|
importance: 'medium',
|
|
type: 'milestonecomment',
|
|
});
|
|
}
|
|
if (t === Math.floor(totalTurns * 0.5)) {
|
|
const p0Score = turn.scores?.[0] ?? 0;
|
|
const p1Score = turn.scores?.[1] ?? 0;
|
|
lines.push({
|
|
turn: t,
|
|
text: pickTemplate(HALFWAY_TEMPLATES, { p0, p1, p0Score, p1Score }),
|
|
importance: 'medium',
|
|
type: 'milestonecomment',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add critical moments that aren't already covered
|
|
for (const cm of criticalMoments) {
|
|
if (!lines.find(l => l.turn === cm.turn)) {
|
|
lines.push({
|
|
turn: cm.turn,
|
|
text: cm.description,
|
|
importance: 'high',
|
|
type: 'analysis',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by turn
|
|
lines.sort((a, b) => a.turn - b.turn);
|
|
|
|
// Outro
|
|
const outro = buildOutro({ winner, p0, p1, reason, totalTurns });
|
|
|
|
return {
|
|
matchId: replay.match_id,
|
|
intro,
|
|
lines,
|
|
outro,
|
|
generatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Template rendering
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
function pickTemplate(templates: string[], vars: Record<string, any>): string {
|
|
const tmpl = templates[Math.floor(Math.random() * templates.length)];
|
|
return tmpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? `{${k}}`));
|
|
}
|
|
|
|
function buildOutro(vars: { winner: number; p0: string; p1: string; reason: string; totalTurns: number }): string {
|
|
if (vars.winner < 0) return pickTemplate(DRAW_OUTROS, vars);
|
|
const winnerName = vars.winner === 0 ? vars.p0 : vars.p1;
|
|
const loserName = vars.winner === 0 ? vars.p1 : vars.p0;
|
|
return pickTemplate(WIN_OUTROS, { ...vars, winner: winnerName, loser: loserName });
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Commentary renderer (HTML)
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function renderCommentaryPanel(container: HTMLElement, commentary: MatchCommentary, currentTurn?: number): void {
|
|
const lines = currentTurn !== undefined
|
|
? commentary.lines.filter(l => l.turn <= currentTurn)
|
|
: commentary.lines;
|
|
|
|
container.innerHTML = `
|
|
<div class="commentary-panel">
|
|
<p class="commentary-intro">${escapeHtml(commentary.intro)}</p>
|
|
<div class="commentary-feed">
|
|
${lines.slice(-10).reverse().map(l => `
|
|
<div class="commentary-line importance-${l.importance}">
|
|
<span class="commentary-turn">Turn ${l.turn}</span>
|
|
<span class="commentary-text">${escapeHtml(l.text)}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
${currentTurn !== undefined && currentTurn >= (commentary.lines[commentary.lines.length - 1]?.turn ?? 0) - 5
|
|
? `<p class="commentary-outro">${escapeHtml(commentary.outro)}</p>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
export const COMMENTARY_STYLES = `
|
|
<style>
|
|
.commentary-panel { font-size: 0.875rem; }
|
|
.commentary-intro { color: var(--text-muted); margin-bottom: 12px; font-style: italic; }
|
|
.commentary-outro { color: var(--text-primary); margin-top: 12px; font-weight: 600; }
|
|
.commentary-feed { display: flex; flex-direction: column; gap: 6px; }
|
|
.commentary-line { display: flex; gap: 10px; padding: 6px 10px; border-radius: 4px; border-left: 3px solid transparent; }
|
|
.commentary-line.importance-high { background: rgba(59,130,246,0.1); border-color: var(--accent); }
|
|
.commentary-line.importance-medium { background: rgba(245,158,11,0.08); border-color: var(--warning); }
|
|
.commentary-line.importance-low { background: var(--bg-primary); border-color: var(--bg-tertiary); }
|
|
.commentary-turn { color: var(--text-muted); min-width: 52px; font-size: 0.75rem; padding-top: 2px; }
|
|
.commentary-text { color: var(--text-secondary); flex: 1; }
|
|
</style>
|
|
`;
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Template banks
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
const INTROS = [
|
|
"Welcome to this clash between {p0} and {p1} on a {turns}-turn battlefield. May the best algorithm win!",
|
|
"It's {p0} vs {p1} in what promises to be a tactical showdown. {turns} turns stand between them and glory.",
|
|
"Two bots enter, one leaves victorious. {p0} and {p1} face off in a contest of strategy and speed.",
|
|
"The grid is set, the bots are ready. {p0} against {p1} — {turns} turns to prove dominance.",
|
|
"In the arena of silicon and logic, {p0} squares up against {p1}. Let the match begin!",
|
|
];
|
|
|
|
const MASS_KILL_TEMPLATES = [
|
|
"Carnage on the grid! {count} bots fall in rapid succession — neither side escapes unscathed.",
|
|
"A fierce skirmish erupts, leaving {count} units destroyed in a matter of moments.",
|
|
"The battlefield runs hot as {count} bots are eliminated in a single dramatic turn.",
|
|
"Chaos reigns! {count} bots are lost in a collision of forces.",
|
|
];
|
|
|
|
const CORE_CAPTURE_TEMPLATES = [
|
|
"{capturer} strikes deep into enemy territory, razing {victim}'s core! The tactical situation shifts dramatically.",
|
|
"A bold offensive play by {capturer} — {victim}'s core falls! This could be the turning point.",
|
|
"{victim}'s core is captured by {capturer}'s forces. The tide of war is turning.",
|
|
"Critical blow! {capturer} eliminates {victim}'s core, threatening to end this match early.",
|
|
];
|
|
|
|
const SPAWN_TEMPLATES = [
|
|
"{player} is rapidly expanding its forces. Numbers could be decisive here.",
|
|
"Steady energy collection allows {player} to keep the bot production line running.",
|
|
"{player}'s economy is humming — fresh units pour onto the battlefield.",
|
|
];
|
|
|
|
const PROB_SWING_TEMPLATES = [
|
|
"The models give {leading} a {prob}% win probability now — {trailing} needs to respond quickly.",
|
|
"Statistical edge shifting toward {leading} ({prob}%). {trailing} is under pressure.",
|
|
"{leading} has established clear momentum, pushing win probability to {prob}%.",
|
|
"A {prob}% win probability for {leading} — but this grid has seen bigger comebacks.",
|
|
];
|
|
|
|
const QUARTER_TEMPLATES = [
|
|
"Quarter-point check: {p0} has {p0Bots} bots, {p1} has {p1Bots}. {p0Bots > p1Bots ? p0 + ' holds the numerical edge' : p1 + ' has the numbers advantage'}.",
|
|
"25 turns in: bot counts are {p0}:{p0Bots} vs {p1}:{p1Bots}. The positioning battle is just beginning.",
|
|
];
|
|
|
|
const HALFWAY_TEMPLATES = [
|
|
"Halfway through! Score: {p0} at {p0Score} vs {p1} at {p1Score}. {p0Score > p1Score ? p0 : p1} leads on energy collected.",
|
|
"The midpoint of the match sees {p0} scoring {p0Score} to {p1}'s {p1Score}. Still everything to play for.",
|
|
];
|
|
|
|
const WIN_OUTROS = [
|
|
"{winner} clinches it via {reason}! A commanding performance that leaves no doubt about the result.",
|
|
"Victory for {winner} by {reason} — {loser} fought hard but couldn't overcome the tactical deficit.",
|
|
"{winner} takes the match! {reason} sealed the deal in {totalTurns} turns of intense grid warfare.",
|
|
"What a match! {winner} prevails through {reason}. {loser} will need to reconsider its strategy.",
|
|
];
|
|
|
|
const DRAW_OUTROS = [
|
|
"The match ends in a draw after {totalTurns} turns! An evenly matched contest that honours both competitors.",
|
|
"Neither {p0} nor {p1} could claim dominance in {totalTurns} turns — honours even!",
|
|
"A stalemate after {totalTurns} turns. Both bots showed equal resilience on the grid.",
|
|
];
|