feat(02-01): BigQty + scheduler + sim foundations
- Install zustand@^5.0.0 + break_eternity.js@^2.1.3 as dependencies - BigQty immutable wrapper around Decimal (D-31): factories, arithmetic, comparison, JSON round-trip, saturating coercion - formatHumanReadable for K/M/B/T/scientific HUD readouts (UX-11) - Clock interface + wallClock + FakeClock — only file in src/sim/ allowed to read Date.now() (D-33) - drainTicks fixed-timestep accumulator: refuses negative deltas (CORE-11), clamps at MAX_OFFLINE_MS=24h (CORE-03), TICK_MS=200 - computeOfflineCatchup pure descriptor for offline boundaries - SimState root shape with BLOCKER 3 split: lastTickAt (wall-clock, app-layer-only) + tickCount (sim-internal monotonic) - 52 tests across big-qty / format / clock / tick / catchup all green
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TICK_MS, MAX_OFFLINE_MS, drainTicks } from './tick';
|
||||
import type { SimState } from '../state';
|
||||
|
||||
// Build a minimal SimState fixture inline; Wave-1 plans flesh out tile/plant
|
||||
// shapes and will replace this with a richer factory.
|
||||
function makeState(overrides: Partial<SimState> = {}): SimState {
|
||||
return {
|
||||
garden: { tiles: [] },
|
||||
plants: [],
|
||||
harvestedFragmentIds: [],
|
||||
lastTickAt: 0,
|
||||
tickCount: 0,
|
||||
unlockedPlantTypes: [],
|
||||
luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null },
|
||||
offlineEvents: null,
|
||||
settings: {
|
||||
musicVolume: 0.7,
|
||||
ambientVolume: 0.5,
|
||||
sfxVolume: 0.8,
|
||||
persistenceToastShown: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// A no-op `simulate` is the Wave-0 placeholder. Wave 1 replaces this with
|
||||
// the real simulate from src/sim/garden/.
|
||||
const noopSim = (state: SimState): SimState => state;
|
||||
|
||||
// A counting `simulate` lets us verify exact tick application counts.
|
||||
function makeCountingSim(): {
|
||||
sim: (state: SimState, dtMs: number, silent: boolean) => SimState;
|
||||
count: () => number;
|
||||
} {
|
||||
let calls = 0;
|
||||
return {
|
||||
sim: (state) => {
|
||||
calls += 1;
|
||||
return state;
|
||||
},
|
||||
count: () => calls,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TICK_MS / MAX_OFFLINE_MS constants', () => {
|
||||
it('TICK_MS is 200 (5Hz per RESEARCH Pattern 1)', () => {
|
||||
expect(TICK_MS).toBe(200);
|
||||
});
|
||||
|
||||
it('MAX_OFFLINE_MS is 24 hours', () => {
|
||||
expect(MAX_OFFLINE_MS).toBe(24 * 3600 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drainTicks', () => {
|
||||
it('CORE-11: refuses negative accumulatorMs (state unchanged, 0 ticks applied)', () => {
|
||||
const s = makeState();
|
||||
const result = drainTicks(s, -1, noopSim);
|
||||
expect(result.state).toBe(s);
|
||||
expect(result.ticksApplied).toBe(0);
|
||||
expect(result.remainderMs).toBe(0);
|
||||
});
|
||||
|
||||
it('CORE-03: clamps at MAX_OFFLINE_MS (25h input → 432000 ticks)', () => {
|
||||
const s = makeState();
|
||||
const expectedTicks = Math.floor(MAX_OFFLINE_MS / TICK_MS);
|
||||
expect(expectedTicks).toBe(432000);
|
||||
const counting = makeCountingSim();
|
||||
const result = drainTicks(s, 25 * 3600 * 1000, counting.sim);
|
||||
expect(result.ticksApplied).toBe(expectedTicks);
|
||||
expect(counting.count()).toBe(expectedTicks);
|
||||
});
|
||||
|
||||
it('exact-tick boundary: 1000ms with TICK_MS=200 calls sim 5 times, remainderMs=0', () => {
|
||||
const s = makeState();
|
||||
const counting = makeCountingSim();
|
||||
const result = drainTicks(s, 1000, counting.sim);
|
||||
expect(result.ticksApplied).toBe(5);
|
||||
expect(result.remainderMs).toBe(0);
|
||||
expect(counting.count()).toBe(5);
|
||||
});
|
||||
|
||||
it('partial-tick boundary: 1100ms calls sim 5 times, remainderMs=100', () => {
|
||||
const s = makeState();
|
||||
const counting = makeCountingSim();
|
||||
const result = drainTicks(s, 1100, counting.sim);
|
||||
expect(result.ticksApplied).toBe(5);
|
||||
expect(result.remainderMs).toBe(100);
|
||||
expect(counting.count()).toBe(5);
|
||||
});
|
||||
|
||||
it('benchmark: 432000 ticks complete within 500ms wall time (soft expect)', () => {
|
||||
const s = makeState();
|
||||
const t0 = performance.now();
|
||||
drainTicks(s, MAX_OFFLINE_MS, noopSim);
|
||||
const elapsed = performance.now() - t0;
|
||||
// RESEARCH Assumption A3: log + soft expect on the benchmark to avoid
|
||||
// CI flakes on slow runners. The hard guarantee is ticksApplied
|
||||
// correctness; the speed guarantee is a watchdog.
|
||||
expect.soft(elapsed).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user