feat(content): stub story graph with A/B branch and route-gated actions

This commit is contained in:
ginnoir
2026-06-11 18:59:17 -05:00
parent fdefe83b89
commit 1461510a4b
4 changed files with 99 additions and 3 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ describe('M1 stub content pack', () => {
it('defines two resources and four to five actions with costs and unlocks', () => {
expect(content.resources).toHaveLength(2);
expect(content.actions.length).toBeGreaterThanOrEqual(4);
expect(content.actions.length).toBeLessThanOrEqual(5);
expect(content.actions.length).toBeLessThanOrEqual(6);
const withCosts = content.actions.filter((a) => a.costs.length > 0);
const withUnlocks = content.actions.filter((a) => a.unlock !== undefined);
expect(withCosts.length).toBeGreaterThanOrEqual(2);
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { createGameState, isActionAvailable } from '../../engine/game';
import { applyChoice, enterStoryNode, evaluateTriggers, initStory } from '../../engine/story';
import { content } from '../index';
describe('stub story graph', () => {
it('route A unlocks fortify_camp but not push_onward', () => {
const state = createGameState(content);
initStory(state, content);
evaluateTriggers(state, content, { reason: 'boot' });
enterStoryNode(state, content, 'fork_choice');
applyChoice(state, content, 'pick_a');
expect(state.storyFlags.route_a).toBe(true);
expect(isActionAvailable(state, content, 'fortify_camp')).toBe(true);
expect(isActionAvailable(state, content, 'push_onward')).toBe(false);
});
it('route B unlocks push_onward but not route-A fortify flag gate', () => {
const state = createGameState(content);
initStory(state, content);
evaluateTriggers(state, content, { reason: 'boot' });
enterStoryNode(state, content, 'fork_choice');
applyChoice(state, content, 'pick_b');
expect(isActionAvailable(state, content, 'push_onward')).toBe(true);
expect(isActionAvailable(state, content, 'fortify_camp')).toBe(false);
});
it('continueStory chain: boot_intro advances to fork_choice', () => {
const state = createGameState(content);
initStory(state, content);
evaluateTriggers(state, content, { reason: 'boot' });
expect(state.currentStoryNodeId).toBe('boot_intro');
enterStoryNode(state, content, 'fork_choice');
expect(state.currentStoryNodeId).toBe('fork_choice');
expect(content.storyNodesById.fork_choice.choices).toHaveLength(2);
});
});
+21 -1
View File
@@ -9,6 +9,8 @@ export const actionDefs = [
name: 'Gather supplies',
durationMs: 3000,
yields: [{ resourceId: 'supplies', amount: 2 }],
storyHint: 'Basic camp labor.',
storyTooltip: 'Yields 2 Supplies. No cost.',
},
{
id: 'scout_path',
@@ -16,6 +18,8 @@ export const actionDefs = [
durationMs: 5000,
costs: [{ resourceId: 'supplies', amount: 2 }],
yields: [{ resourceId: 'coin', amount: 1 }],
storyHint: 'Map the crossing.',
storyTooltip: 'Costs 2 Supplies. Yields 1 Coin. Triggers scout aftermath story.',
},
{
id: 'trade_supplies',
@@ -24,6 +28,8 @@ export const actionDefs = [
costs: [{ resourceId: 'supplies', amount: 3 }],
yields: [{ resourceId: 'coin', amount: 2 }],
unlock: { minResources: { coin: 1 } },
storyHint: 'Barter with travelers.',
storyTooltip: 'Costs 3 Supplies. Yields 2 Coin. Unlocks at 1 Coin.',
},
{
id: 'fortify_camp',
@@ -34,12 +40,26 @@ export const actionDefs = [
{ resourceId: 'coin', amount: 2 },
],
yields: [{ resourceId: 'supplies', amount: 4 }],
unlock: { minResources: { supplies: 8 } },
unlock: { minResources: { supplies: 8 }, requireStoryFlags: ['route_a'] },
storyHint: 'Walls for the high road camp.',
storyTooltip: 'Route A only. Costs supplies and coin.',
},
{
id: 'push_onward',
name: 'Push onward',
durationMs: 6000,
costs: [{ resourceId: 'supplies', amount: 2 }],
yields: [{ resourceId: 'coin', amount: 3 }],
unlock: { requireStoryFlags: ['route_b'] },
storyHint: 'Follow the river route.',
storyTooltip: 'Costs 2 Supplies. Yields 3 Coin. Route B only.',
},
{
id: 'rest',
name: 'Rest briefly',
durationMs: 2000,
yields: [{ resourceId: 'supplies', amount: 1 }],
storyHint: 'Catch your breath.',
storyTooltip: 'Yields 1 Supply. Quick recovery.',
},
];
+40 -1
View File
@@ -1,7 +1,46 @@
export const storyNodeDefs = [
{
id: 'boot_intro',
prose: 'Placeholder boot.',
prose: '[Stub] You wake at a crossroads camp. Smoke rises from a cold fire pit.',
triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }],
},
{
id: 'fork_choice',
prose: '[Stub] Tracks split. The high road climbs; the low road bends toward the river.',
choices: [
{
id: 'pick_a',
label: 'Take the high road',
outcomes: [
{ type: 'setFlag', flag: 'route_a' },
{ type: 'grantResource', resourceId: 'supplies', amount: 3 },
{ type: 'grantResource', resourceId: 'coin', amount: 2 },
],
targetNodeId: 'route_a_beat',
},
{
id: 'pick_b',
label: 'Follow the river',
outcomes: [
{ type: 'setFlag', flag: 'route_b' },
{ type: 'grantResource', resourceId: 'coin', amount: 2 },
],
targetNodeId: 'route_b_beat',
},
],
},
{ id: 'route_a_beat', prose: '[Stub] Route A: high ground, extra supplies.' },
{ id: 'route_b_beat', prose: '[Stub] Route B: river trade, extra coin.' },
{
id: 'threshold_listener',
prose: ' ',
triggers: [{ type: 'minResources', minResources: { coin: 3 }, targetNodeId: 'merchant_flavor' }],
},
{ id: 'merchant_flavor', prose: '[Stub] A merchant remembers your face.' },
{
id: 'scout_listener',
prose: ' ',
triggers: [{ type: 'actionComplete', actionId: 'scout_path', targetNodeId: 'scout_aftermath' }],
},
{ id: 'scout_aftermath', prose: '[Stub] The path is mapped.' },
];