feat(engine): automation runner with manual-first precedence
This commit is contained in:
@@ -5,9 +5,10 @@ import {
|
||||
automationUnlockThreshold,
|
||||
clearAutomationQueue,
|
||||
isAutomationUnlocked,
|
||||
maybeRunAutomation,
|
||||
removeFromAutomationQueue,
|
||||
} from '../automation';
|
||||
import { createGameState } from '../game';
|
||||
import { createGameState, enqueueAction, tickGame } from '../game';
|
||||
|
||||
const DEFAULT_GROUP = { id: 'automation', label: 'Automation' };
|
||||
|
||||
@@ -151,3 +152,91 @@ describe('automation queue CRUD', () => {
|
||||
expect(state.automationQueue).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function runnerContent() {
|
||||
return buildContent({
|
||||
resources: [
|
||||
{ id: 'supplies', name: 'Supplies', startAmount: 0 },
|
||||
{ id: 'coin', name: 'Coin', startAmount: 2 },
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 'gather',
|
||||
name: 'Gather',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 100,
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'buy',
|
||||
name: 'Buy',
|
||||
kind: 'instant',
|
||||
group: DEFAULT_GROUP,
|
||||
costs: [{ resourceId: 'coin', amount: 1 }],
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'rest',
|
||||
name: 'Rest',
|
||||
kind: 'loop',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 100,
|
||||
loopPriority: 0,
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('maybeRunAutomation()', () => {
|
||||
it('starts the first affordable automation action when manual queue is idle', () => {
|
||||
const content = runnerContent();
|
||||
const state = createGameState(content);
|
||||
state.manualCompletionCounts.gather = 1;
|
||||
state.automationQueue = ['gather'];
|
||||
|
||||
maybeRunAutomation(state, content);
|
||||
|
||||
expect(state.activeActionId).toBe('gather');
|
||||
});
|
||||
|
||||
it('does not run when the manual queue has items', () => {
|
||||
const content = runnerContent();
|
||||
const state = createGameState(content);
|
||||
state.actionQueue = ['gather'];
|
||||
state.manualCompletionCounts.gather = 1;
|
||||
state.automationQueue = ['gather'];
|
||||
|
||||
maybeRunAutomation(state, content);
|
||||
|
||||
expect(state.activeActionId).toBeNull();
|
||||
});
|
||||
|
||||
it('executes affordable instant automation and advances to the next candidate', () => {
|
||||
const content = runnerContent();
|
||||
const state = createGameState(content);
|
||||
state.manualCompletionCounts.buy = 1;
|
||||
state.manualCompletionCounts.gather = 1;
|
||||
state.automationQueue = ['buy', 'gather'];
|
||||
|
||||
maybeRunAutomation(state, content);
|
||||
|
||||
expect(state.resources.coin).toBe(1);
|
||||
expect(state.resources.supplies).toBe(1);
|
||||
expect(state.activeActionId).toBe('gather');
|
||||
});
|
||||
|
||||
it('starts automation before enabled loop actions after manual queue exhausts', () => {
|
||||
const content = runnerContent();
|
||||
const state = createGameState(content);
|
||||
state.manualCompletionCounts.gather = 1;
|
||||
state.automationQueue = ['gather'];
|
||||
state.enabledLoopActionIds.rest = true;
|
||||
enqueueAction(state, content, 'gather');
|
||||
|
||||
tickGame(state, content, 100);
|
||||
|
||||
expect(state.activeActionId).toBe('gather');
|
||||
expect(state.enabledLoopActionIds.rest).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { Content } from '../content/schema';
|
||||
import type { GameState } from './game';
|
||||
import type { StoryContent } from '../content/storySchema';
|
||||
import {
|
||||
beginAction,
|
||||
executeInstant,
|
||||
executeStoryAction,
|
||||
type GameState,
|
||||
isActionAvailable,
|
||||
} from './game';
|
||||
|
||||
type GameContent = Content & StoryContent;
|
||||
|
||||
function assertKnownAction(content: Content, actionId: string): void {
|
||||
if (!content.actionsById[actionId]) {
|
||||
@@ -46,3 +55,32 @@ export function removeFromAutomationQueue(state: GameState, index: number): void
|
||||
export function clearAutomationQueue(state: GameState): void {
|
||||
state.automationQueue.length = 0;
|
||||
}
|
||||
|
||||
export function maybeRunAutomation(state: GameState, content: Content): void {
|
||||
if (state.activeActionId !== null || state.actionQueue.length > 0) return;
|
||||
|
||||
for (const actionId of state.automationQueue) {
|
||||
if (!isAutomationUnlocked(state, content, actionId)) continue;
|
||||
if (!isActionAvailable(state, content, actionId)) continue;
|
||||
|
||||
const action = content.actionsById[actionId];
|
||||
if (!action) continue;
|
||||
|
||||
switch (action.kind) {
|
||||
case 'instant':
|
||||
executeInstant(state, content, actionId);
|
||||
continue;
|
||||
case 'timed':
|
||||
case 'loop':
|
||||
beginAction(state, content, actionId);
|
||||
return;
|
||||
case 'story':
|
||||
executeStoryAction(state, content as GameContent, actionId);
|
||||
return;
|
||||
case 'context':
|
||||
continue;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
import type { Content } from '../content/schema';
|
||||
import type { StoryContent } from '../content/storySchema';
|
||||
import { maybeRunAutomation } from './automation';
|
||||
import { applyChoice, isStoryChoiceAvailable, type StoryEvent } from './story';
|
||||
|
||||
type GameContent = Content & StoryContent;
|
||||
@@ -108,7 +109,7 @@ export function recordManualCompletion(state: GameState, content: Content, actio
|
||||
state.manualCompletionCounts[actionId] = (state.manualCompletionCounts[actionId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
function beginAction(state: GameState, content: Content, actionId: string): void {
|
||||
export function beginAction(state: GameState, content: Content, actionId: string): void {
|
||||
assertKnownAction(content, actionId);
|
||||
deductCosts(state, content, actionId);
|
||||
state.activeActionId = actionId;
|
||||
@@ -128,6 +129,7 @@ function startNextFromQueue(state: GameState, content: Content): void {
|
||||
}
|
||||
state.activeActionId = null;
|
||||
state.actionElapsedMs = 0;
|
||||
maybeRunAutomation(state, content);
|
||||
}
|
||||
|
||||
export interface TickResult {
|
||||
@@ -287,7 +289,10 @@ export function performAction(
|
||||
*/
|
||||
export function tickGame(state: GameState, content: Content, tickMs: number): TickResult {
|
||||
const completedActionIds: string[] = [];
|
||||
if (!state.activeActionId) {
|
||||
maybeRunAutomation(state, content);
|
||||
if (!state.activeActionId) return { completedActionIds };
|
||||
}
|
||||
|
||||
state.actionElapsedMs += tickMs;
|
||||
while (state.activeActionId) {
|
||||
|
||||
Reference in New Issue
Block a user