feat(engine): track manualCompletionCounts on action complete

This commit is contained in:
ginnoir
2026-06-12 00:19:24 -05:00
parent f7ad9e7ecd
commit d625216018
6 changed files with 97 additions and 1 deletions
+26
View File
@@ -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');
});
+43
View File
@@ -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<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', () => {
@@ -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<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', () => {
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', () => {
+20 -1
View File
@@ -28,6 +28,10 @@ export interface GameState {
seenStoryNodeIds: string[];
/** loop-kind action id -> whether the player has enabled it for idle running. */
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 {
@@ -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);
}
+4
View File
@@ -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],
},
};
}
+2
View File
@@ -111,6 +111,8 @@ describe('loadGame()', () => {
currentStoryNodeId: '',
seenStoryNodeIds: [],
enabledLoopActionIds: {},
manualCompletionCounts: {},
automationQueue: [],
},
1000,
),
+2
View File
@@ -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 {