feat(m1): PR3.1 automation unlock and recipes #19
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ describe('loadGame()', () => {
|
|||||||
currentStoryNodeId: '',
|
currentStoryNodeId: '',
|
||||||
seenStoryNodeIds: [],
|
seenStoryNodeIds: [],
|
||||||
enabledLoopActionIds: {},
|
enabledLoopActionIds: {},
|
||||||
|
manualCompletionCounts: {},
|
||||||
|
automationQueue: [],
|
||||||
},
|
},
|
||||||
1000,
|
1000,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user