diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index e4f7e77..9f97768 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -11,6 +11,7 @@ import { executeInstant, maybeStartLoopAction, performAction, + recordManualCompletion, startAction, tickGame, } from '../game'; @@ -104,6 +105,11 @@ describe('createGameState()', () => { expect(state.actionElapsedMs).toBe(0); expect(state.actionQueue).toEqual([]); }); + + it('initializes empty manual completion counts', () => { + const state = createGameState(testContent()); + expect(state.manualCompletionCounts).toEqual({}); + }); }); describe('createGameState() story fields', () => { @@ -351,6 +357,24 @@ describe('tickGame() completion result', () => { const result = tickGame(state, content, 100); 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()', () => { @@ -417,6 +441,7 @@ describe('executeInstant()', () => { expect(state.resources.supplies).toBe(1); expect(state.activeActionId).toBeNull(); expect(state.actionQueue).toEqual([]); + expect(state.manualCompletionCounts.buy_supply).toBe(1); }); it('throws when unaffordable', () => { @@ -493,6 +518,7 @@ describe('performAction()', () => { enterStoryNode(state, gameContent, 'fork_choice'); const events = performAction(state, gameContent, 'pick_high_road'); expect(state.storyFlags.route_a).toBe(true); + expect(state.manualCompletionCounts.pick_high_road).toBe(1); expect(events.length).toBeGreaterThan(0); expect(events[0].kind).toBe('enter'); }); diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index 25e9401..58d6db5 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -79,6 +79,22 @@ describe('createSave()', () => { state.enabledLoopActionIds.rest = false; expect(save.state.enabledLoopActionIds).toEqual({ rest: true }); }); + + it('snapshots manualCompletionCounts and automationQueue in the save payload', () => { + const state = sampleState() as ReturnType & { + manualCompletionCounts: Record; + 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', () => { @@ -101,6 +117,18 @@ describe('serialize / deserialize round-trip', () => { expect(restored.state.enabledLoopActionIds).toEqual({ rest: true, patrol: false }); }); + it('preserves manualCompletionCounts and automationQueue through a round-trip', () => { + const state = sampleState() as ReturnType & { + manualCompletionCounts: Record; + 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', () => { const json = JSON.stringify({ version: 1, @@ -114,6 +142,21 @@ describe('serialize / deserialize round-trip', () => { const restored = deserializeSave(json); 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', () => { diff --git a/src/engine/game.ts b/src/engine/game.ts index eeb3da4..94f823e 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -28,6 +28,10 @@ export interface GameState { seenStoryNodeIds: string[]; /** loop-kind action id -> whether the player has enabled it for idle running. */ enabledLoopActionIds: Record; + /** action id -> number of successful player-enabled completions. */ + manualCompletionCounts: Record; + /** Action ids configured for future automation repeat. */ + automationQueue: string[]; } export function createGameState(content: Content): GameState { @@ -44,6 +48,8 @@ export function createGameState(content: Content): GameState { currentStoryNodeId: '', seenStoryNodeIds: [], enabledLoopActionIds: {}, + manualCompletionCounts: {}, + automationQueue: [], }; } @@ -97,6 +103,15 @@ function grantYields(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; +} + function beginAction(state: GameState, content: Content, actionId: string): void { assertKnownAction(content, actionId); deductCosts(state, content, actionId); @@ -178,6 +193,7 @@ export function executeInstant(state: GameState, content: Content, actionId: str } deductCosts(state, content, actionId); grantYields(state, content, actionId); + recordManualCompletion(state, content, actionId); } /** @@ -197,7 +213,9 @@ export function executeStoryAction( if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) { 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; } /** @@ -285,6 +303,7 @@ export function tickGame(state: GameState, content: Content, tickMs: number): Ti state.actionElapsedMs -= action.durationMs; grantYields(state, content, actionId); + recordManualCompletion(state, content, actionId); completedActionIds.push(actionId); startNextFromQueue(state, content); } diff --git a/src/engine/save.ts b/src/engine/save.ts index 6d125d0..ee538e6 100644 --- a/src/engine/save.ts +++ b/src/engine/save.ts @@ -33,6 +33,8 @@ export const gameStateSchema = z.object({ currentStoryNodeId: z.string().default(''), seenStoryNodeIds: z.array(z.string()).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({ @@ -57,6 +59,8 @@ export function createSave(state: GameState, now: number): SaveData { currentStoryNodeId: state.currentStoryNodeId, seenStoryNodeIds: [...state.seenStoryNodeIds], enabledLoopActionIds: { ...state.enabledLoopActionIds }, + manualCompletionCounts: { ...state.manualCompletionCounts }, + automationQueue: [...state.automationQueue], }, }; } diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index 2103567..f1492ce 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -111,6 +111,8 @@ describe('loadGame()', () => { currentStoryNodeId: '', seenStoryNodeIds: [], enabledLoopActionIds: {}, + manualCompletionCounts: {}, + automationQueue: [], }, 1000, ), diff --git a/src/state/persistence.ts b/src/state/persistence.ts index f036a32..5f0f94a 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -107,6 +107,8 @@ export async function loadGame( currentStoryNodeId: save.state.currentStoryNodeId ?? '', seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])], enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) }, + manualCompletionCounts: { ...(save.state.manualCompletionCounts ?? {}) }, + automationQueue: [...(save.state.automationQueue ?? [])], }; savedAt = save.savedAt; } catch {