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
+85 -40
View File
@@ -1,20 +1,20 @@
import { content } from '../content';
import {
cancelQueuedAction as engineCancelQueuedAction,
enqueueAction as engineEnqueueAction,
type GameState,
isActionAvailable,
maybeStartLoopAction,
performAction as performActionEngine,
tickGame,
} from '../engine/game';
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 { type ActivePanel, useGameStore } from './store';
import {
processStoryTriggers,
type StoryUiEffect,
shouldAutoOpenPanel,
shouldAutoNavigateToStory,
storyEventsToLogEntries,
} from './storyOrchestration';
import { formatOfflineDuration, toView } from './viewModel';
@@ -32,7 +32,7 @@ import { formatOfflineDuration, toView } from './viewModel';
const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh
const AUTOSAVE_INTERVAL_MS = 10_000;
class GameRuntime {
export class GameRuntime {
private state: GameState | null = null;
private readonly loop: TickLoop = createTickLoop();
private readonly backend: SaveBackend = createDefaultBackend();
@@ -77,45 +77,83 @@ class GameRuntime {
}
}
enqueueAction(actionId: string): void {
performAction(actionId: string): void {
const state = this.state;
if (!state) {
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;
}
if (!state) return;
const action = content.actionsById[actionId];
if (!action) return;
try {
engineEnqueueAction(state, content, actionId);
const action = content.actionsById[actionId];
if (action) {
const isStory = action.kind === 'story';
const events = performActionEngine(state, content, actionId);
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';
useGameStore.getState().appendLog(`${verb}: ${action.name}.`);
store.appendLog(`${verb}: ${action.name}.`);
}
this.publish();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Cannot start action';
useGameStore.getState().appendLog(msg);
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Action failed');
}
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 {
const state = this.state;
if (!state) return;
try {
const events = engineApplyChoice(state, content, choiceId);
const entries = storyEventsToLogEntries(events);
const store = useGameStore.getState();
for (const entry of entries) store.appendStoryLog(entry);
const choiceLabel = entries.at(-1)?.choiceLabel;
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`);
store.setStoryPanelOpen(false);
this.runPublishTriggers();
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed');
const action = content.actions.find((a) => a.storyChoiceId === choiceId);
if (action) {
this.performAction(action.id);
} else {
const state = this.state;
if (!state) return;
try {
const events = engineApplyChoice(state, content, choiceId);
const entries = storyEventsToLogEntries(events);
const store = useGameStore.getState();
for (const entry of entries) store.appendStoryLog(entry);
const choiceLabel = entries.at(-1)?.choiceLabel;
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`);
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 {
useGameStore.getState().setStoryPanelOpen(true);
useGameStore.getState().setStoryHasUnread(false);
this.setActivePanel('story');
}
closeStoryPanel(): void {
useGameStore.getState().setStoryPanelOpen(false);
this.setActivePanel('play');
}
continueStory(): void {
@@ -152,7 +189,7 @@ class GameRuntime {
store.appendLog(`Story: ${content.storyNodesById[entry.nodeId]?.prose.slice(0, 40)}`);
}
const prefs = store.prefs;
if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) {
if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) {
store.setStoryPanelOpen(true);
store.setStoryHasUnread(false);
} else {
@@ -168,11 +205,18 @@ class GameRuntime {
private applyStoryUiEffect(effect: StoryUiEffect): void {
const store = useGameStore.getState();
const prefs = store.prefs;
for (const entry of effect.logEntries) store.appendStoryLog(entry);
for (const line of effect.eventLogLines) store.appendLog(line);
if (effect.shouldOpenPanel) {
if (prefs.storyOpenMode === 'auto') {
store.setActivePanel('story');
store.setStoryHasUnread(false);
} else {
store.setStoryHasUnread(true);
}
store.setStoryPanelOpen(true);
store.setStoryHasUnread(false);
} else if (effect.enteredNodeIds.length > 0) {
store.setStoryHasUnread(true);
}
@@ -201,6 +245,7 @@ class GameRuntime {
);
this.applyStoryUiEffect(effect);
}
maybeStartLoopAction(state, content);
});
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {