docs: document story engine and PR2 playable loop
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user