From c76265d648d878b77f1d4f56f0cc34555f42d98e Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 19:00:49 -0500 Subject: [PATCH] docs: document story engine and PR2 playable loop --- docs/architecture.md | 5 ++ src/content/__tests__/storySchema.test.ts | 16 ++++- src/content/index.ts | 2 +- src/content/story.ts | 4 +- src/content/storySchema.ts | 12 +++- src/engine/__tests__/story.test.ts | 87 +++++++++++++++++++++++ src/engine/game.ts | 5 +- src/engine/story.ts | 9 +-- src/state/runtime.ts | 14 ++-- src/state/store.ts | 2 +- src/state/viewModel.ts | 2 +- src/ui/App.tsx | 3 +- src/ui/SettingsDrawer.tsx | 2 +- src/ui/StoryPanel.tsx | 4 +- 14 files changed, 137 insertions(+), 30 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 90639b1..960808c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,6 +12,8 @@ Idlegame is split into four layers. queue advancement (actions do not auto-repeat when the queue is empty). - `save.ts`: versioned save schema, serialized export/import strings, and offline elapsed calculation. +- `story.ts`: story graph traversal, triggers (boot, actionComplete, + minResources), choices, outcomes. - `num.ts`: branded numeric boundary and human-readable formatting. The engine must stay pure. It does not import React, Zustand, browser APIs, @@ -33,6 +35,9 @@ story nodes, automation unlocks, and prestige definitions. - requestAnimationFrame loop and lifecycle hooks - mapping engine state to view models - Zustand store updates for React +- `storyOrchestration.ts`: evaluates triggers after boot/publish/action + completion; maps prefs to panel auto-open. +- Player prefs (`prefs.ts`) in localStorage — not in save v1. ## UI diff --git a/src/content/__tests__/storySchema.test.ts b/src/content/__tests__/storySchema.test.ts index 23c380e..046e897 100644 --- a/src/content/__tests__/storySchema.test.ts +++ b/src/content/__tests__/storySchema.test.ts @@ -6,7 +6,13 @@ const resourcesById = { coin: { id: 'coin', name: 'Coin', startAmount: 0 }, }; const actionsById = { - scout_path: { id: 'scout_path', name: 'Scout', durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }] }, + scout_path: { + id: 'scout_path', + name: 'Scout', + durationMs: 1000, + costs: [], + yields: [{ resourceId: 'coin', amount: 1 }], + }, }; const validNodes = [ @@ -47,7 +53,13 @@ describe('buildStoryContent()', () => { it('rejects dangling targetNodeId on choices', () => { expect(() => buildStoryContent( - [{ id: 'n', prose: 'x', choices: [{ id: 'c', label: 'y', outcomes: [], targetNodeId: 'missing' }] }], + [ + { + id: 'n', + prose: 'x', + choices: [{ id: 'c', label: 'y', outcomes: [], targetNodeId: 'missing' }], + }, + ], actionsById, resourcesById, ), diff --git a/src/content/index.ts b/src/content/index.ts index 27c6058..59ad305 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,7 +1,7 @@ import { actionDefs, resourceDefs } from './definitions'; import { buildContent, type Content } from './schema'; -import { buildStoryContent, type StoryContent } from './storySchema'; import { storyNodeDefs } from './story'; +import { buildStoryContent, type StoryContent } from './storySchema'; const base = buildContent({ resources: resourceDefs, actions: actionDefs }); const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesById); diff --git a/src/content/story.ts b/src/content/story.ts index 681badd..78e2274 100644 --- a/src/content/story.ts +++ b/src/content/story.ts @@ -34,7 +34,9 @@ export const storyNodeDefs = [ { id: 'threshold_listener', prose: ' ', - triggers: [{ type: 'minResources', minResources: { coin: 3 }, targetNodeId: 'merchant_flavor' }], + triggers: [ + { type: 'minResources', minResources: { coin: 3 }, targetNodeId: 'merchant_flavor' }, + ], }, { id: 'merchant_flavor', prose: '[Stub] A merchant remembers your face.' }, { diff --git a/src/content/storySchema.ts b/src/content/storySchema.ts index 313f8bf..fb5b440 100644 --- a/src/content/storySchema.ts +++ b/src/content/storySchema.ts @@ -4,8 +4,16 @@ import type { ActionDef } from './schema'; export const storyOutcomeSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('setFlag'), flag: z.string().min(1) }), z.object({ type: z.literal('clearFlag'), flag: z.string().min(1) }), - z.object({ type: z.literal('grantResource'), resourceId: z.string().min(1), amount: z.number().positive() }), - z.object({ type: z.literal('consumeResource'), resourceId: z.string().min(1), amount: z.number().positive() }), + z.object({ + type: z.literal('grantResource'), + resourceId: z.string().min(1), + amount: z.number().positive(), + }), + z.object({ + type: z.literal('consumeResource'), + resourceId: z.string().min(1), + amount: z.number().positive(), + }), z.object({ type: z.literal('log'), text: z.string().min(1) }), ]); diff --git a/src/engine/__tests__/story.test.ts b/src/engine/__tests__/story.test.ts index 60de407..d6bdad5 100644 --- a/src/engine/__tests__/story.test.ts +++ b/src/engine/__tests__/story.test.ts @@ -4,10 +4,13 @@ import { buildStoryContent } from '../../content/storySchema'; import { createGameState } from '../game'; import { applyChoice, + applyOutcomes, enterStoryNode, evaluateTriggers, getAvailableChoices, + getCurrentNode, initStory, + type StoryEvent, } from '../story'; function gameContent() { @@ -304,4 +307,88 @@ describe('getAvailableChoices()', () => { const choices = getAvailableChoices(state, content); expect(choices.map((c) => c.id)).not.toContain('pick_b_if_excluded'); }); + + it('shows choices only when requireStoryFlags are set', () => { + const base = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies', startAmount: 10 }], + actions: [], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'flag_gate', + prose: 'Need a key.', + choices: [ + { + id: 'unlocked', + label: 'Open', + requirements: { requireStoryFlags: ['has_key'] }, + outcomes: [], + targetNodeId: 'flag_gate', + }, + ], + }, + ], + base.actionsById, + base.resourcesById, + ); + const content = { ...base, ...story }; + const state = createGameState(content); + enterStoryNode(state, content, 'flag_gate'); + expect(getAvailableChoices(state, content)).toEqual([]); + state.storyFlags.has_key = true; + expect(getAvailableChoices(state, content).map((c) => c.id)).toEqual(['unlocked']); + }); +}); + +describe('applyOutcomes()', () => { + it('applies clearFlag, consumeResource, and log outcomes', () => { + const content = gameContent(); + const state = createGameState(content); + state.resources.coin = 5; + state.currentStoryNodeId = 'boot_intro'; + const events: StoryEvent[] = []; + + applyOutcomes( + state, + content, + [ + { type: 'clearFlag', flag: 'visited' }, + { type: 'consumeResource', resourceId: 'coin', amount: 2 }, + { type: 'log', text: 'A note in the margin.' }, + ], + events, + ); + + expect(state.storyFlags.visited).toBe(false); + expect(state.resources.coin).toBe(3); + expect(events).toEqual([ + { kind: 'log', nodeId: 'boot_intro', prose: 'A note in the margin.' }, + ]); + }); + + it('throws when consumeResource exceeds balance', () => { + const content = gameContent(); + const state = createGameState(content); + state.resources.coin = 1; + + expect(() => + applyOutcomes(state, content, [{ type: 'consumeResource', resourceId: 'coin', amount: 2 }], []), + ).toThrow(/Cannot consume 2 coin/); + }); +}); + +describe('getCurrentNode()', () => { + it('returns null for unknown currentStoryNodeId', () => { + const content = gameContent(); + const state = createGameState(content); + state.currentStoryNodeId = 'missing_node'; + expect(getCurrentNode(state, content)).toBeNull(); + expect(getAvailableChoices(state, content)).toEqual([]); + }); }); diff --git a/src/engine/game.ts b/src/engine/game.ts index a046ad3..b83b52e 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -71,10 +71,7 @@ export function canUnlockAction(state: GameState, content: Content, actionId: st } export function isActionAvailable(state: GameState, content: Content, actionId: string): boolean { - return ( - canAffordAction(state, content, actionId) && - canUnlockAction(state, content, actionId) - ); + return canAffordAction(state, content, actionId) && canUnlockAction(state, content, actionId); } function deductCosts(state: GameState, content: Content, actionId: string): void { diff --git a/src/engine/story.ts b/src/engine/story.ts index 3256cad..2d6714c 100644 --- a/src/engine/story.ts +++ b/src/engine/story.ts @@ -82,12 +82,13 @@ export interface TriggerResult { } function meetsMinResources(state: GameState, minResources: Record): boolean { - return Object.entries(minResources).every( - ([id, min]) => (state.resources[id] ?? 0) >= min, - ); + return Object.entries(minResources).every(([id, min]) => (state.resources[id] ?? 0) >= min); } -function shouldSkipTrigger(state: GameState, trigger: { targetNodeId: string; once: boolean }): boolean { +function shouldSkipTrigger( + state: GameState, + trigger: { targetNodeId: string; once: boolean }, +): boolean { return trigger.once !== false && state.seenStoryNodeIds.includes(trigger.targetNodeId); } diff --git a/src/state/runtime.ts b/src/state/runtime.ts index 82ee19e..6ccd13a 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -6,21 +6,17 @@ import { isActionAvailable, tickGame, } from '../engine/game'; -import { - applyChoice as engineApplyChoice, - enterStoryNode, - initStory, -} from '../engine/story'; +import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story'; import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop'; import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence'; import { getPrefs } from './prefs'; +import { useGameStore } from './store'; import { processStoryTriggers, + type StoryUiEffect, shouldAutoOpenPanel, storyEventsToLogEntries, - type StoryUiEffect, } from './storyOrchestration'; -import { useGameStore } from './store'; import { formatOfflineDuration, toView } from './viewModel'; /** @@ -153,9 +149,7 @@ class GameRuntime { const store = useGameStore.getState(); for (const entry of entries) store.appendStoryLog(entry); for (const entry of entries) { - store.appendLog( - `Story: ${content.storyNodesById[entry.nodeId]?.prose.slice(0, 40)}…`, - ); + store.appendLog(`Story: ${content.storyNodesById[entry.nodeId]?.prose.slice(0, 40)}…`); } const prefs = store.prefs; if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) { diff --git a/src/state/store.ts b/src/state/store.ts index 9e8547c..b498971 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { getPrefs, setPrefs as persistPrefs, type GamePrefs } from './prefs'; +import { type GamePrefs, getPrefs, setPrefs as persistPrefs } from './prefs'; import type { GameView } from './viewModel'; /** diff --git a/src/state/viewModel.ts b/src/state/viewModel.ts index 54ae862..64b9957 100644 --- a/src/state/viewModel.ts +++ b/src/state/viewModel.ts @@ -2,8 +2,8 @@ import type { GameContent } from '../content/index'; import { canAffordAction, canUnlockAction, - isActionAvailable, type GameState, + isActionAvailable, } from '../engine/game'; import { getAvailableChoices, getCurrentNode } from '../engine/story'; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 81d3f3f..bdc9ee0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -30,6 +30,7 @@ export function App() {
diff --git a/src/ui/SettingsDrawer.tsx b/src/ui/SettingsDrawer.tsx index 863fa73..0d467ff 100644 --- a/src/ui/SettingsDrawer.tsx +++ b/src/ui/SettingsDrawer.tsx @@ -1,5 +1,5 @@ -import { useGameStore } from '../state/store'; import type { ActionDetailMode, StoryOpenMode } from '../state/prefs'; +import { useGameStore } from '../state/store'; /** Preference panel toggled from the header gear; changes persist immediately. */ export function SettingsDrawer() { diff --git a/src/ui/StoryPanel.tsx b/src/ui/StoryPanel.tsx index 833aedd..97481f2 100644 --- a/src/ui/StoryPanel.tsx +++ b/src/ui/StoryPanel.tsx @@ -24,8 +24,8 @@ export function StoryPanel() { Story log
    - {storyLog.map((entry, i) => ( -
  • + {storyLog.map((entry) => ( +
  • {entry.choiceLabel ? ( [{entry.choiceLabel}] ) : null}