feat(engine): applyChoice and gated choice filtering
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user