feat(engine): evaluateTriggers for boot, actionComplete, minResources
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user