feat(state): project actions into columns and story tree
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildContent } from '../../content/schema';
|
import { buildContent } from '../../content/schema';
|
||||||
import { buildStoryContent } from '../../content/storySchema';
|
import { buildStoryContent } from '../../content/storySchema';
|
||||||
import { createGameState, enqueueAction, startAction } from '../../engine/game';
|
import { content } from '../../content/index';
|
||||||
|
import { createGameState, enqueueAction, performAction, startAction } from '../../engine/game';
|
||||||
import { enterStoryNode } from '../../engine/story';
|
import { enterStoryNode } from '../../engine/story';
|
||||||
import { formatOfflineDuration, toView } from '../viewModel';
|
import { formatOfflineDuration, toView } from '../viewModel';
|
||||||
|
|
||||||
@@ -193,3 +194,72 @@ describe('formatOfflineDuration()', () => {
|
|||||||
expect(formatOfflineDuration(7_200_000)).toBe('2h');
|
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('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 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']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+2
-1
@@ -40,7 +40,8 @@ export const useGameStore = create<GameStoreState>((set) => ({
|
|||||||
queuedActionIds: [],
|
queuedActionIds: [],
|
||||||
queuedActionNames: [],
|
queuedActionNames: [],
|
||||||
actions: [],
|
actions: [],
|
||||||
story: { currentProse: null, choices: [] },
|
story: { currentProse: null, choices: [], tree: [] },
|
||||||
|
actionColumns: [],
|
||||||
log: [],
|
log: [],
|
||||||
storyPanelOpen: false,
|
storyPanelOpen: false,
|
||||||
storyHasUnread: false,
|
storyHasUnread: false,
|
||||||
|
|||||||
+124
-11
@@ -5,7 +5,7 @@ import {
|
|||||||
type GameState,
|
type GameState,
|
||||||
isActionAvailable,
|
isActionAvailable,
|
||||||
} from '../engine/game';
|
} 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.
|
* Pure mapping from engine state to the view model the React shell renders.
|
||||||
@@ -18,6 +18,17 @@ export interface ResourceView {
|
|||||||
amount: number;
|
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 {
|
export interface ActionView {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -27,6 +38,20 @@ export interface ActionView {
|
|||||||
storyTooltip?: string;
|
storyTooltip?: string;
|
||||||
costsSummary: string | null;
|
costsSummary: string | null;
|
||||||
yieldsSummary: 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 {
|
export interface StoryChoiceView {
|
||||||
@@ -36,9 +61,18 @@ export interface StoryChoiceView {
|
|||||||
disabledReason: string | null;
|
disabledReason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoryTreeNodeView {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
seen: boolean;
|
||||||
|
active: boolean;
|
||||||
|
children: StoryTreeNodeView[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoryView {
|
export interface StoryView {
|
||||||
currentProse: string | null;
|
currentProse: string | null;
|
||||||
choices: StoryChoiceView[];
|
choices: StoryChoiceView[];
|
||||||
|
tree: StoryTreeNodeView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameView {
|
export interface GameView {
|
||||||
@@ -51,6 +85,7 @@ export interface GameView {
|
|||||||
queuedActionNames: string[];
|
queuedActionNames: string[];
|
||||||
actions: ActionView[];
|
actions: ActionView[];
|
||||||
story: StoryView;
|
story: StoryView;
|
||||||
|
actionColumns: ActionColumnView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionDisabledReason(
|
function actionDisabledReason(
|
||||||
@@ -72,6 +107,38 @@ function formatResourceList(
|
|||||||
.join(', ');
|
.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 {
|
export function toView(state: GameState, content: GameContent): GameView {
|
||||||
const resources: ResourceView[] = content.resources.map((resource) => ({
|
const resources: ResourceView[] = content.resources.map((resource) => ({
|
||||||
id: resource.id,
|
id: resource.id,
|
||||||
@@ -84,16 +151,60 @@ export function toView(state: GameState, content: GameContent): GameView {
|
|||||||
const queuedActionIds = [...state.actionQueue];
|
const queuedActionIds = [...state.actionQueue];
|
||||||
const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id);
|
const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id);
|
||||||
|
|
||||||
const actions: ActionView[] = content.actions.map((a) => ({
|
// Build a map of ActionView by id for column assembly
|
||||||
id: a.id,
|
const actionViewMap = new Map<string, ActionView>();
|
||||||
name: a.name,
|
const actions: ActionView[] = content.actions.map((a) => {
|
||||||
available: isActionAvailable(state, content, a.id),
|
const isStory = a.kind === 'story';
|
||||||
disabledReason: actionDisabledReason(state, content, a.id),
|
const available = isStory
|
||||||
storyHint: a.storyHint,
|
? isStoryChoiceAvailable(state, content, a.storyChoiceId ?? '')
|
||||||
storyTooltip: a.storyTooltip,
|
: isActionAvailable(state, content, a.id);
|
||||||
costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null,
|
const view: ActionView = {
|
||||||
yieldsSummary: formatResourceList(a.yields, content),
|
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 node = getCurrentNode(state, content);
|
||||||
const availableChoices = getAvailableChoices(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',
|
disabledReason: available ? null : 'Requirements not met',
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
tree: buildStoryTree(state, content),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -121,6 +233,7 @@ export function toView(state: GameState, content: GameContent): GameView {
|
|||||||
queuedActionNames,
|
queuedActionNames,
|
||||||
actions,
|
actions,
|
||||||
story,
|
story,
|
||||||
|
actionColumns,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user