import type { GameContent } from '../content/index'; import { isAutomationUnlocked } from '../engine/automation'; import { canAffordAction, canUnlockAction, type GameState, isActionAvailable, } from '../engine/game'; import { getAvailableChoices, getCurrentNode, isStoryChoiceAvailable } from '../engine/story'; /** * Pure mapping from engine state to the view model the React shell renders. * No React, no store — just data shaping, so it can be unit-tested directly. */ export interface ResourceView { id: string; name: string; 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; available: boolean; disabledReason: string | null; storyHint?: string; storyTooltip?: string; costsSummary: string | null; yieldsSummary: string | null; kind: ActionColumnKind; loopEnabled: boolean; automationUnlocked: boolean; inAutomationQueue: boolean; } export interface ActionGroupView { id: string; label: string; actions: ActionView[]; } export interface ActionColumnView { kind: ActionColumnKind; label: string; groups: ActionGroupView[]; } export interface StoryChoiceView { id: string; label: string; disabled: boolean; disabledReason: string | null; } export interface StoryTreeNodeView { id: string; label: string; seen: boolean; active: boolean; children: StoryTreeNodeView[]; } export interface StoryView { currentProse: string | null; atBootIntro: boolean; choices: StoryChoiceView[]; tree: StoryTreeNodeView[]; } export interface GameView { resources: ResourceView[]; activeActionId: string | null; actionName: string | null; /** Progress of the active action, clamped to 0..1. */ actionProgress: number; queuedActionIds: string[]; queuedActionNames: string[]; automationQueueIds: string[]; automationQueueNames: string[]; actions: ActionView[]; story: StoryView; actionColumns: ActionColumnView[]; } 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(', '); } 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, name: resource.name, amount: state.resources[resource.id] ?? 0, })); const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined; 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); const automationQueueIds = [...state.automationQueue]; const automationQueueNames = automationQueueIds.map((id) => content.actionsById[id]?.name ?? id); // 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], automationUnlocked: isAutomationUnlocked(state, content, a.id), inAutomationQueue: state.automationQueue.includes(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)) .filter((g): g is ActionGroupView => g !== undefined); return { kind, label: COLUMN_LABELS[kind], groups, }; }); const node = getCurrentNode(state, content); const availableChoices = getAvailableChoices(state, content); const allChoices = node?.choices ?? []; const bootEntryNodeId = content.storyNodes.find((n) => n.triggers?.some((t) => t.type === 'boot'), )?.id; const atBootIntro = node != null && node.id === bootEntryNodeId; const story: StoryView = { currentProse: node?.prose ?? null, atBootIntro, 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', }; }), tree: buildStoryTree(state, content), }; return { resources, activeActionId: state.activeActionId, actionName: action ? action.name : null, actionProgress, queuedActionIds, queuedActionNames, automationQueueIds, automationQueueNames, actions, story, actionColumns, }; } /** Render an offline gap as `45s`, `1m 30s`, `2m`, `1h 1m`, `2h`. */ export function formatOfflineDuration(ms: number): string { const totalSeconds = Math.floor(ms / 1000); if (totalSeconds < 60) { return `${totalSeconds}s`; } const totalMinutes = Math.floor(totalSeconds / 60); if (totalMinutes < 60) { const seconds = totalSeconds % 60; return seconds === 0 ? `${totalMinutes}m` : `${totalMinutes}m ${seconds}s`; } const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; }