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
+2
View File
@@ -0,0 +1,2 @@
# Data-driven, zod-validated content definitions (resources, actions, story nodes).
# Populated in T3.2 (resource + action) and M1 (story graph).
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../schema';
const validResources = [{ id: 'gold', name: 'Gold' }];
const validActions = [
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
];
describe('buildContent()', () => {
it('validates definitions and indexes them by id', () => {
const content = buildContent({ resources: validResources, actions: validActions });
expect(content.resourcesById.gold.name).toBe('Gold');
expect(content.actionsById.forage.durationMs).toBe(3000);
});
it('applies the startAmount default of 0', () => {
const content = buildContent({ resources: validResources, actions: validActions });
expect(content.resourcesById.gold.startAmount).toBe(0);
});
it('rejects an action that yields an unknown resource', () => {
const actions = [
{
id: 'forage',
name: 'Forage',
durationMs: 3000,
yields: { resourceId: 'ghost', amount: 1 },
},
];
expect(() => buildContent({ resources: validResources, actions })).toThrow(/unknown resource/i);
});
it('rejects duplicate resource ids', () => {
const resources = [
{ id: 'gold', name: 'Gold' },
{ id: 'gold', name: 'Gold Again' },
];
expect(() => buildContent({ resources, actions: validActions })).toThrow(/duplicate/i);
});
it('rejects a structurally invalid definition', () => {
const actions = [
{ id: 'forage', name: 'Forage', durationMs: -1, yields: { resourceId: 'gold', amount: 1 } },
];
expect(() => buildContent({ resources: validResources, actions })).toThrow();
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Walking-skeleton content: one resource, one timed action.
*
* M1 expands this into the real resource/action set and the story graph. Kept as
* plain data so it stays diffable and authorable without touching engine code.
*/
export const resourceDefs = [{ id: 'gold', name: 'Gold', startAmount: 0 }];
export const actionDefs = [
{
id: 'forage',
name: 'Forage for coin',
durationMs: 3000,
yields: { resourceId: 'gold', amount: 1 },
},
];
+7
View File
@@ -0,0 +1,7 @@
import { actionDefs, resourceDefs } from './definitions';
import { buildContent } from './schema';
/** The validated, indexed content the engine and view consume. */
export const content = buildContent({ resources: resourceDefs, actions: actionDefs });
export type { ActionDef, Content, ResourceDef } from './schema';
+66
View File
@@ -0,0 +1,66 @@
import { z } from 'zod';
/**
* Content schemas.
*
* Game content (resources, actions, later story nodes) is data, validated at
* load time with zod so a malformed definition fails loudly instead of corrupting
* runtime state. The engine consumes the validated `Content` object and never
* reaches for raw definitions.
*/
export const resourceDefSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
startAmount: z.number().nonnegative().default(0),
});
export const actionDefSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
durationMs: z.number().positive(),
yields: z.object({
resourceId: z.string().min(1),
amount: z.number().positive(),
}),
});
export type ResourceDef = z.infer<typeof resourceDefSchema>;
export type ActionDef = z.infer<typeof actionDefSchema>;
export interface Content {
resources: ResourceDef[];
actions: ActionDef[];
resourcesById: Record<string, ResourceDef>;
actionsById: Record<string, ActionDef>;
}
function indexById<T extends { id: string }>(items: T[], kind: string): Record<string, T> {
const byId: Record<string, T> = {};
for (const item of items) {
if (byId[item.id]) {
throw new Error(`Duplicate ${kind} id "${item.id}"`);
}
byId[item.id] = item;
}
return byId;
}
/** Validate raw definitions and build the indexed, referentially-checked Content. */
export function buildContent(input: { resources: unknown[]; actions: unknown[] }): Content {
const resources = input.resources.map((r) => resourceDefSchema.parse(r));
const actions = input.actions.map((a) => actionDefSchema.parse(a));
const resourcesById = indexById(resources, 'resource');
const actionsById = indexById(actions, 'action');
for (const action of actions) {
if (!resourcesById[action.yields.resourceId]) {
throw new Error(
`Action "${action.id}" yields unknown resource "${action.yields.resourceId}"`,
);
}
}
return { resources, actions, resourcesById, actionsById };
}
+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;
}
+12
View File
@@ -0,0 +1,12 @@
@import "tailwindcss";
:root {
color-scheme: dark;
}
html,
body {
margin: 0;
min-height: 100dvh;
background-color: #020617; /* slate-950 */
}
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import { App } from './ui/App';
const root = document.getElementById('root');
if (!root) {
throw new Error('Root element #root not found');
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+1
View File
@@ -0,0 +1 @@
# Zustand bridge: engine snapshots -> view (~10 fps). Built in T3.4.
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState, startAction } from '../../engine/game';
import { createSave, serializeSave } from '../../engine/save';
import { createMemoryBackend, loadGame, saveGame } from '../persistence';
function testContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
],
});
}
describe('loadGame()', () => {
it('returns a fresh game when no save exists', async () => {
const content = testContent();
const result = await loadGame(content, createMemoryBackend(), 1000);
expect(result.state.resources.gold).toBe(0);
expect(result.offlineMs).toBe(0);
});
it('round-trips a saved game and credits offline progress', async () => {
const content = testContent();
const state = createGameState(content);
state.resources.gold = 10;
startAction(state, content, 'forage');
const backend = createMemoryBackend();
await saveGame(state, backend, 1000);
const result = await loadGame(content, backend, 1000 + 9000); // 9s offline
expect(result.offlineMs).toBe(9000);
expect(result.state.resources.gold).toBe(13); // 10 + 9000/3000
expect(result.state.activeActionId).toBe('forage');
});
it('falls back to a fresh game on a corrupt save instead of throwing', async () => {
const content = testContent();
const result = await loadGame(content, createMemoryBackend('@@@garbage@@@'), 1000);
expect(result.state.resources.gold).toBe(0);
expect(result.offlineMs).toBe(0);
});
it('drops an active action that no longer exists in content', async () => {
const content = testContent();
const stale = serializeSave(
createSave(
{ resources: { gold: 1 }, activeActionId: 'ghost-action', actionElapsedMs: 0 },
1000,
),
);
const result = await loadGame(content, createMemoryBackend(stale), 1000);
expect(result.state.activeActionId).toBeNull();
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState, startAction } from '../../engine/game';
import { formatOfflineDuration, toView } from '../viewModel';
function testContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
actions: [
{ id: 'forage', name: 'Forage', durationMs: 200, yields: { resourceId: 'gold', amount: 1 } },
],
});
}
describe('toView()', () => {
it('maps resources with their names and amounts', () => {
const content = testContent();
const view = toView(createGameState(content), content);
expect(view.resources).toEqual([{ id: 'gold', name: 'Gold', amount: 4 }]);
});
it('reports no progress and no action name when idle', () => {
const content = testContent();
const view = toView(createGameState(content), content);
expect(view.activeActionId).toBeNull();
expect(view.actionName).toBeNull();
expect(view.actionProgress).toBe(0);
});
it('reports the active action name and fractional progress', () => {
const content = testContent();
const state = createGameState(content);
startAction(state, content, 'forage');
state.actionElapsedMs = 50; // of 200ms
const view = toView(state, content);
expect(view.actionName).toBe('Forage');
expect(view.actionProgress).toBeCloseTo(0.25);
});
it('clamps progress to at most 1', () => {
const content = testContent();
const state = createGameState(content);
startAction(state, content, 'forage');
state.actionElapsedMs = 999;
expect(toView(state, content).actionProgress).toBe(1);
});
});
describe('formatOfflineDuration()', () => {
it('formats sub-minute durations in seconds', () => {
expect(formatOfflineDuration(0)).toBe('0s');
expect(formatOfflineDuration(45_000)).toBe('45s');
});
it('formats minutes with seconds', () => {
expect(formatOfflineDuration(90_000)).toBe('1m 30s');
expect(formatOfflineDuration(120_000)).toBe('2m');
});
it('formats hours with minutes', () => {
expect(formatOfflineDuration(3_660_000)).toBe('1h 1m');
expect(formatOfflineDuration(7_200_000)).toBe('2h');
});
});
+117
View File
@@ -0,0 +1,117 @@
import { del, get, set } from 'idb-keyval';
import type { Content } from '../content/schema';
import { createGameState, type GameState } from '../engine/game';
import { applyOfflineProgress, createSave, deserializeSave, serializeSave } from '../engine/save';
/**
* Persistence adapter (IO layer — deliberately outside the pure engine).
*
* A `SaveBackend` is a pluggable string store; the orchestration (`loadGame`,
* `saveGame`) turns it into typed game state via the engine's save module and
* credits offline progress on boot. The real backend prefers IndexedDB
* (idb-keyval) and falls back to localStorage, then to an in-memory store so the
* game still runs (without persistence) in hostile environments.
*/
export const SAVE_KEY = 'idlegame:save:v1';
export interface SaveBackend {
load(): Promise<string | null>;
save(serialized: string): Promise<void>;
clear(): Promise<void>;
}
export function createMemoryBackend(initial: string | null = null): SaveBackend {
let value = initial;
return {
load: () => Promise.resolve(value),
save: (serialized) => {
value = serialized;
return Promise.resolve();
},
clear: () => {
value = null;
return Promise.resolve();
},
};
}
function createLocalStorageBackend(key: string): SaveBackend {
return {
load: () => Promise.resolve(globalThis.localStorage.getItem(key)),
save: (serialized) => {
globalThis.localStorage.setItem(key, serialized);
return Promise.resolve();
},
clear: () => {
globalThis.localStorage.removeItem(key);
return Promise.resolve();
},
};
}
function createIdbBackend(key: string): SaveBackend {
return {
load: async () => (await get<string>(key)) ?? null,
save: (serialized) => set(key, serialized),
clear: () => del(key),
};
}
/** Choose the best available backend for the current environment. */
export function createDefaultBackend(key: string = SAVE_KEY): SaveBackend {
if (typeof indexedDB !== 'undefined') {
return createIdbBackend(key);
}
if (typeof localStorage !== 'undefined') {
return createLocalStorageBackend(key);
}
return createMemoryBackend();
}
export interface LoadResult {
state: GameState;
/** Milliseconds of offline time credited on this load (0 for a fresh game). */
offlineMs: number;
}
/**
* Load and hydrate game state, crediting offline progress. A missing or corrupt
* save yields a fresh game rather than throwing — never block boot on bad data.
*/
export async function loadGame(
content: Content,
backend: SaveBackend,
now: number,
): Promise<LoadResult> {
const raw = await backend.load();
if (!raw) {
return { state: createGameState(content), offlineMs: 0 };
}
let savedAt: number;
let state: GameState;
try {
const save = deserializeSave(raw);
const base = createGameState(content);
const activeActionId =
save.state.activeActionId && content.actionsById[save.state.activeActionId]
? save.state.activeActionId
: null;
state = {
resources: { ...base.resources, ...save.state.resources },
activeActionId,
actionElapsedMs: save.state.actionElapsedMs,
};
savedAt = save.savedAt;
} catch {
return { state: createGameState(content), offlineMs: 0 };
}
const offlineMs = applyOfflineProgress(state, content, savedAt, now);
return { state, offlineMs };
}
export async function saveGame(state: GameState, backend: SaveBackend, now: number): Promise<void> {
await backend.save(serializeSave(createSave(state, now)));
}
+120
View File
@@ -0,0 +1,120 @@
import { content } from '../content';
import { startAction as engineStartAction, type GameState, tickGame } from '../engine/game';
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
import { useGameStore } from './store';
import { formatOfflineDuration, toView } from './viewModel';
/**
* The game runtime: the one place that owns the engine state and the wall clock.
*
* It drives the fixed-timestep tick loop off requestAnimationFrame, mirrors a
* view snapshot into the Zustand store at ~10 fps, autosaves on an interval, and
* persists on tab-hide / unload. Boot loads the save and credits offline time.
* All environment coupling (RAF, Date.now, DOM events, IndexedDB) lives here so
* the engine stays pure.
*/
const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh
const AUTOSAVE_INTERVAL_MS = 10_000;
class GameRuntime {
private state: GameState | null = null;
private readonly loop: TickLoop = createTickLoop();
private readonly backend: SaveBackend = createDefaultBackend();
private rafId: number | null = null;
private lastPublishAt = 0;
private lastSaveAt = 0;
private booted = false;
async boot(): Promise<void> {
if (this.booted) {
return;
}
this.booted = true;
const now = Date.now();
const { state, offlineMs } = await loadGame(content, this.backend, now);
this.state = state;
this.lastSaveAt = now;
const store = useGameStore.getState();
store.appendLog(
offlineMs >= 1000
? `Welcome back — credited ${formatOfflineDuration(offlineMs)} of offline progress.`
: 'A new tale begins. Choose an action.',
);
this.publish();
this.installLifecycleHooks();
this.rafId = requestAnimationFrame(this.frame);
}
/** Stop the loop. Used on teardown (e.g. HMR dispose); not needed in normal play. */
stop(): void {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
startAction(actionId: string): void {
const state = this.state;
if (!state) {
return;
}
engineStartAction(state, content, actionId);
const action = content.actionsById[actionId];
if (action) {
useGameStore.getState().appendLog(`Started: ${action.name}.`);
}
this.publish();
}
private readonly frame = (monoNow: number): void => {
const state = this.state;
if (state) {
advance(this.loop, monoNow, () => tickGame(state, content, TICK_MS));
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
this.publish();
this.lastPublishAt = monoNow;
}
const wall = Date.now();
if (wall - this.lastSaveAt >= AUTOSAVE_INTERVAL_MS) {
void this.save(wall);
}
}
this.rafId = requestAnimationFrame(this.frame);
};
private publish(): void {
const state = this.state;
if (state) {
useGameStore.getState().setView(toView(state, content));
}
}
private async save(now: number = Date.now()): Promise<void> {
const state = this.state;
if (!state) {
return;
}
this.lastSaveAt = now;
await saveGame(state, this.backend, now);
}
private installLifecycleHooks(): void {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
void this.save();
}
});
window.addEventListener('beforeunload', () => {
void this.save();
});
}
}
export const gameRuntime = new GameRuntime();
+26
View File
@@ -0,0 +1,26 @@
import { create } from 'zustand';
import type { GameView } from './viewModel';
/**
* The view store. The runtime owns the authoritative engine state and pushes a
* fresh view snapshot here at ~10 fps; React components subscribe to slices of
* it. The store is a dumb mirror plus an event log — no game logic lives here.
*/
const MAX_LOG_LINES = 50;
export interface GameStoreState extends GameView {
log: string[];
setView: (view: GameView) => void;
appendLog: (line: string) => void;
}
export const useGameStore = create<GameStoreState>((set) => ({
resources: [],
activeActionId: null,
actionName: null,
actionProgress: 0,
log: [],
setView: (view) => set(view),
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
}));
+55
View File
@@ -0,0 +1,55 @@
import type { Content } from '../content/schema';
import type { GameState } from '../engine/game';
/**
* Pure mapping from engine state to the view model the React shell renders.
* No React, no store — just data shaping, so it can be unit-tested directly.
*/
export interface ResourceView {
id: string;
name: string;
amount: number;
}
export interface GameView {
resources: ResourceView[];
activeActionId: string | null;
actionName: string | null;
/** Progress of the active action, clamped to 0..1. */
actionProgress: number;
}
export function toView(state: GameState, content: Content): GameView {
const resources: ResourceView[] = content.resources.map((resource) => ({
id: resource.id,
name: resource.name,
amount: state.resources[resource.id] ?? 0,
}));
const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined;
const actionProgress = action ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0;
return {
resources,
activeActionId: state.activeActionId,
actionName: action ? action.name : null,
actionProgress,
};
}
/** Render an offline gap as `45s`, `1m 30s`, `2m`, `1h 1m`, `2h`. */
export function formatOfflineDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) {
const seconds = totalSeconds % 60;
return seconds === 0 ? `${totalMinutes}m` : `${totalMinutes}m ${seconds}s`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
}
+38
View File
@@ -0,0 +1,38 @@
import { content } from '../content';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
/** Action list — a start button per action, with a progress bar on the active one. */
export function ActionPanel() {
const activeActionId = useGameStore((state) => state.activeActionId);
const actionProgress = useGameStore((state) => state.actionProgress);
return (
<section aria-label="Actions" className="flex flex-col gap-2">
<h2 className="font-medium text-slate-300 text-sm uppercase tracking-wide">Actions</h2>
{content.actions.map((action) => {
const isActive = action.id === activeActionId;
return (
<button
type="button"
key={action.id}
onClick={() => gameRuntime.startAction(action.id)}
className="relative overflow-hidden rounded-lg border border-slate-700 bg-slate-800/70 px-4 py-3 text-left transition-colors hover:border-amber-500/60 hover:bg-slate-800"
>
{isActive ? (
<div
className="absolute inset-y-0 left-0 bg-amber-500/15"
style={{ width: `${Math.round(actionProgress * 100)}%` }}
aria-hidden="true"
/>
) : null}
<div className="relative flex items-center justify-between">
<span className="font-medium text-slate-100">{action.name}</span>
<span className="text-slate-400 text-xs">{isActive ? 'running…' : 'start'}</span>
</div>
</button>
);
})}
</section>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { gameRuntime } from '../state/runtime';
import { ActionPanel } from './ActionPanel';
import { EventLog } from './EventLog';
import { ResourceBar } from './ResourceBar';
/**
* Walking-skeleton view shell. Boots the runtime once on mount; everything else
* renders from the Zustand store the runtime feeds. M1 turns this into a game.
*/
export function App() {
useEffect(() => {
void gameRuntime.boot();
}, []);
return (
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-5 px-4 py-8 text-slate-100">
<header>
<h1 className="font-bold text-2xl tracking-tight">Idlegame</h1>
<p className="text-slate-500 text-sm">Walking skeleton M0 scaffold.</p>
</header>
<ResourceBar />
<ActionPanel />
<EventLog />
</main>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { useGameStore } from '../state/store';
/** Event log stub — newest entries at the bottom. */
export function EventLog() {
const log = useGameStore((state) => state.log);
return (
<section aria-label="Event log" className="flex flex-col gap-1">
<h2 className="font-medium text-slate-300 text-sm uppercase tracking-wide">Log</h2>
<ol className="flex max-h-48 flex-col gap-1 overflow-y-auto rounded-lg border border-slate-800 bg-slate-900/60 p-3 text-sm">
{log.length === 0 ? (
<li className="text-slate-500"></li>
) : (
log.map((line, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: log is append-only; index is stable
<li key={index} className="text-slate-400">
{line}
</li>
))
)}
</ol>
</section>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { formatNum } from '../engine/num';
import { useGameStore } from '../state/store';
/** Resource readout — name + human-readable amount for each resource. */
export function ResourceBar() {
const resources = useGameStore((state) => state.resources);
return (
<section
aria-label="Resources"
className="flex flex-wrap gap-3 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
>
{resources.length === 0 ? (
<span className="text-slate-500 text-sm">Loading</span>
) : (
resources.map((resource) => (
<div key={resource.id} className="flex items-baseline gap-2">
<span className="text-slate-400 text-sm">{resource.name}</span>
<span className="font-semibold text-amber-400 tabular-nums">
{formatNum(resource.amount)}
</span>
</div>
))
)}
</section>
);
}