diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index b3fc61b..5dfce5c 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -0c223aa10dee97194b2eb7d8c62dada24226e7f5 +b93ea06d4c202f3d1ff53b6ccb2decb3bb522ba8 diff --git a/web/src/components/event-timeline.ts b/web/src/components/event-timeline.ts index 167c008..c3a54a0 100644 --- a/web/src/components/event-timeline.ts +++ b/web/src/components/event-timeline.ts @@ -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 = { - 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 = { + // 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(); + 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 {
+ title="Turn ${e.turn}: ${e.description}"> ${e.icon}
`; @@ -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 { diff --git a/web/src/pages/replay.ts b/web/src/pages/replay.ts index a5e5059..72c7d6e 100644 --- a/web/src/pages/replay.ts +++ b/web/src/pages/replay.ts @@ -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 = ''; }