feat(engine): add story fields to GameState

This commit is contained in:
ginnoir
2026-06-11 18:47:18 -05:00
parent 56af082590
commit d2692bcc3c
4 changed files with 37 additions and 17 deletions
+12 -1
View File
@@ -76,6 +76,16 @@ describe('createGameState()', () => {
});
});
describe('createGameState() story fields', () => {
it('initializes empty story state', () => {
const content = testContent();
const state = createGameState(content);
expect(state.storyFlags).toEqual({});
expect(state.currentStoryNodeId).toBe('');
expect(state.seenStoryNodeIds).toEqual([]);
});
});
describe('startAction()', () => {
it('activates the action and resets its progress', () => {
const content = testContent();
@@ -202,7 +212,8 @@ describe('unlock conditions', () => {
});
const state = createGameState(content);
expect(() => enqueueAction(state, content, 'secret')).toThrow(/cannot enqueue/i);
expect(canUnlockAction(state, content, 'secret', { path_scouted: true })).toBe(true);
state.storyFlags.path_scouted = true;
expect(canUnlockAction(state, content, 'secret')).toBe(true);
});
});
+19 -16
View File
@@ -16,6 +16,12 @@ export interface GameState {
actionElapsedMs: number;
/** Action ids waiting to run after the active action finishes. */
actionQueue: string[];
/** Story progression flags set by the narrative graph. */
storyFlags: Record<string, boolean>;
/** Id of the story node currently displayed, or empty when none. */
currentStoryNodeId: string;
/** Story node ids the player has already seen. */
seenStoryNodeIds: string[];
}
export function createGameState(content: Content): GameState {
@@ -23,7 +29,15 @@ export function createGameState(content: Content): GameState {
for (const resource of content.resources) {
resources[resource.id] = resource.startAmount;
}
return { resources, activeActionId: null, actionElapsedMs: 0, actionQueue: [] };
return {
resources,
activeActionId: null,
actionElapsedMs: 0,
actionQueue: [],
storyFlags: {},
currentStoryNodeId: '',
seenStoryNodeIds: [],
};
}
function assertKnownAction(content: Content, actionId: string): void {
@@ -38,13 +52,7 @@ export function canAffordAction(state: GameState, content: Content, actionId: st
return action.costs.every((cost) => (state.resources[cost.resourceId] ?? 0) >= cost.amount);
}
/** Story flags land in PR2; placeholder field keeps unlock schema honest. */
export function canUnlockAction(
state: GameState,
content: Content,
actionId: string,
storyFlags: Record<string, boolean> = {},
): boolean {
export function canUnlockAction(state: GameState, content: Content, actionId: string): boolean {
const action = content.actionsById[actionId];
if (!action) return false;
const unlock = action.unlock;
@@ -56,21 +64,16 @@ export function canUnlockAction(
}
if (unlock.requireStoryFlags) {
for (const flag of unlock.requireStoryFlags) {
if (!storyFlags[flag]) return false;
if (!state.storyFlags[flag]) return false;
}
}
return true;
}
export function isActionAvailable(
state: GameState,
content: Content,
actionId: string,
storyFlags: Record<string, boolean> = {},
): boolean {
export function isActionAvailable(state: GameState, content: Content, actionId: string): boolean {
return (
canAffordAction(state, content, actionId) &&
canUnlockAction(state, content, actionId, storyFlags)
canUnlockAction(state, content, actionId)
);
}
+3
View File
@@ -79,6 +79,9 @@ describe('loadGame()', () => {
activeActionId: 'ghost-action',
actionElapsedMs: 0,
actionQueue: [],
storyFlags: {},
currentStoryNodeId: '',
seenStoryNodeIds: [],
},
1000,
),
+3
View File
@@ -103,6 +103,9 @@ export async function loadGame(
activeActionId,
actionElapsedMs: save.state.actionElapsedMs,
actionQueue: [...(save.state.actionQueue ?? [])],
storyFlags: { ...base.storyFlags },
currentStoryNodeId: base.currentStoryNodeId,
seenStoryNodeIds: [...base.seenStoryNodeIds],
};
savedAt = save.savedAt;
} catch {