feat: timeline drift + next-hard-trigger analysis

This commit is contained in:
2026-07-20 13:46:36 +01:00
parent 40ac770304
commit 1861f4512f
2 changed files with 59 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
export function sortedTimeline(timeline) {
return [...timeline].sort((a, b) => a.targetMin - b.targetMin);
}
export function nextUnreachedBeat(timeline, stamps) {
const t = sortedTimeline(timeline);
return t.find((b) => !(b.id in stamps)) || null;
}
export function analyze(timeline, elapsedMin, stamps) {
const t = sortedTimeline(timeline);
const reached = t.filter((b) => b.id in stamps);
const currentBeat = reached.length ? reached[reached.length - 1] : null;
const currentIndex = currentBeat ? t.findIndex((b) => b.id === currentBeat.id) : -1;
const nextBeat = t[currentIndex + 1] || null;
const nextHardTrigger = t.slice(currentIndex + 1).find((b) => b.hardTrigger) || null;
const minutesToNextHard = nextHardTrigger ? nextHardTrigger.targetMin - elapsedMin : null;
const driftMin = currentBeat ? stamps[currentBeat.id] - currentBeat.targetMin : null;
return { currentBeat, nextBeat, nextHardTrigger, minutesToNextHard, driftMin, cutHint: nextBeat?.cutHint ?? null };
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { sortedTimeline, nextUnreachedBeat, analyze } from '../../src/core/timeline.js';
const T = [
{ id: 'a', act: 1, label: 'Open', targetMin: 0, hardTrigger: true },
{ id: 'b', act: 1, label: 'Parlor', targetMin: 35, cutHint: 'skip the tea' },
{ id: 'c', act: 2, label: 'Door-cam', targetMin: 120, hardTrigger: true },
];
describe('timeline analysis', () => {
it('sorts by targetMin without mutating input', () => {
const shuffled = [T[2], T[0], T[1]];
expect(sortedTimeline(shuffled).map((b) => b.id)).toEqual(['a', 'b', 'c']);
expect(shuffled[0].id).toBe('c');
});
it('finds the next unreached beat', () => {
expect(nextUnreachedBeat(T, {}).id).toBe('a');
expect(nextUnreachedBeat(T, { a: 0 }).id).toBe('b');
});
it('before any stamp: no current, next hard is the first hard beat', () => {
const m = analyze(T, 5, {});
expect(m.currentBeat).toBe(null);
expect(m.nextBeat.id).toBe('a');
expect(m.driftMin).toBe(null);
expect(m.nextHardTrigger.id).toBe('a');
});
it('after stamping "a" late: drift is behind, next hard is "c"', () => {
const m = analyze(T, 40, { a: 6 }); // reached Open at 6 min (target 0) => 6 behind
expect(m.currentBeat.id).toBe('a');
expect(m.driftMin).toBe(6);
expect(m.nextBeat.id).toBe('b');
expect(m.cutHint).toBe('skip the tea');
expect(m.nextHardTrigger.id).toBe('c');
expect(m.minutesToNextHard).toBe(80); // 120 - 40
});
});