diff --git a/docs/architecture.md b/docs/architecture.md index 86fa5c5..90639b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,8 @@ Idlegame is split into four layers. - `tickLoop.ts`: fixed-timestep accumulator. Large elapsed deltas, including offline catch-up, drain through the same tick path as active play. -- `game.ts`: core game state, active action progress, and resource accrual. +- `game.ts`: core game state, action queue, costs/unlocks on start, completion-driven + queue advancement (actions do not auto-repeat when the queue is empty). - `save.ts`: versioned save schema, serialized export/import strings, and offline elapsed calculation. - `num.ts`: branded numeric boundary and human-readable formatting. diff --git a/src/content/__tests__/definitions.test.ts b/src/content/__tests__/definitions.test.ts new file mode 100644 index 0000000..52f2584 --- /dev/null +++ b/src/content/__tests__/definitions.test.ts @@ -0,0 +1,26 @@ +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); + if (!trade) { + throw new Error('expected at least one costed action'); + } + enqueueAction(state, content, trade.id); + tickGame(state, content, trade.durationMs); + expect(state.activeActionId).toBeNull(); + }); +}); diff --git a/src/content/__tests__/schema.test.ts b/src/content/__tests__/schema.test.ts index 6d9a75f..1d2ad19 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,82 @@ 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([]); + }); +}); + +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/definitions.ts b/src/content/definitions.ts index 343827d..39c6fb5 100644 --- a/src/content/definitions.ts +++ b/src/content/definitions.ts @@ -1,17 +1,45 @@ -/** - * 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', + id: 'gather_supplies', + name: 'Gather supplies', durationMs: 3000, - yields: { resourceId: 'gold', amount: 1 }, + 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 }], }, ]; diff --git a/src/content/schema.ts b/src/content/schema.ts index b5e023c..976bfcf 100644 --- a/src/content/schema.ts +++ b/src/content/schema.ts @@ -15,17 +15,28 @@ 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 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(), - yields: z.object({ - resourceId: z.string().min(1), - amount: 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 { @@ -55,10 +66,15 @@ 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 new file mode 100644 index 0000000..6e6ef1f --- /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, enqueueAction, 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); + enqueueAction(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); + }); +}); diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index 58c6d5d..58791da 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -1,12 +1,67 @@ import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; -import { createGameState, startAction, tickGame } from '../game'; +import { + cancelQueuedAction, + canUnlockAction, + clearQueue, + createGameState, + enqueueAction, + 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 } }, + { + id: 'forage', + name: 'Forage', + durationMs: 300, + yields: [{ resourceId: 'gold', amount: 2 }], + }, + ], + }); +} + +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 }] }, + ], + }); +} + +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 } }, + }, ], }); } @@ -17,6 +72,7 @@ describe('createGameState()', () => { expect(state.resources.gold).toBe(5); expect(state.activeActionId).toBeNull(); expect(state.actionElapsedMs).toBe(0); + expect(state.actionQueue).toEqual([]); }); }); @@ -37,6 +93,201 @@ 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('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'); + }); + + it('rejects actions until required story flags are set', () => { + const content = buildContent({ + resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], + actions: [ + { + id: 'secret', + name: 'Secret', + durationMs: 100, + yields: [{ resourceId: 'gold', amount: 1 }], + unlock: { requireStoryFlags: ['path_scouted'] }, + }, + ], + }); + const state = createGameState(content); + expect(() => enqueueAction(state, content, 'secret')).toThrow(/cannot enqueue/i); + expect(canUnlockAction(state, content, 'secret', { path_scouted: true })).toBe(true); + }); +}); + +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('skips queued actions that are unaffordable when their turn arrives', () => { + const content = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }], + actions: [ + { + id: 'cheap', + name: 'Cheap', + durationMs: 100, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + { + id: 'dear', + name: 'Dear', + durationMs: 100, + costs: [{ resourceId: 'supplies', amount: 6 }], + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + { + id: 'free', + name: 'Free', + durationMs: 100, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); + const state = createGameState(content); + state.activeActionId = 'cheap'; + state.actionElapsedMs = 99; + state.actionQueue = ['dear', 'free']; + state.resources.supplies = 3; + tickGame(state, content, 1); + expect(state.activeActionId).toBe('free'); + expect(state.actionQueue).toEqual([]); + }); + + 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(); @@ -54,7 +305,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'); @@ -62,15 +313,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__/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); + } + } + }); +}); diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index 3725092..e65f78b 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -15,7 +15,12 @@ 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 }], + }, ], }); } @@ -44,6 +49,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', () => { @@ -86,7 +100,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 +118,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 e6eeaa6..de6a3e9 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,35 +23,156 @@ 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. */ -export function startAction(state: GameState, content: Content, actionId: string): void { +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 (!nextId) { + break; + } + 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 { + assertKnownAction(content, 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. + * Start an action immediately when idle, otherwise append it to the queue. + * Costs deduct on start (D-0012). Throws when unknown, unaffordable, or locked. */ -export function tickGame(state: GameState, content: Content, tickMs: number): void { - if (!state.activeActionId) { - return; +export function enqueueAction(state: GameState, content: Content, actionId: string): void { + assertKnownAction(content, actionId); + if (!isActionAvailable(state, content, actionId)) { + throw new Error(`Cannot enqueue action "${actionId}"`); } - const action = content.actionsById[state.activeActionId]; - if (!action) { - return; + if (state.activeActionId === null) { + beginAction(state, content, actionId); + } else { + 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`. 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; + 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; - state.resources[action.yields.resourceId] += action.yields.amount; + completeActiveAction(state, content); } } diff --git a/src/engine/save.ts b/src/engine/save.ts index cf9a46a..7aa6059 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; @@ -24,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({ @@ -43,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/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; diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index c22d4a4..1ed517d 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'; @@ -8,7 +8,22 @@ 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 }], + }, + ], + }); +} + +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 }] }, ], }); } @@ -31,8 +46,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 () => { @@ -42,11 +57,29 @@ 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( createSave( - { resources: { gold: 1 }, activeActionId: 'ghost-action', actionElapsedMs: 0 }, + { + resources: { gold: 1 }, + activeActionId: 'ghost-action', + actionElapsedMs: 0, + actionQueue: [], + }, 1000, ), ); diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index 971e2ff..8555c53 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -1,13 +1,18 @@ 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() { 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 }], + }, ], }); } @@ -44,6 +49,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/persistence.ts b/src/state/persistence.ts index cc77c3d..e4284d6 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: [...(save.state.actionQueue ?? [])], }; savedAt = save.savedAt; } catch { 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() {