feat(engine): add action queue state and enqueue APIs
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user