feat(state): runtime performAction and nav story signals

This commit is contained in:
ginnoir
2026-06-11 21:16:27 -05:00
parent adf730702d
commit 239b2506ec
5 changed files with 257 additions and 65 deletions
+35 -12
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { content as gameContent } from '../../content/index';
import { buildContent } from '../../content/schema'; import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema';
import { import {
cancelQueuedAction, cancelQueuedAction,
canUnlockAction, canUnlockAction,
@@ -12,8 +14,6 @@ import {
startAction, startAction,
tickGame, tickGame,
} from '../game'; } from '../game';
import { content as gameContent } from '../../content/index';
import { buildStoryContent } from '../../content/storySchema';
import { enterStoryNode } from '../story'; import { enterStoryNode } from '../story';
const DEFAULT_GROUP = { id: 'test', label: 'Test' }; const DEFAULT_GROUP = { id: 'test', label: 'Test' };
@@ -37,9 +37,27 @@ function queueContent() {
return buildContent({ return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [ actions: [
{ id: 'a', name: 'A', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, {
{ id: 'b', name: 'B', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, id: 'a',
{ id: 'c', name: 'C', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, name: 'A',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
{
id: 'b',
name: 'B',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
{
id: 'c',
name: 'C',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
], ],
}); });
} }
@@ -452,26 +470,31 @@ describe('loop idle runner', () => {
}); });
describe('performAction()', () => { describe('performAction()', () => {
it('dispatches timed actions through enqueueAction', () => { it('dispatches timed actions through enqueueAction and returns empty array', () => {
const state = createGameState(gameContent); const state = createGameState(gameContent);
performAction(state, gameContent, 'gather_supplies'); const events = performAction(state, gameContent, 'gather_supplies');
expect(state.activeActionId).toBe('gather_supplies'); expect(state.activeActionId).toBe('gather_supplies');
expect(events).toEqual([]);
}); });
it('toggles loop actions and starts them when idle', () => { it('toggles loop actions, starts them when idle, and returns empty array', () => {
const state = createGameState(gameContent); const state = createGameState(gameContent);
performAction(state, gameContent, 'rest'); // enable const events1 = performAction(state, gameContent, 'rest'); // enable
expect(state.enabledLoopActionIds.rest).toBe(true); expect(state.enabledLoopActionIds.rest).toBe(true);
expect(state.activeActionId).toBe('rest'); // started because idle + available expect(state.activeActionId).toBe('rest'); // started because idle + available
performAction(state, gameContent, 'rest'); // disable expect(events1).toEqual([]);
const events2 = performAction(state, gameContent, 'rest'); // disable
expect(state.enabledLoopActionIds.rest).toBe(false); expect(state.enabledLoopActionIds.rest).toBe(false);
expect(events2).toEqual([]);
}); });
it('dispatches story actions through executeStoryAction', () => { it('dispatches story actions through executeStoryAction and returns events', () => {
const state = createGameState(gameContent); const state = createGameState(gameContent);
enterStoryNode(state, gameContent, 'fork_choice'); enterStoryNode(state, gameContent, 'fork_choice');
performAction(state, gameContent, 'pick_high_road'); const events = performAction(state, gameContent, 'pick_high_road');
expect(state.storyFlags.route_a).toBe(true); expect(state.storyFlags.route_a).toBe(true);
expect(events.length).toBeGreaterThan(0);
expect(events[0].kind).toBe('enter');
}); });
it('executes instant actions immediately', () => { it('executes instant actions immediately', () => {
+14 -11
View File
@@ -1,6 +1,6 @@
import type { Content } from '../content/schema'; import type { Content } from '../content/schema';
import type { StoryContent } from '../content/storySchema'; import type { StoryContent } from '../content/storySchema';
import { applyChoice, isStoryChoiceAvailable } from './story'; import { applyChoice, isStoryChoiceAvailable, type StoryEvent } from './story';
type GameContent = Content & StoryContent; type GameContent = Content & StoryContent;
@@ -170,7 +170,7 @@ export function clearQueue(state: GameState): void {
*/ */
export function executeInstant(state: GameState, content: Content, actionId: string): void { export function executeInstant(state: GameState, content: Content, actionId: string): void {
const action = content.actionsById[actionId]; const action = content.actionsById[actionId];
if (!action || action.kind !== 'instant') { if (action?.kind !== 'instant') {
throw new Error(`Action "${actionId}" is not instant`); throw new Error(`Action "${actionId}" is not instant`);
} }
if (!isActionAvailable(state, content, actionId)) { if (!isActionAvailable(state, content, actionId)) {
@@ -189,15 +189,15 @@ export function executeStoryAction(
state: GameState, state: GameState,
content: GameContent, content: GameContent,
actionId: string, actionId: string,
): void { ): StoryEvent[] {
const action = content.actionsById[actionId]; const action = content.actionsById[actionId];
if (!action || action.kind !== 'story' || !action.storyChoiceId) { if (action?.kind !== 'story' || !action.storyChoiceId) {
throw new Error(`Action "${actionId}" is not a story action`); throw new Error(`Action "${actionId}" is not a story action`);
} }
if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) { if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) {
throw new Error(`Story choice "${action.storyChoiceId}" is not available`); throw new Error(`Story choice "${action.storyChoiceId}" is not available`);
} }
applyChoice(state, content, action.storyChoiceId); return applyChoice(state, content, action.storyChoiceId);
} }
/** /**
@@ -235,28 +235,31 @@ export function maybeStartLoopAction(state: GameState, content: Content): void {
* re-checks each tick). Throwing on unaffordable before toggling would wrongly * re-checks each tick). Throwing on unaffordable before toggling would wrongly
* block the player from DISABLING an active but now-unaffordable loop. * block the player from DISABLING an active but now-unaffordable loop.
*/ */
export function performAction(state: GameState, content: GameContent, actionId: string): void { export function performAction(
state: GameState,
content: GameContent,
actionId: string,
): StoryEvent[] {
const action = content.actionsById[actionId]; const action = content.actionsById[actionId];
if (!action) throw new Error(`Unknown action "${actionId}"`); if (!action) throw new Error(`Unknown action "${actionId}"`);
switch (action.kind) { switch (action.kind) {
case 'instant': case 'instant':
executeInstant(state, content, actionId); executeInstant(state, content, actionId);
break; return [];
case 'timed': case 'timed':
enqueueAction(state, content, actionId); enqueueAction(state, content, actionId);
break; return [];
case 'loop': { case 'loop': {
const willEnable = !state.enabledLoopActionIds[actionId]; const willEnable = !state.enabledLoopActionIds[actionId];
state.enabledLoopActionIds[actionId] = willEnable; state.enabledLoopActionIds[actionId] = willEnable;
if (willEnable) { if (willEnable) {
maybeStartLoopAction(state, content); maybeStartLoopAction(state, content);
} }
break; return [];
} }
case 'story': case 'story':
executeStoryAction(state, content, actionId); return executeStoryAction(state, content, actionId);
break;
case 'context': case 'context':
throw new Error(`Context action "${actionId}" is not implemented`); throw new Error(`Context action "${actionId}" is not implemented`);
default: default:
+121
View File
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getPrefs } from '../prefs';
import { GameRuntime } from '../runtime';
import { useGameStore } from '../store';
describe('GameRuntime', () => {
let runtime: GameRuntime;
beforeEach(async () => {
// Stub localStorage
const storage: Record<string, string> = {};
vi.stubGlobal('localStorage', {
getItem(key: string) {
return storage[key] ?? null;
},
setItem(key: string, value: string) {
storage[key] = value;
},
removeItem(key: string) {
delete storage[key];
},
clear() {
for (const k of Object.keys(storage)) {
delete storage[k];
}
},
});
// Stub requestAnimationFrame
vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1));
vi.stubGlobal('cancelAnimationFrame', vi.fn());
// Stub visibilityState and document.addEventListener
vi.stubGlobal('document', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
visibilityState: 'visible',
});
// Stub window.addEventListener
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
// Setup clean store
useGameStore.setState({
log: [],
storyPanelOpen: false,
storyHasUnread: false,
storyLog: [],
prefs: getPrefs(),
activePanel: 'play',
});
runtime = new GameRuntime();
// Boot the runtime to initialize state
await runtime.boot();
});
afterEach(() => {
runtime.stop();
vi.unstubAllGlobals();
});
it('performs timed action', () => {
runtime.performAction('gather_supplies');
const store = useGameStore.getState();
expect(store.log).toContain('Started: Gather supplies.');
});
it('performs loop action', () => {
// Loop actions toggle enable state
runtime.performAction('rest');
const view = useGameStore.getState();
// Verify it is enabled
expect(view.actions.find((a) => a.id === 'rest')?.loopEnabled).toBe(true);
});
it('performs story action and appends story log', () => {
// Let's first move state to fork_choice node where story choices are available
runtime.setActivePanel('story');
// Pick the high road
runtime.performAction('pick_high_road');
const store = useGameStore.getState();
// The story log should have the node we entered: route_a_beat
expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true);
// Should append choice label to normal log
expect(store.log).toContain('Story: Take the high road');
});
it('sets active panel and handles auto-advance from boot_intro', () => {
// Initially we boot into boot_intro. Since prefs.storyOpenMode is 'auto',
// the boot trigger will auto-navigate to the 'story' panel immediately.
expect(useGameStore.getState().activePanel).toBe('story');
// If we call setActivePanel('story') again, it should trigger the auto-advance logic
// from 'boot_intro' to 'fork_choice'.
runtime.setActivePanel('story');
expect(useGameStore.getState().activePanel).toBe('story');
// Since we were at boot_intro and active panel set to story, it should auto-advance to fork_choice
const store = useGameStore.getState();
expect(store.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true);
expect(store.log.some((line) => line.includes('Story:'))).toBe(true);
});
it('delegates openStoryPanel and closeStoryPanel to setActivePanel', () => {
// Force activePanel back to play first
runtime.setActivePanel('play');
expect(useGameStore.getState().activePanel).toBe('play');
runtime.openStoryPanel();
expect(useGameStore.getState().activePanel).toBe('story');
runtime.closeStoryPanel();
expect(useGameStore.getState().activePanel).toBe('play');
});
});
+85 -40
View File
@@ -1,20 +1,20 @@
import { content } from '../content'; import { content } from '../content';
import { import {
cancelQueuedAction as engineCancelQueuedAction, cancelQueuedAction as engineCancelQueuedAction,
enqueueAction as engineEnqueueAction,
type GameState, type GameState,
isActionAvailable, maybeStartLoopAction,
performAction as performActionEngine,
tickGame, tickGame,
} from '../engine/game'; } 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 { 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 { type ActivePanel, useGameStore } from './store';
import { import {
processStoryTriggers, processStoryTriggers,
type StoryUiEffect, type StoryUiEffect,
shouldAutoOpenPanel, shouldAutoNavigateToStory,
storyEventsToLogEntries, storyEventsToLogEntries,
} from './storyOrchestration'; } from './storyOrchestration';
import { formatOfflineDuration, toView } from './viewModel'; import { formatOfflineDuration, toView } from './viewModel';
@@ -32,7 +32,7 @@ import { formatOfflineDuration, toView } from './viewModel';
const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh
const AUTOSAVE_INTERVAL_MS = 10_000; const AUTOSAVE_INTERVAL_MS = 10_000;
class GameRuntime { export class GameRuntime {
private state: GameState | null = null; private state: GameState | null = null;
private readonly loop: TickLoop = createTickLoop(); private readonly loop: TickLoop = createTickLoop();
private readonly backend: SaveBackend = createDefaultBackend(); private readonly backend: SaveBackend = createDefaultBackend();
@@ -77,45 +77,83 @@ class GameRuntime {
} }
} }
enqueueAction(actionId: string): void { performAction(actionId: string): void {
const state = this.state; const state = this.state;
if (!state) { if (!state) return;
return; const action = content.actionsById[actionId];
} if (!action) return;
if (!isActionAvailable(state, content, actionId)) {
const actionView = toView(state, content).actions.find((a) => a.id === actionId);
useGameStore.getState().appendLog(actionView?.disabledReason ?? 'Cannot enqueue');
return;
}
try { try {
engineEnqueueAction(state, content, actionId); const isStory = action.kind === 'story';
const action = content.actionsById[actionId]; const events = performActionEngine(state, content, actionId);
if (action) { const store = useGameStore.getState();
if (isStory) {
const entries = storyEventsToLogEntries(events);
for (const entry of entries) {
store.appendStoryLog(entry);
}
const choiceLabel = entries.at(-1)?.choiceLabel;
if (choiceLabel) {
store.appendLog(`Story: ${choiceLabel}`);
}
this.runPublishTriggers();
} else if (action.kind === 'timed') {
const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started'; const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started';
useGameStore.getState().appendLog(`${verb}: ${action.name}.`); store.appendLog(`${verb}: ${action.name}.`);
} }
this.publish();
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Cannot start action'; useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Action failed');
useGameStore.getState().appendLog(msg);
} }
this.publish(); }
setActivePanel(panel: ActivePanel): void {
const store = useGameStore.getState();
store.setActivePanel(panel);
if (panel === 'story') {
store.setStoryHasUnread(false);
const state = this.state;
if (state && state.currentStoryNodeId === 'boot_intro') {
const events = enterStoryNode(state, content, 'fork_choice');
const entries = storyEventsToLogEntries(events);
for (const entry of entries) {
store.appendStoryLog(entry);
}
for (const entry of entries) {
const nodeProse = content.storyNodesById[entry.nodeId]?.prose ?? '';
store.appendLog(`Story: ${nodeProse.slice(0, 40)}`);
}
this.publish();
}
}
}
enqueueAction(actionId: string): void {
this.performAction(actionId);
} }
applyStoryChoice(choiceId: string): void { applyStoryChoice(choiceId: string): void {
const state = this.state; const action = content.actions.find((a) => a.storyChoiceId === choiceId);
if (!state) return; if (action) {
try { this.performAction(action.id);
const events = engineApplyChoice(state, content, choiceId); } else {
const entries = storyEventsToLogEntries(events); const state = this.state;
const store = useGameStore.getState(); if (!state) return;
for (const entry of entries) store.appendStoryLog(entry); try {
const choiceLabel = entries.at(-1)?.choiceLabel; const events = engineApplyChoice(state, content, choiceId);
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); const entries = storyEventsToLogEntries(events);
store.setStoryPanelOpen(false); const store = useGameStore.getState();
this.runPublishTriggers(); for (const entry of entries) store.appendStoryLog(entry);
this.publish(); const choiceLabel = entries.at(-1)?.choiceLabel;
} catch (err) { if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`);
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed'); store.setStoryPanelOpen(false);
this.runPublishTriggers();
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed');
}
} }
} }
@@ -132,12 +170,11 @@ class GameRuntime {
} }
openStoryPanel(): void { openStoryPanel(): void {
useGameStore.getState().setStoryPanelOpen(true); this.setActivePanel('story');
useGameStore.getState().setStoryHasUnread(false);
} }
closeStoryPanel(): void { closeStoryPanel(): void {
useGameStore.getState().setStoryPanelOpen(false); this.setActivePanel('play');
} }
continueStory(): void { continueStory(): void {
@@ -152,7 +189,7 @@ class GameRuntime {
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; const prefs = store.prefs;
if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) { if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) {
store.setStoryPanelOpen(true); store.setStoryPanelOpen(true);
store.setStoryHasUnread(false); store.setStoryHasUnread(false);
} else { } else {
@@ -168,11 +205,18 @@ class GameRuntime {
private applyStoryUiEffect(effect: StoryUiEffect): void { private applyStoryUiEffect(effect: StoryUiEffect): void {
const store = useGameStore.getState(); const store = useGameStore.getState();
const prefs = store.prefs;
for (const entry of effect.logEntries) store.appendStoryLog(entry); for (const entry of effect.logEntries) store.appendStoryLog(entry);
for (const line of effect.eventLogLines) store.appendLog(line); for (const line of effect.eventLogLines) store.appendLog(line);
if (effect.shouldOpenPanel) { if (effect.shouldOpenPanel) {
if (prefs.storyOpenMode === 'auto') {
store.setActivePanel('story');
store.setStoryHasUnread(false);
} else {
store.setStoryHasUnread(true);
}
store.setStoryPanelOpen(true); store.setStoryPanelOpen(true);
store.setStoryHasUnread(false);
} else if (effect.enteredNodeIds.length > 0) { } else if (effect.enteredNodeIds.length > 0) {
store.setStoryHasUnread(true); store.setStoryHasUnread(true);
} }
@@ -201,6 +245,7 @@ class GameRuntime {
); );
this.applyStoryUiEffect(effect); this.applyStoryUiEffect(effect);
} }
maybeStartLoopAction(state, content);
}); });
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) { if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
+2 -2
View File
@@ -17,7 +17,7 @@ export function storyEventsToLogEntries(events: StoryEvent[]): StoryLogEntry[] {
.map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel })); .map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel }));
} }
export function shouldAutoOpenPanel( export function shouldAutoNavigateToStory(
prefs: GamePrefs, prefs: GamePrefs,
nodeId: string, nodeId: string,
content: GameContent, content: GameContent,
@@ -39,7 +39,7 @@ export function processStoryTriggers(
const logEntries = storyEventsToLogEntries(events); const logEntries = storyEventsToLogEntries(events);
const shouldOpenPanel = const shouldOpenPanel =
enteredNodeIds.length > 0 && enteredNodeIds.length > 0 &&
enteredNodeIds.some((id) => shouldAutoOpenPanel(prefs, id, content)); enteredNodeIds.some((id) => shouldAutoNavigateToStory(prefs, id, content));
const eventLogLines = logEntries.map((e) => const eventLogLines = logEntries.map((e) =>
e.choiceLabel e.choiceLabel
? `Story: ${e.choiceLabel}` ? `Story: ${e.choiceLabel}`