Finding 1 (critical): atBootIntro was derived from tree.length === 0 in
StoryView.tsx, but buildStoryTree always returns all topology-root nodes,
so tree.length is always > 0 and the Continue button never rendered.
Fix: add atBootIntro: boolean to StoryView (viewModel), computed by
finding the boot-trigger node id without hardcoding the literal string,
then comparing to the current node. StoryView.tsx reads it from the store.
Also add a viewModel test covering both true and false cases.
Finding 2: onSelect in StoryView.tsx called useGameStore.getState()
directly, bypassing the runtime command layer. Fix: add
GameRuntime.selectStoryNode(id) and wire onSelect through gameRuntime.
Finding 3: aria-pressed on story tree nodes wrongly signals toggle-button
semantics. Fix: replace with aria-current={isSelected ? 'true' : undefined}.
Optional: hoist h-[calc(100dvh-6rem)] to SHELL_HEIGHT const in StoryView.tsx.
74 lines
2.4 KiB
TypeScript
74 lines
2.4 KiB
TypeScript
import { create } from 'zustand';
|
|
import { type GamePrefs, getPrefs, setPrefs as persistPrefs } from './prefs';
|
|
import type { GameView } from './viewModel';
|
|
|
|
/**
|
|
* The view store. The runtime owns the authoritative engine state and pushes a
|
|
* fresh view snapshot here at ~10 fps; React components subscribe to slices of
|
|
* it. The store is a dumb mirror plus an event log — no game logic lives here.
|
|
*/
|
|
|
|
const MAX_LOG_LINES = 50;
|
|
|
|
export interface StoryLogEntry {
|
|
nodeId: string;
|
|
prose: string;
|
|
choiceLabel?: string;
|
|
}
|
|
|
|
export type ActivePanel = 'play' | 'story' | 'settings' | 'about';
|
|
|
|
export interface GameStoreState extends GameView {
|
|
log: string[];
|
|
storyHasUnread: boolean;
|
|
storyLog: StoryLogEntry[];
|
|
prefs: GamePrefs;
|
|
activePanel: ActivePanel;
|
|
selectedStoryNodeId: string | null;
|
|
setView: (view: GameView) => void;
|
|
appendLog: (line: string) => void;
|
|
appendStoryLog: (entry: StoryLogEntry) => void;
|
|
setStoryHasUnread: (unread: boolean) => void;
|
|
setPrefs: (partial: Partial<GamePrefs>) => void;
|
|
setActivePanel: (panel: ActivePanel) => void;
|
|
setSelectedStoryNodeId: (id: string | null) => void;
|
|
toggleActionGroupCollapsed: (groupKey: string) => void;
|
|
}
|
|
|
|
export const useGameStore = create<GameStoreState>((set, get) => ({
|
|
resources: [],
|
|
activeActionId: null,
|
|
actionName: null,
|
|
actionProgress: 0,
|
|
queuedActionIds: [],
|
|
queuedActionNames: [],
|
|
actions: [],
|
|
story: { currentProse: null, atBootIntro: false, choices: [], tree: [] },
|
|
actionColumns: [],
|
|
log: [],
|
|
storyHasUnread: false,
|
|
storyLog: [],
|
|
prefs: getPrefs(),
|
|
activePanel: 'play',
|
|
selectedStoryNodeId: null,
|
|
setView: (view) => set((state) => ({ ...state, ...view })),
|
|
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
|
|
appendStoryLog: (entry) => set((state) => ({ storyLog: [...state.storyLog, entry] })),
|
|
setStoryHasUnread: (unread) => set({ storyHasUnread: unread }),
|
|
setPrefs: (partial) => {
|
|
const prefs = persistPrefs(partial);
|
|
set({ prefs });
|
|
},
|
|
setActivePanel: (panel) => set({ activePanel: panel }),
|
|
setSelectedStoryNodeId: (id) => set({ selectedStoryNodeId: id }),
|
|
toggleActionGroupCollapsed: (groupKey) => {
|
|
const currentPrefs = get().prefs;
|
|
const nextCollapsed = {
|
|
...currentPrefs.collapsedActionGroups,
|
|
[groupKey]: !currentPrefs.collapsedActionGroups[groupKey],
|
|
};
|
|
const prefs = persistPrefs({ collapsedActionGroups: nextCollapsed });
|
|
set({ prefs });
|
|
},
|
|
}));
|