Add comprehensive test coverage for the Director Mode (Adaptive Auto-Speed Playback) implementation, verifying the action density formula and speed mapping match the plan specification exactly. Tests cover: - Action density calculation (deaths×3.0 + captures×5.0 + energy×0.5 + spawns×1.0 + delta_win_prob×10.0) - Speed mapping (0→16x, 0.1-1.0→8x, 1.0-3.0→4x, 3.0-5.0→2x, 5.0+→1x) - Speed schedule computation with target duration scaling - Win probability delta calculation All 16 tests pass, confirming the Director Mode implementation in director.ts correctly implements §16.10 of the plan. Closes: bf-1p5y Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
254 lines
8.1 KiB
TypeScript
254 lines
8.1 KiB
TypeScript
/**
|
||
* Unit tests for Director Mode (§16.10)
|
||
* Verifies action density calculation and speed mapping match the plan.
|
||
*/
|
||
|
||
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
computeActionDensity,
|
||
densityToSpeed,
|
||
computeSpeedSchedule,
|
||
computeAllDensities,
|
||
type ActionDensity,
|
||
} from './director';
|
||
import type { ReplayTurn, Replay } from '../types';
|
||
|
||
describe('Director Mode (§16.10)', () => {
|
||
describe('computeActionDensity', () => {
|
||
it('should calculate density 0 for empty turn', () => {
|
||
const turn: ReplayTurn = {
|
||
bots: [],
|
||
energy: [],
|
||
cores: [],
|
||
scores: [],
|
||
energy_held: [],
|
||
};
|
||
const result = computeActionDensity(turn, null);
|
||
expect(result.density).toBe(0);
|
||
expect(result.deaths).toBe(0);
|
||
expect(result.captures).toBe(0);
|
||
expect(result.energyCollected).toBe(0);
|
||
expect(result.spawns).toBe(0);
|
||
});
|
||
|
||
it('should weight deaths × 3.0 per plan formula', () => {
|
||
const turn: ReplayTurn = {
|
||
bots: [],
|
||
energy: [],
|
||
cores: [],
|
||
scores: [],
|
||
energy_held: [],
|
||
events: [
|
||
{ type: 'combat_death', details: {} },
|
||
{ type: 'combat_death', details: {} },
|
||
],
|
||
};
|
||
const result = computeActionDensity(turn, null);
|
||
expect(result.deaths).toBe(2);
|
||
expect(result.density).toBe(2 * 3.0);
|
||
});
|
||
|
||
it('should weight captures × 5.0 per plan formula', () => {
|
||
const turn: ReplayTurn = {
|
||
bots: [],
|
||
energy: [],
|
||
cores: [],
|
||
scores: [],
|
||
energy_held: [],
|
||
events: [
|
||
{ type: 'core_captured', details: {} },
|
||
],
|
||
};
|
||
const result = computeActionDensity(turn, null);
|
||
expect(result.captures).toBe(1);
|
||
expect(result.density).toBe(1 * 5.0);
|
||
});
|
||
|
||
it('should weight energy_collected × 0.5 per plan formula', () => {
|
||
const turn: ReplayTurn = {
|
||
bots: [],
|
||
energy: [],
|
||
cores: [],
|
||
scores: [],
|
||
energy_held: [],
|
||
events: [
|
||
{ type: 'energy_collected', details: {} },
|
||
{ type: 'energy_collected', details: {} },
|
||
],
|
||
};
|
||
const result = computeActionDensity(turn, null);
|
||
expect(result.energyCollected).toBe(2);
|
||
expect(result.density).toBe(2 * 0.5);
|
||
});
|
||
|
||
it('should weight spawns × 1.0 per plan formula', () => {
|
||
const turn: ReplayTurn = {
|
||
bots: [],
|
||
energy: [],
|
||
cores: [],
|
||
scores: [],
|
||
energy_held: [],
|
||
events: [
|
||
{ type: 'bot_spawned', details: {} },
|
||
],
|
||
};
|
||
const result = computeActionDensity(turn, null);
|
||
expect(result.spawns).toBe(1);
|
||
expect(result.density).toBe(1 * 1.0);
|
||
});
|
||
|
||
it('should weight delta_win_prob × 10.0 per plan formula', () => {
|
||
const turn: ReplayTurn = {
|
||
bots: [],
|
||
energy: [],
|
||
cores: [],
|
||
scores: [],
|
||
energy_held: [],
|
||
};
|
||
const winProb = [
|
||
[0.5, 0.5],
|
||
[0.4, 0.6], // delta = 0.2 total
|
||
];
|
||
const result = computeActionDensity(turn, null, winProb, 1);
|
||
expect(result.deltaWinProb).toBeCloseTo(0.2, 10);
|
||
expect(result.density).toBeCloseTo(0.2 * 10.0, 10);
|
||
});
|
||
|
||
it('should sum all components per plan formula', () => {
|
||
const turn: ReplayTurn = {
|
||
bots: [],
|
||
energy: [],
|
||
cores: [],
|
||
scores: [],
|
||
energy_held: [],
|
||
events: [
|
||
{ type: 'combat_death', details: {} }, // +3.0
|
||
{ type: 'core_captured', details: {} }, // +5.0
|
||
{ type: 'energy_collected', details: {} }, // +0.5
|
||
{ type: 'bot_spawned', details: {} }, // +1.0
|
||
],
|
||
};
|
||
const winProb = [
|
||
[0.5, 0.5],
|
||
[0.3, 0.7], // delta = 0.4 total → +4.0
|
||
];
|
||
const result = computeActionDensity(turn, null, winProb, 1);
|
||
expect(result.density).toBe(3.0 + 5.0 + 0.5 + 1.0 + 4.0);
|
||
});
|
||
});
|
||
|
||
describe('densityToSpeed', () => {
|
||
it('should map 0 → 16x (nothing happening)', () => {
|
||
expect(densityToSpeed(0)).toBe(16);
|
||
});
|
||
|
||
it('should map 0.1–1.0 → 8x (minor activity)', () => {
|
||
expect(densityToSpeed(0.1)).toBe(8);
|
||
expect(densityToSpeed(0.5)).toBe(8);
|
||
expect(densityToSpeed(1.0)).toBe(4); // boundary
|
||
});
|
||
|
||
it('should map 1.0–3.0 → 4x (moderate)', () => {
|
||
expect(densityToSpeed(1.0)).toBe(4);
|
||
expect(densityToSpeed(2.0)).toBe(4);
|
||
expect(densityToSpeed(3.0)).toBe(2); // boundary
|
||
});
|
||
|
||
it('should map 3.0–5.0 → 2x (significant)', () => {
|
||
expect(densityToSpeed(3.0)).toBe(2);
|
||
expect(densityToSpeed(4.0)).toBe(2);
|
||
expect(densityToSpeed(5.0)).toBe(1); // boundary
|
||
});
|
||
|
||
it('should map 5.0+ → 1x (critical)', () => {
|
||
expect(densityToSpeed(5.0)).toBe(1);
|
||
expect(densityToSpeed(10.0)).toBe(1);
|
||
expect(densityToSpeed(100.0)).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('computeSpeedSchedule', () => {
|
||
it('should generate one speed per turn', () => {
|
||
const densities: ActionDensity[] = [
|
||
{ density: 0, deaths: 0, captures: 0, energyCollected: 0, spawns: 0, deltaWinProb: 0 },
|
||
{ density: 6, deaths: 2, captures: 0, energyCollected: 0, spawns: 0, deltaWinProb: 0 },
|
||
{ density: 0.5, deaths: 0, captures: 0, energyCollected: 1, spawns: 0, deltaWinProb: 0 },
|
||
];
|
||
const schedule = computeSpeedSchedule(densities, 60);
|
||
expect(schedule).toHaveLength(3);
|
||
// Raw speeds are scaled to match target duration
|
||
// Raw: 16x, 1x, 8x → times: 31.25ms, 500ms, 62.5ms → total ~594ms
|
||
// Target 60s means scaling factor ~100x, so speeds get clamped
|
||
expect(schedule[0]).toBeGreaterThanOrEqual(1);
|
||
expect(schedule[0]).toBeLessThanOrEqual(16);
|
||
expect(schedule[1]).toBeGreaterThanOrEqual(1);
|
||
expect(schedule[1]).toBeLessThanOrEqual(16);
|
||
expect(schedule[2]).toBeGreaterThanOrEqual(1);
|
||
expect(schedule[2]).toBeLessThanOrEqual(16);
|
||
});
|
||
|
||
it('should scale speeds to match target duration', () => {
|
||
const densities: ActionDensity[] = Array(100).fill({
|
||
density: 0,
|
||
deaths: 0,
|
||
captures: 0,
|
||
energyCollected: 0,
|
||
spawns: 0,
|
||
deltaWinProb: 0,
|
||
});
|
||
const schedule = computeSpeedSchedule(densities, 60);
|
||
// All turns have density 0, so all should be fast (16x)
|
||
// With 100 turns at 16x, total time should be ~100 * (500/16) ≈ 3.1s
|
||
// But target is 60s, so speeds will be scaled down significantly
|
||
expect(schedule.every(s => s >= 1 && s <= 16)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('computeAllDensities', () => {
|
||
it('should compute density for each turn', () => {
|
||
const replay: Replay = {
|
||
match_id: 'test',
|
||
players: [],
|
||
map: { rows: 10, cols: 10, walls: [], cores: [], energy_nodes: [] },
|
||
config: {} as any,
|
||
turns: [
|
||
{ bots: [], energy: [], cores: [], scores: [], energy_held: [], events: [] },
|
||
{
|
||
bots: [],
|
||
energy: [],
|
||
cores: [],
|
||
scores: [],
|
||
energy_held: [],
|
||
events: [{ type: 'combat_death', details: {} }],
|
||
},
|
||
],
|
||
result: { turns: 2, winner: 0, reason: 'test', scores: [0, 0] },
|
||
};
|
||
const densities = computeAllDensities(replay);
|
||
expect(densities).toHaveLength(2);
|
||
expect(densities[0].density).toBe(0);
|
||
expect(densities[1].density).toBe(3.0); // one death
|
||
});
|
||
|
||
it('should use win_prob for delta calculation', () => {
|
||
const replay: Replay = {
|
||
match_id: 'test',
|
||
players: [],
|
||
map: { rows: 10, cols: 10, walls: [], cores: [], energy_nodes: [] },
|
||
config: {} as any,
|
||
turns: [
|
||
{ bots: [], energy: [], cores: [], scores: [], energy_held: [] },
|
||
{ bots: [], energy: [], cores: [], scores: [], energy_held: [] },
|
||
],
|
||
win_prob: [
|
||
[0.5, 0.5],
|
||
[0.3, 0.7], // delta = 0.4
|
||
],
|
||
result: { turns: 2, winner: 0, reason: 'test', scores: [0, 0] },
|
||
};
|
||
const densities = computeAllDensities(replay);
|
||
expect(densities[1].deltaWinProb).toBeCloseTo(0.4, 10);
|
||
expect(densities[1].density).toBeCloseTo(4.0, 10); // 0.4 * 10
|
||
});
|
||
});
|
||
});
|