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 { buildContent } from '../../content/schema';
|
||||||
import { buildStoryContent } from '../../content/storySchema';
|
import { buildStoryContent } from '../../content/storySchema';
|
||||||
import { createGameState } from '../game';
|
import { createGameState } from '../game';
|
||||||
import { enterStoryNode, evaluateTriggers, initStory } from '../story';
|
import {
|
||||||
|
applyChoice,
|
||||||
|
enterStoryNode,
|
||||||
|
evaluateTriggers,
|
||||||
|
getAvailableChoices,
|
||||||
|
initStory,
|
||||||
|
} from '../story';
|
||||||
|
|
||||||
function gameContent() {
|
function gameContent() {
|
||||||
const base = buildContent({
|
const base = buildContent({
|
||||||
@@ -113,6 +119,106 @@ function gameContentWithThreshold() {
|
|||||||
return { ...base, ...story };
|
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()', () => {
|
describe('enterStoryNode()', () => {
|
||||||
it('sets current node, marks seen, applies enterOutcomes', () => {
|
it('sets current node, marks seen, applies enterOutcomes', () => {
|
||||||
const content = gameContent();
|
const content = gameContent();
|
||||||
@@ -170,3 +276,32 @@ describe('evaluateTriggers()', () => {
|
|||||||
expect(second.enteredNodeIds).toEqual([]);
|
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 { Content } from '../content/schema';
|
||||||
import type { StoryContent, StoryOutcome } from '../content/storySchema';
|
import type { StoryChoice, StoryContent, StoryOutcome } from '../content/storySchema';
|
||||||
import type { GameState } from './game';
|
import type { GameState } from './game';
|
||||||
|
|
||||||
type GameContent = Content & StoryContent;
|
type GameContent = Content & StoryContent;
|
||||||
@@ -130,3 +130,56 @@ export function evaluateTriggers(
|
|||||||
|
|
||||||
return { enteredNodeIds, events };
|
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