feat(engine): automation unlock and queue CRUD

This commit is contained in:
ginnoir
2026-06-12 00:29:32 -05:00
parent d625216018
commit 4a85bf85ae
2 changed files with 169 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import {
addToAutomationQueue,
clearAutomationQueue,
isAutomationUnlocked,
removeFromAutomationQueue,
} from '../automation';
import { createGameState } from '../game';
const DEFAULT_GROUP = { id: 'automation', label: 'Automation' };
function automationContent() {
return buildContent({
resources: [
{ id: 'supplies', name: 'Supplies', startAmount: 0 },
{ id: 'renown', name: 'Renown', startAmount: 0 },
],
actions: [
{
id: 'forage',
name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }],
automation: { unlockAfterManualCompletions: 1 },
},
{
id: 'survey',
name: 'Survey',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'renown', amount: 1 }],
},
],
});
}
describe('isAutomationUnlocked()', () => {
it('returns false before the first manual completion when an action unlocks after one', () => {
const content = automationContent();
const state = createGameState(content);
expect(isAutomationUnlocked(state, content, 'forage')).toBe(false);
});
it('returns true once the manual completion threshold is met', () => {
const content = automationContent();
const state = createGameState(content);
state.manualCompletionCounts.forage = 1;
expect(isAutomationUnlocked(state, content, 'forage')).toBe(true);
});
});
describe('automation queue CRUD', () => {
it('throws for unknown action ids', () => {
const content = automationContent();
const state = createGameState(content);
expect(() => addToAutomationQueue(state, content, 'missing')).toThrow(
'Unknown action "missing"',
);
});
it('throws for locked action ids', () => {
const content = automationContent();
const state = createGameState(content);
expect(() => addToAutomationQueue(state, content, 'forage')).toThrow(/automation.*locked/i);
});
it('appends an unlocked action id', () => {
const content = automationContent();
const state = createGameState(content);
state.manualCompletionCounts.forage = 1;
addToAutomationQueue(state, content, 'forage');
expect(state.automationQueue).toEqual(['forage']);
});
it('does not duplicate an action already in the queue', () => {
const content = automationContent();
const state = createGameState(content);
state.manualCompletionCounts.forage = 1;
addToAutomationQueue(state, content, 'forage');
addToAutomationQueue(state, content, 'forage');
expect(state.automationQueue).toEqual(['forage']);
});
it('removes by index', () => {
const content = automationContent();
const state = createGameState(content);
state.automationQueue = ['forage', 'survey'];
removeFromAutomationQueue(state, 0);
expect(state.automationQueue).toEqual(['survey']);
});
it('throws RangeError when removing an out-of-range index', () => {
const content = automationContent();
const state = createGameState(content);
state.automationQueue = ['forage'];
expect(() => removeFromAutomationQueue(state, 1)).toThrow(RangeError);
});
it('empties the queue', () => {
const content = automationContent();
const state = createGameState(content);
state.automationQueue = ['forage', 'survey'];
clearAutomationQueue(state);
expect(state.automationQueue).toEqual([]);
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { Content } from '../content/schema';
import type { GameState } from './game';
function assertKnownAction(content: Content, actionId: string): void {
if (!content.actionsById[actionId]) {
throw new Error(`Unknown action "${actionId}"`);
}
}
export function automationUnlockThreshold(content: Content, actionId: string): number {
const action = content.actionsById[actionId];
if (!action) {
throw new Error(`Unknown action "${actionId}"`);
}
return action.automation?.unlockAfterManualCompletions ?? 1;
}
export function isAutomationUnlocked(
state: GameState,
content: Content,
actionId: string,
): boolean {
assertKnownAction(content, actionId);
return (
(state.manualCompletionCounts[actionId] ?? 0) >= automationUnlockThreshold(content, actionId)
);
}
export function addToAutomationQueue(state: GameState, content: Content, actionId: string): void {
assertKnownAction(content, actionId);
if (!isAutomationUnlocked(state, content, actionId)) {
throw new Error(`Automation for action "${actionId}" is locked`);
}
if (!state.automationQueue.includes(actionId)) {
state.automationQueue.push(actionId);
}
}
export function removeFromAutomationQueue(state: GameState, index: number): void {
if (index < 0 || index >= state.automationQueue.length) {
throw new RangeError(`Automation queue index ${index} is out of range`);
}
state.automationQueue.splice(index, 1);
}
export function clearAutomationQueue(state: GameState): void {
state.automationQueue.length = 0;
}