feat(m1): PR3 T3.0 — shell UI, action kinds, story tab #16
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
+25
-21
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user