From 58db53227cef3ef0f5e58a330bc7bbc892efc21c Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 9 May 2026 09:14:10 -0400 Subject: [PATCH] feat(02-01): BigQty + scheduler + sim foundations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package-lock.json | 43 ++++++++- package.json | 4 +- src/sim/index.ts | 13 +++ src/sim/numbers/big-qty.test.ts | 142 ++++++++++++++++++++++++++++++ src/sim/numbers/big-qty.ts | 124 ++++++++++++++++++++++++++ src/sim/numbers/format.test.ts | 55 ++++++++++++ src/sim/numbers/format.ts | 31 +++++++ src/sim/numbers/index.ts | 7 ++ src/sim/scheduler/catchup.test.ts | 40 +++++++++ src/sim/scheduler/catchup.ts | 43 +++++++++ src/sim/scheduler/clock.test.ts | 38 ++++++++ src/sim/scheduler/clock.ts | 44 +++++++++ src/sim/scheduler/index.ts | 11 +++ src/sim/scheduler/tick.test.ts | 103 ++++++++++++++++++++++ src/sim/scheduler/tick.ts | 67 ++++++++++++++ src/sim/state.ts | 40 +++++++++ 16 files changed, 801 insertions(+), 4 deletions(-) create mode 100644 src/sim/index.ts create mode 100644 src/sim/numbers/big-qty.test.ts create mode 100644 src/sim/numbers/big-qty.ts create mode 100644 src/sim/numbers/format.test.ts create mode 100644 src/sim/numbers/format.ts create mode 100644 src/sim/numbers/index.ts create mode 100644 src/sim/scheduler/catchup.test.ts create mode 100644 src/sim/scheduler/catchup.ts create mode 100644 src/sim/scheduler/clock.test.ts create mode 100644 src/sim/scheduler/clock.ts create mode 100644 src/sim/scheduler/index.ts create mode 100644 src/sim/scheduler/tick.test.ts create mode 100644 src/sim/scheduler/tick.ts create mode 100644 src/sim/state.ts diff --git a/package-lock.json b/package-lock.json index 2c4c61e..a2b1aa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "the-last-garden", "version": "0.0.0", "dependencies": { + "break_eternity.js": "^2.1.3", "crc-32": "^1.2.2", "gray-matter": "^4.0.3", "idb": "^8.0.3", @@ -17,7 +18,8 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "yaml": "^2.8.4", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.13" }, "devDependencies": { "@playwright/test": "^1.59.1", @@ -724,7 +726,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1612,6 +1614,12 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1721,7 +1729,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -4130,6 +4138,35 @@ "funding": { "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 + } + } } } } diff --git a/package.json b/package.json index 8ccd251..cfd68e4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "ci": "npm run lint && npm run test && npm run validate:assets && npm run build" }, "dependencies": { + "break_eternity.js": "^2.1.3", "crc-32": "^1.2.2", "gray-matter": "^4.0.3", "idb": "^8.0.3", @@ -25,7 +26,8 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "yaml": "^2.8.4", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.13" }, "devDependencies": { "@playwright/test": "^1.59.1", diff --git a/src/sim/index.ts b/src/sim/index.ts new file mode 100644 index 0000000..505bee5 --- /dev/null +++ b/src/sim/index.ts @@ -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'; diff --git a/src/sim/numbers/big-qty.test.ts b/src/sim/numbers/big-qty.test.ts new file mode 100644 index 0000000..0fd4b39 --- /dev/null +++ b/src/sim/numbers/big-qty.test.ts @@ -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'); + }); + }); +}); diff --git a/src/sim/numbers/big-qty.ts b/src/sim/numbers/big-qty.ts new file mode 100644 index 0000000..41c76a5 --- /dev/null +++ b/src/sim/numbers/big-qty.ts @@ -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); + } +} diff --git a/src/sim/numbers/format.test.ts b/src/sim/numbers/format.test.ts new file mode 100644 index 0000000..2f81bd4 --- /dev/null +++ b/src/sim/numbers/format.test.ts @@ -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'); + }); +}); diff --git a/src/sim/numbers/format.ts b/src/sim/numbers/format.ts new file mode 100644 index 0000000..6e24dda --- /dev/null +++ b/src/sim/numbers/format.ts @@ -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); +} diff --git a/src/sim/numbers/index.ts b/src/sim/numbers/index.ts new file mode 100644 index 0000000..6a4fac0 --- /dev/null +++ b/src/sim/numbers/index.ts @@ -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'; diff --git a/src/sim/scheduler/catchup.test.ts b/src/sim/scheduler/catchup.test.ts new file mode 100644 index 0000000..2b6aa01 --- /dev/null +++ b/src/sim/scheduler/catchup.test.ts @@ -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); + }); +}); diff --git a/src/sim/scheduler/catchup.ts b/src/sim/scheduler/catchup.ts new file mode 100644 index 0000000..a44bb2d --- /dev/null +++ b/src/sim/scheduler/catchup.ts @@ -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, + }; +} diff --git a/src/sim/scheduler/clock.test.ts b/src/sim/scheduler/clock.test.ts new file mode 100644 index 0000000..851f227 --- /dev/null +++ b/src/sim/scheduler/clock.test.ts @@ -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); + }); +}); diff --git a/src/sim/scheduler/clock.ts b/src/sim/scheduler/clock.ts new file mode 100644 index 0000000..e9bce37 --- /dev/null +++ b/src/sim/scheduler/clock.ts @@ -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; + } +} diff --git a/src/sim/scheduler/index.ts b/src/sim/scheduler/index.ts new file mode 100644 index 0000000..efd8f82 --- /dev/null +++ b/src/sim/scheduler/index.ts @@ -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'; diff --git a/src/sim/scheduler/tick.test.ts b/src/sim/scheduler/tick.test.ts new file mode 100644 index 0000000..b5a12c7 --- /dev/null +++ b/src/sim/scheduler/tick.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/sim/scheduler/tick.ts b/src/sim/scheduler/tick.ts new file mode 100644 index 0000000..05e4ac6 --- /dev/null +++ b/src/sim/scheduler/tick.ts @@ -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, + }; +} diff --git a/src/sim/state.ts b/src/sim/state.ts new file mode 100644 index 0000000..a308537 --- /dev/null +++ b/src/sim/state.ts @@ -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; + }; +}