feat: bootstrap walking skeleton

This commit is contained in:
ginnoir
2026-06-11 17:06:52 -05:00
commit ec8f857845
44 changed files with 6677 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState, startAction, tickGame } from '../game';
function testContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }],
actions: [
{ id: 'forage', name: 'Forage', durationMs: 300, yields: { resourceId: 'gold', amount: 2 } },
],
});
}
describe('createGameState()', () => {
it('seeds resource amounts from their start amounts with no active action', () => {
const state = createGameState(testContent());
expect(state.resources.gold).toBe(5);
expect(state.activeActionId).toBeNull();
expect(state.actionElapsedMs).toBe(0);
});
});
describe('startAction()', () => {
it('activates the action and resets its progress', () => {
const content = testContent();
const state = createGameState(content);
state.actionElapsedMs = 999;
startAction(state, content, 'forage');
expect(state.activeActionId).toBe('forage');
expect(state.actionElapsedMs).toBe(0);
});
it('throws on an unknown action id', () => {
const content = testContent();
const state = createGameState(content);
expect(() => startAction(state, content, 'nope')).toThrow(/unknown action/i);
});
});
describe('tickGame()', () => {
it('does nothing when no action is active', () => {
const content = testContent();
const state = createGameState(content);
tickGame(state, content, 100);
expect(state.resources.gold).toBe(5);
});
it('advances action progress without yielding before completion', () => {
const content = testContent();
const state = createGameState(content);
startAction(state, content, 'forage');
tickGame(state, content, 100); // 100 of 300ms
expect(state.resources.gold).toBe(5);
expect(state.actionElapsedMs).toBe(100);
});
it('grants the yield on completion and repeats, carrying the remainder', () => {
const content = testContent();
const state = createGameState(content);
startAction(state, content, 'forage');
tickGame(state, content, 100);
tickGame(state, content, 100);
tickGame(state, content, 100); // 300ms -> one completion, +2 gold
expect(state.resources.gold).toBe(7);
expect(state.actionElapsedMs).toBe(0);
});
it('handles multiple completions within a single large tick (offline catch-up)', () => {
const content = testContent();
const state = createGameState(content);
startAction(state, content, 'forage');
tickGame(state, content, 1000); // 3 completions (900ms) + 100ms remainder
expect(state.resources.gold).toBe(11); // 5 + 3*2
expect(state.actionElapsedMs).toBe(100);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { formatNum, num } from '../num';
describe('num()', () => {
it('returns the same numeric value, branded', () => {
expect(num(42)).toBe(42);
expect(num(0)).toBe(0);
expect(num(-7.5)).toBe(-7.5);
});
it('rejects non-finite values', () => {
expect(() => num(Number.NaN)).toThrow(RangeError);
expect(() => num(Number.POSITIVE_INFINITY)).toThrow(RangeError);
expect(() => num(Number.NEGATIVE_INFINITY)).toThrow(RangeError);
});
});
describe('formatNum()', () => {
it('formats values under 1000 without a suffix', () => {
expect(formatNum(0)).toBe('0');
expect(formatNum(42)).toBe('42');
expect(formatNum(999)).toBe('999');
});
it('trims fractional values to at most two decimals', () => {
expect(formatNum(12.5)).toBe('12.5');
expect(formatNum(12.3456)).toBe('12.35');
expect(formatNum(7.0)).toBe('7');
});
it('uses K / M / B suffixes for larger magnitudes', () => {
expect(formatNum(1000)).toBe('1K');
expect(formatNum(1234)).toBe('1.23K');
expect(formatNum(12_000)).toBe('12K');
expect(formatNum(1_500_000)).toBe('1.5M');
expect(formatNum(2_000_000_000)).toBe('2B');
});
it('never uses scientific notation, even past a billion', () => {
const s = formatNum(1_500_000_000_000);
expect(s).not.toMatch(/e/i);
expect(s).toBe('1,500B');
});
it('preserves sign for negative values', () => {
expect(formatNum(-42)).toBe('-42');
expect(formatNum(-1500)).toBe('-1.5K');
});
});
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState, startAction } from '../game';
import {
applyOfflineProgress,
createSave,
deserializeSave,
fromExportString,
SAVE_VERSION,
serializeSave,
toExportString,
} from '../save';
function testContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
],
});
}
function sampleState() {
const content = testContent();
const state = createGameState(content);
state.resources.gold = 12;
startAction(state, content, 'forage');
state.actionElapsedMs = 500;
return state;
}
describe('createSave()', () => {
it('stamps the current version and timestamp around a snapshot of state', () => {
const save = createSave(sampleState(), 1700);
expect(save.version).toBe(SAVE_VERSION);
expect(save.savedAt).toBe(1700);
expect(save.state.resources.gold).toBe(12);
expect(save.state.activeActionId).toBe('forage');
});
it('snapshots state so later mutation does not affect the save', () => {
const state = sampleState();
const save = createSave(state, 1700);
state.resources.gold = 999;
expect(save.state.resources.gold).toBe(12);
});
});
describe('serialize / deserialize round-trip', () => {
it('survives JSON serialization unchanged', () => {
const save = createSave(sampleState(), 1700);
const restored = deserializeSave(serializeSave(save));
expect(restored).toEqual(save);
});
it('survives the compressed export string unchanged', () => {
const save = createSave(sampleState(), 1700);
const restored = fromExportString(toExportString(save));
expect(restored).toEqual(save);
});
});
describe('invalid / tampered saves', () => {
it('rejects a non-JSON export string cleanly', () => {
expect(() => fromExportString('@@@not-a-valid-payload@@@')).toThrow();
});
it('rejects JSON that does not match the save schema', () => {
expect(() => deserializeSave('{"version":1,"savedAt":1,"state":{}}')).toThrow();
});
it('rejects an unsupported save version', () => {
const future = JSON.stringify({
version: 999,
savedAt: 1,
state: { resources: {}, activeActionId: null, actionElapsedMs: 0 },
});
expect(() => deserializeSave(future)).toThrow(/version/i);
});
});
describe('applyOfflineProgress()', () => {
it('credits whole ticks of elapsed time to the active action', () => {
const content = testContent();
const state = createGameState(content);
startAction(state, content, 'forage'); // 3000ms per gold
const credited = applyOfflineProgress(state, content, 1000, 1000 + 9000); // 9s
expect(credited).toBe(9000);
expect(state.resources.gold).toBe(3); // 9000 / 3000
});
it('credits nothing when the clock did not advance', () => {
const content = testContent();
const state = createGameState(content);
startAction(state, content, 'forage');
expect(applyOfflineProgress(state, content, 5000, 4000)).toBe(0);
expect(state.resources.gold).toBe(0);
});
it('clamps credited time to the offline cap', () => {
const content = testContent();
const state = createGameState(content);
startAction(state, content, 'forage');
const credited = applyOfflineProgress(state, content, 0, 10_000_000, 6000);
expect(credited).toBe(6000);
expect(state.resources.gold).toBe(2); // 6000 / 3000
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { advance, createTickLoop } from '../tickLoop';
describe('createTickLoop()', () => {
it('defaults to the standard tick rate and an empty accumulator', () => {
const loop = createTickLoop();
expect(loop.tickMs).toBe(100);
expect(loop.tickCount).toBe(0);
expect(loop.accumulatorMs).toBe(0);
});
it('accepts a custom tick length and start timestamp', () => {
const loop = createTickLoop({ tickMs: 250, startNow: 1000 });
expect(loop.tickMs).toBe(250);
expect(loop.lastNow).toBe(1000);
});
});
describe('advance()', () => {
it('establishes a baseline without running ticks on the first call when no startNow given', () => {
const loop = createTickLoop({ tickMs: 100 });
expect(advance(loop, 5000)).toBe(0);
expect(loop.lastNow).toBe(5000);
expect(advance(loop, 5100)).toBe(1);
});
it('runs one tick per whole tickMs elapsed and keeps the remainder', () => {
const loop = createTickLoop({ tickMs: 100, startNow: 0 });
expect(advance(loop, 350)).toBe(3);
expect(loop.tickCount).toBe(3);
expect(loop.accumulatorMs).toBe(50);
});
it('invokes onTick once per tick with an increasing tick index', () => {
const loop = createTickLoop({ tickMs: 100, startNow: 0 });
const seen: number[] = [];
advance(loop, 300, (i) => seen.push(i));
expect(seen).toEqual([1, 2, 3]);
});
it('ignores backward clock movement', () => {
const loop = createTickLoop({ tickMs: 100, startNow: 1000 });
expect(advance(loop, 500)).toBe(0);
expect(loop.lastNow).toBe(500);
expect(loop.tickCount).toBe(0);
});
it('yields the same total tick count for the same elapsed time regardless of chunking', () => {
const tickMs = 100;
const elapsedMs = 10_000;
const expected = 100;
const whole = createTickLoop({ tickMs, startNow: 0 });
expect(advance(whole, elapsedMs)).toBe(expected);
const chunked = createTickLoop({ tickMs, startNow: 0 });
for (let t = 37; t < elapsedMs; t += 37) {
advance(chunked, t);
}
advance(chunked, elapsedMs);
expect(chunked.tickCount).toBe(expected);
});
it('caps ticks per advance and defers the remainder so none are lost (offline catch-up path)', () => {
const loop = createTickLoop({ tickMs: 100, startNow: 0 });
// 10s of elapsed = 100 ticks owed, but cap at 10 per call.
expect(advance(loop, 10_000, undefined, 10)).toBe(10);
expect(loop.tickCount).toBe(10);
let guard = 0;
while (advance(loop, 10_000, undefined, 10) > 0 && guard < 1000) {
guard += 1;
}
expect(loop.tickCount).toBe(100);
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { Content } from '../content/schema';
/**
* Core game state and per-tick simulation.
*
* Pure TS — no React, no DOM, no wall clock. The tick loop (tickLoop.ts) drives
* `tickGame` once per tick; `now`/scheduling lives entirely outside this module.
*/
export interface GameState {
/** resourceId -> current amount. */
resources: Record<string, number>;
/** The action currently running, or null. */
activeActionId: string | null;
/** Progress of the active action, in milliseconds. */
actionElapsedMs: number;
}
export function createGameState(content: Content): GameState {
const resources: Record<string, number> = {};
for (const resource of content.resources) {
resources[resource.id] = resource.startAmount;
}
return { resources, activeActionId: null, actionElapsedMs: 0 };
}
/** Begin running an action, resetting its progress. Throws on an unknown id. */
export function startAction(state: GameState, content: Content, actionId: string): void {
if (!content.actionsById[actionId]) {
throw new Error(`Unknown action "${actionId}"`);
}
state.activeActionId = actionId;
state.actionElapsedMs = 0;
}
/**
* Advance the active action by `tickMs`. Each time it reaches its duration it
* grants its yield and repeats, carrying the remainder — so one large tick (the
* offline catch-up path) can complete an action many times.
*/
export function tickGame(state: GameState, content: Content, tickMs: number): void {
if (!state.activeActionId) {
return;
}
const action = content.actionsById[state.activeActionId];
if (!action) {
return;
}
state.actionElapsedMs += tickMs;
while (state.actionElapsedMs >= action.durationMs) {
state.actionElapsedMs -= action.durationMs;
state.resources[action.yields.resourceId] += action.yields.amount;
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Numbers, engine-side.
*
* The design keeps values human-readable (≤ billions, K/M/B suffixes, never
* scientific notation — see GDD D-0009). Plain JS numbers suffice; the `Num`
* brand documents "this number is a game quantity" without runtime cost. If a
* late prestige layer ever needs arbitrary precision, swap the brand for a
* Decimal wrapper here — call sites that use `num()`/`formatNum()` stay put.
*/
declare const numBrand: unique symbol;
export type Num = number & { readonly [numBrand]: true };
/** Brand a finite number as a game quantity. Throws on NaN/Infinity. */
export function num(value: number): Num {
if (!Number.isFinite(value)) {
throw new RangeError(`num() requires a finite value, got ${value}`);
}
return value as Num;
}
const SUFFIX_TIERS = [
{ limit: 1e9, div: 1e9, suffix: 'B' },
{ limit: 1e6, div: 1e6, suffix: 'M' },
{ limit: 1e3, div: 1e3, suffix: 'K' },
] as const;
/** Round to ≤2 decimals, trim trailing zeros, add thousands separators. */
function formatScaled(value: number): string {
const rounded = Math.round(value * 100) / 100;
const raw = Number.isInteger(rounded)
? String(rounded)
: rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
const [intPart, fracPart] = raw.split('.');
const withSeparators = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return fracPart ? `${withSeparators}.${fracPart}` : withSeparators;
}
/** Human-readable rendering: `1.23K`, `1.5M`, `2B`, `1,500B`. No `e` notation. */
export function formatNum(value: Num | number): string {
const n = value as number;
if (!Number.isFinite(n)) {
return '0';
}
const sign = n < 0 ? '-' : '';
const abs = Math.abs(n);
for (const tier of SUFFIX_TIERS) {
if (abs >= tier.limit) {
return `${sign}${formatScaled(abs / tier.div)}${tier.suffix}`;
}
}
return `${sign}${formatScaled(abs)}`;
}
+98
View File
@@ -0,0 +1,98 @@
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string';
import { z } from 'zod';
import type { Content } from '../content/schema';
import type { GameState } from './game';
import { tickGame } from './game';
import { TICK_MS } from './tickLoop';
/**
* Save format, serialization, and offline crediting.
*
* Pure TS — no IndexedDB, no DOM. The IO adapter (idb-keyval + localStorage
* fallback) lives in src/state/persistence.ts; this module only turns game state
* into a validated, versioned, compressed string and back. Saves carry a
* `version` so future migrations have a hook; for now a version mismatch is
* rejected cleanly rather than silently coerced.
*/
export const SAVE_VERSION = 1;
/** Default cap on credited offline time: 24 hours. */
export const DEFAULT_MAX_OFFLINE_MS = 24 * 60 * 60 * 1000;
export const gameStateSchema = z.object({
resources: z.record(z.string(), z.number()),
activeActionId: z.string().nullable(),
actionElapsedMs: z.number().nonnegative(),
});
export const saveSchema = z.object({
version: z.number().int().nonnegative(),
savedAt: z.number().nonnegative(),
state: gameStateSchema,
});
export type SaveData = z.infer<typeof saveSchema>;
/** Snapshot the current state into a versioned, timestamped save. */
export function createSave(state: GameState, now: number): SaveData {
return {
version: SAVE_VERSION,
savedAt: now,
state: {
resources: { ...state.resources },
activeActionId: state.activeActionId,
actionElapsedMs: state.actionElapsedMs,
},
};
}
export function serializeSave(save: SaveData): string {
return JSON.stringify(save);
}
/** Parse + validate a save from JSON. Throws on malformed or unsupported saves. */
export function deserializeSave(json: string): SaveData {
const parsed: unknown = JSON.parse(json);
const save = saveSchema.parse(parsed);
if (save.version !== SAVE_VERSION) {
throw new Error(
`Unsupported save version ${save.version} (this build expects ${SAVE_VERSION})`,
);
}
return save;
}
export function toExportString(save: SaveData): string {
return compressToEncodedURIComponent(serializeSave(save));
}
/** Decompress + validate an export string. Throws cleanly on tampered input. */
export function fromExportString(compressed: string): SaveData {
const json = decompressFromEncodedURIComponent(compressed);
if (json === null || json === '') {
throw new Error('Save string is corrupt or empty');
}
return deserializeSave(json);
}
/**
* Credit elapsed wall-clock time to the running simulation on load, using the
* same per-tick path the live loop uses. Elapsed is clamped to `maxOfflineMs`
* and floored to whole ticks. Returns the milliseconds actually credited.
*/
export function applyOfflineProgress(
state: GameState,
content: Content,
savedAt: number,
now: number,
maxOfflineMs: number = DEFAULT_MAX_OFFLINE_MS,
): number {
const elapsed = Math.max(0, now - savedAt);
const credited = Math.min(elapsed, maxOfflineMs);
const ticks = Math.floor(credited / TICK_MS);
for (let i = 0; i < ticks; i += 1) {
tickGame(state, content, TICK_MS);
}
return credited;
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Fixed-timestep tick loop with an accumulator.
*
* The engine advances in whole ticks of `tickMs`. `advance(loop, now)` folds the
* elapsed wall-clock time into an accumulator and drains it one tick at a time,
* keeping the sub-tick remainder so results are independent of how the caller
* chunks calls (determinism — see the test suite).
*
* Offline catch-up is the *same* code path: a large `now` delta simply owes many
* ticks. `maxTicks` caps how many run per call so a huge delta can't lock the
* thread; the remainder stays in the accumulator and drains on the next call,
* so no ticks are ever lost. Clamping the *credited* offline window lives in the
* save layer (T3.3), not here.
*/
export const TICK_HZ = 10;
export const TICK_MS = 1000 / TICK_HZ;
export const DEFAULT_MAX_TICKS_PER_ADVANCE = 100_000;
export interface TickLoop {
/** Milliseconds of simulated time per tick. */
readonly tickMs: number;
/** Total ticks run over this loop's lifetime. */
tickCount: number;
/** Sub-tick wall-clock remainder, in milliseconds. */
accumulatorMs: number;
/** Last timestamp seen, or null until the first `advance` establishes a baseline. */
lastNow: number | null;
}
export function createTickLoop(opts?: { tickMs?: number; startNow?: number }): TickLoop {
const tickMs = opts?.tickMs ?? TICK_MS;
if (!(tickMs > 0)) {
throw new RangeError(`tickMs must be > 0, got ${tickMs}`);
}
return {
tickMs,
tickCount: 0,
accumulatorMs: 0,
lastNow: opts?.startNow ?? null,
};
}
/**
* Advance the loop to `now`, running owed ticks (capped at `maxTicks`).
* Returns the number of ticks run this call.
*/
export function advance(
loop: TickLoop,
now: number,
onTick?: (tickIndex: number) => void,
maxTicks: number = DEFAULT_MAX_TICKS_PER_ADVANCE,
): number {
if (loop.lastNow === null) {
loop.lastNow = now;
return 0;
}
const elapsed = now - loop.lastNow;
loop.lastNow = now;
if (elapsed > 0) {
loop.accumulatorMs += elapsed;
}
let ran = 0;
while (loop.accumulatorMs >= loop.tickMs && ran < maxTicks) {
loop.accumulatorMs -= loop.tickMs;
loop.tickCount += 1;
ran += 1;
onTick?.(loop.tickCount);
}
return ran;
}