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 {