From b29b17c6cb81da318b2222c843f3e3835de21af3 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:20:52 -0500 Subject: [PATCH] refactor(state): fix code quality findings for runtime and story log --- src/state/__tests__/runtime.test.ts | 180 ++++++++++++++++++++++++++-- src/state/runtime.ts | 46 +++---- src/state/storyOrchestration.ts | 14 ++- 3 files changed, 204 insertions(+), 36 deletions(-) diff --git a/src/state/__tests__/runtime.test.ts b/src/state/__tests__/runtime.test.ts index aa41019..7c22589 100644 --- a/src/state/__tests__/runtime.test.ts +++ b/src/state/__tests__/runtime.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { content } from '../../content'; import { getPrefs } from '../prefs'; import { GameRuntime } from '../runtime'; import { useGameStore } from '../store'; @@ -29,6 +30,7 @@ describe('GameRuntime', () => { // Stub requestAnimationFrame vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1)); vi.stubGlobal('cancelAnimationFrame', vi.fn()); + vi.stubGlobal('indexedDB', undefined); // Stub visibilityState and document.addEventListener vi.stubGlobal('document', { @@ -79,7 +81,7 @@ describe('GameRuntime', () => { 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'); + runtime.continueStory(); // Pick the high road runtime.performAction('pick_high_road'); @@ -91,20 +93,160 @@ describe('GameRuntime', () => { 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'); + it('performs story action 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]; - // 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'. 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); + expect(store.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(false); + }); + + 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', () => { @@ -118,4 +260,22 @@ describe('GameRuntime', () => { runtime.closeStoryPanel(); 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)); + }); }); diff --git a/src/state/runtime.ts b/src/state/runtime.ts index facba6a..d907322 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -41,6 +41,16 @@ export class GameRuntime { private lastSaveAt = 0; private booted = false; + private readonly visibilityChangeListener = (): void => { + if (document.visibilityState === 'hidden') { + void this.save(); + } + }; + + private readonly beforeUnloadListener = (): void => { + void this.save(); + }; + async boot(): Promise { if (this.booted) { return; @@ -75,6 +85,8 @@ export class GameRuntime { cancelAnimationFrame(this.rafId); this.rafId = null; } + document.removeEventListener('visibilitychange', this.visibilityChangeListener); + window.removeEventListener('beforeunload', this.beforeUnloadListener); } performAction(actionId: string): void { @@ -88,6 +100,11 @@ export class GameRuntime { const events = performActionEngine(state, content, actionId); 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) { const entries = storyEventsToLogEntries(events); for (const entry of entries) { @@ -114,19 +131,6 @@ export class GameRuntime { 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(); - } } } @@ -145,6 +149,12 @@ export class GameRuntime { const events = engineApplyChoice(state, content, choiceId); const entries = storyEventsToLogEntries(events); 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); const choiceLabel = entries.at(-1)?.choiceLabel; if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); @@ -279,14 +289,8 @@ export class GameRuntime { } private installLifecycleHooks(): void { - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - void this.save(); - } - }); - window.addEventListener('beforeunload', () => { - void this.save(); - }); + document.addEventListener('visibilitychange', this.visibilityChangeListener); + window.addEventListener('beforeunload', this.beforeUnloadListener); } } diff --git a/src/state/storyOrchestration.ts b/src/state/storyOrchestration.ts index 64ef83c..56f0568 100644 --- a/src/state/storyOrchestration.ts +++ b/src/state/storyOrchestration.ts @@ -40,10 +40,14 @@ export function processStoryTriggers( const shouldOpenPanel = enteredNodeIds.length > 0 && enteredNodeIds.some((id) => shouldAutoNavigateToStory(prefs, id, content)); - const eventLogLines = logEntries.map((e) => - e.choiceLabel - ? `Story: ${e.choiceLabel}` - : `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`, - ); + const customLogs = events.filter((e) => e.kind === 'log').map((e) => e.prose); + const eventLogLines = [ + ...customLogs, + ...logEntries.map((e) => + e.choiceLabel + ? `Story: ${e.choiceLabel}` + : `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`, + ), + ]; return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines }; }