diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index 7bfd198..2103567 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -25,8 +25,20 @@ function queueTestContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ - { id: 'a', name: 'A', group: DEFAULT_GROUP, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, - { id: 'b', name: 'B', group: DEFAULT_GROUP, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { + id: 'a', + name: 'A', + group: DEFAULT_GROUP, + durationMs: 3000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, + { + id: 'b', + name: 'B', + group: DEFAULT_GROUP, + durationMs: 3000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, ], }); } diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index d08cd44..089c315 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { content } from '../../content/index'; import { buildContent } from '../../content/schema'; import { buildStoryContent } from '../../content/storySchema'; -import { content } from '../../content/index'; import { createGameState, enqueueAction, performAction, startAction } from '../../engine/game'; import { enterStoryNode } from '../../engine/story'; import { formatOfflineDuration, toView } from '../viewModel'; @@ -91,8 +91,20 @@ describe('toView()', () => { const content = contentWithActions( [{ id: 'gold', name: 'Gold' }], [ - { id: 'a', name: 'Alpha', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, - { id: 'b', name: 'Bravo', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { + id: 'a', + name: 'Alpha', + group: DEFAULT_GROUP, + durationMs: 1000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, + { + id: 'b', + name: 'Bravo', + group: DEFAULT_GROUP, + durationMs: 1000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, ], ); const state = createGameState(content); @@ -200,7 +212,11 @@ describe('action columns projection', () => { const state = createGameState(content); const view = toView(state, content); expect(view.actionColumns.map((c) => c.kind)).toEqual([ - 'instant', 'loop', 'timed', 'story', 'context', + 'instant', + 'loop', + 'timed', + 'story', + 'context', ]); }); @@ -246,7 +262,10 @@ describe('story tree projection', () => { enterStoryNode(state, content, 'fork_choice'); const view = toView(state, content); // fork_choice should be a node in the tree, marked active+seen, with route children - const findNode = (nodes: typeof view.story.tree, id: string): (typeof nodes)[number] | undefined => { + const findNode = ( + nodes: typeof view.story.tree, + id: string, + ): (typeof nodes)[number] | undefined => { for (const n of nodes) { if (n.id === id) return n; const deeper = findNode(n.children, id); diff --git a/src/state/viewModel.ts b/src/state/viewModel.ts index 85d0114..561a2a8 100644 --- a/src/state/viewModel.ts +++ b/src/state/viewModel.ts @@ -147,7 +147,9 @@ export function toView(state: GameState, content: GameContent): GameView { })); const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined; - const actionProgress = action && action.durationMs ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0; + const actionProgress = action?.durationMs + ? Math.min(1, state.actionElapsedMs / action.durationMs) + : 0; const queuedActionIds = [...state.actionQueue]; const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id); @@ -180,9 +182,10 @@ export function toView(state: GameState, content: GameContent): GameView { const kindActions = content.actions.filter((a) => a.kind === kind); // For story kind: only include available actions (hides siblings after fork) - const includedActions = kind === 'story' - ? kindActions.filter((a) => actionViewMap.get(a.id)?.available === true) - : kindActions; + const includedActions = + kind === 'story' + ? kindActions.filter((a) => actionViewMap.get(a.id)?.available === true) + : kindActions; // Group by action.group, preserving first-seen order const groupOrder: string[] = []; @@ -194,10 +197,12 @@ export function toView(state: GameState, content: GameContent): GameView { groupOrder.push(a.group.id); groupMap.set(a.group.id, { id: a.group.id, label: a.group.label, actions: [] }); } - groupMap.get(a.group.id)!.actions.push(view); + groupMap.get(a.group.id)?.actions.push(view); } - const groups: ActionGroupView[] = groupOrder.map((gid) => groupMap.get(gid)!); + const groups: ActionGroupView[] = groupOrder + .map((gid) => groupMap.get(gid)) + .filter((g): g is ActionGroupView => g !== undefined); return { kind, diff --git a/src/ui/ActionCard.tsx b/src/ui/ActionCard.tsx index 9b5ef1e..cf8f2e4 100644 --- a/src/ui/ActionCard.tsx +++ b/src/ui/ActionCard.tsx @@ -14,7 +14,11 @@ export function ActionCard({ action }: ActionCardProps) { const [openInfoId, setOpenInfoId] = useState(null); const containerRef = useRef(null); + const isOpen = openInfoId === action.id; + useEffect(() => { + if (!isOpen) return; + function handleClickOutside(event: MouseEvent) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setOpenInfoId(null); @@ -25,7 +29,7 @@ export function ActionCard({ action }: ActionCardProps) { return () => { document.removeEventListener('click', handleClickOutside); }; - }, []); + }, [isOpen]); const isActive = action.id === activeActionId; const isDisabled = !action.available && !isActive; diff --git a/src/ui/ActionGroup.tsx b/src/ui/ActionGroup.tsx index 3974a80..58ca8ae 100644 --- a/src/ui/ActionGroup.tsx +++ b/src/ui/ActionGroup.tsx @@ -17,6 +17,7 @@ export function ActionGroup({ group, actionKind }: ActionGroupProps) {