From 9a7eb3065a6965b26e5fdab6543f8e53d6bf2186 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:55:42 -0500 Subject: [PATCH] feat(state): wire story triggers and choices through runtime --- src/state/runtime.ts | 132 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 2 deletions(-) diff --git a/src/state/runtime.ts b/src/state/runtime.ts index be5d570..82ee19e 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -1,7 +1,25 @@ import { content } from '../content'; -import { enqueueAction as engineEnqueueAction, type GameState, tickGame } from '../engine/game'; +import { + cancelQueuedAction as engineCancelQueuedAction, + enqueueAction as engineEnqueueAction, + type GameState, + isActionAvailable, + 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 { + processStoryTriggers, + shouldAutoOpenPanel, + storyEventsToLogEntries, + type StoryUiEffect, +} from './storyOrchestration'; import { useGameStore } from './store'; import { formatOfflineDuration, toView } from './viewModel'; @@ -45,6 +63,11 @@ class GameRuntime { : 'A new tale begins. Choose an action.', ); + initStory(this.state, content); + const prefs = getPrefs(); + store.setPrefs(prefs); + const bootEffect = processStoryTriggers(this.state, content, { reason: 'boot' }, prefs); + this.applyStoryUiEffect(bootEffect); this.publish(); this.installLifecycleHooks(); this.rafId = requestAnimationFrame(this.frame); @@ -63,6 +86,11 @@ class GameRuntime { 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; + } try { engineEnqueueAction(state, content, actionId); const action = content.actionsById[actionId]; @@ -77,12 +105,112 @@ class GameRuntime { this.publish(); } + 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'); + } + } + + cancelQueuedAction(index: number): void { + const state = this.state; + if (!state) return; + try { + engineCancelQueuedAction(state, index); + useGameStore.getState().appendLog('Removed queued action.'); + this.publish(); + } catch (err) { + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Cancel failed'); + } + } + + openStoryPanel(): void { + useGameStore.getState().setStoryPanelOpen(true); + useGameStore.getState().setStoryHasUnread(false); + } + + closeStoryPanel(): void { + useGameStore.getState().setStoryPanelOpen(false); + } + + continueStory(): void { + const state = this.state; + if (!state) return; + if (state.currentStoryNodeId === 'boot_intro') { + const events = enterStoryNode(state, content, 'fork_choice'); + const entries = storyEventsToLogEntries(events); + 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)}…`, + ); + } + const prefs = store.prefs; + if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) { + store.setStoryPanelOpen(true); + store.setStoryHasUnread(false); + } else { + store.setStoryHasUnread(true); + store.setStoryPanelOpen(false); + } + this.publish(); + return; + } + this.closeStoryPanel(); + this.publish(); + } + + private applyStoryUiEffect(effect: StoryUiEffect): void { + const store = useGameStore.getState(); + for (const entry of effect.logEntries) store.appendStoryLog(entry); + for (const line of effect.eventLogLines) store.appendLog(line); + if (effect.shouldOpenPanel) { + store.setStoryPanelOpen(true); + store.setStoryHasUnread(false); + } else if (effect.enteredNodeIds.length > 0) { + store.setStoryHasUnread(true); + } + } + + private runPublishTriggers(): void { + const state = this.state; + if (!state) return; + const prefs = useGameStore.getState().prefs; + const effect = processStoryTriggers(state, content, { reason: 'publish' }, prefs); + this.applyStoryUiEffect(effect); + } + private readonly frame = (monoNow: number): void => { const state = this.state; if (state) { - advance(this.loop, monoNow, () => tickGame(state, content, TICK_MS)); + advance(this.loop, monoNow, () => { + const result = tickGame(state, content, TICK_MS); + for (const actionId of result.completedActionIds) { + const prefs = useGameStore.getState().prefs; + const effect = processStoryTriggers( + state, + content, + { reason: 'actionComplete', actionId }, + prefs, + ); + this.applyStoryUiEffect(effect); + } + }); if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) { + this.runPublishTriggers(); this.publish(); this.lastPublishAt = monoNow; }