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:
2026-05-09 09:14:10 -04:00
parent 5ddaabcdc1
commit 58db53227c
16 changed files with 801 additions and 4 deletions
+103
View File
@@ -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);
});
});