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 { buildContent } from '../../content/schema';
|
||||||
import { buildStoryContent } from '../../content/storySchema';
|
import { buildStoryContent } from '../../content/storySchema';
|
||||||
import { createGameState } from '../game';
|
import { createGameState } from '../game';
|
||||||
import { enterStoryNode, initStory } from '../story';
|
import { enterStoryNode, evaluateTriggers, initStory } from '../story';
|
||||||
|
|
||||||
function gameContent() {
|
function gameContent() {
|
||||||
const base = buildContent({
|
const base = buildContent({
|
||||||
@@ -35,6 +35,84 @@ function gameContent() {
|
|||||||
return { ...base, ...story };
|
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()', () => {
|
describe('enterStoryNode()', () => {
|
||||||
it('sets current node, marks seen, applies enterOutcomes', () => {
|
it('sets current node, marks seen, applies enterOutcomes', () => {
|
||||||
const content = gameContent();
|
const content = gameContent();
|
||||||
@@ -55,3 +133,40 @@ describe('initStory()', () => {
|
|||||||
expect(state.currentStoryNodeId).toBe('');
|
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 = '';
|
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