feat(engine): evaluateTriggers for boot, actionComplete, minResources

This commit is contained in:
ginnoir
2026-06-11 18:51:07 -05:00
parent 717af1ef51
commit 720f6166cf
2 changed files with 176 additions and 1 deletions
+116 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema';
import { createGameState } from '../game';
import { enterStoryNode, initStory } from '../story';
import { enterStoryNode, evaluateTriggers, initStory } from '../story';
function gameContent() {
const base = buildContent({
@@ -35,6 +35,84 @@ function gameContent() {
return { ...base, ...story };
}
function gameContentWithActionTrigger() {
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: 'scout_aftermath',
prose: 'After scouting.',
triggers: [
{ type: 'actionComplete', actionId: 'scout_path', targetNodeId: 'scout_aftermath' },
],
},
],
base.actionsById,
base.resourcesById,
);
return { ...base, ...story };
}
function gameContentWithThreshold() {
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: 'merchant_flavor',
prose: 'Merchant.',
triggers: [
{
type: 'minResources',
minResources: { coin: 3 },
targetNodeId: 'merchant_flavor',
},
],
},
],
base.actionsById,
base.resourcesById,
);
return { ...base, ...story };
}
describe('enterStoryNode()', () => {
it('sets current node, marks seen, applies enterOutcomes', () => {
const content = gameContent();
@@ -55,3 +133,40 @@ describe('initStory()', () => {
expect(state.currentStoryNodeId).toBe('');
});
});
describe('evaluateTriggers()', () => {
it('fires boot trigger on boot reason', () => {
const content = gameContent();
const state = createGameState(content);
initStory(state, content);
const { enteredNodeIds } = evaluateTriggers(state, content, { reason: 'boot' });
expect(enteredNodeIds).toEqual(['boot_intro']);
expect(state.currentStoryNodeId).toBe('boot_intro');
});
it('fires actionComplete when scout_path finishes', () => {
const content = gameContentWithActionTrigger();
const state = createGameState(content);
const { enteredNodeIds } = evaluateTriggers(state, content, {
reason: 'actionComplete',
actionId: 'scout_path',
});
expect(enteredNodeIds).toEqual(['scout_aftermath']);
});
it('fires minResources on publish when thresholds met', () => {
const content = gameContentWithThreshold();
const state = createGameState(content);
state.resources.coin = 3;
const { enteredNodeIds } = evaluateTriggers(state, content, { reason: 'publish' });
expect(enteredNodeIds).toEqual(['merchant_flavor']);
});
it('does not re-fire once-only triggers for seen targets', () => {
const content = gameContent();
const state = createGameState(content);
evaluateTriggers(state, content, { reason: 'boot' });
const second = evaluateTriggers(state, content, { reason: 'boot' });
expect(second.enteredNodeIds).toEqual([]);
});
});
+60
View File
@@ -70,3 +70,63 @@ export function initStory(state: GameState, _content: GameContent): void {
state.currentStoryNodeId = '';
}
}
export type TriggerContext = {
reason: 'boot' | 'publish' | 'actionComplete';
actionId?: string;
};
export interface TriggerResult {
enteredNodeIds: string[];
events: StoryEvent[];
}
function meetsMinResources(state: GameState, minResources: Record<string, number>): boolean {
return Object.entries(minResources).every(
([id, min]) => (state.resources[id] ?? 0) >= min,
);
}
function shouldSkipTrigger(state: GameState, trigger: { targetNodeId: string; once: boolean }): boolean {
return trigger.once !== false && state.seenStoryNodeIds.includes(trigger.targetNodeId);
}
export function evaluateTriggers(
state: GameState,
content: GameContent,
ctx: TriggerContext,
): TriggerResult {
const enteredNodeIds: string[] = [];
const events: StoryEvent[] = [];
for (const node of content.storyNodes) {
for (const trigger of node.triggers ?? []) {
if (shouldSkipTrigger(state, trigger)) continue;
let matches = false;
if (ctx.reason === 'boot' && trigger.type === 'boot') matches = true;
if (
ctx.reason === 'actionComplete' &&
trigger.type === 'actionComplete' &&
trigger.actionId === ctx.actionId
) {
matches = true;
}
if (
(ctx.reason === 'publish' || ctx.reason === 'actionComplete') &&
trigger.type === 'minResources' &&
trigger.minResources &&
meetsMinResources(state, trigger.minResources)
) {
matches = true;
}
if (matches) {
enteredNodeIds.push(trigger.targetNodeId);
events.push(...enterStoryNode(state, content, trigger.targetNodeId));
}
}
}
return { enteredNodeIds, events };
}