feat(engine): add action queue state and enqueue APIs

This commit is contained in:
ginnoir
2026-06-11 18:18:17 -05:00
parent 923a7a3dac
commit ea2fb0afab
4 changed files with 127 additions and 4 deletions
+83 -1
View File
@@ -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();
+37 -2
View File
@@ -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
+6 -1
View File
@@ -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,
),
);
+1
View File
@@ -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 {