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 () => {