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).
|
||||
- `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
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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.' },
|
||||
{
|
||||
|
||||
@@ -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) }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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,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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user