Add director.ts component with action density computation, speed schedule generation, and eased speed transitions. Integrate into replay viewer with Director option in speed selector, target duration presets (30s/1min/2min/5min), speed indicator display, and scrubbing pause/resume. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
285 lines
8 KiB
TypeScript
285 lines
8 KiB
TypeScript
// Director Mode: adaptive auto-speed playback per §16.10
|
||
import type { Replay, ReplayTurn } from '../types';
|
||
|
||
// Speed multiplier to ms-per-turn mapping
|
||
// "x" means that many turns per second at the base rate of 2 turns/sec at 1x
|
||
export const SPEED_MULTIPLIERS = [1, 2, 4, 8, 16] as const;
|
||
export type SpeedMultiplier = typeof SPEED_MULTIPLIERS[number];
|
||
|
||
// Base rate: 1x = 500ms/turn (2 turns/sec)
|
||
const BASE_MS_PER_TURN = 500;
|
||
|
||
export function multiplierToMs(mult: SpeedMultiplier): number {
|
||
return BASE_MS_PER_TURN / mult;
|
||
}
|
||
|
||
export function msToMultiplier(ms: number): SpeedMultiplier {
|
||
const ratio = BASE_MS_PER_TURN / ms;
|
||
let best: SpeedMultiplier = 16;
|
||
for (const m of SPEED_MULTIPLIERS) {
|
||
if (Math.abs(m - ratio) < Math.abs(best - ratio)) best = m;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// Target duration presets (in seconds)
|
||
export const DURATION_PRESETS = [30, 60, 120, 300] as const;
|
||
export type DurationPreset = typeof DURATION_PRESETS[number];
|
||
|
||
export const DURATION_LABELS: Record<DurationPreset, string> = {
|
||
30: '30s',
|
||
60: '1min',
|
||
120: '2min',
|
||
300: '5min',
|
||
};
|
||
|
||
export interface DirectorConfig {
|
||
targetDuration: DurationPreset;
|
||
}
|
||
|
||
export const DEFAULT_DIRECTOR_CONFIG: DirectorConfig = {
|
||
targetDuration: 60,
|
||
};
|
||
|
||
export function loadDirectorConfig(): DirectorConfig {
|
||
try {
|
||
const raw = localStorage.getItem('acb-director-config');
|
||
if (raw) return { ...DEFAULT_DIRECTOR_CONFIG, ...JSON.parse(raw) };
|
||
} catch {}
|
||
return { ...DEFAULT_DIRECTOR_CONFIG };
|
||
}
|
||
|
||
export function saveDirectorConfig(config: DirectorConfig): void {
|
||
try {
|
||
localStorage.setItem('acb-director-config', JSON.stringify(config));
|
||
} catch {}
|
||
}
|
||
|
||
// ── Action density computation ──────────────────────────────────────────────
|
||
|
||
export interface ActionDensity {
|
||
density: number;
|
||
deaths: number;
|
||
captures: number;
|
||
energyCollected: number;
|
||
spawns: number;
|
||
deltaWinProb: number;
|
||
}
|
||
|
||
/**
|
||
* Compute action density for a single turn using the formula from §16.10:
|
||
* action_density(turn) = deaths × 3.0 + captures × 5.0 +
|
||
* energy_collected × 0.5 + spawns × 1.0 +
|
||
* abs(delta_win_prob) × 10.0
|
||
*/
|
||
export function computeActionDensity(
|
||
turn: ReplayTurn,
|
||
_prevTurn: ReplayTurn | null,
|
||
winProb?: number[][],
|
||
turnIndex?: number,
|
||
): ActionDensity {
|
||
const events = turn.events ?? [];
|
||
|
||
let deaths = 0;
|
||
let captures = 0;
|
||
let energyCollected = 0;
|
||
let spawns = 0;
|
||
|
||
for (const event of events) {
|
||
switch (event.type) {
|
||
case 'bot_died':
|
||
case 'combat_death':
|
||
case 'collision_death':
|
||
deaths++;
|
||
break;
|
||
case 'core_captured':
|
||
case 'core_destroyed':
|
||
captures++;
|
||
break;
|
||
case 'energy_collected':
|
||
energyCollected++;
|
||
break;
|
||
case 'bot_spawned':
|
||
spawns++;
|
||
break;
|
||
}
|
||
}
|
||
|
||
let deltaWinProb = 0;
|
||
if (winProb && turnIndex != null && turnIndex > 0) {
|
||
const prev = winProb[turnIndex - 1];
|
||
const curr = winProb[turnIndex];
|
||
if (prev && curr) {
|
||
// Sum absolute delta across all players
|
||
for (let i = 0; i < prev.length; i++) {
|
||
deltaWinProb += Math.abs((curr[i] ?? 0) - (prev[i] ?? 0));
|
||
}
|
||
}
|
||
}
|
||
|
||
const density =
|
||
deaths * 3.0 +
|
||
captures * 5.0 +
|
||
energyCollected * 0.5 +
|
||
spawns * 1.0 +
|
||
deltaWinProb * 10.0;
|
||
|
||
return { density, deaths, captures, energyCollected, spawns, deltaWinProb };
|
||
}
|
||
|
||
/**
|
||
* Pre-compute action density for all turns in a replay.
|
||
* Returns an array indexed by turn index (0-based).
|
||
*/
|
||
export function computeAllDensities(replay: Replay): ActionDensity[] {
|
||
const turns = replay.turns;
|
||
const winProb = replay.win_prob;
|
||
const densities: ActionDensity[] = [];
|
||
|
||
for (let i = 0; i < turns.length; i++) {
|
||
const prev = i > 0 ? turns[i - 1] : null;
|
||
densities.push(computeActionDensity(turns[i], prev, winProb, i));
|
||
}
|
||
|
||
return densities;
|
||
}
|
||
|
||
// ── Speed mapping ───────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Map action density to a speed multiplier per §16.10:
|
||
* 0 → 16x (nothing happening)
|
||
* 0.1–1.0 → 8x (minor activity)
|
||
* 1.0–3.0 → 4x (moderate)
|
||
* 3.0–5.0 → 2x (significant)
|
||
* 5.0+ → 1x (critical)
|
||
*/
|
||
export function densityToSpeed(density: number): SpeedMultiplier {
|
||
if (density === 0) return 16;
|
||
if (density < 1.0) return 8;
|
||
if (density < 3.0) return 4;
|
||
if (density < 5.0) return 2;
|
||
return 1;
|
||
}
|
||
|
||
/**
|
||
* Compute a raw speed schedule (one multiplier per turn) from densities,
|
||
* scaled so the total approximate playback time matches the target duration.
|
||
*/
|
||
export function computeSpeedSchedule(
|
||
densities: ActionDensity[],
|
||
targetDurationSec: number,
|
||
): SpeedMultiplier[] {
|
||
const totalTurns = densities.length;
|
||
if (totalTurns === 0) return [];
|
||
|
||
// First pass: raw speeds from density
|
||
const rawSpeeds = densities.map(d => densityToSpeed(d.density));
|
||
|
||
// Compute raw total duration: sum of (base_ms / speed) for each turn
|
||
let rawTotalMs = 0;
|
||
for (const speed of rawSpeeds) {
|
||
rawTotalMs += BASE_MS_PER_TURN / speed;
|
||
}
|
||
|
||
const targetMs = targetDurationSec * 1000;
|
||
|
||
// Scale factor: if raw duration is 2x target, we need 2x all speeds
|
||
const scaleFactor = rawTotalMs > 0 ? targetMs / rawTotalMs : 1;
|
||
|
||
// Apply scale factor to speeds, clamping to valid multipliers
|
||
const schedule: SpeedMultiplier[] = rawSpeeds.map(raw => {
|
||
const scaledMs = (BASE_MS_PER_TURN / raw) * scaleFactor;
|
||
// Find closest valid multiplier
|
||
let best: SpeedMultiplier = 1;
|
||
let bestDiff = Infinity;
|
||
for (const m of SPEED_MULTIPLIERS) {
|
||
const ms = BASE_MS_PER_TURN / m;
|
||
const diff = Math.abs(ms - scaledMs);
|
||
if (diff < bestDiff) {
|
||
bestDiff = diff;
|
||
best = m;
|
||
}
|
||
}
|
||
return best;
|
||
});
|
||
|
||
return schedule;
|
||
}
|
||
|
||
// ── Eased speed transition ──────────────────────────────────────────────────
|
||
|
||
const EASE_DURATION_MS = 500;
|
||
|
||
export interface DirectorState {
|
||
enabled: boolean;
|
||
currentMultiplier: SpeedMultiplier;
|
||
targetMultiplier: SpeedMultiplier;
|
||
easedMsPerTurn: number;
|
||
easeStartTime: number;
|
||
easeStartMs: number;
|
||
pauseReason: 'none' | 'scrubbing';
|
||
}
|
||
|
||
export function createDirectorState(): DirectorState {
|
||
return {
|
||
enabled: false,
|
||
currentMultiplier: 16,
|
||
targetMultiplier: 16,
|
||
easedMsPerTurn: multiplierToMs(16),
|
||
easeStartTime: 0,
|
||
easeStartMs: multiplierToMs(16),
|
||
pauseReason: 'none',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Update the eased speed for a given turn.
|
||
* Called each render frame to compute the current ms/turn.
|
||
*/
|
||
export function tickDirectorSpeed(
|
||
state: DirectorState,
|
||
schedule: SpeedMultiplier[],
|
||
turnIndex: number,
|
||
now: number,
|
||
): number {
|
||
if (!state.enabled || state.pauseReason !== 'none') {
|
||
return state.easedMsPerTurn;
|
||
}
|
||
|
||
const target = schedule[turnIndex] ?? 16;
|
||
|
||
if (target !== state.targetMultiplier) {
|
||
// Start a new ease transition
|
||
state.easeStartTime = now;
|
||
state.easeStartMs = state.easedMsPerTurn;
|
||
state.targetMultiplier = target;
|
||
state.currentMultiplier = target;
|
||
}
|
||
|
||
// Compute eased value
|
||
const elapsed = now - state.easeStartTime;
|
||
const t = Math.min(1, elapsed / EASE_DURATION_MS);
|
||
// Ease-in-out cubic
|
||
const eased = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||
|
||
const targetMs = multiplierToMs(state.targetMultiplier);
|
||
state.easedMsPerTurn = state.easeStartMs + (targetMs - state.easeStartMs) * eased;
|
||
|
||
return state.easedMsPerTurn;
|
||
}
|
||
|
||
/**
|
||
* Format the director speed indicator string.
|
||
* e.g., "Director 8x → 2x" or "Director 4x"
|
||
*/
|
||
export function formatDirectorLabel(
|
||
current: SpeedMultiplier,
|
||
target: SpeedMultiplier,
|
||
transitioning: boolean,
|
||
): string {
|
||
if (transitioning && current !== target) {
|
||
return `Director ${current}x → ${target}x`;
|
||
}
|
||
return `Director ${current}x`;
|
||
}
|