feat: bootstrap walking skeleton
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Data-driven, zod-validated content definitions (resources, actions, story nodes).
|
||||
# Populated in T3.2 (resource + action) and M1 (story graph).
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
background-color: #020617; /* slate-950 */
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
# Zustand bridge: engine snapshots -> view (~10 fps). Built in T3.4.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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) })),
|
||||
}));
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user