diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index 3954e72..d08cd44 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; import { buildStoryContent } from '../../content/storySchema'; -import { createGameState, enqueueAction, startAction } from '../../engine/game'; +import { content } from '../../content/index'; +import { createGameState, enqueueAction, performAction, startAction } from '../../engine/game'; import { enterStoryNode } from '../../engine/story'; import { formatOfflineDuration, toView } from '../viewModel'; @@ -193,3 +194,72 @@ describe('formatOfflineDuration()', () => { expect(formatOfflineDuration(7_200_000)).toBe('2h'); }); }); + +describe('action columns projection', () => { + it('produces all five kind columns in fixed order', () => { + const state = createGameState(content); + const view = toView(state, content); + expect(view.actionColumns.map((c) => c.kind)).toEqual([ + 'instant', 'loop', 'timed', 'story', 'context', + ]); + }); + + it('groups timed actions by their content group', () => { + const state = createGameState(content); + const view = toView(state, content); + const timed = view.actionColumns.find((c) => c.kind === 'timed'); + expect(timed?.groups.some((g) => g.id === 'camp')).toBe(true); + expect(timed?.groups.some((g) => g.id === 'travel')).toBe(true); + }); + + it('marks loopEnabled from enabledLoopActionIds', () => { + const state = createGameState(content); + state.enabledLoopActionIds = { rest: true }; + const view = toView(state, content); + const rest = view.actionColumns + .flatMap((c) => c.groups) + .flatMap((g) => g.actions) + .find((a) => a.id === 'rest'); + expect(rest?.loopEnabled).toBe(true); + }); + + it('shows story actions only when their choice is available, hiding siblings after a fork is taken', () => { + const state = createGameState(content); + // before reaching the fork, story actions are hidden + let storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story'); + expect(storyCol?.groups.flatMap((g) => g.actions)).toHaveLength(0); + // at the fork, both story actions appear + enterStoryNode(state, content, 'fork_choice'); + storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story'); + const idsAtFork = storyCol?.groups.flatMap((g) => g.actions).map((a) => a.id) ?? []; + expect(idsAtFork).toEqual(expect.arrayContaining(['pick_high_road', 'follow_river'])); + // after taking route A, the sibling hides + performAction(state, content, 'pick_high_road'); + storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story'); + expect(storyCol?.groups.flatMap((g) => g.actions)).toHaveLength(0); + }); +}); + +describe('story tree projection', () => { + it('builds a tree marking seen and active nodes', () => { + const state = createGameState(content); + 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 => { + for (const n of nodes) { + if (n.id === id) return n; + const deeper = findNode(n.children, id); + if (deeper) return deeper; + } + return undefined; + }; + const fork = findNode(view.story.tree, 'fork_choice'); + expect(fork).toBeDefined(); + expect(fork?.active).toBe(true); + expect(fork?.seen).toBe(true); + expect(fork?.children.map((c) => c.id)).toEqual( + expect.arrayContaining(['route_a_beat', 'route_b_beat']), + ); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts index b498971..4a40a14 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -40,7 +40,8 @@ export const useGameStore = create((set) => ({ queuedActionIds: [], queuedActionNames: [], actions: [], - story: { currentProse: null, choices: [] }, + story: { currentProse: null, choices: [], tree: [] }, + actionColumns: [], log: [], storyPanelOpen: false, storyHasUnread: false, diff --git a/src/state/viewModel.ts b/src/state/viewModel.ts index 168df55..85d0114 100644 --- a/src/state/viewModel.ts +++ b/src/state/viewModel.ts @@ -5,7 +5,7 @@ import { type GameState, isActionAvailable, } from '../engine/game'; -import { getAvailableChoices, getCurrentNode } from '../engine/story'; +import { getAvailableChoices, getCurrentNode, isStoryChoiceAvailable } from '../engine/story'; /** * Pure mapping from engine state to the view model the React shell renders. @@ -18,6 +18,17 @@ export interface ResourceView { amount: number; } +export const ACTION_COLUMN_ORDER = ['instant', 'loop', 'timed', 'story', 'context'] as const; +export type ActionColumnKind = (typeof ACTION_COLUMN_ORDER)[number]; + +const COLUMN_LABELS: Record = { + instant: 'Instant', + loop: 'Loop', + timed: 'Timed', + story: 'Story', + context: 'Context', +}; + export interface ActionView { id: string; name: string; @@ -27,6 +38,20 @@ export interface ActionView { storyTooltip?: string; costsSummary: string | null; yieldsSummary: string | null; + kind: ActionColumnKind; + loopEnabled: boolean; +} + +export interface ActionGroupView { + id: string; + label: string; + actions: ActionView[]; +} + +export interface ActionColumnView { + kind: ActionColumnKind; + label: string; + groups: ActionGroupView[]; } export interface StoryChoiceView { @@ -36,9 +61,18 @@ export interface StoryChoiceView { disabledReason: string | null; } +export interface StoryTreeNodeView { + id: string; + label: string; + seen: boolean; + active: boolean; + children: StoryTreeNodeView[]; +} + export interface StoryView { currentProse: string | null; choices: StoryChoiceView[]; + tree: StoryTreeNodeView[]; } export interface GameView { @@ -51,6 +85,7 @@ export interface GameView { queuedActionNames: string[]; actions: ActionView[]; story: StoryView; + actionColumns: ActionColumnView[]; } function actionDisabledReason( @@ -72,6 +107,38 @@ function formatResourceList( .join(', '); } +function buildStoryTree(state: GameState, content: GameContent): StoryTreeNodeView[] { + // Edges come from choice targets and trigger targets, skipping self-edges. + const childIds = new Set(); + const childrenOf = new Map(); + for (const node of content.storyNodes) { + const targets: string[] = []; + for (const choice of node.choices ?? []) { + if (choice.targetNodeId !== node.id) targets.push(choice.targetNodeId); + } + for (const trigger of node.triggers ?? []) { + if (trigger.targetNodeId !== node.id) targets.push(trigger.targetNodeId); + } + childrenOf.set(node.id, targets); + for (const t of targets) childIds.add(t); + } + const build = (id: string, seenOnPath: Set): StoryTreeNodeView => { + const children = seenOnPath.has(id) + ? [] + : (childrenOf.get(id) ?? []).map((c) => build(c, new Set(seenOnPath).add(id))); + return { + id, + label: id, // minimal label per spec (T3.0 ships a minimal tree) + seen: state.seenStoryNodeIds.includes(id), + active: state.currentStoryNodeId === id, + children, + }; + }; + return content.storyNodes + .filter((n) => !childIds.has(n.id)) + .map((n) => build(n.id, new Set())); +} + export function toView(state: GameState, content: GameContent): GameView { const resources: ResourceView[] = content.resources.map((resource) => ({ id: resource.id, @@ -84,16 +151,60 @@ export function toView(state: GameState, content: GameContent): GameView { const queuedActionIds = [...state.actionQueue]; const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id); - const actions: ActionView[] = content.actions.map((a) => ({ - id: a.id, - name: a.name, - available: isActionAvailable(state, content, a.id), - disabledReason: actionDisabledReason(state, content, a.id), - storyHint: a.storyHint, - storyTooltip: a.storyTooltip, - costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null, - yieldsSummary: formatResourceList(a.yields, content), - })); + // Build a map of ActionView by id for column assembly + const actionViewMap = new Map(); + const actions: ActionView[] = content.actions.map((a) => { + const isStory = a.kind === 'story'; + const available = isStory + ? isStoryChoiceAvailable(state, content, a.storyChoiceId ?? '') + : isActionAvailable(state, content, a.id); + const view: ActionView = { + id: a.id, + name: a.name, + available, + disabledReason: isStory ? null : actionDisabledReason(state, content, a.id), + storyHint: a.storyHint, + storyTooltip: a.storyTooltip, + costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null, + yieldsSummary: formatResourceList(a.yields, content), + kind: a.kind, + loopEnabled: !!state.enabledLoopActionIds[a.id], + }; + actionViewMap.set(a.id, view); + return view; + }); + + // Build action columns: one per kind in fixed order + const actionColumns: ActionColumnView[] = ACTION_COLUMN_ORDER.map((kind) => { + // Collect actions of this kind + 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; + + // Group by action.group, preserving first-seen order + const groupOrder: string[] = []; + const groupMap = new Map(); + for (const a of includedActions) { + const view = actionViewMap.get(a.id); + if (!view) continue; + if (!groupMap.has(a.group.id)) { + 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); + } + + const groups: ActionGroupView[] = groupOrder.map((gid) => groupMap.get(gid)!); + + return { + kind, + label: COLUMN_LABELS[kind], + groups, + }; + }); const node = getCurrentNode(state, content); const availableChoices = getAvailableChoices(state, content); @@ -110,6 +221,7 @@ export function toView(state: GameState, content: GameContent): GameView { disabledReason: available ? null : 'Requirements not met', }; }), + tree: buildStoryTree(state, content), }; return { @@ -121,6 +233,7 @@ export function toView(state: GameState, content: GameContent): GameView { queuedActionNames, actions, story, + actionColumns, }; }