phase-9: implement match event timeline with computed events

- Client-side event extraction from replay turn data
- Icon ribbon overlaid on replay viewer timeline
- Click-to-jump to event moment
- Computed events: mass death (5+ bots), spawn wave (3+ spawns),
  momentum shift (win prob crosses 50%), critical moment (>15% shift)
- Energy milestone detection (3+ energy collected)
- Hover tooltips with event descriptions
- Updated icons matching plan §14.8 specification

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-08 11:39:04 -04:00
parent b93ea06d4c
commit 199a2ea0fe
3 changed files with 163 additions and 21 deletions

View file

@ -1 +1 @@
0c223aa10dee97194b2eb7d8c62dada24226e7f5
b93ea06d4c202f3d1ff53b6ccb2decb3bb522ba8

View file

@ -1,4 +1,5 @@
// Event Timeline Ribbon - Visual event strip with click-to-jump
// §14.8 Match Event Timeline - client-side event extraction with computed events
import type { GameEvent } from '../types';
import type { Annotation } from './annotation';
@ -7,17 +8,23 @@ export interface TimelineEvent {
type: string;
icon: string;
color: string;
description: string;
}
// Map event types to icons and colors
const EVENT_CONFIG: Record<string, { icon: string; color: string }> = {
bot_spawned: { icon: '◆', color: '#22c55e' },
bot_died: { icon: '✕', color: '#ef4444' },
combat_death: { icon: '⚔', color: '#f97316' },
collision_death: { icon: '💥', color: '#eab308' },
energy_collected: { icon: '★', color: '#fbbf24' },
core_captured: { icon: '◉', color: '#3b82f6' },
core_destroyed: { icon: '⊘', color: '#6b7280' },
// Map event types to icons and colors (matching plan §14.8)
const EVENT_CONFIG: Record<string, { icon: string; color: string; label: string }> = {
// Raw game events
bot_spawned: { icon: '🐣', color: '#22c55e', label: 'Spawn' },
bot_died: { icon: '💀', color: '#ef4444', label: 'Death' },
energy_collected: { icon: '💎', color: '#fbbf24', label: 'Energy' },
core_captured: { icon: '🏰', color: '#3b82f6', label: 'Core' },
core_destroyed: { icon: '🏰', color: '#6b7280', label: 'Core' },
// Computed events (§14.8)
combat: { icon: '⚔️', color: '#f97316', label: 'Combat' },
mass_death: { icon: '💀', color: '#dc2626', label: 'Mass Death' },
spawn_wave: { icon: '🐣', color: '#22c55e', label: 'Spawn Wave' },
momentum_shift: { icon: '📈', color: '#8b5cf6', label: 'Momentum' },
critical_moment: { icon: '🌟', color: '#fbbf24', label: 'Critical' },
};
export class EventTimeline {
@ -34,22 +41,137 @@ export class EventTimeline {
this.render();
}
// Extract events from replay turns
setEvents(turns: { turn: number; events?: GameEvent[] }[]): void {
// Extract events from replay turns with client-side computed events (§14.8)
setEvents(
turns: { turn: number; events?: GameEvent[]; energy_collected?: any }[],
winProb?: number[][]
): void {
this.events = [];
this.totalTurns = turns.length;
for (const turnData of turns) {
const turnEvents = turnData.events ?? [];
for (const event of turnEvents) {
const config = EVENT_CONFIG[event.type] || { icon: '•', color: '#94a3b8' };
const turn = turnData.turn;
const rawEvents = turnData.events ?? [];
// Count deaths this turn
const deaths = rawEvents.filter((e: GameEvent) => e.type === 'bot_died').length;
// Count spawns this turn
const spawns = rawEvents.filter((e: GameEvent) => e.type === 'bot_spawned').length;
// Count energy collected per player
const energyByPlayer = new Map<number, number>();
for (const e of rawEvents) {
if (e.type === 'energy_collected') {
const owner = (e.details as any)?.owner ?? 0;
energyByPlayer.set(owner, (energyByPlayer.get(owner) ?? 0) + 1);
}
}
const maxEnergy = Math.max(0, ...energyByPlayer.values());
// Add raw game events
for (const event of rawEvents) {
const config = EVENT_CONFIG[event.type] || { icon: '•', color: '#94a3b8', label: 'Event' };
this.events.push({
turn: turnData.turn,
turn,
type: event.type,
icon: config.icon,
color: config.color,
description: `${config.label}: turn ${turn}`,
});
}
// Add computed events (§14.8)
// Mass death: 5+ bots died this turn
if (deaths >= 5) {
const config = EVENT_CONFIG.mass_death;
this.events.push({
turn,
type: 'mass_death',
icon: config.icon,
color: config.color,
description: `Mass Death: ${deaths} bots eliminated`,
});
}
// Combat: 2+ bots died (but not mass death)
if (deaths >= 2 && deaths < 5) {
const config = EVENT_CONFIG.combat;
this.events.push({
turn,
type: 'combat',
icon: config.icon,
color: config.color,
description: `Combat: ${deaths} bots killed`,
});
}
// Spawn wave: 3+ bots spawned this turn
if (spawns >= 3) {
const config = EVENT_CONFIG.spawn_wave;
this.events.push({
turn,
type: 'spawn_wave',
icon: config.icon,
color: config.color,
description: `Spawn Wave: ${spawns} new bots`,
});
}
// Energy milestone: player collected 3+ energy
if (maxEnergy >= 3) {
const config = EVENT_CONFIG.energy_collected;
this.events.push({
turn,
type: 'energy_milestone',
icon: config.icon,
color: config.color,
description: `Energy: ${maxEnergy} collected`,
});
}
// Momentum shift: win probability crossed 50%
if (winProb && winProb[turn] && winProb[turn - 1]) {
const prevProb = winProb[turn - 1];
const currProb = winProb[turn];
// Check if any player crossed 50%
for (let p = 0; p < currProb.length; p++) {
const prevWasAbove = prevProb[p] > 0.5;
const currIsAbove = currProb[p] > 0.5;
if (prevWasAbove !== currIsAbove) {
const config = EVENT_CONFIG.momentum_shift;
this.events.push({
turn,
type: 'momentum_shift',
icon: config.icon,
color: config.color,
description: `Momentum Shift: player ${p}`,
});
break; // Only add one marker per turn
}
}
}
// Critical moment: win probability shifted >15%
if (winProb && winProb[turn] && winProb[turn - 1]) {
const prevProb = winProb[turn - 1];
const currProb = winProb[turn];
for (let p = 0; p < currProb.length; p++) {
const delta = Math.abs(currProb[p] - prevProb[p]);
if (delta > 0.15) {
const config = EVENT_CONFIG.critical_moment;
this.events.push({
turn,
type: 'critical_moment',
icon: config.icon,
color: config.color,
description: `Critical: ${(delta * 100).toFixed(0)}% shift`,
});
break; // Only add one marker per turn
}
}
}
}
this.render();
@ -80,8 +202,9 @@ export class EventTimeline {
<div class="timeline-event"
data-index="${idx}"
data-turn="${e.turn}"
data-tooltip="${e.description}"
style="left: ${leftPercent}%; color: ${e.color}"
title="Turn ${e.turn}: ${e.type.replace(/_/g, ' ')}">
title="Turn ${e.turn}: ${e.description}">
${e.icon}
</div>
`;
@ -199,7 +322,7 @@ export const EVENT_TIMELINE_STYLES = `
.timeline-track {
position: relative;
height: 28px;
height: 32px;
background-color: var(--bg-tertiary, #334155);
border-radius: 4px;
cursor: pointer;
@ -221,7 +344,7 @@ export const EVENT_TIMELINE_STYLES = `
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
font-size: 14px;
font-size: 16px;
cursor: pointer;
transition: transform 0.15s, text-shadow 0.15s;
z-index: 1;
@ -231,11 +354,29 @@ export const EVENT_TIMELINE_STYLES = `
.timeline-event:hover {
transform: translate(-50%, -50%) scale(1.4);
text-shadow: 0 0 4px currentColor;
z-index: 10;
}
.timeline-event:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
white-space: nowrap;
pointer-events: none;
margin-bottom: 4px;
}
.timeline-event.active {
transform: translate(-50%, -50%) scale(1.5);
text-shadow: 0 0 6px currentColor;
z-index: 5;
}
.timeline-turn-label {

View file

@ -983,12 +983,13 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
updateEventLog();
},
});
// Extract events from replay turns and feed to timeline
// Extract events from replay turns and feed to timeline with win probability data
const timelineTurns = replay.turns.map((t: any, i: number) => ({
turn: i,
events: t.events ?? [],
energy_collected: t.energy_collected ?? [],
}));
eventTimeline.setEvents(timelineTurns);
eventTimeline.setEvents(timelineTurns, replay.win_prob);
timelineContainer.style.display = '';
}