feat(engine): story outcomes and enterStoryNode
This commit is contained in:
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user