feat(engine): automation runner with manual-first precedence
This commit is contained in:
@@ -5,9 +5,10 @@ import {
|
|||||||
automationUnlockThreshold,
|
automationUnlockThreshold,
|
||||||
clearAutomationQueue,
|
clearAutomationQueue,
|
||||||
isAutomationUnlocked,
|
isAutomationUnlocked,
|
||||||
|
maybeRunAutomation,
|
||||||
removeFromAutomationQueue,
|
removeFromAutomationQueue,
|
||||||
} from '../automation';
|
} from '../automation';
|
||||||
import { createGameState } from '../game';
|
import { createGameState, enqueueAction, tickGame } from '../game';
|
||||||
|
|
||||||
const DEFAULT_GROUP = { id: 'automation', label: 'Automation' };
|
const DEFAULT_GROUP = { id: 'automation', label: 'Automation' };
|
||||||
|
|
||||||
@@ -151,3 +152,91 @@ describe('automation queue CRUD', () => {
|
|||||||
expect(state.automationQueue).toEqual([]);
|
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 { 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 {
|
function assertKnownAction(content: Content, actionId: string): void {
|
||||||
if (!content.actionsById[actionId]) {
|
if (!content.actionsById[actionId]) {
|
||||||
@@ -46,3 +55,32 @@ export function removeFromAutomationQueue(state: GameState, index: number): void
|
|||||||
export function clearAutomationQueue(state: GameState): void {
|
export function clearAutomationQueue(state: GameState): void {
|
||||||
state.automationQueue.length = 0;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+7
-2
@@ -1,5 +1,6 @@
|
|||||||
import type { Content } from '../content/schema';
|
import type { Content } from '../content/schema';
|
||||||
import type { StoryContent } from '../content/storySchema';
|
import type { StoryContent } from '../content/storySchema';
|
||||||
|
import { maybeRunAutomation } from './automation';
|
||||||
import { applyChoice, isStoryChoiceAvailable, type StoryEvent } from './story';
|
import { applyChoice, isStoryChoiceAvailable, type StoryEvent } from './story';
|
||||||
|
|
||||||
type GameContent = Content & StoryContent;
|
type GameContent = Content & StoryContent;
|
||||||
@@ -108,7 +109,7 @@ export function recordManualCompletion(state: GameState, content: Content, actio
|
|||||||
state.manualCompletionCounts[actionId] = (state.manualCompletionCounts[actionId] ?? 0) + 1;
|
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);
|
assertKnownAction(content, actionId);
|
||||||
deductCosts(state, content, actionId);
|
deductCosts(state, content, actionId);
|
||||||
state.activeActionId = actionId;
|
state.activeActionId = actionId;
|
||||||
@@ -128,6 +129,7 @@ function startNextFromQueue(state: GameState, content: Content): void {
|
|||||||
}
|
}
|
||||||
state.activeActionId = null;
|
state.activeActionId = null;
|
||||||
state.actionElapsedMs = 0;
|
state.actionElapsedMs = 0;
|
||||||
|
maybeRunAutomation(state, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TickResult {
|
export interface TickResult {
|
||||||
@@ -287,7 +289,10 @@ export function performAction(
|
|||||||
*/
|
*/
|
||||||
export function tickGame(state: GameState, content: Content, tickMs: number): TickResult {
|
export function tickGame(state: GameState, content: Content, tickMs: number): TickResult {
|
||||||
const completedActionIds: string[] = [];
|
const completedActionIds: string[] = [];
|
||||||
if (!state.activeActionId) return { completedActionIds };
|
if (!state.activeActionId) {
|
||||||
|
maybeRunAutomation(state, content);
|
||||||
|
if (!state.activeActionId) return { completedActionIds };
|
||||||
|
}
|
||||||
|
|
||||||
state.actionElapsedMs += tickMs;
|
state.actionElapsedMs += tickMs;
|
||||||
while (state.activeActionId) {
|
while (state.activeActionId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user