Merge pull request 'feat(m1): PR3.1 automation unlock and recipes' (#19) from feat/m1-progression into main
CI / verify (push) Successful in 49s
Deploy Internal Playtest / deploy (push) Successful in 45s

This commit was merged in pull request #19.
This commit is contained in:
2026-06-12 01:26:50 -05:00
19 changed files with 858 additions and 3 deletions
+33
View File
@@ -82,6 +82,39 @@ progression is driven by Story-kind actions.
The full design lives in `docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md`. The full design lives in `docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md`.
### Automation
Automation is universal across action kinds once an action has at least its
configured number of successful completions (`automation.unlockAfterManualCompletions`,
default `1`). The engine stores those counts in
`GameState.manualCompletionCounts` and stores configured automation in
`GameState.automationQueue`.
`src/engine/automation.ts` owns unlock checks, queue mutation, and the runner.
The runner preserves precedence: manual active/queued actions first,
automation second, loop-idle actions last. `tickGame` can start automation when
the manual queue exhausts, including during offline catch-up; loop actions still
start only from the live runtime path.
`src/engine/recipe.ts` serializes automation queues as shareable text using
content action ids. The v1 multiline format is:
```text
idlegame-recipe/v1
# name: Camp loop
gather_supplies
rest
```
The single-line alias is:
```text
idlegame-recipe/v1:gather_supplies,rest
```
Import rejects unknown action ids and actions that are not automation-unlocked
for the current save.
## Verification ## Verification
Run the full local gate before pushing: Run the full local gate before pushing:
+242
View File
@@ -0,0 +1,242 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import {
addToAutomationQueue,
automationUnlockThreshold,
clearAutomationQueue,
isAutomationUnlocked,
maybeRunAutomation,
removeFromAutomationQueue,
} from '../automation';
import { createGameState, enqueueAction, tickGame } 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 }],
},
{
id: 'train',
name: 'Train',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'renown', amount: 1 }],
automation: { unlockAfterManualCompletions: 2 },
},
],
});
}
describe('automationUnlockThreshold()', () => {
it('returns the action-specific automation threshold when configured', () => {
const content = automationContent();
expect(automationUnlockThreshold(content, 'train')).toBe(2);
});
it('defaults to one manual completion when automation config is absent', () => {
const content = automationContent();
expect(automationUnlockThreshold(content, 'survey')).toBe(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('throws RangeError when removing a non-integer index', () => {
const content = automationContent();
const state = createGameState(content);
state.automationQueue = ['forage'];
expect(() => removeFromAutomationQueue(state, 0.5)).toThrow(RangeError);
expect(state.automationQueue).toEqual(['forage']);
});
it('empties the queue', () => {
const content = automationContent();
const state = createGameState(content);
state.automationQueue = ['forage', 'survey'];
clearAutomationQueue(state);
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);
});
});
+26
View File
@@ -11,6 +11,7 @@ import {
executeInstant, executeInstant,
maybeStartLoopAction, maybeStartLoopAction,
performAction, performAction,
recordManualCompletion,
startAction, startAction,
tickGame, tickGame,
} from '../game'; } from '../game';
@@ -104,6 +105,11 @@ describe('createGameState()', () => {
expect(state.actionElapsedMs).toBe(0); expect(state.actionElapsedMs).toBe(0);
expect(state.actionQueue).toEqual([]); expect(state.actionQueue).toEqual([]);
}); });
it('initializes empty manual completion counts', () => {
const state = createGameState(testContent());
expect(state.manualCompletionCounts).toEqual({});
});
}); });
describe('createGameState() story fields', () => { describe('createGameState() story fields', () => {
@@ -351,6 +357,24 @@ describe('tickGame() completion result', () => {
const result = tickGame(state, content, 100); const result = tickGame(state, content, 100);
expect(result.completedActionIds).toEqual([]); expect(result.completedActionIds).toEqual([]);
}); });
it('increments manualCompletionCounts when a timed action completes', () => {
const content = testContent();
const state = createGameState(content);
enqueueAction(state, content, 'forage');
tickGame(state, content, 300);
expect(state.manualCompletionCounts.forage).toBe(1);
});
});
describe('recordManualCompletion()', () => {
it('increments known content actions only', () => {
const content = testContent();
const state = createGameState(content);
recordManualCompletion(state, content, 'forage');
recordManualCompletion(state, content, 'missing');
expect(state.manualCompletionCounts).toEqual({ forage: 1 });
});
}); });
describe('tickGame()', () => { describe('tickGame()', () => {
@@ -417,6 +441,7 @@ describe('executeInstant()', () => {
expect(state.resources.supplies).toBe(1); expect(state.resources.supplies).toBe(1);
expect(state.activeActionId).toBeNull(); expect(state.activeActionId).toBeNull();
expect(state.actionQueue).toEqual([]); expect(state.actionQueue).toEqual([]);
expect(state.manualCompletionCounts.buy_supply).toBe(1);
}); });
it('throws when unaffordable', () => { it('throws when unaffordable', () => {
@@ -493,6 +518,7 @@ describe('performAction()', () => {
enterStoryNode(state, gameContent, 'fork_choice'); enterStoryNode(state, gameContent, 'fork_choice');
const events = performAction(state, gameContent, 'pick_high_road'); const events = performAction(state, gameContent, 'pick_high_road');
expect(state.storyFlags.route_a).toBe(true); expect(state.storyFlags.route_a).toBe(true);
expect(state.manualCompletionCounts.pick_high_road).toBe(1);
expect(events.length).toBeGreaterThan(0); expect(events.length).toBeGreaterThan(0);
expect(events[0].kind).toBe('enter'); expect(events[0].kind).toBe('enter');
}); });
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState } from '../game';
import { exportRecipe, importRecipe, RECIPE_HEADER_V1 } from '../recipe';
const DEFAULT_GROUP = { id: 'camp', label: 'Camp' };
function recipeContent() {
return buildContent({
resources: [{ id: 'supplies', name: 'Supplies' }],
actions: [
{
id: 'gather_supplies',
name: 'Gather',
kind: 'timed',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
{
id: 'rest',
name: 'Rest',
kind: 'loop',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
],
});
}
describe('recipe export/import', () => {
it('round-trips multi-line format', () => {
const content = recipeContent();
const state = createGameState(content);
state.manualCompletionCounts = { gather_supplies: 1, rest: 1 };
state.automationQueue = ['gather_supplies', 'rest'];
const text = exportRecipe(state, content, { name: 'Camp loop' });
expect(text).toContain(RECIPE_HEADER_V1);
expect(text).toContain('gather_supplies');
const fresh = createGameState(content);
fresh.manualCompletionCounts = { gather_supplies: 1, rest: 1 };
importRecipe(fresh, content, text);
expect(fresh.automationQueue).toEqual(['gather_supplies', 'rest']);
});
it('rejects unknown action ids', () => {
const content = recipeContent();
const state = createGameState(content);
expect(() => importRecipe(state, content, `${RECIPE_HEADER_V1}\nnot_real`)).toThrow(/unknown/i);
});
it('rejects locked action ids', () => {
const content = recipeContent();
const state = createGameState(content);
const text = `${RECIPE_HEADER_V1}\ngather_supplies`;
expect(() => importRecipe(state, content, text)).toThrow(/locked/i);
});
it('parses single-line alias', () => {
const content = recipeContent();
const state = createGameState(content);
state.manualCompletionCounts.gather_supplies = 1;
importRecipe(state, content, `${RECIPE_HEADER_V1}:gather_supplies`);
expect(state.automationQueue).toEqual(['gather_supplies']);
});
});
+43
View File
@@ -79,6 +79,22 @@ describe('createSave()', () => {
state.enabledLoopActionIds.rest = false; state.enabledLoopActionIds.rest = false;
expect(save.state.enabledLoopActionIds).toEqual({ rest: true }); expect(save.state.enabledLoopActionIds).toEqual({ rest: true });
}); });
it('snapshots manualCompletionCounts and automationQueue in the save payload', () => {
const state = sampleState() as ReturnType<typeof sampleState> & {
manualCompletionCounts: Record<string, number>;
automationQueue: string[];
};
state.manualCompletionCounts = { forage: 2 };
state.automationQueue = ['forage'];
const save = createSave(state, 1700);
expect(save.state.manualCompletionCounts).toEqual({ forage: 2 });
expect(save.state.automationQueue).toEqual(['forage']);
state.manualCompletionCounts.forage = 3;
state.automationQueue.push('forage');
expect(save.state.manualCompletionCounts).toEqual({ forage: 2 });
expect(save.state.automationQueue).toEqual(['forage']);
});
}); });
describe('serialize / deserialize round-trip', () => { describe('serialize / deserialize round-trip', () => {
@@ -101,6 +117,18 @@ describe('serialize / deserialize round-trip', () => {
expect(restored.state.enabledLoopActionIds).toEqual({ rest: true, patrol: false }); expect(restored.state.enabledLoopActionIds).toEqual({ rest: true, patrol: false });
}); });
it('preserves manualCompletionCounts and automationQueue through a round-trip', () => {
const state = sampleState() as ReturnType<typeof sampleState> & {
manualCompletionCounts: Record<string, number>;
automationQueue: string[];
};
state.manualCompletionCounts = { forage: 4 };
state.automationQueue = ['forage'];
const restored = deserializeSave(serializeSave(createSave(state, 1700)));
expect(restored.state.manualCompletionCounts).toEqual({ forage: 4 });
expect(restored.state.automationQueue).toEqual(['forage']);
});
it('defaults enabledLoopActionIds to {} when absent from save JSON', () => { it('defaults enabledLoopActionIds to {} when absent from save JSON', () => {
const json = JSON.stringify({ const json = JSON.stringify({
version: 1, version: 1,
@@ -114,6 +142,21 @@ describe('serialize / deserialize round-trip', () => {
const restored = deserializeSave(json); const restored = deserializeSave(json);
expect(restored.state.enabledLoopActionIds).toEqual({}); expect(restored.state.enabledLoopActionIds).toEqual({});
}); });
it('defaults manualCompletionCounts and automationQueue when absent from save JSON', () => {
const json = JSON.stringify({
version: 1,
savedAt: 1700,
state: {
resources: { gold: 0 },
activeActionId: null,
actionElapsedMs: 0,
},
});
const restored = deserializeSave(json);
expect(restored.state.manualCompletionCounts).toEqual({});
expect(restored.state.automationQueue).toEqual([]);
});
}); });
describe('invalid / tampered saves', () => { describe('invalid / tampered saves', () => {
+86
View File
@@ -0,0 +1,86 @@
import type { Content } from '../content/schema';
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]) {
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 (!Number.isInteger(index) || 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;
}
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;
}
}
}
+23 -3
View File
@@ -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;
@@ -28,6 +29,10 @@ export interface GameState {
seenStoryNodeIds: string[]; seenStoryNodeIds: string[];
/** loop-kind action id -> whether the player has enabled it for idle running. */ /** loop-kind action id -> whether the player has enabled it for idle running. */
enabledLoopActionIds: Record<string, boolean>; enabledLoopActionIds: Record<string, boolean>;
/** action id -> number of successful player-enabled completions. */
manualCompletionCounts: Record<string, number>;
/** Action ids configured for future automation repeat. */
automationQueue: string[];
} }
export function createGameState(content: Content): GameState { export function createGameState(content: Content): GameState {
@@ -44,6 +49,8 @@ export function createGameState(content: Content): GameState {
currentStoryNodeId: '', currentStoryNodeId: '',
seenStoryNodeIds: [], seenStoryNodeIds: [],
enabledLoopActionIds: {}, enabledLoopActionIds: {},
manualCompletionCounts: {},
automationQueue: [],
}; };
} }
@@ -97,7 +104,12 @@ function grantYields(state: GameState, content: Content, actionId: string): void
} }
} }
function beginAction(state: GameState, content: Content, actionId: string): void { export function recordManualCompletion(state: GameState, content: Content, actionId: string): void {
if (!content.actionsById[actionId]) return;
state.manualCompletionCounts[actionId] = (state.manualCompletionCounts[actionId] ?? 0) + 1;
}
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;
@@ -117,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 {
@@ -178,6 +191,7 @@ export function executeInstant(state: GameState, content: Content, actionId: str
} }
deductCosts(state, content, actionId); deductCosts(state, content, actionId);
grantYields(state, content, actionId); grantYields(state, content, actionId);
recordManualCompletion(state, content, actionId);
} }
/** /**
@@ -197,7 +211,9 @@ export function executeStoryAction(
if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) { if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) {
throw new Error(`Story choice "${action.storyChoiceId}" is not available`); throw new Error(`Story choice "${action.storyChoiceId}" is not available`);
} }
return applyChoice(state, content, action.storyChoiceId); const events = applyChoice(state, content, action.storyChoiceId);
recordManualCompletion(state, content, actionId);
return events;
} }
/** /**
@@ -273,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) {
@@ -285,6 +304,7 @@ export function tickGame(state: GameState, content: Content, tickMs: number): Ti
state.actionElapsedMs -= action.durationMs; state.actionElapsedMs -= action.durationMs;
grantYields(state, content, actionId); grantYields(state, content, actionId);
recordManualCompletion(state, content, actionId);
completedActionIds.push(actionId); completedActionIds.push(actionId);
startNextFromQueue(state, content); startNextFromQueue(state, content);
} }
+79
View File
@@ -0,0 +1,79 @@
import type { Content } from '../content/schema';
import { addToAutomationQueue, clearAutomationQueue, isAutomationUnlocked } from './automation';
import type { GameState } from './game';
export const RECIPE_HEADER_V1 = 'idlegame-recipe/v1';
const ACTION_ID_RE = /^[a-z][a-z0-9_]*$/;
export interface RecipeMeta {
name?: string;
}
export function exportRecipe(state: GameState, content: Content, meta?: RecipeMeta): string {
const lines = [RECIPE_HEADER_V1];
if (meta?.name) lines.push(`# name: ${meta.name}`);
for (const actionId of state.automationQueue) {
if (content.actionsById[actionId]) {
lines.push(actionId);
}
}
return lines.join('\n');
}
export function parseRecipeLines(text: string): { name?: string; actionIds: string[] } {
const trimmed = text.trim();
if (trimmed.startsWith(`${RECIPE_HEADER_V1}:`)) {
const actionIds = trimmed
.slice(RECIPE_HEADER_V1.length + 1)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return { actionIds };
}
const lines = trimmed.split(/\r?\n/);
if (lines[0]?.trim() !== RECIPE_HEADER_V1) {
throw new Error(`Invalid recipe header; expected "${RECIPE_HEADER_V1}"`);
}
let name: string | undefined;
const actionIds: string[] = [];
for (let i = 1; i < lines.length; i += 1) {
const line = lines[i]?.trim() ?? '';
if (!line) continue;
if (line.startsWith('# name:')) {
name = line.slice('# name:'.length).trim();
continue;
}
if (line.startsWith('#')) continue;
if (!ACTION_ID_RE.test(line)) {
throw new Error(`Invalid action id "${line}"`);
}
actionIds.push(line);
}
return { name, actionIds };
}
export function importRecipe(state: GameState, content: Content, text: string): { name?: string } {
const { name, actionIds } = parseRecipeLines(text);
const unknown = actionIds.filter((id) => !content.actionsById[id]);
if (unknown.length > 0) {
throw new Error(`Unknown action ids: ${unknown.join(', ')}`);
}
const locked = actionIds.filter((id) => !isAutomationUnlocked(state, content, id));
if (locked.length > 0) {
throw new Error(`Automation locked for: ${locked.join(', ')}`);
}
clearAutomationQueue(state);
for (const actionId of actionIds) {
addToAutomationQueue(state, content, actionId);
}
return { name };
}
+4
View File
@@ -33,6 +33,8 @@ export const gameStateSchema = z.object({
currentStoryNodeId: z.string().default(''), currentStoryNodeId: z.string().default(''),
seenStoryNodeIds: z.array(z.string()).default([]), seenStoryNodeIds: z.array(z.string()).default([]),
enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}), enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}),
manualCompletionCounts: z.record(z.string(), z.number()).default({}),
automationQueue: z.array(z.string()).default([]),
}); });
export const saveSchema = z.object({ export const saveSchema = z.object({
@@ -57,6 +59,8 @@ export function createSave(state: GameState, now: number): SaveData {
currentStoryNodeId: state.currentStoryNodeId, currentStoryNodeId: state.currentStoryNodeId,
seenStoryNodeIds: [...state.seenStoryNodeIds], seenStoryNodeIds: [...state.seenStoryNodeIds],
enabledLoopActionIds: { ...state.enabledLoopActionIds }, enabledLoopActionIds: { ...state.enabledLoopActionIds },
manualCompletionCounts: { ...state.manualCompletionCounts },
automationQueue: [...state.automationQueue],
}, },
}; };
} }
+2
View File
@@ -111,6 +111,8 @@ describe('loadGame()', () => {
currentStoryNodeId: '', currentStoryNodeId: '',
seenStoryNodeIds: [], seenStoryNodeIds: [],
enabledLoopActionIds: {}, enabledLoopActionIds: {},
manualCompletionCounts: {},
automationQueue: [],
}, },
1000, 1000,
), ),
+46
View File
@@ -1,9 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { content } from '../../content'; import { content } from '../../content';
import type { GameState } from '../../engine/game';
import { RECIPE_HEADER_V1 } from '../../engine/recipe';
import { getPrefs } from '../prefs'; import { getPrefs } from '../prefs';
import { GameRuntime } from '../runtime'; import { GameRuntime } from '../runtime';
import { useGameStore } from '../store'; import { useGameStore } from '../store';
function runtimeState(runtime: GameRuntime): GameState {
return (runtime as unknown as { state: GameState }).state;
}
describe('GameRuntime', () => { describe('GameRuntime', () => {
let runtime: GameRuntime; let runtime: GameRuntime;
@@ -78,6 +84,46 @@ describe('GameRuntime', () => {
expect(view.actions.find((a) => a.id === 'rest')?.loopEnabled).toBe(true); expect(view.actions.find((a) => a.id === 'rest')?.loopEnabled).toBe(true);
}); });
it('toggles an unlocked action in and out of the automation queue', () => {
const state = runtimeState(runtime);
state.manualCompletionCounts.gather_supplies = 1;
runtime.toggleAutomation('gather_supplies');
expect(useGameStore.getState().automationQueueIds).toEqual(['gather_supplies']);
runtime.toggleAutomation('gather_supplies');
expect(useGameStore.getState().automationQueueIds).toEqual([]);
});
it('logs automation toggle errors for locked actions', () => {
runtime.toggleAutomation('gather_supplies');
expect(useGameStore.getState().log.at(-1)).toMatch(/automation.*locked/i);
});
it('exports the automation recipe text', () => {
const state = runtimeState(runtime);
state.manualCompletionCounts.gather_supplies = 1;
state.automationQueue = ['gather_supplies'];
const text = runtime.exportAutomationRecipe();
expect(text).toContain(RECIPE_HEADER_V1);
expect(text).toContain('gather_supplies');
});
it('imports an automation recipe and publishes the queue', () => {
const state = runtimeState(runtime);
state.manualCompletionCounts.gather_supplies = 1;
runtime.importAutomationRecipe(`${RECIPE_HEADER_V1}:gather_supplies`);
expect(useGameStore.getState().automationQueueIds).toEqual(['gather_supplies']);
expect(useGameStore.getState().log.at(-1)).toMatch(/imported recipe/i);
});
it('performs story action and appends story log', () => { it('performs story action and appends story log', () => {
// Let's first move state to fork_choice node where story choices are available // Let's first move state to fork_choice node where story choices are available
runtime.continueStory(); runtime.continueStory();
+33
View File
@@ -239,6 +239,39 @@ describe('action columns projection', () => {
expect(rest?.loopEnabled).toBe(true); expect(rest?.loopEnabled).toBe(true);
}); });
it('marks automationUnlocked on actions after manual completion', () => {
const state = createGameState(content);
state.manualCompletionCounts.gather_supplies = 1;
const view = toView(state, content);
const gather = view.actionColumns
.flatMap((c) => c.groups)
.flatMap((g) => g.actions)
.find((a) => a.id === 'gather_supplies');
expect(gather?.automationUnlocked).toBe(true);
});
it('marks actions already in the automation queue', () => {
const state = createGameState(content);
state.automationQueue = ['gather_supplies'];
const view = toView(state, content);
const gather = view.actionColumns
.flatMap((c) => c.groups)
.flatMap((g) => g.actions)
.find((a) => a.id === 'gather_supplies');
expect(gather?.inAutomationQueue).toBe(true);
});
it('projects automation queue ids and display names', () => {
const state = createGameState(content);
state.automationQueue = ['gather_supplies', 'missing_action'];
const view = toView(state, content);
expect(view.automationQueueIds).toEqual(['gather_supplies', 'missing_action']);
expect(view.automationQueueNames).toEqual(['Gather supplies', 'missing_action']);
});
it('shows story actions only when their choice is available, hiding siblings after a fork is taken', () => { it('shows story actions only when their choice is available, hiding siblings after a fork is taken', () => {
const state = createGameState(content); const state = createGameState(content);
// before reaching the fork, story actions are hidden // before reaching the fork, story actions are hidden
+2
View File
@@ -107,6 +107,8 @@ export async function loadGame(
currentStoryNodeId: save.state.currentStoryNodeId ?? '', currentStoryNodeId: save.state.currentStoryNodeId ?? '',
seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])], seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])],
enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) }, enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) },
manualCompletionCounts: { ...(save.state.manualCompletionCounts ?? {}) },
automationQueue: [...(save.state.automationQueue ?? [])],
}; };
savedAt = save.savedAt; savedAt = save.savedAt;
} catch { } catch {
+44
View File
@@ -1,4 +1,9 @@
import { content } from '../content'; import { content } from '../content';
import {
addToAutomationQueue,
maybeRunAutomation,
removeFromAutomationQueue,
} from '../engine/automation';
import { import {
cancelQueuedAction as engineCancelQueuedAction, cancelQueuedAction as engineCancelQueuedAction,
type GameState, type GameState,
@@ -6,6 +11,7 @@ import {
performAction as performActionEngine, performAction as performActionEngine,
tickGame, tickGame,
} from '../engine/game'; } from '../engine/game';
import { exportRecipe, importRecipe } from '../engine/recipe';
import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story'; import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story';
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop'; import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence'; import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
@@ -147,6 +153,44 @@ export class GameRuntime {
this.performAction(actionId); this.performAction(actionId);
} }
toggleAutomation(actionId: string): void {
const state = this.state;
if (!state) return;
try {
const existingIndex = state.automationQueue.indexOf(actionId);
if (existingIndex >= 0) {
removeFromAutomationQueue(state, existingIndex);
} else {
addToAutomationQueue(state, content, actionId);
maybeRunAutomation(state, content);
}
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Automation failed');
}
}
exportAutomationRecipe(): string {
const state = this.state;
if (!state) return '';
return exportRecipe(state, content);
}
importAutomationRecipe(text: string): void {
const state = this.state;
if (!state) return;
try {
const meta = importRecipe(state, content, text);
maybeRunAutomation(state, content);
useGameStore
.getState()
.appendLog(meta.name ? `Imported recipe: ${meta.name}` : 'Imported recipe.');
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Import failed');
}
}
applyStoryChoice(choiceId: string): void { applyStoryChoice(choiceId: string): void {
const action = content.actions.find((a) => a.storyChoiceId === choiceId); const action = content.actions.find((a) => a.storyChoiceId === choiceId);
if (action) { if (action) {
+2
View File
@@ -42,6 +42,8 @@ export const useGameStore = create<GameStoreState>((set, get) => ({
actionProgress: 0, actionProgress: 0,
queuedActionIds: [], queuedActionIds: [],
queuedActionNames: [], queuedActionNames: [],
automationQueueIds: [],
automationQueueNames: [],
actions: [], actions: [],
story: { currentProse: null, atBootIntro: false, choices: [], tree: [] }, story: { currentProse: null, atBootIntro: false, choices: [], tree: [] },
actionColumns: [], actionColumns: [],
+11
View File
@@ -1,4 +1,5 @@
import type { GameContent } from '../content/index'; import type { GameContent } from '../content/index';
import { isAutomationUnlocked } from '../engine/automation';
import { import {
canAffordAction, canAffordAction,
canUnlockAction, canUnlockAction,
@@ -40,6 +41,8 @@ export interface ActionView {
yieldsSummary: string | null; yieldsSummary: string | null;
kind: ActionColumnKind; kind: ActionColumnKind;
loopEnabled: boolean; loopEnabled: boolean;
automationUnlocked: boolean;
inAutomationQueue: boolean;
} }
export interface ActionGroupView { export interface ActionGroupView {
@@ -84,6 +87,8 @@ export interface GameView {
actionProgress: number; actionProgress: number;
queuedActionIds: string[]; queuedActionIds: string[];
queuedActionNames: string[]; queuedActionNames: string[];
automationQueueIds: string[];
automationQueueNames: string[];
actions: ActionView[]; actions: ActionView[];
story: StoryView; story: StoryView;
actionColumns: ActionColumnView[]; actionColumns: ActionColumnView[];
@@ -153,6 +158,8 @@ export function toView(state: GameState, content: GameContent): GameView {
: 0; : 0;
const queuedActionIds = [...state.actionQueue]; const queuedActionIds = [...state.actionQueue];
const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id); const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id);
const automationQueueIds = [...state.automationQueue];
const automationQueueNames = automationQueueIds.map((id) => content.actionsById[id]?.name ?? id);
// Build a map of ActionView by id for column assembly // Build a map of ActionView by id for column assembly
const actionViewMap = new Map<string, ActionView>(); const actionViewMap = new Map<string, ActionView>();
@@ -172,6 +179,8 @@ export function toView(state: GameState, content: GameContent): GameView {
yieldsSummary: formatResourceList(a.yields, content), yieldsSummary: formatResourceList(a.yields, content),
kind: a.kind, kind: a.kind,
loopEnabled: !!state.enabledLoopActionIds[a.id], loopEnabled: !!state.enabledLoopActionIds[a.id],
automationUnlocked: isAutomationUnlocked(state, content, a.id),
inAutomationQueue: state.automationQueue.includes(a.id),
}; };
actionViewMap.set(a.id, view); actionViewMap.set(a.id, view);
return view; return view;
@@ -243,6 +252,8 @@ export function toView(state: GameState, content: GameContent): GameView {
actionProgress, actionProgress,
queuedActionIds, queuedActionIds,
queuedActionNames, queuedActionNames,
automationQueueIds,
automationQueueNames,
actions, actions,
story, story,
actionColumns, actionColumns,
+16
View File
@@ -148,6 +148,22 @@ export function ActionCard({ action }: ActionCardProps) {
) : null} ) : null}
</div> </div>
) : null} ) : null}
{action.automationUnlocked ? (
<button
type="button"
aria-label={`${action.inAutomationQueue ? 'Disable' : 'Enable'} automation for ${action.name}`}
aria-pressed={action.inAutomationQueue}
title={`${action.inAutomationQueue ? 'Disable' : 'Enable'} automation for ${action.name}`}
onClick={() => gameRuntime.toggleAutomation(action.id)}
className={`shrink-0 rounded-lg border px-2 text-xs font-semibold transition-colors ${
action.inAutomationQueue
? 'border-amber-500 bg-amber-500/15 text-amber-300 hover:bg-amber-500/20'
: 'border-slate-700 bg-slate-800/70 text-slate-400 hover:border-amber-500/60 hover:text-amber-300'
}`}
>
Auto
</button>
) : null}
</div> </div>
); );
} }
+88
View File
@@ -0,0 +1,88 @@
import { useState } from 'react';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
export function AutomationBar() {
const automationQueueIds = useGameStore((s) => s.automationQueueIds);
const automationQueueNames = useGameStore((s) => s.automationQueueNames);
const [importText, setImportText] = useState('');
async function copyRecipe() {
const text = gameRuntime.exportAutomationRecipe();
try {
await navigator.clipboard?.writeText(text);
return;
} catch {
// Fall through to the legacy selection path.
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.append(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
function importRecipeText() {
gameRuntime.importAutomationRecipe(importText);
setImportText('');
}
return (
<section
aria-label="Automation"
className="rounded-xl border border-slate-800 bg-slate-900/30 p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="font-semibold text-slate-400 text-xs uppercase tracking-wider">
Automation
</h3>
<button
type="button"
onClick={copyRecipe}
className="rounded border border-slate-700 px-2.5 py-1 text-slate-300 text-xs transition-colors hover:border-amber-500/60 hover:text-amber-300"
>
Copy recipe
</button>
</div>
{automationQueueNames.length > 0 ? (
<ol aria-label="Automation queue" className="mb-3 flex flex-col gap-1.5">
{automationQueueNames.map((name, index) => (
<li
// biome-ignore lint/suspicious/noArrayIndexKey: indices distinguish repeated automation entries in display order
key={`${automationQueueIds[index]}-${index}`}
className="rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-slate-300 text-sm"
>
{index + 1}. {name}
</li>
))}
</ol>
) : (
<p className="mb-3 text-slate-500 text-sm">No automated processes.</p>
)}
<div className="flex flex-col gap-2">
<textarea
aria-label="Recipe text"
className="min-h-24 w-full resize-y rounded-lg border border-slate-700 bg-slate-950/70 p-2 text-slate-200 text-xs outline-none transition-colors placeholder:text-slate-600 focus:border-amber-500/70"
placeholder="Paste recipe text..."
value={importText}
onChange={(event) => setImportText(event.target.value)}
/>
<button
type="button"
disabled={importText.trim().length === 0}
onClick={importRecipeText}
className="self-start rounded border border-slate-700 px-3 py-1.5 text-slate-300 text-xs transition-colors hover:border-amber-500/60 hover:text-amber-300 disabled:cursor-not-allowed disabled:opacity-40"
>
Import recipe
</button>
</div>
</section>
);
}
+3
View File
@@ -1,6 +1,7 @@
import { gameRuntime } from '../state/runtime'; import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store'; import { useGameStore } from '../state/store';
import { ActionColumn } from './ActionColumn'; import { ActionColumn } from './ActionColumn';
import { AutomationBar } from './AutomationBar';
import { EventLog } from './EventLog'; import { EventLog } from './EventLog';
import { ResourceBar } from './ResourceBar'; import { ResourceBar } from './ResourceBar';
@@ -29,6 +30,8 @@ export function PlayPanel() {
))} ))}
</section> </section>
<AutomationBar />
{queuedActionNames.length > 0 ? ( {queuedActionNames.length > 0 ? (
<div className="border border-slate-800 bg-slate-900/30 rounded-xl p-4"> <div className="border border-slate-800 bg-slate-900/30 rounded-xl p-4">
<h3 className="font-semibold text-slate-400 text-xs uppercase tracking-wider mb-2"> <h3 className="font-semibold text-slate-400 text-xs uppercase tracking-wider mb-2">