diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index 8555c53..ff8816b 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; +import { buildStoryContent } from '../../content/storySchema'; import { createGameState, enqueueAction, startAction } from '../../engine/game'; +import { enterStoryNode } from '../../engine/story'; import { formatOfflineDuration, toView } from '../viewModel'; function testContent() { - return buildContent({ + const base = buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }], actions: [ { @@ -15,6 +17,37 @@ function testContent() { }, ], }); + const story = buildStoryContent( + [ + { + id: 'boot', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot' }], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + +function contentWithActions( + resources: Parameters[0]['resources'], + actions: Parameters[0]['actions'], +) { + const base = buildContent({ resources, actions }); + const story = buildStoryContent( + [ + { + id: 'boot', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot' }], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; } describe('toView()', () => { @@ -51,13 +84,13 @@ describe('toView()', () => { }); it('includes queued action ids in order', () => { - const content = buildContent({ - resources: [{ id: 'gold', name: 'Gold' }], - actions: [ + const content = contentWithActions( + [{ id: 'gold', name: 'Gold' }], + [ { id: 'a', name: 'Alpha', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, { id: 'b', name: 'Bravo', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], - }); + ); const state = createGameState(content); enqueueAction(state, content, 'a'); enqueueAction(state, content, 'b'); @@ -67,6 +100,78 @@ describe('toView()', () => { }); }); +describe('toView() action availability', () => { + it('marks locked actions unavailable with reason', () => { + const content = contentWithActions( + [{ id: 'coin', name: 'Coin', startAmount: 0 }], + [ + { + id: 'locked', + name: 'Locked', + durationMs: 1000, + yields: [{ resourceId: 'coin', amount: 1 }], + unlock: { requireStoryFlags: ['route_a'] }, + }, + ], + ); + const state = createGameState(content); + const view = toView(state, content); + expect(view.actions[0].available).toBe(false); + expect(view.actions[0].disabledReason).toMatch(/locked/i); + }); + + it('includes story passage and choices from current node', () => { + const base = buildContent({ + resources: [{ id: 'coin', name: 'Coin', startAmount: 0 }], + actions: [ + { + id: 'forage', + name: 'Forage', + durationMs: 1000, + yields: [{ resourceId: 'coin', amount: 1 }], + }, + ], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'fork_choice', + prose: 'Which way?', + choices: [ + { + id: 'pick_a', + label: 'High road', + outcomes: [{ type: 'setFlag', flag: 'route_a' }], + targetNodeId: 'route_a_beat', + }, + { + id: 'pick_b', + label: 'Low road', + outcomes: [{ type: 'setFlag', flag: 'route_b' }], + targetNodeId: 'route_b_beat', + }, + ], + }, + { id: 'route_a_beat', prose: 'The high road.' }, + { id: 'route_b_beat', prose: 'The low road.' }, + ], + base.actionsById, + base.resourcesById, + ); + const content = { ...base, ...story }; + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + const view = toView(state, content); + expect(view.story.currentProse).toContain('Which way'); + expect(view.story.choices.length).toBe(2); + }); +}); + describe('formatOfflineDuration()', () => { it('formats sub-minute durations in seconds', () => { expect(formatOfflineDuration(0)).toBe('0s'); diff --git a/src/state/store.ts b/src/state/store.ts index cf55e94..03015db 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -22,6 +22,8 @@ export const useGameStore = create((set) => ({ actionProgress: 0, queuedActionIds: [], queuedActionNames: [], + actions: [], + story: { currentProse: null, choices: [] }, log: [], setView: (view) => set(view), appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })), diff --git a/src/state/viewModel.ts b/src/state/viewModel.ts index 8e40b00..54ae862 100644 --- a/src/state/viewModel.ts +++ b/src/state/viewModel.ts @@ -1,5 +1,11 @@ -import type { Content } from '../content/schema'; -import type { GameState } from '../engine/game'; +import type { GameContent } from '../content/index'; +import { + canAffordAction, + canUnlockAction, + isActionAvailable, + type GameState, +} from '../engine/game'; +import { getAvailableChoices, getCurrentNode } from '../engine/story'; /** * Pure mapping from engine state to the view model the React shell renders. @@ -12,6 +18,29 @@ export interface ResourceView { amount: number; } +export interface ActionView { + id: string; + name: string; + available: boolean; + disabledReason: string | null; + storyHint?: string; + storyTooltip?: string; + costsSummary: string | null; + yieldsSummary: string | null; +} + +export interface StoryChoiceView { + id: string; + label: string; + disabled: boolean; + disabledReason: string | null; +} + +export interface StoryView { + currentProse: string | null; + choices: StoryChoiceView[]; +} + export interface GameView { resources: ResourceView[]; activeActionId: string | null; @@ -20,9 +49,30 @@ export interface GameView { actionProgress: number; queuedActionIds: string[]; queuedActionNames: string[]; + actions: ActionView[]; + story: StoryView; } -export function toView(state: GameState, content: Content): GameView { +function actionDisabledReason( + state: GameState, + content: GameContent, + actionId: string, +): string | null { + if (!canUnlockAction(state, content, actionId)) return 'Locked'; + if (!canAffordAction(state, content, actionId)) return 'Not enough resources'; + return null; +} + +function formatResourceList( + items: { resourceId: string; amount: number }[], + content: GameContent, +): string { + return items + .map((i) => `${i.amount} ${content.resourcesById[i.resourceId]?.name ?? i.resourceId}`) + .join(', '); +} + +export function toView(state: GameState, content: GameContent): GameView { const resources: ResourceView[] = content.resources.map((resource) => ({ id: resource.id, name: resource.name, @@ -34,6 +84,34 @@ export function toView(state: GameState, content: Content): 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), + })); + + const node = getCurrentNode(state, content); + const availableChoices = getAvailableChoices(state, content); + const allChoices = node?.choices ?? []; + + const story: StoryView = { + currentProse: node?.prose ?? null, + choices: allChoices.map((choice) => { + const available = availableChoices.some((c) => c.id === choice.id); + return { + id: choice.id, + label: choice.label, + disabled: !available, + disabledReason: available ? null : 'Requirements not met', + }; + }), + }; + return { resources, activeActionId: state.activeActionId, @@ -41,6 +119,8 @@ export function toView(state: GameState, content: Content): GameView { actionProgress, queuedActionIds, queuedActionNames, + actions, + story, }; }