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