refactor(state): fix code quality findings for runtime and story log

This commit is contained in:
ginnoir
2026-06-11 21:20:52 -05:00
parent 239b2506ec
commit b29b17c6cb
3 changed files with 204 additions and 36 deletions
+170 -10
View File
@@ -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
View File
@@ -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();
});
} }
} }
+9 -5
View File
@@ -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 };
} }