import { describe, expect, it } from 'vitest'; import { content } from '../../content/index'; import { buildContent } from '../../content/schema'; import { buildStoryContent } from '../../content/storySchema'; import { createGameState, enqueueAction, performAction, startAction } from '../../engine/game'; import { enterStoryNode } from '../../engine/story'; import { formatOfflineDuration, toView } from '../viewModel'; const DEFAULT_GROUP = { id: 'test', label: 'Test' }; function testContent() { const base = buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }], actions: [ { id: 'forage', name: 'Forage', group: DEFAULT_GROUP, durationMs: 200, yields: [{ resourceId: 'gold', amount: 1 }], }, ], }); 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()', () => { it('maps resources with their names and amounts', () => { const content = testContent(); const view = toView(createGameState(content), content); expect(view.resources).toEqual([{ id: 'gold', name: 'Gold', amount: 4 }]); }); it('reports no progress and no action name when idle', () => { const content = testContent(); const view = toView(createGameState(content), content); expect(view.activeActionId).toBeNull(); expect(view.actionName).toBeNull(); expect(view.actionProgress).toBe(0); }); it('reports the active action name and fractional progress', () => { const content = testContent(); const state = createGameState(content); startAction(state, content, 'forage'); state.actionElapsedMs = 50; // of 200ms const view = toView(state, content); expect(view.actionName).toBe('Forage'); expect(view.actionProgress).toBeCloseTo(0.25); }); it('clamps progress to at most 1', () => { const content = testContent(); const state = createGameState(content); startAction(state, content, 'forage'); state.actionElapsedMs = 999; expect(toView(state, content).actionProgress).toBe(1); }); it('includes queued action ids in order', () => { 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 }], }, ], ); const state = createGameState(content); enqueueAction(state, content, 'a'); enqueueAction(state, content, 'b'); const view = toView(state, content); expect(view.queuedActionIds).toEqual(['b']); expect(view.queuedActionNames).toEqual(['Bravo']); }); }); describe('toView() action availability', () => { it('marks locked actions unavailable with reason', () => { const content = contentWithActions( [{ id: 'coin', name: 'Coin', startAmount: 0 }], [ { id: 'locked', name: 'Locked', group: DEFAULT_GROUP, 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', group: DEFAULT_GROUP, 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'); expect(formatOfflineDuration(45_000)).toBe('45s'); }); it('formats minutes with seconds', () => { expect(formatOfflineDuration(90_000)).toBe('1m 30s'); expect(formatOfflineDuration(120_000)).toBe('2m'); }); it('formats hours with minutes', () => { expect(formatOfflineDuration(3_660_000)).toBe('1h 1m'); 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('marks automationUnlocked on actions after manual completion', () => { const state = createGameState(content); state.manualCompletionCounts.gather_supplies = 1; const view = toView(state, content); const gather = view.actionColumns .flatMap((c) => c.groups) .flatMap((g) => g.actions) .find((a) => a.id === 'gather_supplies'); expect(gather?.automationUnlocked).toBe(true); }); it('marks actions already in the automation queue', () => { const state = createGameState(content); state.automationQueue = ['gather_supplies']; const view = toView(state, content); const gather = view.actionColumns .flatMap((c) => c.groups) .flatMap((g) => g.actions) .find((a) => a.id === 'gather_supplies'); expect(gather?.inAutomationQueue).toBe(true); }); it('projects automation queue ids and display names', () => { const state = createGameState(content); state.automationQueue = ['gather_supplies', 'missing_action']; const view = toView(state, content); expect(view.automationQueueIds).toEqual(['gather_supplies', 'missing_action']); expect(view.automationQueueNames).toEqual(['Gather supplies', 'missing_action']); }); 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.atBootIntro', () => { it('is true at the boot-entry node and false after entering a different node', () => { const base = buildContent({ resources: [{ id: 'coin', name: 'Coin', startAmount: 0 }], actions: [ { id: 'forage', name: 'Forage', group: DEFAULT_GROUP, 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: 'route_a_beat', prose: 'The high road.' }, ], base.actionsById, base.resourcesById, ); const c = { ...base, ...story }; const state = createGameState(c); // Simulate boot: enter the boot-entry node enterStoryNode(state, c, 'boot_intro'); expect(toView(state, c).story.atBootIntro).toBe(true); // After moving past boot into fork_choice, atBootIntro must be false enterStoryNode(state, c, 'fork_choice'); expect(toView(state, c).story.atBootIntro).toBe(false); }); }); 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']), ); }); });