feat(engine): track manualCompletionCounts on action complete
This commit is contained in:
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
+20
-1
@@ -28,6 +28,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 +48,8 @@ export function createGameState(content: Content): GameState {
|
|||||||
currentStoryNodeId: '',
|
currentStoryNodeId: '',
|
||||||
seenStoryNodeIds: [],
|
seenStoryNodeIds: [],
|
||||||
enabledLoopActionIds: {},
|
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 {
|
function beginAction(state: GameState, content: Content, actionId: string): void {
|
||||||
assertKnownAction(content, actionId);
|
assertKnownAction(content, actionId);
|
||||||
deductCosts(state, content, actionId);
|
deductCosts(state, content, actionId);
|
||||||
@@ -178,6 +193,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 +213,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -285,6 +303,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user