diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index 0a58bfe..2e0091d 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -7,6 +7,7 @@ import { createGameState, enqueueAction, executeInstant, + maybeStartLoopAction, startAction, tickGame, } from '../game'; @@ -402,3 +403,46 @@ describe('executeInstant()', () => { expect(() => executeInstant(state, instantContent, 'buy_supply')).toThrow(/Cannot/); }); }); + +describe('loop idle runner', () => { + const loopContent = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }], + actions: [ + { + id: 'rest', + name: 'Rest', + kind: 'loop', + group: { id: 'camp_loop', label: 'Camp' }, + durationMs: 1000, + loopPriority: 0, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); + + it('defaults enabledLoopActionIds to empty', () => { + const state = createGameState(loopContent); + expect(state.enabledLoopActionIds).toEqual({}); + }); + + it('starts enabled loop action when idle', () => { + const state = createGameState(loopContent); + state.enabledLoopActionIds = { rest: true }; + maybeStartLoopAction(state, loopContent); + expect(state.activeActionId).toBe('rest'); + }); + + it('does not start loop when queue has items', () => { + const state = createGameState(loopContent); + state.enabledLoopActionIds = { rest: true }; + state.actionQueue.push('rest'); + maybeStartLoopAction(state, loopContent); + expect(state.activeActionId).toBeNull(); + }); + + it('does not start a disabled loop action', () => { + const state = createGameState(loopContent); + maybeStartLoopAction(state, loopContent); + expect(state.activeActionId).toBeNull(); + }); +}); diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index 776f826..0685b75 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -70,6 +70,15 @@ describe('createSave()', () => { expect(save.state.currentStoryNodeId).toBe('route_a_beat'); expect(save.state.seenStoryNodeIds).toHaveLength(2); }); + + it('snapshots enabledLoopActionIds in the save payload and isolates from mutation', () => { + const state = sampleState(); + state.enabledLoopActionIds = { rest: true }; + const save = createSave(state, 1700); + expect(save.state.enabledLoopActionIds).toEqual({ rest: true }); + state.enabledLoopActionIds.rest = false; + expect(save.state.enabledLoopActionIds).toEqual({ rest: true }); + }); }); describe('serialize / deserialize round-trip', () => { @@ -84,6 +93,27 @@ describe('serialize / deserialize round-trip', () => { const restored = fromExportString(toExportString(save)); expect(restored).toEqual(save); }); + + it('preserves enabledLoopActionIds through a round-trip', () => { + const state = sampleState(); + state.enabledLoopActionIds = { rest: true, patrol: false }; + const restored = deserializeSave(serializeSave(createSave(state, 1700))); + expect(restored.state.enabledLoopActionIds).toEqual({ rest: true, patrol: false }); + }); + + it('defaults enabledLoopActionIds to {} 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.enabledLoopActionIds).toEqual({}); + }); }); describe('invalid / tampered saves', () => { diff --git a/src/engine/game.ts b/src/engine/game.ts index 0f6501b..04fccc3 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -26,6 +26,8 @@ export interface GameState { currentStoryNodeId: string; /** Story node ids the player has already seen. */ seenStoryNodeIds: string[]; + /** loop-kind action id -> whether the player has enabled it for idle running. */ + enabledLoopActionIds: Record; } export function createGameState(content: Content): GameState { @@ -41,6 +43,7 @@ export function createGameState(content: Content): GameState { storyFlags: {}, currentStoryNodeId: '', seenStoryNodeIds: [], + enabledLoopActionIds: {}, }; } @@ -197,6 +200,26 @@ export function executeStoryAction( applyChoice(state, content, action.storyChoiceId); } +/** + * Start the highest-priority enabled, available loop action when the game is idle. + * Invoked by the runtime AFTER each live tick — never from `tickGame`, so loop + * actions do not run during offline catch-up (which replays `tickGame` directly). + */ +export function maybeStartLoopAction(state: GameState, content: Content): void { + if (state.activeActionId !== null || state.actionQueue.length > 0) return; + + const candidates = content.actions + .filter((a) => a.kind === 'loop' && state.enabledLoopActionIds[a.id]) + .sort((a, b) => (a.loopPriority ?? 0) - (b.loopPriority ?? 0)); + + for (const action of candidates) { + if (isActionAvailable(state, content, action.id)) { + beginAction(state, content, action.id); + return; + } + } +} + /** * Advance the active action by `tickMs`. On completion, grants yields and * advances the queue — actions do not auto-repeat when the queue is empty. diff --git a/src/engine/save.ts b/src/engine/save.ts index f4c4cf5..6d125d0 100644 --- a/src/engine/save.ts +++ b/src/engine/save.ts @@ -32,6 +32,7 @@ export const gameStateSchema = z.object({ storyFlags: z.record(z.string(), z.boolean()).default({}), currentStoryNodeId: z.string().default(''), seenStoryNodeIds: z.array(z.string()).default([]), + enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}), }); export const saveSchema = z.object({ @@ -55,6 +56,7 @@ export function createSave(state: GameState, now: number): SaveData { storyFlags: { ...state.storyFlags }, currentStoryNodeId: state.currentStoryNodeId, seenStoryNodeIds: [...state.seenStoryNodeIds], + enabledLoopActionIds: { ...state.enabledLoopActionIds }, }, }; } diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index b639082..7bfd198 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -98,6 +98,7 @@ describe('loadGame()', () => { storyFlags: {}, currentStoryNodeId: '', seenStoryNodeIds: [], + enabledLoopActionIds: {}, }, 1000, ), diff --git a/src/state/persistence.ts b/src/state/persistence.ts index 8455258..f036a32 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -106,6 +106,7 @@ export async function loadGame( storyFlags: { ...save.state.storyFlags }, currentStoryNodeId: save.state.currentStoryNodeId ?? '', seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])], + enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) }, }; savedAt = save.savedAt; } catch {