docs: document story engine and PR2 playable loop
CI / verify (push) Failing after 58s
CI / verify (pull_request) Failing after 41s

This commit is contained in:
ginnoir
2026-06-11 19:00:49 -05:00
parent 1461510a4b
commit c76265d648
14 changed files with 137 additions and 30 deletions
+87
View File
@@ -4,10 +4,13 @@ import { buildStoryContent } from '../../content/storySchema';
import { createGameState } from '../game';
import {
applyChoice,
applyOutcomes,
enterStoryNode,
evaluateTriggers,
getAvailableChoices,
getCurrentNode,
initStory,
type StoryEvent,
} from '../story';
function gameContent() {
@@ -304,4 +307,88 @@ describe('getAvailableChoices()', () => {
const choices = getAvailableChoices(state, content);
expect(choices.map((c) => c.id)).not.toContain('pick_b_if_excluded');
});
it('shows choices only when requireStoryFlags are set', () => {
const base = buildContent({
resources: [{ id: 'supplies', name: 'Supplies', startAmount: 10 }],
actions: [],
});
const story = buildStoryContent(
[
{
id: 'boot_intro',
prose: 'Boot.',
triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }],
},
{
id: 'flag_gate',
prose: 'Need a key.',
choices: [
{
id: 'unlocked',
label: 'Open',
requirements: { requireStoryFlags: ['has_key'] },
outcomes: [],
targetNodeId: 'flag_gate',
},
],
},
],
base.actionsById,
base.resourcesById,
);
const content = { ...base, ...story };
const state = createGameState(content);
enterStoryNode(state, content, 'flag_gate');
expect(getAvailableChoices(state, content)).toEqual([]);
state.storyFlags.has_key = true;
expect(getAvailableChoices(state, content).map((c) => c.id)).toEqual(['unlocked']);
});
});
describe('applyOutcomes()', () => {
it('applies clearFlag, consumeResource, and log outcomes', () => {
const content = gameContent();
const state = createGameState(content);
state.resources.coin = 5;
state.currentStoryNodeId = 'boot_intro';
const events: StoryEvent[] = [];
applyOutcomes(
state,
content,
[
{ type: 'clearFlag', flag: 'visited' },
{ type: 'consumeResource', resourceId: 'coin', amount: 2 },
{ type: 'log', text: 'A note in the margin.' },
],
events,
);
expect(state.storyFlags.visited).toBe(false);
expect(state.resources.coin).toBe(3);
expect(events).toEqual([
{ kind: 'log', nodeId: 'boot_intro', prose: 'A note in the margin.' },
]);
});
it('throws when consumeResource exceeds balance', () => {
const content = gameContent();
const state = createGameState(content);
state.resources.coin = 1;
expect(() =>
applyOutcomes(state, content, [{ type: 'consumeResource', resourceId: 'coin', amount: 2 }], []),
).toThrow(/Cannot consume 2 coin/);
});
});
describe('getCurrentNode()', () => {
it('returns null for unknown currentStoryNodeId', () => {
const content = gameContent();
const state = createGameState(content);
state.currentStoryNodeId = 'missing_node';
expect(getCurrentNode(state, content)).toBeNull();
expect(getAvailableChoices(state, content)).toEqual([]);
});
});
+1 -4
View File
@@ -71,10 +71,7 @@ export function canUnlockAction(state: GameState, content: Content, actionId: st
}
export function isActionAvailable(state: GameState, content: Content, actionId: string): boolean {
return (
canAffordAction(state, content, actionId) &&
canUnlockAction(state, content, actionId)
);
return canAffordAction(state, content, actionId) && canUnlockAction(state, content, actionId);
}
function deductCosts(state: GameState, content: Content, actionId: string): void {
+5 -4
View File
@@ -82,12 +82,13 @@ export interface TriggerResult {
}
function meetsMinResources(state: GameState, minResources: Record<string, number>): boolean {
return Object.entries(minResources).every(
([id, min]) => (state.resources[id] ?? 0) >= min,
);
return Object.entries(minResources).every(([id, min]) => (state.resources[id] ?? 0) >= min);
}
function shouldSkipTrigger(state: GameState, trigger: { targetNodeId: string; once: boolean }): boolean {
function shouldSkipTrigger(
state: GameState,
trigger: { targetNodeId: string; once: boolean },
): boolean {
return trigger.once !== false && state.seenStoryNodeIds.includes(trigger.targetNodeId);
}