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()', () => { describe('startAction()', () => {
it('activates the action and resets its progress', () => { it('activates the action and resets its progress', () => {
const content = testContent(); const content = testContent();
@@ -202,7 +212,8 @@ describe('unlock conditions', () => {
}); });
const state = createGameState(content); const state = createGameState(content);
expect(() => enqueueAction(state, content, 'secret')).toThrow(/cannot enqueue/i); 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; actionElapsedMs: number;
/** Action ids waiting to run after the active action finishes. */ /** Action ids waiting to run after the active action finishes. */
actionQueue: string[]; 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 { export function createGameState(content: Content): GameState {
@@ -23,7 +29,15 @@ export function createGameState(content: Content): GameState {
for (const resource of content.resources) { for (const resource of content.resources) {
resources[resource.id] = resource.startAmount; 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 { 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); 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): boolean {
export function canUnlockAction(
state: GameState,
content: Content,
actionId: string,
storyFlags: Record<string, boolean> = {},
): boolean {
const action = content.actionsById[actionId]; const action = content.actionsById[actionId];
if (!action) return false; if (!action) return false;
const unlock = action.unlock; const unlock = action.unlock;
@@ -56,21 +64,16 @@ export function canUnlockAction(
} }
if (unlock.requireStoryFlags) { if (unlock.requireStoryFlags) {
for (const flag of unlock.requireStoryFlags) { for (const flag of unlock.requireStoryFlags) {
if (!storyFlags[flag]) return false; if (!state.storyFlags[flag]) return false;
} }
} }
return true; return true;
} }
export function isActionAvailable( export function isActionAvailable(state: GameState, content: Content, actionId: string): boolean {
state: GameState,
content: Content,
actionId: string,
storyFlags: Record<string, boolean> = {},
): boolean {
return ( return (
canAffordAction(state, content, actionId) && 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', activeActionId: 'ghost-action',
actionElapsedMs: 0, actionElapsedMs: 0,
actionQueue: [], actionQueue: [],
storyFlags: {},
currentStoryNodeId: '',
seenStoryNodeIds: [],
}, },
1000, 1000,
), ),
+3
View File
@@ -103,6 +103,9 @@ export async function loadGame(
activeActionId, activeActionId,
actionElapsedMs: save.state.actionElapsedMs, actionElapsedMs: save.state.actionElapsedMs,
actionQueue: [...(save.state.actionQueue ?? [])], actionQueue: [...(save.state.actionQueue ?? [])],
storyFlags: { ...base.storyFlags },
currentStoryNodeId: base.currentStoryNodeId,
seenStoryNodeIds: [...base.seenStoryNodeIds],
}; };
savedAt = save.savedAt; savedAt = save.savedAt;
} catch { } catch {