docs: document story engine and PR2 playable loop
This commit is contained in:
@@ -12,6 +12,8 @@ Idlegame is split into four layers.
|
|||||||
queue advancement (actions do not auto-repeat when the queue is empty).
|
queue advancement (actions do not auto-repeat when the queue is empty).
|
||||||
- `save.ts`: versioned save schema, serialized export/import strings, and
|
- `save.ts`: versioned save schema, serialized export/import strings, and
|
||||||
offline elapsed calculation.
|
offline elapsed calculation.
|
||||||
|
- `story.ts`: story graph traversal, triggers (boot, actionComplete,
|
||||||
|
minResources), choices, outcomes.
|
||||||
- `num.ts`: branded numeric boundary and human-readable formatting.
|
- `num.ts`: branded numeric boundary and human-readable formatting.
|
||||||
|
|
||||||
The engine must stay pure. It does not import React, Zustand, browser APIs,
|
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
|
- requestAnimationFrame loop and lifecycle hooks
|
||||||
- mapping engine state to view models
|
- mapping engine state to view models
|
||||||
- Zustand store updates for React
|
- 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
|
## UI
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ const resourcesById = {
|
|||||||
coin: { id: 'coin', name: 'Coin', startAmount: 0 },
|
coin: { id: 'coin', name: 'Coin', startAmount: 0 },
|
||||||
};
|
};
|
||||||
const actionsById = {
|
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 = [
|
const validNodes = [
|
||||||
@@ -47,7 +53,13 @@ describe('buildStoryContent()', () => {
|
|||||||
it('rejects dangling targetNodeId on choices', () => {
|
it('rejects dangling targetNodeId on choices', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
buildStoryContent(
|
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,
|
actionsById,
|
||||||
resourcesById,
|
resourcesById,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { actionDefs, resourceDefs } from './definitions';
|
import { actionDefs, resourceDefs } from './definitions';
|
||||||
import { buildContent, type Content } from './schema';
|
import { buildContent, type Content } from './schema';
|
||||||
import { buildStoryContent, type StoryContent } from './storySchema';
|
|
||||||
import { storyNodeDefs } from './story';
|
import { storyNodeDefs } from './story';
|
||||||
|
import { buildStoryContent, type StoryContent } from './storySchema';
|
||||||
|
|
||||||
const base = buildContent({ resources: resourceDefs, actions: actionDefs });
|
const base = buildContent({ resources: resourceDefs, actions: actionDefs });
|
||||||
const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesById);
|
const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesById);
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export const storyNodeDefs = [
|
|||||||
{
|
{
|
||||||
id: 'threshold_listener',
|
id: 'threshold_listener',
|
||||||
prose: ' ',
|
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.' },
|
{ id: 'merchant_flavor', prose: '[Stub] A merchant remembers your face.' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ import type { ActionDef } from './schema';
|
|||||||
export const storyOutcomeSchema = z.discriminatedUnion('type', [
|
export const storyOutcomeSchema = z.discriminatedUnion('type', [
|
||||||
z.object({ type: z.literal('setFlag'), flag: z.string().min(1) }),
|
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('clearFlag'), flag: z.string().min(1) }),
|
||||||
z.object({ type: z.literal('grantResource'), resourceId: z.string().min(1), amount: z.number().positive() }),
|
z.object({
|
||||||
z.object({ type: z.literal('consumeResource'), resourceId: z.string().min(1), amount: z.number().positive() }),
|
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) }),
|
z.object({ type: z.literal('log'), text: z.string().min(1) }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { buildStoryContent } from '../../content/storySchema';
|
|||||||
import { createGameState } from '../game';
|
import { createGameState } from '../game';
|
||||||
import {
|
import {
|
||||||
applyChoice,
|
applyChoice,
|
||||||
|
applyOutcomes,
|
||||||
enterStoryNode,
|
enterStoryNode,
|
||||||
evaluateTriggers,
|
evaluateTriggers,
|
||||||
getAvailableChoices,
|
getAvailableChoices,
|
||||||
|
getCurrentNode,
|
||||||
initStory,
|
initStory,
|
||||||
|
type StoryEvent,
|
||||||
} from '../story';
|
} from '../story';
|
||||||
|
|
||||||
function gameContent() {
|
function gameContent() {
|
||||||
@@ -304,4 +307,88 @@ describe('getAvailableChoices()', () => {
|
|||||||
const choices = getAvailableChoices(state, content);
|
const choices = getAvailableChoices(state, content);
|
||||||
expect(choices.map((c) => c.id)).not.toContain('pick_b_if_excluded');
|
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
@@ -71,10 +71,7 @@ export function canUnlockAction(state: GameState, content: Content, actionId: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isActionAvailable(state: GameState, content: Content, actionId: string): boolean {
|
export function isActionAvailable(state: GameState, content: Content, actionId: string): boolean {
|
||||||
return (
|
return canAffordAction(state, content, actionId) && canUnlockAction(state, content, actionId);
|
||||||
canAffordAction(state, content, actionId) &&
|
|
||||||
canUnlockAction(state, content, actionId)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deductCosts(state: GameState, content: Content, actionId: string): void {
|
function deductCosts(state: GameState, content: Content, actionId: string): void {
|
||||||
|
|||||||
+5
-4
@@ -82,12 +82,13 @@ export interface TriggerResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function meetsMinResources(state: GameState, minResources: Record<string, number>): boolean {
|
function meetsMinResources(state: GameState, minResources: Record<string, number>): boolean {
|
||||||
return Object.entries(minResources).every(
|
return Object.entries(minResources).every(([id, min]) => (state.resources[id] ?? 0) >= min);
|
||||||
([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);
|
return trigger.once !== false && state.seenStoryNodeIds.includes(trigger.targetNodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-10
@@ -6,21 +6,17 @@ import {
|
|||||||
isActionAvailable,
|
isActionAvailable,
|
||||||
tickGame,
|
tickGame,
|
||||||
} from '../engine/game';
|
} from '../engine/game';
|
||||||
import {
|
import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story';
|
||||||
applyChoice as engineApplyChoice,
|
|
||||||
enterStoryNode,
|
|
||||||
initStory,
|
|
||||||
} from '../engine/story';
|
|
||||||
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
|
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
|
||||||
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
|
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
|
||||||
import { getPrefs } from './prefs';
|
import { getPrefs } from './prefs';
|
||||||
|
import { useGameStore } from './store';
|
||||||
import {
|
import {
|
||||||
processStoryTriggers,
|
processStoryTriggers,
|
||||||
|
type StoryUiEffect,
|
||||||
shouldAutoOpenPanel,
|
shouldAutoOpenPanel,
|
||||||
storyEventsToLogEntries,
|
storyEventsToLogEntries,
|
||||||
type StoryUiEffect,
|
|
||||||
} from './storyOrchestration';
|
} from './storyOrchestration';
|
||||||
import { useGameStore } from './store';
|
|
||||||
import { formatOfflineDuration, toView } from './viewModel';
|
import { formatOfflineDuration, toView } from './viewModel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,9 +149,7 @@ class GameRuntime {
|
|||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
for (const entry of entries) store.appendStoryLog(entry);
|
for (const entry of entries) store.appendStoryLog(entry);
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
store.appendLog(
|
store.appendLog(`Story: ${content.storyNodesById[entry.nodeId]?.prose.slice(0, 40)}…`);
|
||||||
`Story: ${content.storyNodesById[entry.nodeId]?.prose.slice(0, 40)}…`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const prefs = store.prefs;
|
const prefs = store.prefs;
|
||||||
if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) {
|
if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) {
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
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';
|
import type { GameView } from './viewModel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { GameContent } from '../content/index';
|
|||||||
import {
|
import {
|
||||||
canAffordAction,
|
canAffordAction,
|
||||||
canUnlockAction,
|
canUnlockAction,
|
||||||
isActionAvailable,
|
|
||||||
type GameState,
|
type GameState,
|
||||||
|
isActionAvailable,
|
||||||
} from '../engine/game';
|
} from '../engine/game';
|
||||||
import { getAvailableChoices, getCurrentNode } from '../engine/story';
|
import { getAvailableChoices, getCurrentNode } from '../engine/story';
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -30,6 +30,7 @@ export function App() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={storyHasUnread ? 'Story, unread' : 'Story'}
|
||||||
onClick={() => gameRuntime.openStoryPanel()}
|
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"
|
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 ? (
|
{storyHasUnread ? (
|
||||||
<span
|
<span
|
||||||
className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-amber-500"
|
className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-amber-500"
|
||||||
aria-label="Unread story"
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useGameStore } from '../state/store';
|
|
||||||
import type { ActionDetailMode, StoryOpenMode } from '../state/prefs';
|
import type { ActionDetailMode, StoryOpenMode } from '../state/prefs';
|
||||||
|
import { useGameStore } from '../state/store';
|
||||||
|
|
||||||
/** Preference panel toggled from the header gear; changes persist immediately. */
|
/** Preference panel toggled from the header gear; changes persist immediately. */
|
||||||
export function SettingsDrawer() {
|
export function SettingsDrawer() {
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export function StoryPanel() {
|
|||||||
Story log
|
Story log
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="flex flex-col gap-2 text-slate-300 text-sm">
|
<ul className="flex flex-col gap-2 text-slate-300 text-sm">
|
||||||
{storyLog.map((entry, i) => (
|
{storyLog.map((entry) => (
|
||||||
<li key={`${entry.nodeId}-${i}`}>
|
<li key={`${entry.nodeId}:${entry.choiceLabel ?? ''}:${entry.prose}`}>
|
||||||
{entry.choiceLabel ? (
|
{entry.choiceLabel ? (
|
||||||
<span className="text-amber-400/80">[{entry.choiceLabel}] </span>
|
<span className="text-amber-400/80">[{entry.choiceLabel}] </span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user