feat(engine): applyChoice and gated choice filtering

This commit is contained in:
ginnoir
2026-06-11 18:51:49 -05:00
parent 720f6166cf
commit 51d3ec1a4b
2 changed files with 190 additions and 2 deletions
+136 -1
View File
@@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema';
import { createGameState } from '../game';
import { enterStoryNode, evaluateTriggers, initStory } from '../story';
import {
applyChoice,
enterStoryNode,
evaluateTriggers,
getAvailableChoices,
initStory,
} from '../story';
function gameContent() {
const base = buildContent({
@@ -113,6 +119,106 @@ function gameContentWithThreshold() {
return { ...base, ...story };
}
function gameContentWithFork() {
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: 'You wake at the crossroads.',
triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }],
},
{
id: 'fork_choice',
prose: 'Which way?',
choices: [
{
id: 'pick_a',
label: 'High road',
outcomes: [{ type: 'setFlag', flag: 'route_a' }],
targetNodeId: 'route_a_beat',
},
{
id: 'pick_b',
label: 'Low road',
outcomes: [{ type: 'setFlag', flag: 'route_b' }],
targetNodeId: 'route_b_beat',
},
{
id: 'pick_b_if_excluded',
label: 'Low road (excluded after A)',
requirements: { excludeStoryFlags: ['route_a'] },
outcomes: [{ type: 'setFlag', flag: 'route_b' }],
targetNodeId: 'route_b_beat',
},
],
},
{ id: 'route_a_beat', prose: 'The high road.' },
{ id: 'route_b_beat', prose: 'The low road.' },
],
base.actionsById,
base.resourcesById,
);
return { ...base, ...story };
}
function gameContentWithGatedChoice() {
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' }],
},
{
id: 'gated',
prose: 'A gated choice.',
choices: [
{
id: 'needs_coin',
label: 'Pay the toll',
requirements: { minResources: { coin: 5 } },
outcomes: [],
targetNodeId: 'boot_intro',
},
],
},
],
base.actionsById,
base.resourcesById,
);
return { ...base, ...story };
}
describe('enterStoryNode()', () => {
it('sets current node, marks seen, applies enterOutcomes', () => {
const content = gameContent();
@@ -170,3 +276,32 @@ describe('evaluateTriggers()', () => {
expect(second.enteredNodeIds).toEqual([]);
});
});
describe('applyChoice()', () => {
it('applies outcomes and advances on fork', () => {
const content = gameContentWithFork();
const state = createGameState(content);
enterStoryNode(state, content, 'fork_choice');
applyChoice(state, content, 'pick_a');
expect(state.storyFlags.route_a).toBe(true);
expect(state.currentStoryNodeId).toBe('route_a_beat');
});
it('throws when requirements not met', () => {
const content = gameContentWithGatedChoice();
const state = createGameState(content);
enterStoryNode(state, content, 'gated');
expect(() => applyChoice(state, content, 'needs_coin')).toThrow(/requirements/i);
});
});
describe('getAvailableChoices()', () => {
it('hides choices blocked by excludeStoryFlags', () => {
const content = gameContentWithFork();
const state = createGameState(content);
state.storyFlags.route_a = true;
enterStoryNode(state, content, 'fork_choice');
const choices = getAvailableChoices(state, content);
expect(choices.map((c) => c.id)).not.toContain('pick_b_if_excluded');
});
});
+54 -1
View File
@@ -1,5 +1,5 @@
import type { Content } from '../content/schema';
import type { StoryContent, StoryOutcome } from '../content/storySchema';
import type { StoryChoice, StoryContent, StoryOutcome } from '../content/storySchema';
import type { GameState } from './game';
type GameContent = Content & StoryContent;
@@ -130,3 +130,56 @@ export function evaluateTriggers(
return { enteredNodeIds, events };
}
function meetsChoiceRequirements(
state: GameState,
requirements: StoryChoice['requirements'],
): boolean {
if (!requirements) return true;
if (requirements.minResources) {
for (const [id, min] of Object.entries(requirements.minResources)) {
if ((state.resources[id] ?? 0) < min) return false;
}
}
if (requirements.requireStoryFlags) {
for (const flag of requirements.requireStoryFlags) {
if (!state.storyFlags[flag]) return false;
}
}
if (requirements.excludeStoryFlags) {
for (const flag of requirements.excludeStoryFlags) {
if (state.storyFlags[flag]) return false;
}
}
return true;
}
export function getCurrentNode(state: GameState, content: GameContent) {
return content.storyNodesById[state.currentStoryNodeId] ?? null;
}
export function getAvailableChoices(state: GameState, content: GameContent): StoryChoice[] {
const node = getCurrentNode(state, content);
if (!node?.choices) return [];
return node.choices.filter((c) => meetsChoiceRequirements(state, c.requirements));
}
export function applyChoice(
state: GameState,
content: GameContent,
choiceId: string,
): StoryEvent[] {
const node = getCurrentNode(state, content);
if (!node?.choices) throw new Error(`Node "${node?.id}" has no choices`);
const choice = node.choices.find((c) => c.id === choiceId);
if (!choice) throw new Error(`Unknown choice "${choiceId}"`);
if (!meetsChoiceRequirements(state, choice.requirements)) {
throw new Error(`Choice "${choiceId}" requirements not met`);
}
const events: StoryEvent[] = [];
applyOutcomes(state, content, choice.outcomes, events);
events.push(...enterStoryNode(state, content, choice.targetNodeId));
const last = events.find((e) => e.kind === 'enter');
if (last) last.choiceLabel = choice.label;
return events;
}