refactor(state): fix code quality findings for runtime and story log
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { content } from '../../content';
|
||||||
import { getPrefs } from '../prefs';
|
import { getPrefs } from '../prefs';
|
||||||
import { GameRuntime } from '../runtime';
|
import { GameRuntime } from '../runtime';
|
||||||
import { useGameStore } from '../store';
|
import { useGameStore } from '../store';
|
||||||
@@ -29,6 +30,7 @@ describe('GameRuntime', () => {
|
|||||||
// Stub requestAnimationFrame
|
// Stub requestAnimationFrame
|
||||||
vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1));
|
vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1));
|
||||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||||
|
vi.stubGlobal('indexedDB', undefined);
|
||||||
|
|
||||||
// Stub visibilityState and document.addEventListener
|
// Stub visibilityState and document.addEventListener
|
||||||
vi.stubGlobal('document', {
|
vi.stubGlobal('document', {
|
||||||
@@ -79,7 +81,7 @@ describe('GameRuntime', () => {
|
|||||||
|
|
||||||
it('performs story action and appends story log', () => {
|
it('performs story action and appends story log', () => {
|
||||||
// Let's first move state to fork_choice node where story choices are available
|
// Let's first move state to fork_choice node where story choices are available
|
||||||
runtime.setActivePanel('story');
|
runtime.continueStory();
|
||||||
|
|
||||||
// Pick the high road
|
// Pick the high road
|
||||||
runtime.performAction('pick_high_road');
|
runtime.performAction('pick_high_road');
|
||||||
@@ -91,20 +93,160 @@ describe('GameRuntime', () => {
|
|||||||
expect(store.log).toContain('Story: Take the high road');
|
expect(store.log).toContain('Story: Take the high road');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets active panel and handles auto-advance from boot_intro', () => {
|
it('performs story action and appends custom log outcomes', () => {
|
||||||
// Initially we boot into boot_intro. Since prefs.storyOpenMode is 'auto',
|
// Save original fork_choice node
|
||||||
// the boot trigger will auto-navigate to the 'story' panel immediately.
|
const originalNode = content.storyNodesById.fork_choice;
|
||||||
expect(useGameStore.getState().activePanel).toBe('story');
|
if (!originalNode) {
|
||||||
|
throw new Error('fork_choice node not found in content');
|
||||||
|
}
|
||||||
|
const originalNodes = [...content.storyNodes];
|
||||||
|
|
||||||
// If we call setActivePanel('story') again, it should trigger the auto-advance logic
|
// Create a modified fork_choice node with a log outcome on pick_a
|
||||||
|
const choices = originalNode.choices ?? [];
|
||||||
|
const firstChoice = choices[0];
|
||||||
|
if (!firstChoice) {
|
||||||
|
throw new Error('first choice not found on fork_choice');
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiedChoice = {
|
||||||
|
...firstChoice,
|
||||||
|
outcomes: [
|
||||||
|
...(firstChoice.outcomes ?? []),
|
||||||
|
{ type: 'log' as const, text: 'Custom log from story action!' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const modifiedNode = {
|
||||||
|
...originalNode,
|
||||||
|
choices: [modifiedChoice, ...choices.slice(1)],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mutate content
|
||||||
|
content.storyNodesById.fork_choice = modifiedNode;
|
||||||
|
content.storyNodes = content.storyNodes.map((n) => (n.id === 'fork_choice' ? modifiedNode : n));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Move to fork_choice
|
||||||
|
runtime.continueStory();
|
||||||
|
|
||||||
|
// Clear logs to check cleanly
|
||||||
|
useGameStore.setState({ log: [], storyLog: [] });
|
||||||
|
|
||||||
|
// Perform the story action
|
||||||
|
runtime.performAction('pick_high_road');
|
||||||
|
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
expect(store.log).toContain('Custom log from story action!');
|
||||||
|
} finally {
|
||||||
|
// Restore content
|
||||||
|
content.storyNodesById.fork_choice = originalNode;
|
||||||
|
content.storyNodes = originalNodes;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setActivePanel does not auto-advance boot_intro to fork_choice', () => {
|
||||||
|
// Initially we boot into boot_intro.
|
||||||
|
// If we call setActivePanel('story'), it should NOT trigger the auto-advance logic
|
||||||
// from 'boot_intro' to 'fork_choice'.
|
// from 'boot_intro' to 'fork_choice'.
|
||||||
runtime.setActivePanel('story');
|
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();
|
const store = useGameStore.getState();
|
||||||
expect(store.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true);
|
expect(store.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(false);
|
||||||
expect(store.log.some((line) => line.includes('Story:'))).toBe(true);
|
});
|
||||||
|
|
||||||
|
it('continueStory() advances boot_intro to fork_choice and opens panel when storyOpenMode is auto', () => {
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
store.setPrefs({ ...store.prefs, storyOpenMode: 'auto' });
|
||||||
|
|
||||||
|
// Force play panel and closed/no unread story
|
||||||
|
store.setActivePanel('play');
|
||||||
|
store.setStoryPanelOpen(false);
|
||||||
|
store.setStoryHasUnread(false);
|
||||||
|
|
||||||
|
runtime.continueStory();
|
||||||
|
|
||||||
|
const updated = useGameStore.getState();
|
||||||
|
// Verifies it advances to fork_choice
|
||||||
|
expect(updated.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true);
|
||||||
|
// Verifies it opens panel and does not set unread (since it's open)
|
||||||
|
expect(updated.storyPanelOpen).toBe(true);
|
||||||
|
expect(updated.storyHasUnread).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continueStory() advances boot_intro to fork_choice and sets unread when storyOpenMode is manual', () => {
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
store.setPrefs({ ...store.prefs, storyOpenMode: 'manual' });
|
||||||
|
|
||||||
|
// Force play panel and closed/no unread story
|
||||||
|
store.setActivePanel('play');
|
||||||
|
store.setStoryPanelOpen(false);
|
||||||
|
store.setStoryHasUnread(false);
|
||||||
|
|
||||||
|
runtime.continueStory();
|
||||||
|
|
||||||
|
const updated = useGameStore.getState();
|
||||||
|
// Verifies it advances to fork_choice
|
||||||
|
expect(updated.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true);
|
||||||
|
// Verifies it does NOT open panel and sets unread flag to true
|
||||||
|
expect(updated.storyPanelOpen).toBe(false);
|
||||||
|
expect(updated.storyHasUnread).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies story choice with an action mapping and processes log outcomes', () => {
|
||||||
|
// Move to fork_choice
|
||||||
|
runtime.continueStory();
|
||||||
|
|
||||||
|
// Apply choice 'pick_a' (which maps to 'pick_high_road' action)
|
||||||
|
runtime.applyStoryChoice('pick_a');
|
||||||
|
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true);
|
||||||
|
expect(store.log).toContain('Story: Take the high road');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies story choice without an action mapping and appends custom log outcomes', () => {
|
||||||
|
// Save original fork_choice node
|
||||||
|
const originalNode = content.storyNodesById.fork_choice;
|
||||||
|
if (!originalNode) {
|
||||||
|
throw new Error('fork_choice node not found in content');
|
||||||
|
}
|
||||||
|
const originalNodes = [...content.storyNodes];
|
||||||
|
|
||||||
|
// Create a modified fork_choice node with a custom choice that has a 'log' outcome
|
||||||
|
const customChoice = {
|
||||||
|
id: 'custom_choice_no_action',
|
||||||
|
label: 'Perform custom choice',
|
||||||
|
outcomes: [{ type: 'log' as const, text: 'This is a custom log outcome!' }],
|
||||||
|
targetNodeId: 'route_a_beat',
|
||||||
|
};
|
||||||
|
|
||||||
|
const modifiedNode = {
|
||||||
|
...originalNode,
|
||||||
|
choices: [...(originalNode.choices ?? []), customChoice],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mutate content
|
||||||
|
content.storyNodesById.fork_choice = modifiedNode;
|
||||||
|
content.storyNodes = content.storyNodes.map((n) => (n.id === 'fork_choice' ? modifiedNode : n));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Move to fork_choice
|
||||||
|
runtime.continueStory();
|
||||||
|
|
||||||
|
// Clear logs to check cleanly
|
||||||
|
useGameStore.setState({ log: [], storyLog: [] });
|
||||||
|
|
||||||
|
// Apply choice
|
||||||
|
runtime.applyStoryChoice('custom_choice_no_action');
|
||||||
|
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
expect(store.log).toContain('This is a custom log outcome!');
|
||||||
|
expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true);
|
||||||
|
} finally {
|
||||||
|
// Restore content
|
||||||
|
content.storyNodesById.fork_choice = originalNode;
|
||||||
|
content.storyNodes = originalNodes;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delegates openStoryPanel and closeStoryPanel to setActivePanel', () => {
|
it('delegates openStoryPanel and closeStoryPanel to setActivePanel', () => {
|
||||||
@@ -118,4 +260,22 @@ describe('GameRuntime', () => {
|
|||||||
runtime.closeStoryPanel();
|
runtime.closeStoryPanel();
|
||||||
expect(useGameStore.getState().activePanel).toBe('play');
|
expect(useGameStore.getState().activePanel).toBe('play');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('registers lifecycle listeners on boot and removes them on stop', () => {
|
||||||
|
const addSpyDoc = vi.spyOn(document, 'addEventListener');
|
||||||
|
const removeSpyDoc = vi.spyOn(document, 'removeEventListener');
|
||||||
|
const addSpyWin = vi.spyOn(window, 'addEventListener');
|
||||||
|
const removeSpyWin = vi.spyOn(window, 'removeEventListener');
|
||||||
|
|
||||||
|
const testRuntime = new GameRuntime();
|
||||||
|
testRuntime.boot();
|
||||||
|
|
||||||
|
expect(addSpyDoc).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
|
||||||
|
expect(addSpyWin).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||||
|
|
||||||
|
testRuntime.stop();
|
||||||
|
|
||||||
|
expect(removeSpyDoc).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
|
||||||
|
expect(removeSpyWin).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-21
@@ -41,6 +41,16 @@ export class GameRuntime {
|
|||||||
private lastSaveAt = 0;
|
private lastSaveAt = 0;
|
||||||
private booted = false;
|
private booted = false;
|
||||||
|
|
||||||
|
private readonly visibilityChangeListener = (): void => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
void this.save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly beforeUnloadListener = (): void => {
|
||||||
|
void this.save();
|
||||||
|
};
|
||||||
|
|
||||||
async boot(): Promise<void> {
|
async boot(): Promise<void> {
|
||||||
if (this.booted) {
|
if (this.booted) {
|
||||||
return;
|
return;
|
||||||
@@ -75,6 +85,8 @@ export class GameRuntime {
|
|||||||
cancelAnimationFrame(this.rafId);
|
cancelAnimationFrame(this.rafId);
|
||||||
this.rafId = null;
|
this.rafId = null;
|
||||||
}
|
}
|
||||||
|
document.removeEventListener('visibilitychange', this.visibilityChangeListener);
|
||||||
|
window.removeEventListener('beforeunload', this.beforeUnloadListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
performAction(actionId: string): void {
|
performAction(actionId: string): void {
|
||||||
@@ -88,6 +100,11 @@ export class GameRuntime {
|
|||||||
const events = performActionEngine(state, content, actionId);
|
const events = performActionEngine(state, content, actionId);
|
||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
|
|
||||||
|
// Append any custom log outcomes
|
||||||
|
for (const event of events.filter((e) => e.kind === 'log')) {
|
||||||
|
store.appendLog(event.prose);
|
||||||
|
}
|
||||||
|
|
||||||
if (isStory) {
|
if (isStory) {
|
||||||
const entries = storyEventsToLogEntries(events);
|
const entries = storyEventsToLogEntries(events);
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -114,19 +131,6 @@ export class GameRuntime {
|
|||||||
store.setActivePanel(panel);
|
store.setActivePanel(panel);
|
||||||
if (panel === 'story') {
|
if (panel === 'story') {
|
||||||
store.setStoryHasUnread(false);
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +149,12 @@ export class GameRuntime {
|
|||||||
const events = engineApplyChoice(state, content, choiceId);
|
const events = engineApplyChoice(state, content, choiceId);
|
||||||
const entries = storyEventsToLogEntries(events);
|
const entries = storyEventsToLogEntries(events);
|
||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
|
|
||||||
|
// Append any custom log outcomes
|
||||||
|
for (const event of events.filter((e) => e.kind === 'log')) {
|
||||||
|
store.appendLog(event.prose);
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of entries) store.appendStoryLog(entry);
|
for (const entry of entries) store.appendStoryLog(entry);
|
||||||
const choiceLabel = entries.at(-1)?.choiceLabel;
|
const choiceLabel = entries.at(-1)?.choiceLabel;
|
||||||
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`);
|
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`);
|
||||||
@@ -279,14 +289,8 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private installLifecycleHooks(): void {
|
private installLifecycleHooks(): void {
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', this.visibilityChangeListener);
|
||||||
if (document.visibilityState === 'hidden') {
|
window.addEventListener('beforeunload', this.beforeUnloadListener);
|
||||||
void this.save();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
void this.save();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,14 @@ export function processStoryTriggers(
|
|||||||
const shouldOpenPanel =
|
const shouldOpenPanel =
|
||||||
enteredNodeIds.length > 0 &&
|
enteredNodeIds.length > 0 &&
|
||||||
enteredNodeIds.some((id) => shouldAutoNavigateToStory(prefs, id, content));
|
enteredNodeIds.some((id) => shouldAutoNavigateToStory(prefs, id, content));
|
||||||
const eventLogLines = logEntries.map((e) =>
|
const customLogs = events.filter((e) => e.kind === 'log').map((e) => e.prose);
|
||||||
e.choiceLabel
|
const eventLogLines = [
|
||||||
? `Story: ${e.choiceLabel}`
|
...customLogs,
|
||||||
: `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`,
|
...logEntries.map((e) =>
|
||||||
);
|
e.choiceLabel
|
||||||
|
? `Story: ${e.choiceLabel}`
|
||||||
|
: `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`,
|
||||||
|
),
|
||||||
|
];
|
||||||
return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines };
|
return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user