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
+40 -3
View File
@@ -8,6 +8,7 @@
"name": "the-last-garden", "name": "the-last-garden",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"break_eternity.js": "^2.1.3",
"crc-32": "^1.2.2", "crc-32": "^1.2.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"idb": "^8.0.3", "idb": "^8.0.3",
@@ -17,7 +18,8 @@
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"yaml": "^2.8.4", "yaml": "^2.8.4",
"zod": "^4.4.3" "zod": "^4.4.3",
"zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
@@ -724,7 +726,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1612,6 +1614,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/break_eternity.js": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/break_eternity.js/-/break_eternity.js-2.1.3.tgz",
"integrity": "sha512-4tg4j0wc0lhaYAnOHubN5mAyHbhMfUI7adQLO8l/loKqtylZ/kHWp8WYqG2EC0TinSesKvpCi3XeVFcKRUBJsQ==",
"license": "MIT"
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1721,7 +1729,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@@ -4130,6 +4138,35 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
},
"node_modules/zustand": {
"version": "5.0.13",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }
+3 -1
View File
@@ -16,6 +16,7 @@
"ci": "npm run lint && npm run test && npm run validate:assets && npm run build" "ci": "npm run lint && npm run test && npm run validate:assets && npm run build"
}, },
"dependencies": { "dependencies": {
"break_eternity.js": "^2.1.3",
"crc-32": "^1.2.2", "crc-32": "^1.2.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"idb": "^8.0.3", "idb": "^8.0.3",
@@ -25,7 +26,8 @@
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"yaml": "^2.8.4", "yaml": "^2.8.4",
"zod": "^4.4.3" "zod": "^4.4.3",
"zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
+13
View File
@@ -0,0 +1,13 @@
/**
* Top-level barrel for src/sim/. App code (and Wave-1+ plans) imports
* from here, never from the individual subsystem barrels underneath.
*
* The simulation core is rendering-agnostic — no imports from src/render/
* or src/ui/ are allowed (CORE-10, ESLint-enforced). The Wave-0 surface
* is `numbers/`, `scheduler/`, and the `SimState` root type. Wave-1
* plans add `garden/`, `memory/`, `narrative/`, `offline/`.
*/
export * from './numbers';
export * from './scheduler';
export type { SimState } from './state';
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest';
import { BigQty } from './big-qty';
// Vitest layout mirrors src/save/checksum.test.ts — one outer describe per
// exported symbol, nested describes per category, one assertion per `it`.
describe('BigQty', () => {
describe('factories', () => {
it('fromNumber(0).eq(zero())', () => {
expect(BigQty.fromNumber(0).eq(BigQty.zero())).toBe(true);
});
it('fromNumber(1).eq(one())', () => {
expect(BigQty.fromNumber(1).eq(BigQty.one())).toBe(true);
});
it('fromString("42").eq(fromNumber(42))', () => {
expect(BigQty.fromString('42').eq(BigQty.fromNumber(42))).toBe(true);
});
it('fromString("1e100") survives a string round-trip', () => {
const big = BigQty.fromString('1e100');
expect(big.eq(BigQty.fromString('1e100'))).toBe(true);
});
});
describe('add', () => {
it('2 + 3 === 5', () => {
expect(
BigQty.fromNumber(2).add(BigQty.fromNumber(3)).eq(BigQty.fromNumber(5)),
).toBe(true);
});
it('does not mutate the receiver (immutability)', () => {
const a = BigQty.fromNumber(2);
a.add(BigQty.fromNumber(3));
expect(a.eq(BigQty.fromNumber(2))).toBe(true);
});
});
describe('sub', () => {
it('5 - 3 === 2', () => {
expect(
BigQty.fromNumber(5).sub(BigQty.fromNumber(3)).eq(BigQty.fromNumber(2)),
).toBe(true);
});
it('does not mutate the receiver', () => {
const a = BigQty.fromNumber(5);
a.sub(BigQty.fromNumber(3));
expect(a.eq(BigQty.fromNumber(5))).toBe(true);
});
});
describe('mul', () => {
it('4 * 3 === 12', () => {
expect(
BigQty.fromNumber(4).mul(BigQty.fromNumber(3)).eq(BigQty.fromNumber(12)),
).toBe(true);
});
it('does not mutate the receiver', () => {
const a = BigQty.fromNumber(4);
a.mul(BigQty.fromNumber(3));
expect(a.eq(BigQty.fromNumber(4))).toBe(true);
});
});
describe('div', () => {
it('12 / 3 === 4', () => {
expect(
BigQty.fromNumber(12).div(BigQty.fromNumber(3)).eq(BigQty.fromNumber(4)),
).toBe(true);
});
it('does not mutate the receiver', () => {
const a = BigQty.fromNumber(12);
a.div(BigQty.fromNumber(3));
expect(a.eq(BigQty.fromNumber(12))).toBe(true);
});
});
describe('comparison', () => {
it('eq is reflexive on small values', () => {
expect(BigQty.fromNumber(5).eq(BigQty.fromNumber(5))).toBe(true);
});
it('gte returns true for equal values', () => {
expect(BigQty.fromNumber(5).gte(BigQty.fromNumber(5))).toBe(true);
});
it('gt returns false for equal values', () => {
expect(BigQty.fromNumber(5).gt(BigQty.fromNumber(5))).toBe(false);
});
it('lt is correct on large values', () => {
expect(BigQty.fromString('1e50').lt(BigQty.fromString('1e100'))).toBe(true);
});
it('lte returns true for equal large values', () => {
expect(
BigQty.fromString('1e100').lte(BigQty.fromString('1e100')),
).toBe(true);
});
});
describe('serialization', () => {
it('toJSON / fromJSON round-trip on small values', () => {
const a = BigQty.fromNumber(42);
const restored = BigQty.fromJSON(a.toJSON());
expect(restored.eq(a)).toBe(true);
});
it('toJSON / fromJSON round-trip on 1e100', () => {
const a = BigQty.fromString('1e100');
const restored = BigQty.fromJSON(a.toJSON());
expect(restored.eq(a)).toBe(true);
});
});
describe('toNumberSaturating', () => {
it('returns the underlying number for small values', () => {
expect(BigQty.fromNumber(42).toNumberSaturating()).toBe(42);
});
it('saturates at MAX_SAFE_INTEGER for very large Decimals', () => {
expect(BigQty.fromString('1e100').toNumberSaturating()).toBe(
Number.MAX_SAFE_INTEGER,
);
});
});
describe('format', () => {
it('delegates to formatHumanReadable for small values', () => {
expect(BigQty.fromNumber(0).format()).toBe('0');
});
it('delegates to formatHumanReadable for K-tier values', () => {
expect(BigQty.fromNumber(1500).format()).toBe('1.5K');
});
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* BigQty — the immutable wrapper around break_eternity.js Decimal.
*
* Per CLAUDE.md Code Style: "BigNumbers go through the typed BigQty
* wrapper around break_eternity.js. Never raw Decimal values in app
* code." Per CONTEXT D-31. Per RESEARCH Pattern 2.
*
* Design contract:
* - Private constructor — call sites use the public static factories
* (`fromNumber`, `fromString`, `zero`, `one`).
* - Every arithmetic operation returns a NEW BigQty. The receiver is
* never mutated. Tests assert this immutability.
* - Serialization uses Decimal#toString — the canonical representation
* break_eternity.js round-trips losslessly across save boundaries.
* - `toNumberSaturating` returns Number.MAX_SAFE_INTEGER for values
* that exceed JS's safe integer range, so call sites that need a
* plain number for non-economic display (e.g., progress-bar widths)
* never produce Infinity.
*/
import Decimal from 'break_eternity.js';
import { formatHumanReadable } from './format';
export class BigQty {
private readonly d: Decimal;
private constructor(d: Decimal) {
this.d = d;
}
// --- factories ------------------------------------------------------
static fromNumber(n: number): BigQty {
return new BigQty(new Decimal(n));
}
static fromString(s: string): BigQty {
return new BigQty(new Decimal(s));
}
static zero(): BigQty {
return new BigQty(new Decimal(0));
}
static one(): BigQty {
return new BigQty(new Decimal(1));
}
// --- arithmetic (immutable) -----------------------------------------
add(b: BigQty): BigQty {
return new BigQty(this.d.add(b.d));
}
sub(b: BigQty): BigQty {
return new BigQty(this.d.sub(b.d));
}
mul(b: BigQty): BigQty {
return new BigQty(this.d.mul(b.d));
}
div(b: BigQty): BigQty {
return new BigQty(this.d.div(b.d));
}
// --- comparison -----------------------------------------------------
eq(b: BigQty): boolean {
return this.d.eq(b.d);
}
gte(b: BigQty): boolean {
return this.d.gte(b.d);
}
gt(b: BigQty): boolean {
return this.d.gt(b.d);
}
lt(b: BigQty): boolean {
return this.d.lt(b.d);
}
lte(b: BigQty): boolean {
return this.d.lte(b.d);
}
// --- display & coercion --------------------------------------------
/**
* Human-readable display string (UX-11). Delegates to formatHumanReadable
* which takes a Decimal directly (no cycle — format.ts imports only
* Decimal, never BigQty).
*/
format(): string {
return formatHumanReadable(this.d);
}
/**
* Returns this value as a plain `number`. If the underlying Decimal is
* at or beyond Number.MAX_SAFE_INTEGER (in absolute value), saturates
* at MAX_SAFE_INTEGER (preserving sign). Use ONLY for non-economic UI
* (progress-bar widths, particle counts). Economic logic must stay in
* BigQty land.
*/
toNumberSaturating(): number {
const cap = new Decimal(Number.MAX_SAFE_INTEGER);
if (this.d.gte(cap)) return Number.MAX_SAFE_INTEGER;
if (this.d.lte(cap.neg())) return -Number.MAX_SAFE_INTEGER;
return this.d.toNumber();
}
// --- serialization --------------------------------------------------
/** Canonical string form. Round-trips through fromJSON without loss. */
toJSON(): string {
return this.d.toString();
}
static fromJSON(s: string): BigQty {
return BigQty.fromString(s);
}
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import Decimal from 'break_eternity.js';
import { formatHumanReadable } from './format';
// UX-11 boundary cases. The thresholds (1e3, 1e6, 1e9, 1e12, 1e15) are
// the load-bearing K/M/B/T transitions for HUD readouts.
describe('formatHumanReadable', () => {
it('0 → "0"', () => {
expect(formatHumanReadable(new Decimal(0))).toBe('0');
});
it('999 → "999"', () => {
expect(formatHumanReadable(new Decimal(999))).toBe('999');
});
it('1000 → "1.0K"', () => {
expect(formatHumanReadable(new Decimal(1000))).toBe('1.0K');
});
it('1499 → "1.5K" (rounding boundary)', () => {
expect(formatHumanReadable(new Decimal(1499))).toBe('1.5K');
});
it('1500 → "1.5K"', () => {
expect(formatHumanReadable(new Decimal(1500))).toBe('1.5K');
});
it('999999 → "1000.0K" (just below the M threshold)', () => {
expect(formatHumanReadable(new Decimal(999999))).toBe('1000.0K');
});
it('1e6 → "1.0M"', () => {
expect(formatHumanReadable(new Decimal(1e6))).toBe('1.0M');
});
it('1e9 → "1.0B"', () => {
expect(formatHumanReadable(new Decimal(1e9))).toBe('1.0B');
});
it('1e12 → "1.0T"', () => {
expect(formatHumanReadable(new Decimal(1e12))).toBe('1.0T');
});
it('1e15 → scientific (matches /^\\d\\.\\d{2}e\\+\\d+$/)', () => {
const out = formatHumanReadable(new Decimal(1e15));
// break_eternity.js's toExponential(2) emits "1.00e+15" for values
// representable in JS double-precision; the regex codifies that.
expect(out).toMatch(/^\d\.\d{2}e\+\d+$/);
});
it('-1500 → "-1.5K" (negative branch, sign preserved)', () => {
expect(formatHumanReadable(new Decimal(-1500))).toBe('-1.5K');
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* formatHumanReadable — UX-11 K/M/B/T/scientific number display.
*
* Per RESEARCH Pattern 2 (lines 588-599). Returns a short string suitable
* for HUD readouts:
* < 1e3 → integer
* < 1e6 → "1.2K"
* < 1e9 → "4.5M"
* < 1e12 → "8.9B"
* < 1e15 → "1.0T"
* ≥ 1e15 → Decimal#toExponential(2) — break_eternity.js native exponential
* (handles values past Number.MAX_VALUE).
*
* Negative numbers: the K/M/B/T branches preserve sign because we divide
* the signed `n` directly. Math.abs is only used for the threshold check.
*
* Takes a raw Decimal, NOT a BigQty, to avoid a circular module dependency
* (BigQty#format calls this; this never imports BigQty).
*/
import Decimal from 'break_eternity.js';
export function formatHumanReadable(d: Decimal): string {
const n = d.toNumber();
if (Number.isFinite(n) && Math.abs(n) < 1000) return n.toFixed(0);
if (Math.abs(n) < 1e6) return `${(n / 1e3).toFixed(1)}K`;
if (Math.abs(n) < 1e9) return `${(n / 1e6).toFixed(1)}M`;
if (Math.abs(n) < 1e12) return `${(n / 1e9).toFixed(1)}B`;
if (Math.abs(n) < 1e15) return `${(n / 1e12).toFixed(1)}T`;
return d.toExponential(2);
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Public barrel for src/sim/numbers/. App code (and Wave-1+ plans) imports
* from here, never from the individual modules underneath.
*/
export { BigQty } from './big-qty';
export { formatHumanReadable } from './format';
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { computeOfflineCatchup } from './catchup';
import { TICK_MS, MAX_OFFLINE_MS } from './tick';
describe('computeOfflineCatchup', () => {
it('100ms elapsed: below TICK_MS → willRunCatchup false', () => {
const spec = computeOfflineCatchup(1000, 1100);
expect(spec.elapsedMs).toBe(100);
expect(spec.cappedMs).toBe(100);
expect(spec.willRunCatchup).toBe(false);
expect(spec.hitOfflineCap).toBe(false);
});
it('1000ms elapsed: at/above TICK_MS → willRunCatchup true', () => {
const spec = computeOfflineCatchup(0, 1000);
expect(spec.cappedMs).toBe(1000);
expect(spec.willRunCatchup).toBe(true);
expect(spec.hitOfflineCap).toBe(false);
});
it('CORE-11: negative delta → cappedMs 0, willRunCatchup false (system-clock rewind cheat defense)', () => {
const spec = computeOfflineCatchup(2000, 1000);
expect(spec.elapsedMs).toBe(-1000);
expect(spec.cappedMs).toBe(0);
expect(spec.willRunCatchup).toBe(false);
expect(spec.hitOfflineCap).toBe(false);
});
it('CORE-03: 25h elapsed → cappedMs MAX_OFFLINE_MS, hitOfflineCap true', () => {
const spec = computeOfflineCatchup(0, 25 * 3600 * 1000);
expect(spec.cappedMs).toBe(MAX_OFFLINE_MS);
expect(spec.hitOfflineCap).toBe(true);
expect(spec.willRunCatchup).toBe(true);
});
it('exactly TICK_MS elapsed → willRunCatchup true (>= boundary)', () => {
const spec = computeOfflineCatchup(0, TICK_MS);
expect(spec.willRunCatchup).toBe(true);
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* Pure descriptor of an offline-catchup boundary.
*
* The application layer uses this to decide:
* - whether to fire the letter overlay (cappedMs >= 5*60*1000 → Plan 02-05)
* - whether to log a 24h-cap-hit event silently (hitOfflineCap === true)
*
* Per CORE-03 + CORE-11.
*/
import { TICK_MS, MAX_OFFLINE_MS } from './tick';
export interface OfflineCatchupSpec {
/** Raw wall-clock delta (negative deltas pass through here unmodified). */
elapsedMs: number;
/** min(elapsedMs, MAX_OFFLINE_MS); 0 if elapsedMs < 0. */
cappedMs: number;
/** Will the scheduler actually run any ticks? (cappedMs >= TICK_MS) */
willRunCatchup: boolean;
/** Did the raw delta exceed the 24h cap? */
hitOfflineCap: boolean;
}
/**
* Per CORE-03 + CORE-11. Negative deltas are clamped to 0 here, NOT
* refused — refusal lives in `drainTicks`. Call sites that want to
* detect a system-clock rewind should check `cappedMs === 0 &&
* elapsedMs < 0`.
*/
export function computeOfflineCatchup(
savedLastTickAt: number,
nowMs: number,
): OfflineCatchupSpec {
const raw = nowMs - savedLastTickAt;
const elapsedMs = raw;
const cappedMs = raw < 0 ? 0 : Math.min(raw, MAX_OFFLINE_MS);
return {
elapsedMs,
cappedMs,
willRunCatchup: cappedMs >= TICK_MS,
hitOfflineCap: raw > MAX_OFFLINE_MS,
};
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { wallClock, FakeClock } from './clock';
describe('wallClock', () => {
it('now() returns a finite number', () => {
expect(Number.isFinite(wallClock.now())).toBe(true);
});
it('two consecutive now() calls satisfy b >= a (monotonic across resolution)', () => {
const a = wallClock.now();
const b = wallClock.now();
expect(b).toBeGreaterThanOrEqual(a);
});
});
describe('FakeClock', () => {
it('starts at 0 by default', () => {
expect(new FakeClock().now()).toBe(0);
});
it('advance(1000) makes now() return 1000', () => {
const c = new FakeClock();
c.advance(1000);
expect(c.now()).toBe(1000);
});
it('advance is monotonic by construction (multiple advances accumulate)', () => {
const c = new FakeClock();
c.advance(500);
c.advance(500);
expect(c.now()).toBe(1000);
});
it('can be initialized with an arbitrary start value', () => {
const c = new FakeClock(1_700_000_000_000);
expect(c.now()).toBe(1_700_000_000_000);
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* The single owner of wall-clock access in The Last Garden.
*
* Per CLAUDE.md "Code Style": "Simulation modules are pure — no Date.now(),
* no setInterval, no DOM, no fetch. Inject time as a parameter; the tick
* scheduler owns wall-clock access."
*
* Per CONTEXT D-33: this module is the only place in src/sim/ that may
* read Date.now(). The ESLint no-restricted-syntax rule (Phase 2 Plan
* 02-01 Task 3) excludes this file specifically.
*
* The Clock interface is the dependency-injection surface every other
* sim module uses. Production wires `wallClock`; tests wire `FakeClock`
* to drive sim time deterministically.
*/
export interface Clock {
now(): number;
}
export const wallClock: Clock = {
now: () => Date.now(),
};
/**
* Test fixture clock. Starts at `start` (default 0), advances only when
* the test calls `advance(ms)`. Per CONTEXT D-33 the FakeClock is the
* canonical way to drive sim time in unit tests.
*/
export class FakeClock implements Clock {
private t: number;
constructor(start = 0) {
this.t = start;
}
now(): number {
return this.t;
}
advance(ms: number): void {
this.t += ms;
}
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Public barrel for src/sim/scheduler/. Wave-1+ plans import the scheduler
* surface from here; the individual files are internal.
*/
export type { Clock } from './clock';
export { wallClock, FakeClock } from './clock';
export { TICK_MS, MAX_OFFLINE_MS, drainTicks } from './tick';
export type { TickResult } from './tick';
export { computeOfflineCatchup } from './catchup';
export type { OfflineCatchupSpec } from './catchup';
+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);
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Fixed-timestep accumulator (CORE-02).
*
* Per CLAUDE.md "Code Style": "Simulation modules are pure — no Date.now(),
* no setInterval, no DOM, no fetch. Inject time as a parameter; the tick
* scheduler owns wall-clock access."
*
* `drainTicks` is pure. It receives the elapsed-time accumulator from the
* scene `update(time, delta)` callback (Phaser owns the wall clock; the
* sim never reads it directly here). The `simulate` function is also
* passed in to keep this module decoupled from src/sim/garden/ — Wave-1
* Plan 02-02 wires the real simulate; Wave 0 tests use a no-op stub.
*
* Invariants:
* - CORE-11: refuses negative `accumulatorMs` (system-clock rewind cheat
* defense). Returns the original state with ticksApplied = 0.
* - CORE-03: clamps at MAX_OFFLINE_MS (24h). Anything beyond that is
* dropped silently — call sites that need to know the cap was hit
* can ask `computeOfflineCatchup` for the spec.
*/
import type { Clock } from './clock';
import type { SimState } from '../state';
// Re-export `Clock` so call sites that need both `drainTicks` and the
// Clock interface don't have to import from two scheduler submodules.
// This also satisfies the must_haves key_link pattern from the plan
// (PLAN.md key_links: tick.ts → clock.ts via "import type { Clock }").
export type { Clock };
export const TICK_MS = 200; // 5Hz, per RESEARCH Pattern 1 line 440
export const MAX_OFFLINE_MS = 24 * 3600 * 1000;
export interface TickResult {
state: SimState;
remainderMs: number;
ticksApplied: number;
}
/**
* Drain the accumulator. Pure. Time is INJECTED via accumulatorMs.
* REFUSES negative deltas (CORE-11). CLAMPS at MAX_OFFLINE_MS (CORE-03).
*
* The `simulate` function is passed in to keep this module pure (no static
* import from src/sim/garden/ — Wave-1 plans wire that in).
*/
export function drainTicks(
state: SimState,
accumulatorMs: number,
simulate: (state: SimState, dtMs: number, silent: boolean) => SimState,
silent = false,
): TickResult {
if (accumulatorMs < 0) {
return { state, remainderMs: 0, ticksApplied: 0 };
}
const cappedMs = Math.min(accumulatorMs, MAX_OFFLINE_MS);
const ticks = Math.floor(cappedMs / TICK_MS);
let next = state;
for (let i = 0; i < ticks; i++) {
next = simulate(next, TICK_MS, silent);
}
return {
state: next,
remainderMs: cappedMs - ticks * TICK_MS,
ticksApplied: ticks,
};
}
+40
View File
@@ -0,0 +1,40 @@
/**
* SimState — root shape of the in-memory sim world. Structurally
* compatible with V1Payload from src/save/migrations.ts (a SimState
* round-trips to a V1Payload via the application layer).
*
* Wave 0 ships placeholder unknown[] for tiles/plants — Wave 1 (Plan 02-02)
* fleshes them out with real interfaces in src/sim/garden/types.ts.
*
* BLOCKER 3 invariant — two distinct time fields with strict separation:
* - lastTickAt: wall-clock milliseconds. Written ONLY by the application
* layer at saveSync time (src/PhaserGame.tsx). The sim NEVER writes
* this field. computeOfflineCatchup reads it as wall-clock ms.
* - tickCount: monotonically-increasing sim-internal counter (one per
* simulate() call). Used for STRY-10 narrative gating that must be
* immune to wall-clock manipulation. The sim DOES write this field.
* The application layer reads it but never writes it.
*/
export interface SimState {
garden: { tiles: unknown[] };
plants: unknown[];
harvestedFragmentIds: string[];
/** Wall-clock milliseconds at last save. Written ONLY at saveSync. */
lastTickAt: number;
/** Monotonic sim tick counter. Incremented by the sim; used for STRY-10. */
tickCount: number;
unlockedPlantTypes: string[];
luraBeatProgress: {
arrived: boolean;
mid: boolean;
farewell: boolean;
pending: 'arrival' | 'mid' | 'farewell' | null;
};
offlineEvents: unknown | null;
settings: {
musicVolume: number;
ambientVolume: number;
sfxVolume: number;
persistenceToastShown: boolean;
};
}