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:
Generated
+40
-3
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user