docs: document story engine and PR2 playable loop
CI / verify (push) Failing after 58s
CI / verify (pull_request) Failing after 41s

This commit is contained in:
ginnoir
2026-06-11 19:00:49 -05:00
parent 1461510a4b
commit c76265d648
14 changed files with 137 additions and 30 deletions
+5
View File
@@ -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
+14 -2
View File
@@ -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,
),
+1 -1
View File
@@ -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);
+3 -1
View File
@@ -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.' },
{
+10 -2
View File
@@ -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) }),
]);
+87
View File
@@ -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([]);
});
});
+1 -4
View File
@@ -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 {
+5 -4
View File
@@ -82,12 +82,13 @@ export interface TriggerResult {
}
function meetsMinResources(state: GameState, minResources: Record<string, number>): 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);
}
+4 -10
View File
@@ -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)) {
+1 -1
View File
@@ -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';
/**
+1 -1
View File
@@ -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';
+2 -1
View File
@@ -30,6 +30,7 @@ export function App() {
<div className="flex items-center gap-2">
<button
type="button"
aria-label={storyHasUnread ? 'Story, unread' : 'Story'}
onClick={() => gameRuntime.openStoryPanel()}
className="relative rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-1.5 text-sm transition-colors hover:border-amber-500/60"
>
@@ -37,7 +38,7 @@ export function App() {
{storyHasUnread ? (
<span
className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-amber-500"
aria-label="Unread story"
aria-hidden
/>
) : null}
</button>
+1 -1
View File
@@ -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() {
+2 -2
View File
@@ -24,8 +24,8 @@ export function StoryPanel() {
Story log
</h3>
<ul className="flex flex-col gap-2 text-slate-300 text-sm">
{storyLog.map((entry, i) => (
<li key={`${entry.nodeId}-${i}`}>
{storyLog.map((entry) => (
<li key={`${entry.nodeId}:${entry.choiceLabel ?? ''}:${entry.prose}`}>
{entry.choiceLabel ? (
<span className="text-amber-400/80">[{entry.choiceLabel}] </span>
) : null}