feat(state): runtime performAction and nav story signals
This commit is contained in:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
Reference in New Issue
Block a user