From e3f8cf7efc271fbc1bb1f0675ac308896a46b87d Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:12:52 -0500 Subject: [PATCH 01/12] test(engine): add tick loop + game determinism integration test --- src/engine/__tests__/determinism.test.ts | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/engine/__tests__/determinism.test.ts diff --git a/src/engine/__tests__/determinism.test.ts b/src/engine/__tests__/determinism.test.ts new file mode 100644 index 0000000..70cce04 --- /dev/null +++ b/src/engine/__tests__/determinism.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { buildContent } from '../../content/schema'; +import { createGameState, startAction, tickGame } from '../game'; +import { advance, createTickLoop, TICK_MS } from '../tickLoop'; + +function testContent() { + return buildContent({ + resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], + actions: [ + { + id: 'forage', + name: 'Forage', + durationMs: 300, + yields: { resourceId: 'gold', amount: 1 }, + }, + ], + }); +} + +function simulate(elapsedMs: number, chunkMs: number) { + const content = testContent(); + const state = createGameState(content); + startAction(state, content, 'forage'); + + const loop = createTickLoop({ tickMs: TICK_MS, startNow: 0 }); + let now = 0; + while (now < elapsedMs) { + const next = Math.min(now + chunkMs, elapsedMs); + advance(loop, next, () => { + tickGame(state, content, TICK_MS); + }); + now = next; + } + return { tickCount: loop.tickCount, gold: state.resources.gold }; +} + +describe('determinism integration', () => { + it('produces identical tick counts and game state regardless of advance chunking', () => { + const whole = simulate(5000, 5000); + const chunked = simulate(5000, 37); + expect(chunked.tickCount).toBe(whole.tickCount); + expect(chunked.gold).toBe(whole.gold); + }); +}); -- 2.54.0 From 37f4460eaad219be86a9b69420c01fb19f7e277c Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:14:28 -0500 Subject: [PATCH 02/12] test(engine): guard against React/DOM imports in engine --- src/engine/__tests__/purity.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/engine/__tests__/purity.test.ts diff --git a/src/engine/__tests__/purity.test.ts b/src/engine/__tests__/purity.test.ts new file mode 100644 index 0000000..5881642 --- /dev/null +++ b/src/engine/__tests__/purity.test.ts @@ -0,0 +1,26 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ENGINE_DIR = join(import.meta.dirname, '..'); +const FORBIDDEN = [/from\s+['"]react/, /from\s+['"]react-dom/, /from\s+['"]zustand/]; + +async function engineSourceFiles(): Promise { + const entries = await readdir(ENGINE_DIR, { withFileTypes: true }); + return entries + .filter((e) => e.isFile() && e.name.endsWith('.ts') && !e.name.endsWith('.test.ts')) + .map((e) => join(ENGINE_DIR, e.name)); +} + +describe('engine purity', () => { + it('does not import React, react-dom, or Zustand', async () => { + const files = await engineSourceFiles(); + expect(files.length).toBeGreaterThan(0); + for (const file of files) { + const source = await readFile(file, 'utf8'); + for (const pattern of FORBIDDEN) { + expect(source, `${file} must stay free of ${pattern}`).not.toMatch(pattern); + } + } + }); +}); -- 2.54.0 From 458a795014bba614ee462ba1aef17af9cec544dc Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:15:27 -0500 Subject: [PATCH 03/12] docs(engine): clarify offline tick batching vs save-layer clamp --- src/engine/save.ts | 4 ++++ src/engine/tickLoop.ts | 12 +++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/engine/save.ts b/src/engine/save.ts index cf9a46a..5636131 100644 --- a/src/engine/save.ts +++ b/src/engine/save.ts @@ -13,6 +13,10 @@ import { TICK_MS } from './tickLoop'; * 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. + * + * Offline credit: `applyOfflineProgress` floors elapsed ms to whole ticks + * (same `TICK_MS` as the live loop) and runs `tickGame` that many times. + * Elapsed beyond `DEFAULT_MAX_OFFLINE_MS` is not credited. */ export const SAVE_VERSION = 1; diff --git a/src/engine/tickLoop.ts b/src/engine/tickLoop.ts index d1ca0f9..eb65af3 100644 --- a/src/engine/tickLoop.ts +++ b/src/engine/tickLoop.ts @@ -6,11 +6,13 @@ * 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. + * Offline catch-up uses this same path: a large `now` delta owes many ticks. + * `maxTicks` caps work per `advance()` call; the accumulator retains the + * remainder so no ticks are lost across calls. + * + * The save layer (`save.ts`) separately clamps how much *wall-clock* elapsed + * time is credited on load via `DEFAULT_MAX_OFFLINE_MS`. Tick loop batching + * and offline credit clamping are independent concerns. */ export const TICK_HZ = 10; -- 2.54.0 From ee3d463c9c8affb0157869e274dd45827a8f632f Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:16:26 -0500 Subject: [PATCH 04/12] feat(content): add action costs and multi-yield schema --- src/content/__tests__/schema.test.ts | 58 ++++++++++++++++++++++-- src/content/definitions.ts | 2 +- src/content/schema.ts | 29 ++++++++---- src/engine/__tests__/determinism.test.ts | 2 +- src/engine/__tests__/game.test.ts | 2 +- src/engine/__tests__/save.test.ts | 2 +- src/engine/game.ts | 4 +- src/state/__tests__/persistence.test.ts | 2 +- src/state/__tests__/viewModel.test.ts | 2 +- 9 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/content/__tests__/schema.test.ts b/src/content/__tests__/schema.test.ts index 6d9a75f..49c7daf 100644 --- a/src/content/__tests__/schema.test.ts +++ b/src/content/__tests__/schema.test.ts @@ -3,7 +3,12 @@ import { buildContent } from '../schema'; const validResources = [{ id: 'gold', name: 'Gold' }]; const validActions = [ - { id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } }, + { + id: 'forage', + name: 'Forage', + durationMs: 3000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, ]; describe('buildContent()', () => { @@ -24,7 +29,7 @@ describe('buildContent()', () => { id: 'forage', name: 'Forage', durationMs: 3000, - yields: { resourceId: 'ghost', amount: 1 }, + yields: [{ resourceId: 'ghost', amount: 1 }], }, ]; expect(() => buildContent({ resources: validResources, actions })).toThrow(/unknown resource/i); @@ -40,8 +45,55 @@ describe('buildContent()', () => { it('rejects a structurally invalid definition', () => { const actions = [ - { id: 'forage', name: 'Forage', durationMs: -1, yields: { resourceId: 'gold', amount: 1 } }, + { + id: 'forage', + name: 'Forage', + durationMs: -1, + yields: [{ resourceId: 'gold', amount: 1 }], + }, ]; expect(() => buildContent({ resources: validResources, actions })).toThrow(); }); }); + +describe('costs and multi-yield', () => { + it('accepts optional costs and multiple yields', () => { + const resources = [ + { id: 'gold', name: 'Gold' }, + { id: 'wood', name: 'Wood' }, + ]; + const actions = [ + { + id: 'craft', + name: 'Craft', + durationMs: 1000, + costs: [{ resourceId: 'wood', amount: 2 }], + yields: [ + { resourceId: 'gold', amount: 1 }, + { resourceId: 'wood', amount: 1 }, + ], + }, + ]; + const content = buildContent({ resources, actions }); + expect(content.actionsById.craft.costs).toEqual([{ resourceId: 'wood', amount: 2 }]); + expect(content.actionsById.craft.yields).toHaveLength(2); + }); + + it('rejects cost referencing unknown resource', () => { + const actions = [ + { + id: 'craft', + name: 'Craft', + durationMs: 1000, + costs: [{ resourceId: 'ghost', amount: 1 }], + yields: [{ resourceId: 'gold', amount: 1 }], + }, + ]; + expect(() => buildContent({ resources: validResources, actions })).toThrow(/unknown resource/i); + }); + + it('defaults costs to empty array', () => { + const content = buildContent({ resources: validResources, actions: validActions }); + expect(content.actionsById.forage.costs).toEqual([]); + }); +}); diff --git a/src/content/definitions.ts b/src/content/definitions.ts index 343827d..d3779ef 100644 --- a/src/content/definitions.ts +++ b/src/content/definitions.ts @@ -12,6 +12,6 @@ export const actionDefs = [ id: 'forage', name: 'Forage for coin', durationMs: 3000, - yields: { resourceId: 'gold', amount: 1 }, + yields: [{ resourceId: 'gold', amount: 1 }], }, ]; diff --git a/src/content/schema.ts b/src/content/schema.ts index b5e023c..2ac353f 100644 --- a/src/content/schema.ts +++ b/src/content/schema.ts @@ -15,17 +15,21 @@ export const resourceDefSchema = z.object({ startAmount: z.number().nonnegative().default(0), }); +export const resourceAmountSchema = z.object({ + resourceId: z.string().min(1), + amount: z.number().positive(), +}); + 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(), - }), + costs: z.array(resourceAmountSchema).default([]), + yields: z.array(resourceAmountSchema).min(1), }); export type ResourceDef = z.infer; +export type ResourceAmount = z.infer; export type ActionDef = z.infer; export interface Content { @@ -55,10 +59,19 @@ export function buildContent(input: { resources: unknown[]; actions: unknown[] } 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}"`, - ); + for (const y of action.yields) { + if (!resourcesById[y.resourceId]) { + throw new Error( + `Action "${action.id}" yields unknown resource "${y.resourceId}"`, + ); + } + } + for (const c of action.costs) { + if (!resourcesById[c.resourceId]) { + throw new Error( + `Action "${action.id}" cost references unknown resource "${c.resourceId}"`, + ); + } } } diff --git a/src/engine/__tests__/determinism.test.ts b/src/engine/__tests__/determinism.test.ts index 70cce04..fd0cd1c 100644 --- a/src/engine/__tests__/determinism.test.ts +++ b/src/engine/__tests__/determinism.test.ts @@ -11,7 +11,7 @@ function testContent() { id: 'forage', name: 'Forage', durationMs: 300, - yields: { resourceId: 'gold', amount: 1 }, + yields: [{ resourceId: 'gold', amount: 1 }], }, ], }); diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index 58c6d5d..b1722e6 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -6,7 +6,7 @@ function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }], actions: [ - { id: 'forage', name: 'Forage', durationMs: 300, yields: { resourceId: 'gold', amount: 2 } }, + { id: 'forage', name: 'Forage', durationMs: 300, yields: [{ resourceId: 'gold', amount: 2 }] }, ], }); } diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index 3725092..bcad6dc 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -15,7 +15,7 @@ function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ - { id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } }, + { id: 'forage', name: 'Forage', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); } diff --git a/src/engine/game.ts b/src/engine/game.ts index e6eeaa6..e637c5c 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -50,6 +50,8 @@ export function tickGame(state: GameState, content: Content, tickMs: number): vo state.actionElapsedMs += tickMs; while (state.actionElapsedMs >= action.durationMs) { state.actionElapsedMs -= action.durationMs; - state.resources[action.yields.resourceId] += action.yields.amount; + for (const y of action.yields) { + state.resources[y.resourceId] += y.amount; + } } } diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index c22d4a4..bd310d8 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -8,7 +8,7 @@ function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ - { id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } }, + { id: 'forage', name: 'Forage', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); } diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index 971e2ff..48ab5b6 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -7,7 +7,7 @@ function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }], actions: [ - { id: 'forage', name: 'Forage', durationMs: 200, yields: { resourceId: 'gold', amount: 1 } }, + { id: 'forage', name: 'Forage', durationMs: 200, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); } -- 2.54.0 From 923a7a3dacd5956ea7b7fb6073e7441dbb555766 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:17:07 -0500 Subject: [PATCH 05/12] feat(content): add action unlock condition schema --- src/content/__tests__/schema.test.ts | 27 +++++++++++++++++++++++++++ src/content/schema.ts | 7 +++++++ 2 files changed, 34 insertions(+) diff --git a/src/content/__tests__/schema.test.ts b/src/content/__tests__/schema.test.ts index 49c7daf..1d2ad19 100644 --- a/src/content/__tests__/schema.test.ts +++ b/src/content/__tests__/schema.test.ts @@ -97,3 +97,30 @@ describe('costs and multi-yield', () => { expect(content.actionsById.forage.costs).toEqual([]); }); }); + +describe('unlock conditions', () => { + it('accepts optional minResources and requireStoryFlags', () => { + const actions = [ + { + id: 'forage', + name: 'Forage', + durationMs: 3000, + yields: [{ resourceId: 'gold', amount: 1 }], + unlock: { + minResources: { gold: 10 }, + requireStoryFlags: ['intro_complete'], + }, + }, + ]; + const content = buildContent({ resources: validResources, actions }); + expect(content.actionsById.forage.unlock).toEqual({ + minResources: { gold: 10 }, + requireStoryFlags: ['intro_complete'], + }); + }); + + it('defaults unlock to undefined when omitted', () => { + const content = buildContent({ resources: validResources, actions: validActions }); + expect(content.actionsById.forage.unlock).toBeUndefined(); + }); +}); diff --git a/src/content/schema.ts b/src/content/schema.ts index 2ac353f..5a92d62 100644 --- a/src/content/schema.ts +++ b/src/content/schema.ts @@ -20,16 +20,23 @@ export const resourceAmountSchema = z.object({ amount: z.number().positive(), }); +export const unlockDefSchema = z.object({ + minResources: z.record(z.string(), z.number().nonnegative()).optional(), + requireStoryFlags: z.array(z.string().min(1)).optional(), +}); + export const actionDefSchema = z.object({ id: z.string().min(1), name: z.string().min(1), durationMs: z.number().positive(), costs: z.array(resourceAmountSchema).default([]), yields: z.array(resourceAmountSchema).min(1), + unlock: unlockDefSchema.optional(), }); export type ResourceDef = z.infer; export type ResourceAmount = z.infer; +export type UnlockDef = z.infer; export type ActionDef = z.infer; export interface Content { -- 2.54.0 From ea2fb0afab5f7a10ed899c1df7daa31c170a277b Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:18:17 -0500 Subject: [PATCH 06/12] feat(engine): add action queue state and enqueue APIs --- src/engine/__tests__/game.test.ts | 84 ++++++++++++++++++++++++- src/engine/game.ts | 39 +++++++++++- src/state/__tests__/persistence.test.ts | 7 ++- src/state/persistence.ts | 1 + 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index b1722e6..1ac0921 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; -import { createGameState, startAction, tickGame } from '../game'; +import { + cancelQueuedAction, + clearQueue, + createGameState, + enqueueAction, + startAction, + tickGame, +} from '../game'; function testContent() { return buildContent({ @@ -11,12 +18,24 @@ function testContent() { }); } +function queueContent() { + return buildContent({ + resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], + actions: [ + { id: 'a', name: 'A', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'b', name: 'B', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'c', name: 'C', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + ], + }); +} + 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); + expect(state.actionQueue).toEqual([]); }); }); @@ -37,6 +56,69 @@ describe('startAction()', () => { }); }); +describe('enqueueAction()', () => { + it('starts immediately when idle', () => { + const content = queueContent(); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + expect(state.activeActionId).toBe('a'); + expect(state.actionElapsedMs).toBe(0); + expect(state.actionQueue).toEqual([]); + }); + + it('queues when another action is active', () => { + const content = queueContent(); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + enqueueAction(state, content, 'b'); + enqueueAction(state, content, 'c'); + expect(state.activeActionId).toBe('a'); + expect(state.actionQueue).toEqual(['b', 'c']); + }); + + it('throws on an unknown action id', () => { + const content = queueContent(); + const state = createGameState(content); + expect(() => enqueueAction(state, content, 'nope')).toThrow(/unknown action/i); + }); +}); + +describe('cancelQueuedAction()', () => { + it('removes a queued action by index', () => { + const content = queueContent(); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + enqueueAction(state, content, 'b'); + enqueueAction(state, content, 'c'); + cancelQueuedAction(state, 0); + expect(state.activeActionId).toBe('a'); + expect(state.actionQueue).toEqual(['c']); + }); + + it('throws RangeError when index is out of range', () => { + const content = queueContent(); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + enqueueAction(state, content, 'b'); + expect(() => cancelQueuedAction(state, 5)).toThrow(RangeError); + }); +}); + +describe('clearQueue()', () => { + it('empties the queue without stopping the active action', () => { + const content = queueContent(); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + enqueueAction(state, content, 'b'); + enqueueAction(state, content, 'c'); + state.actionElapsedMs = 250; + clearQueue(state); + expect(state.activeActionId).toBe('a'); + expect(state.actionElapsedMs).toBe(250); + expect(state.actionQueue).toEqual([]); + }); +}); + describe('tickGame()', () => { it('does nothing when no action is active', () => { const content = testContent(); diff --git a/src/engine/game.ts b/src/engine/game.ts index e637c5c..9797c5f 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -14,6 +14,8 @@ export interface GameState { activeActionId: string | null; /** Progress of the active action, in milliseconds. */ actionElapsedMs: number; + /** Action ids waiting to run after the active action finishes. */ + actionQueue: string[]; } export function createGameState(content: Content): GameState { @@ -21,10 +23,14 @@ export function createGameState(content: Content): GameState { for (const resource of content.resources) { resources[resource.id] = resource.startAmount; } - return { resources, activeActionId: null, actionElapsedMs: 0 }; + return { resources, activeActionId: null, actionElapsedMs: 0, actionQueue: [] }; } -/** Begin running an action, resetting its progress. Throws on an unknown id. */ +/** + * Begin running an action, resetting its progress. Throws on an unknown id. + * + * @deprecated Prefer `enqueueAction` — it starts immediately when idle and queues otherwise. + */ export function startAction(state: GameState, content: Content, actionId: string): void { if (!content.actionsById[actionId]) { throw new Error(`Unknown action "${actionId}"`); @@ -33,6 +39,35 @@ export function startAction(state: GameState, content: Content, actionId: string state.actionElapsedMs = 0; } +/** + * Start an action immediately when idle, otherwise append it to the queue. + * Throws on an unknown id. Cost/unlock checks are not applied yet. + */ +export function enqueueAction(state: GameState, content: Content, actionId: string): void { + if (!content.actionsById[actionId]) { + throw new Error(`Unknown action "${actionId}"`); + } + if (state.activeActionId === null) { + state.activeActionId = actionId; + state.actionElapsedMs = 0; + return; + } + state.actionQueue.push(actionId); +} + +/** Remove a queued action by index. Throws RangeError when out of range. */ +export function cancelQueuedAction(state: GameState, index: number): void { + if (index < 0 || index >= state.actionQueue.length) { + throw new RangeError(`Queue index ${index} is out of range`); + } + state.actionQueue.splice(index, 1); +} + +/** Clear all queued actions without stopping the active action. */ +export function clearQueue(state: GameState): void { + state.actionQueue.length = 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 diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index bd310d8..bdb51e3 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -46,7 +46,12 @@ describe('loadGame()', () => { const content = testContent(); const stale = serializeSave( createSave( - { resources: { gold: 1 }, activeActionId: 'ghost-action', actionElapsedMs: 0 }, + { + resources: { gold: 1 }, + activeActionId: 'ghost-action', + actionElapsedMs: 0, + actionQueue: [], + }, 1000, ), ); diff --git a/src/state/persistence.ts b/src/state/persistence.ts index cc77c3d..7fca78c 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -102,6 +102,7 @@ export async function loadGame( resources: { ...base.resources, ...save.state.resources }, activeActionId, actionElapsedMs: save.state.actionElapsedMs, + actionQueue: [...base.actionQueue], }; savedAt = save.savedAt; } catch { -- 2.54.0 From f514eeedf88ad394d7a391e0c9a8f3af49662efc Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:19:16 -0500 Subject: [PATCH 07/12] feat(engine): costs on start, unlock checks, queue completion advancement --- src/engine/__tests__/determinism.test.ts | 4 +- src/engine/__tests__/game.test.ts | 123 +++++++++++++++++++-- src/engine/__tests__/save.test.ts | 6 +- src/engine/game.ts | 131 ++++++++++++++++++----- src/state/__tests__/persistence.test.ts | 4 +- 5 files changed, 232 insertions(+), 36 deletions(-) diff --git a/src/engine/__tests__/determinism.test.ts b/src/engine/__tests__/determinism.test.ts index fd0cd1c..6e6ef1f 100644 --- a/src/engine/__tests__/determinism.test.ts +++ b/src/engine/__tests__/determinism.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; -import { createGameState, startAction, tickGame } from '../game'; +import { createGameState, enqueueAction, tickGame } from '../game'; import { advance, createTickLoop, TICK_MS } from '../tickLoop'; function testContent() { @@ -20,7 +20,7 @@ function testContent() { function simulate(elapsedMs: number, chunkMs: number) { const content = testContent(); const state = createGameState(content); - startAction(state, content, 'forage'); + enqueueAction(state, content, 'forage'); const loop = createTickLoop({ tickMs: TICK_MS, startNow: 0 }); let now = 0; diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index 1ac0921..57225f1 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -29,6 +29,37 @@ function queueContent() { }); } +function costContent() { + return buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, + ], + actions: [ + { + id: 'gather', + name: 'Gather', + durationMs: 300, + yields: [{ resourceId: 'supplies', amount: 2 }], + }, + { + id: 'trade', + name: 'Trade', + durationMs: 300, + costs: [{ resourceId: 'supplies', amount: 5 }], + yields: [{ resourceId: 'coin', amount: 3 }], + }, + { + id: 'scout', + name: 'Scout', + durationMs: 300, + yields: [{ resourceId: 'coin', amount: 1 }], + unlock: { minResources: { coin: 1 } }, + }, + ], + }); +} + describe('createGameState()', () => { it('seeds resource amounts from their start amounts with no active action', () => { const state = createGameState(testContent()); @@ -119,6 +150,85 @@ describe('clearQueue()', () => { }); }); +describe('costs on start (D-0012)', () => { + it('deducts costs when an action becomes active', () => { + const content = costContent(); + const state = createGameState(content); + enqueueAction(state, content, 'trade'); + expect(state.resources.supplies).toBe(5); + }); + + it('rejects enqueue when unaffordable', () => { + const content = costContent(); + const state = createGameState(content); + state.resources.supplies = 2; + expect(() => enqueueAction(state, content, 'trade')).toThrow(/cannot enqueue/i); + }); +}); + +describe('unlock conditions', () => { + it('rejects locked actions', () => { + const content = costContent(); + const state = createGameState(content); + expect(() => enqueueAction(state, content, 'scout')).toThrow(/cannot enqueue/i); + }); + + it('allows actions once unlock thresholds are met', () => { + const content = costContent(); + const state = createGameState(content); + state.resources.coin = 1; + enqueueAction(state, content, 'scout'); + expect(state.activeActionId).toBe('scout'); + }); +}); + +describe('completion advances queue', () => { + it('starts the next queued action after the active one completes', () => { + const content = costContent(); + const state = createGameState(content); + enqueueAction(state, content, 'gather'); + enqueueAction(state, content, 'gather'); + tickGame(state, content, 300); + expect(state.activeActionId).toBe('gather'); + expect(state.actionQueue).toEqual([]); + expect(state.resources.supplies).toBe(12); + }); + + it('goes idle when the queue is empty after completion', () => { + const content = costContent(); + const state = createGameState(content); + enqueueAction(state, content, 'gather'); + tickGame(state, content, 300); + expect(state.activeActionId).toBeNull(); + expect(state.actionElapsedMs).toBe(0); + }); + + it('grants all yields on completion', () => { + const content = buildContent({ + resources: [ + { id: 'a', name: 'A', startAmount: 0 }, + { id: 'b', name: 'B', startAmount: 0 }, + ], + actions: [ + { + id: 'combo', + name: 'Combo', + durationMs: 100, + yields: [ + { resourceId: 'a', amount: 2 }, + { resourceId: 'b', amount: 3 }, + ], + }, + ], + }); + const state = createGameState(content); + enqueueAction(state, content, 'combo'); + tickGame(state, content, 100); + expect(state.resources.a).toBe(2); + expect(state.resources.b).toBe(3); + }); +}); + describe('tickGame()', () => { it('does nothing when no action is active', () => { const content = testContent(); @@ -136,7 +246,7 @@ describe('tickGame()', () => { expect(state.actionElapsedMs).toBe(100); }); - it('grants the yield on completion and repeats, carrying the remainder', () => { + it('grants the yield on completion then goes idle', () => { const content = testContent(); const state = createGameState(content); startAction(state, content, 'forage'); @@ -144,15 +254,16 @@ describe('tickGame()', () => { tickGame(state, content, 100); tickGame(state, content, 100); // 300ms -> one completion, +2 gold expect(state.resources.gold).toBe(7); + expect(state.activeActionId).toBeNull(); expect(state.actionElapsedMs).toBe(0); }); - it('handles multiple completions within a single large tick (offline catch-up)', () => { + it('handles a single completion within a 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); + enqueueAction(state, content, 'forage'); + tickGame(state, content, 1000); // one completion only unless re-enqueued + expect(state.resources.gold).toBe(7); // 5 + 2 + expect(state.activeActionId).toBeNull(); }); }); diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index bcad6dc..e806006 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -86,7 +86,8 @@ describe('applyOfflineProgress()', () => { 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 + expect(state.resources.gold).toBe(1); // one completion then idle + expect(state.activeActionId).toBeNull(); }); it('credits nothing when the clock did not advance', () => { @@ -103,6 +104,7 @@ describe('applyOfflineProgress()', () => { 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 + expect(state.resources.gold).toBe(1); // one completion then idle + expect(state.activeActionId).toBeNull(); }); }); diff --git a/src/engine/game.ts b/src/engine/game.ts index 9797c5f..b369c34 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -26,33 +26,121 @@ export function createGameState(content: Content): GameState { return { resources, activeActionId: null, actionElapsedMs: 0, actionQueue: [] }; } +function assertKnownAction(content: Content, actionId: string): void { + if (!content.actionsById[actionId]) { + throw new Error(`Unknown action "${actionId}"`); + } +} + +export function canAffordAction(state: GameState, content: Content, actionId: string): boolean { + const action = content.actionsById[actionId]; + if (!action) return false; + return action.costs.every((cost) => (state.resources[cost.resourceId] ?? 0) >= cost.amount); +} + +/** Story flags land in PR2; placeholder field keeps unlock schema honest. */ +export function canUnlockAction( + state: GameState, + content: Content, + actionId: string, + storyFlags: Record = {}, +): boolean { + const action = content.actionsById[actionId]; + if (!action) return false; + const unlock = action.unlock; + if (!unlock) return true; + if (unlock.minResources) { + for (const [resourceId, min] of Object.entries(unlock.minResources)) { + if ((state.resources[resourceId] ?? 0) < min) return false; + } + } + if (unlock.requireStoryFlags) { + for (const flag of unlock.requireStoryFlags) { + if (!storyFlags[flag]) return false; + } + } + return true; +} + +export function isActionAvailable( + state: GameState, + content: Content, + actionId: string, + storyFlags: Record = {}, +): boolean { + return ( + canAffordAction(state, content, actionId) && + canUnlockAction(state, content, actionId, storyFlags) + ); +} + +function deductCosts(state: GameState, content: Content, actionId: string): void { + const action = content.actionsById[actionId]; + if (!action) return; + for (const cost of action.costs) { + state.resources[cost.resourceId] -= cost.amount; + } +} + +function grantYields(state: GameState, content: Content, actionId: string): void { + const action = content.actionsById[actionId]; + if (!action) return; + for (const y of action.yields) { + state.resources[y.resourceId] = (state.resources[y.resourceId] ?? 0) + y.amount; + } +} + +function beginAction(state: GameState, content: Content, actionId: string): void { + assertKnownAction(content, actionId); + deductCosts(state, content, actionId); + state.activeActionId = actionId; + state.actionElapsedMs = 0; +} + +function startNextFromQueue(state: GameState, content: Content): void { + while (state.actionQueue.length > 0) { + const nextId = state.actionQueue.shift()!; + if (isActionAvailable(state, content, nextId)) { + beginAction(state, content, nextId); + return; + } + } + state.activeActionId = null; + state.actionElapsedMs = 0; +} + +function completeActiveAction(state: GameState, content: Content): void { + const actionId = state.activeActionId; + if (!actionId) return; + grantYields(state, content, actionId); + startNextFromQueue(state, content); +} + /** * Begin running an action, resetting its progress. Throws on an unknown id. * * @deprecated Prefer `enqueueAction` — it starts immediately when idle and queues otherwise. */ export function startAction(state: GameState, content: Content, actionId: string): void { - if (!content.actionsById[actionId]) { - throw new Error(`Unknown action "${actionId}"`); - } + assertKnownAction(content, actionId); state.activeActionId = actionId; state.actionElapsedMs = 0; } /** * Start an action immediately when idle, otherwise append it to the queue. - * Throws on an unknown id. Cost/unlock checks are not applied yet. + * Costs deduct on start (D-0012). Throws when unknown, unaffordable, or locked. */ export function enqueueAction(state: GameState, content: Content, actionId: string): void { - if (!content.actionsById[actionId]) { - throw new Error(`Unknown action "${actionId}"`); + assertKnownAction(content, actionId); + if (!isActionAvailable(state, content, actionId)) { + throw new Error(`Cannot enqueue action "${actionId}"`); } if (state.activeActionId === null) { - state.activeActionId = actionId; - state.actionElapsedMs = 0; - return; + beginAction(state, content, actionId); + } else { + state.actionQueue.push(actionId); } - state.actionQueue.push(actionId); } /** Remove a queued action by index. Throws RangeError when out of range. */ @@ -69,24 +157,19 @@ export function clearQueue(state: GameState): void { } /** - * 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. + * Advance the active action by `tickMs`. On completion, grants yields and + * advances the queue — actions do not auto-repeat when the queue is empty. */ export function tickGame(state: GameState, content: Content, tickMs: number): void { - if (!state.activeActionId) { - return; - } - const action = content.actionsById[state.activeActionId]; - if (!action) { - return; - } + if (!state.activeActionId) return; state.actionElapsedMs += tickMs; - while (state.actionElapsedMs >= action.durationMs) { + while (state.activeActionId) { + const action = content.actionsById[state.activeActionId]; + if (!action) return; + if (state.actionElapsedMs < action.durationMs) return; + state.actionElapsedMs -= action.durationMs; - for (const y of action.yields) { - state.resources[y.resourceId] += y.amount; - } + completeActiveAction(state, content); } } diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index bdb51e3..dc8a39d 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -31,8 +31,8 @@ describe('loadGame()', () => { 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'); + expect(result.state.resources.gold).toBe(11); // 10 + one completion + expect(result.state.activeActionId).toBeNull(); }); it('falls back to a fresh game on a corrupt save instead of throwing', async () => { -- 2.54.0 From 964e9260faa52a60d20274bee6a0ce673089d5c9 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:19:49 -0500 Subject: [PATCH 08/12] feat(save): persist action queue in v1 save payload --- src/engine/__tests__/save.test.ts | 9 +++++++++ src/engine/save.ts | 2 ++ src/state/__tests__/persistence.test.ts | 25 ++++++++++++++++++++++++- src/state/persistence.ts | 2 +- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index e806006..2ae64bd 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -44,6 +44,15 @@ describe('createSave()', () => { state.resources.gold = 999; expect(save.state.resources.gold).toBe(12); }); + + it('snapshots actionQueue in save payload', () => { + const state = sampleState(); + state.actionQueue = ['forage', 'forage']; + const save = createSave(state, 1700); + expect(save.state.actionQueue).toEqual(['forage', 'forage']); + state.actionQueue.push('forage'); + expect(save.state.actionQueue).toEqual(['forage', 'forage']); + }); }); describe('serialize / deserialize round-trip', () => { diff --git a/src/engine/save.ts b/src/engine/save.ts index 5636131..7aa6059 100644 --- a/src/engine/save.ts +++ b/src/engine/save.ts @@ -28,6 +28,7 @@ export const gameStateSchema = z.object({ resources: z.record(z.string(), z.number()), activeActionId: z.string().nullable(), actionElapsedMs: z.number().nonnegative(), + actionQueue: z.array(z.string()).default([]), }); export const saveSchema = z.object({ @@ -47,6 +48,7 @@ export function createSave(state: GameState, now: number): SaveData { resources: { ...state.resources }, activeActionId: state.activeActionId, actionElapsedMs: state.actionElapsedMs, + actionQueue: [...state.actionQueue], }, }; } diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index dc8a39d..7e6f696 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; -import { createGameState, startAction } from '../../engine/game'; +import { createGameState, enqueueAction, startAction } from '../../engine/game'; import { createSave, serializeSave } from '../../engine/save'; import { createMemoryBackend, loadGame, saveGame } from '../persistence'; @@ -13,6 +13,16 @@ function testContent() { }); } +function queueTestContent() { + return buildContent({ + resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], + actions: [ + { id: 'a', name: 'A', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'b', name: 'B', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, + ], + }); +} + describe('loadGame()', () => { it('returns a fresh game when no save exists', async () => { const content = testContent(); @@ -42,6 +52,19 @@ describe('loadGame()', () => { expect(result.offlineMs).toBe(0); }); + it('restores actionQueue on load', async () => { + const content = queueTestContent(); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + enqueueAction(state, content, 'b'); + const backend = createMemoryBackend(); + await saveGame(state, backend, 1000); + + const result = await loadGame(content, backend, 1000); + expect(result.state.activeActionId).toBe('a'); + expect(result.state.actionQueue).toEqual(['b']); + }); + it('drops an active action that no longer exists in content', async () => { const content = testContent(); const stale = serializeSave( diff --git a/src/state/persistence.ts b/src/state/persistence.ts index 7fca78c..e4284d6 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -102,7 +102,7 @@ export async function loadGame( resources: { ...base.resources, ...save.state.resources }, activeActionId, actionElapsedMs: save.state.actionElapsedMs, - actionQueue: [...base.actionQueue], + actionQueue: [...(save.state.actionQueue ?? [])], }; savedAt = save.savedAt; } catch { -- 2.54.0 From 7625e081fd98dd794695b7ed140960bc8b63246e Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:20:26 -0500 Subject: [PATCH 09/12] feat(content): add M1 stub resource and action pack --- src/content/__tests__/definitions.test.ts | 24 +++++++++++++++++++++++ src/content/definitions.ts | 23 +++++++++------------- 2 files changed, 33 insertions(+), 14 deletions(-) create mode 100644 src/content/__tests__/definitions.test.ts diff --git a/src/content/__tests__/definitions.test.ts b/src/content/__tests__/definitions.test.ts new file mode 100644 index 0000000..0a5eb91 --- /dev/null +++ b/src/content/__tests__/definitions.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { createGameState, enqueueAction, tickGame } from '../../engine/game'; +import { content } from '../index'; + +describe('M1 stub content pack', () => { + it('defines two resources and four to five actions with costs and unlocks', () => { + expect(content.resources).toHaveLength(2); + expect(content.actions.length).toBeGreaterThanOrEqual(4); + expect(content.actions.length).toBeLessThanOrEqual(5); + const withCosts = content.actions.filter((a) => a.costs.length > 0); + const withUnlocks = content.actions.filter((a) => a.unlock !== undefined); + expect(withCosts.length).toBeGreaterThanOrEqual(2); + expect(withUnlocks.length).toBeGreaterThanOrEqual(1); + }); + + it('can simulate a costed action without throwing', () => { + const state = createGameState(content); + const trade = content.actions.find((a) => a.costs.length > 0); + expect(trade).toBeDefined(); + enqueueAction(state, content, trade!.id); + tickGame(state, content, trade!.durationMs); + expect(state.activeActionId).toBeNull(); + }); +}); diff --git a/src/content/definitions.ts b/src/content/definitions.ts index d3779ef..fe5be01 100644 --- a/src/content/definitions.ts +++ b/src/content/definitions.ts @@ -1,17 +1,12 @@ -/** - * 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 resourceDefs = [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, +]; export const actionDefs = [ - { - id: 'forage', - name: 'Forage for coin', - durationMs: 3000, - yields: [{ resourceId: 'gold', amount: 1 }], - }, + { id: 'gather_supplies', name: 'Gather supplies', durationMs: 3000, yields: [{ resourceId: 'supplies', amount: 2 }] }, + { id: 'scout_path', name: 'Scout the path', durationMs: 5000, costs: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'coin', amount: 1 }] }, + { id: 'trade_supplies', name: 'Trade at camp', durationMs: 4000, costs: [{ resourceId: 'supplies', amount: 3 }], yields: [{ resourceId: 'coin', amount: 2 }], unlock: { minResources: { coin: 1 } } }, + { id: 'fortify_camp', name: 'Fortify camp', durationMs: 8000, costs: [{ resourceId: 'supplies', amount: 5 }, { resourceId: 'coin', amount: 2 }], yields: [{ resourceId: 'supplies', amount: 4 }], unlock: { minResources: { supplies: 8 } } }, + { id: 'rest', name: 'Rest briefly', durationMs: 2000, yields: [{ resourceId: 'supplies', amount: 1 }] }, ]; -- 2.54.0 From fce561b8e5bc7ba90d7a93c00402c851d995c72d Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:21:12 -0500 Subject: [PATCH 10/12] feat(state): wire enqueueAction through runtime and view model --- src/state/__tests__/viewModel.test.ts | 18 +++++++++++++++++- src/state/runtime.ts | 18 ++++++++++++------ src/state/store.ts | 2 ++ src/state/viewModel.ts | 6 ++++++ src/ui/ActionPanel.tsx | 2 +- 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index 48ab5b6..c3e4b65 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; -import { createGameState, startAction } from '../../engine/game'; +import { createGameState, enqueueAction, startAction } from '../../engine/game'; import { formatOfflineDuration, toView } from '../viewModel'; function testContent() { @@ -44,6 +44,22 @@ describe('toView()', () => { state.actionElapsedMs = 999; expect(toView(state, content).actionProgress).toBe(1); }); + + it('includes queued action ids in order', () => { + const content = buildContent({ + resources: [{ id: 'gold', name: 'Gold' }], + actions: [ + { id: 'a', name: 'Alpha', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'b', name: 'Bravo', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + ], + }); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + enqueueAction(state, content, 'b'); + const view = toView(state, content); + expect(view.queuedActionIds).toEqual(['b']); + expect(view.queuedActionNames).toEqual(['Bravo']); + }); }); describe('formatOfflineDuration()', () => { diff --git a/src/state/runtime.ts b/src/state/runtime.ts index 63b3ea8..be5d570 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -1,5 +1,5 @@ import { content } from '../content'; -import { startAction as engineStartAction, type GameState, tickGame } from '../engine/game'; +import { enqueueAction as engineEnqueueAction, 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'; @@ -58,15 +58,21 @@ class GameRuntime { } } - startAction(actionId: string): void { + enqueueAction(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}.`); + try { + engineEnqueueAction(state, content, actionId); + const action = content.actionsById[actionId]; + if (action) { + const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started'; + useGameStore.getState().appendLog(`${verb}: ${action.name}.`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Cannot start action'; + useGameStore.getState().appendLog(msg); } this.publish(); } diff --git a/src/state/store.ts b/src/state/store.ts index 910e3fc..cf55e94 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -20,6 +20,8 @@ export const useGameStore = create((set) => ({ activeActionId: null, actionName: null, actionProgress: 0, + queuedActionIds: [], + queuedActionNames: [], log: [], setView: (view) => set(view), appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })), diff --git a/src/state/viewModel.ts b/src/state/viewModel.ts index 77b1875..8e40b00 100644 --- a/src/state/viewModel.ts +++ b/src/state/viewModel.ts @@ -18,6 +18,8 @@ export interface GameView { actionName: string | null; /** Progress of the active action, clamped to 0..1. */ actionProgress: number; + queuedActionIds: string[]; + queuedActionNames: string[]; } export function toView(state: GameState, content: Content): GameView { @@ -29,12 +31,16 @@ export function toView(state: GameState, content: Content): GameView { const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined; const actionProgress = action ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0; + const queuedActionIds = [...state.actionQueue]; + const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id); return { resources, activeActionId: state.activeActionId, actionName: action ? action.name : null, actionProgress, + queuedActionIds, + queuedActionNames, }; } diff --git a/src/ui/ActionPanel.tsx b/src/ui/ActionPanel.tsx index 027f5be..e306d7f 100644 --- a/src/ui/ActionPanel.tsx +++ b/src/ui/ActionPanel.tsx @@ -16,7 +16,7 @@ export function ActionPanel() {