feat(m1): PR3 T3.0 — shell UI, action kinds, story tab #16

Merged
ginnoir merged 26 commits from feat/m1-progression into main 2026-06-11 22:52:29 -05:00
6 changed files with 68 additions and 9 deletions
Showing only changes of commit 880abe5888 - Show all commits
+49
View File
@@ -256,6 +256,55 @@ describe('action columns projection', () => {
});
});
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);
+4
View File
@@ -135,6 +135,10 @@ export class GameRuntime {
}
}
selectStoryNode(id: string): void {
useGameStore.getState().setSelectedStoryNodeId(id);
}
toggleActionGroupCollapsed(groupKey: string): void {
useGameStore.getState().toggleActionGroupCollapsed(groupKey);
}
+1 -1
View File
@@ -43,7 +43,7 @@ export const useGameStore = create<GameStoreState>((set, get) => ({
queuedActionIds: [],
queuedActionNames: [],
actions: [],
story: { currentProse: null, choices: [], tree: [] },
story: { currentProse: null, atBootIntro: false, choices: [], tree: [] },
actionColumns: [],
log: [],
storyHasUnread: false,
+7
View File
@@ -71,6 +71,7 @@ export interface StoryTreeNodeView {
export interface StoryView {
currentProse: string | null;
atBootIntro: boolean;
choices: StoryChoiceView[];
tree: StoryTreeNodeView[];
}
@@ -215,8 +216,14 @@ export function toView(state: GameState, content: GameContent): GameView {
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 {
+1 -1
View File
@@ -36,7 +36,7 @@ function StoryTreeNode({ node, depth, selectedId, onSelect }: StoryTreeNodeProps
className={labelClasses}
style={{ marginLeft: `${depth * 1}rem` }}
onClick={() => onSelect(node.id)}
aria-pressed={isSelected}
aria-current={isSelected ? 'true' : undefined}
>
<span className="flex items-center gap-1.5">
{node.active && (
+6 -7
View File
@@ -3,19 +3,18 @@ import { useGameStore } from '../state/store';
import { StoryProseLog } from './StoryProseLog';
import { StoryTree } from './StoryTree';
const SHELL_HEIGHT = 'h-[calc(100dvh-6rem)]';
export function StoryView() {
const tree = useGameStore((s) => s.story.tree);
const log = useGameStore((s) => s.storyLog);
const selectedId = useGameStore((s) => s.selectedStoryNodeId);
const currentProse = useGameStore((s) => s.story.currentProse);
const choicesLength = useGameStore((s) => s.story.choices.length);
// Boot intro: show Continue button when there's prose, no choices, and no tree yet
const isBootIntro = currentProse != null && choicesLength === 0 && tree.length === 0;
const isBootIntro = useGameStore((s) => s.story.atBootIntro);
if (isBootIntro) {
return (
<div className="flex h-[calc(100dvh-6rem)] flex-col items-center justify-center gap-6">
<div className={`flex ${SHELL_HEIGHT} flex-col items-center justify-center gap-6`}>
<p className="max-w-lg text-center text-lg leading-relaxed text-slate-100 whitespace-pre-wrap">
{currentProse}
</p>
@@ -31,11 +30,11 @@ export function StoryView() {
}
return (
<div className="grid h-[calc(100dvh-6rem)] grid-cols-[3fr_2fr] gap-4">
<div className={`grid ${SHELL_HEIGHT} grid-cols-[3fr_2fr] gap-4`}>
<StoryTree
nodes={tree}
selectedId={selectedId}
onSelect={(id) => useGameStore.getState().setSelectedStoryNodeId(id)}
onSelect={(id) => gameRuntime.selectStoryNode(id)}
/>
<StoryProseLog log={log} selectedId={selectedId} />
</div>