feat(state): project actions into columns and story tree

This commit is contained in:
ginnoir
2026-06-11 21:01:42 -05:00
parent 163f710f4c
commit 73d836fe96
3 changed files with 197 additions and 13 deletions
+124 -11
View File
@@ -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<ActionColumnKind, string> = {
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<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,
@@ -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<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],
};
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)!);
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,
};
}