feat(engine): story outcomes and enterStoryNode

This commit is contained in:
ginnoir
2026-06-11 18:50:34 -05:00
parent c7d1f3f51b
commit 717af1ef51
2 changed files with 129 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema';
import { createGameState } from '../game';
import { enterStoryNode, initStory } from '../story';
function gameContent() {
const base = buildContent({
resources: [
{ id: 'supplies', name: 'Supplies', startAmount: 10 },
{ id: 'coin', name: 'Coin', startAmount: 0 },
],
actions: [
{
id: 'scout_path',
name: 'Scout',
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
},
],
});
const story = buildStoryContent(
[
{
id: 'boot_intro',
prose: 'Boot.',
triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }],
enterOutcomes: [{ type: 'grantResource', resourceId: 'coin', amount: 1 }],
},
],
base.actionsById,
base.resourcesById,
);
return { ...base, ...story };
}
describe('enterStoryNode()', () => {
it('sets current node, marks seen, applies enterOutcomes', () => {
const content = gameContent();
const state = createGameState(content);
const events = enterStoryNode(state, content, 'boot_intro');
expect(state.currentStoryNodeId).toBe('boot_intro');
expect(state.seenStoryNodeIds).toContain('boot_intro');
expect(state.resources.coin).toBe(1);
expect(events.length).toBeGreaterThan(0);
});
});
describe('initStory()', () => {
it('leaves currentStoryNodeId empty until triggers run', () => {
const content = gameContent();
const state = createGameState(content);
initStory(state, content);
expect(state.currentStoryNodeId).toBe('');
});
});
+72
View File
@@ -0,0 +1,72 @@
import type { Content } from '../content/schema';
import type { StoryContent, StoryOutcome } from '../content/storySchema';
import type { GameState } from './game';
type GameContent = Content & StoryContent;
export interface StoryEvent {
kind: 'enter' | 'log';
nodeId: string;
prose: string;
choiceLabel?: string;
}
export function applyOutcomes(
state: GameState,
_content: GameContent,
outcomes: StoryOutcome[],
events: StoryEvent[],
): void {
for (const outcome of outcomes) {
switch (outcome.type) {
case 'setFlag':
state.storyFlags[outcome.flag] = true;
break;
case 'clearFlag':
state.storyFlags[outcome.flag] = false;
break;
case 'grantResource':
state.resources[outcome.resourceId] =
(state.resources[outcome.resourceId] ?? 0) + outcome.amount;
break;
case 'consumeResource': {
const current = state.resources[outcome.resourceId] ?? 0;
if (current < outcome.amount) {
throw new Error(
`Cannot consume ${outcome.amount} ${outcome.resourceId} (have ${current})`,
);
}
state.resources[outcome.resourceId] = current - outcome.amount;
break;
}
case 'log':
events.push({ kind: 'log', nodeId: state.currentStoryNodeId, prose: outcome.text });
break;
}
}
}
export function enterStoryNode(
state: GameState,
content: GameContent,
nodeId: string,
): StoryEvent[] {
const node = content.storyNodesById[nodeId];
if (!node) throw new Error(`Unknown story node "${nodeId}"`);
const events: StoryEvent[] = [];
if (!state.seenStoryNodeIds.includes(nodeId)) {
state.seenStoryNodeIds.push(nodeId);
}
state.currentStoryNodeId = nodeId;
applyOutcomes(state, content, node.enterOutcomes ?? [], events);
events.unshift({ kind: 'enter', nodeId, prose: node.prose });
return events;
}
export function initStory(state: GameState, _content: GameContent): void {
state.storyFlags = state.storyFlags ?? {};
state.seenStoryNodeIds = state.seenStoryNodeIds ?? [];
if (!state.currentStoryNodeId) {
state.currentStoryNodeId = '';
}
}