From d2692bcc3cd20caffafb10888a36f5211edf6e3f Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:47:18 -0500 Subject: [PATCH] feat(engine): add story fields to GameState --- src/engine/__tests__/game.test.ts | 13 ++++++++- src/engine/game.ts | 35 ++++++++++++++----------- src/state/__tests__/persistence.test.ts | 3 +++ src/state/persistence.ts | 3 +++ 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index 58791da..3d66041 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -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); }); }); diff --git a/src/engine/game.ts b/src/engine/game.ts index de6a3e9..13ab686 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -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; + /** 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 = {}, -): 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 = {}, -): 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) ); } diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index 1ed517d..c105d89 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -79,6 +79,9 @@ describe('loadGame()', () => { activeActionId: 'ghost-action', actionElapsedMs: 0, actionQueue: [], + storyFlags: {}, + currentStoryNodeId: '', + seenStoryNodeIds: [], }, 1000, ), diff --git a/src/state/persistence.ts b/src/state/persistence.ts index e4284d6..e0843fe 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -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 {