feat(m1): PR3.1 automation unlock and recipes #19

Merged
ginnoir merged 9 commits from feat/m1-progression into main 2026-06-12 01:26:51 -05:00
3 changed files with 136 additions and 4 deletions
Showing only changes of commit a7ecf2225b - Show all commits
+90 -1
View File
@@ -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);
});
});
+39 -1
View File
@@ -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;
}
}
}
+7 -2
View File
@@ -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) return { completedActionIds };
if (!state.activeActionId) {
maybeRunAutomation(state, content);
if (!state.activeActionId) return { completedActionIds };
}
state.actionElapsedMs += tickMs;
while (state.activeActionId) {