278 lines
8.5 KiB
TypeScript
278 lines
8.5 KiB
TypeScript
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<ActionColumnKind, string> = {
|
|
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<string>();
|
|
const childrenOf = new Map<string, string[]>();
|
|
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<string>): 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<string>()));
|
|
}
|
|
|
|
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<string, ActionView>();
|
|
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<string, { id: string; label: string; actions: ActionView[] }>();
|
|
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`;
|
|
}
|