Files
idlegame/docs/superpowers/plans/2026-06-11-m1-pr2-playable-loop.md
T
ginnoir 203478268c
CI / verify (push) Successful in 1m31s
CI / verify (pull_request) Successful in 1m2s
docs(m1): add PR3 shell UI spec and implementation plans
Capture the PR3 UX brainstorm: three-region shell, action columns by kind, story-via-actions, Story tab tree+log, and universal automation with shareable recipes. Includes T3.0/T3.1 implementation plans and M1 vertical slice plan. Also ignore local .worktrees/.
2026-06-11 20:01:30 -05:00

2082 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M1 PR2 — Playable Loop Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship PR2 (`feat/m1-playable-loop`): hybrid story graph engine, story content schema, full-window StoryPanel, upgraded ActionPanel (queue/cancel/disabled/hints), player prefs, stub A/B branch — closing Gitea #5 and #6.
**Architecture:** Pure `src/engine/story.ts` handles triggers, choices, and outcomes; `GameState` gains story fields persisted in save v1. Runtime calls `evaluateTriggers` after boot, publish, and action completion (via `tickGame` return value). UI reads an expanded view model; prefs live in `localStorage` via `src/state/prefs.ts`.
**Tech Stack:** TypeScript strict, Vitest, Zod 4, Biome, pnpm, React 19, Zustand, Tailwind 4.
**Parent spec:** `docs/superpowers/specs/2026-06-11-m1-pr2-playable-loop-design.md`
**Branch:** `feat/m1-playable-loop` off `main`.
---
## File map
| File | Responsibility |
|---|---|
| `src/engine/game.ts` | `GameState` story fields; `isActionAvailable` uses `state.storyFlags`; `tickGame` returns completed action ids |
| `src/engine/story.ts` | **Create** — initStory, evaluateTriggers, applyChoice, outcomes |
| `src/engine/__tests__/story.test.ts` | **Create** — story engine tests |
| `src/engine/save.ts` | Persist story fields in v1 schema |
| `src/engine/__tests__/save.test.ts` | Story field round-trip |
| `src/content/storySchema.ts` | **Create** — Zod story defs + `buildStoryContent` |
| `src/content/__tests__/storySchema.test.ts` | **Create** — schema validation |
| `src/content/schema.ts` | `storyHint`, `storyTooltip` on actions |
| `src/content/story.ts` | **Create** — stub ~6-node graph |
| `src/content/definitions.ts` | `push_onward`, route-gated unlocks, hints/tooltips |
| `src/content/index.ts` | Export merged `GameContent` |
| `src/state/prefs.ts` | **Create** — localStorage prefs |
| `src/state/__tests__/prefs.test.ts` | **Create** — prefs round-trip |
| `src/state/storyOrchestration.ts` | **Create** — trigger evaluation wrapper |
| `src/state/__tests__/storyOrchestration.test.ts` | **Create** — boot trigger smoke |
| `src/state/viewModel.ts` | Action availability, story view fields |
| `src/state/__tests__/viewModel.test.ts` | Extended view model tests |
| `src/state/store.ts` | Story UI state (open, unread, story log) |
| `src/state/runtime.ts` | Orchestration hooks, applyChoice, cancelQueue |
| `src/state/persistence.ts` | Hydrate story fields on load |
| `src/ui/ActionPanel.tsx` | Queue list, cancel, disabled, hints/tooltips |
| `src/ui/StoryPanel.tsx` | **Create** — full-screen VN overlay |
| `src/ui/SettingsDrawer.tsx` | **Create** — two pref selects |
| `src/ui/App.tsx` | Header chrome, StoryPanel, SettingsDrawer |
| `docs/architecture.md` | Story engine + prefs notes |
---
### Task 1: GameState story fields
**Files:**
- Modify: `src/engine/game.ts`
- Modify: `src/engine/__tests__/game.test.ts`
- [ ] **Step 1: Write the failing test**
Add to `src/engine/__tests__/game.test.ts`:
```typescript
describe('createGameState() story fields', () => {
it('initializes empty story state', () => {
const content = testContent();
const state = createGameState(content);
expect(state.storyFlags).toEqual({});
expect(state.currentStoryNodeId).toBe('');
expect(state.seenStoryNodeIds).toEqual([]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test src/engine/__tests__/game.test.ts -t "story fields"`
Expected: FAIL — properties undefined.
- [ ] **Step 3: Extend GameState and createGameState**
In `src/engine/game.ts`:
```typescript
export interface GameState {
resources: Record<string, number>;
activeActionId: string | null;
actionElapsedMs: number;
actionQueue: string[];
storyFlags: Record<string, boolean>;
currentStoryNodeId: string;
seenStoryNodeIds: string[];
}
export function createGameState(content: Content): GameState {
const resources: Record<string, number> = {};
for (const resource of content.resources) {
resources[resource.id] = resource.startAmount;
}
return {
resources,
activeActionId: null,
actionElapsedMs: 0,
actionQueue: [],
storyFlags: {},
currentStoryNodeId: '',
seenStoryNodeIds: [],
};
}
```
Update `isActionAvailable` / `canUnlockAction` call sites inside `game.ts` to pass `state.storyFlags` instead of default `{}`:
```typescript
if (isActionAvailable(state, content, nextId)) {
```
and:
```typescript
if (!isActionAvailable(state, content, actionId)) {
```
Remove the default `storyFlags = {}` parameter from exported `canUnlockAction` and `isActionAvailable` — always require explicit flags from callers (tests pass `state.storyFlags`).
- [ ] **Step 4: Run tests**
Run: `pnpm test src/engine/__tests__/game.test.ts`
Expected: PASS (fix any test `createGameState` expectations).
- [ ] **Step 5: Commit**
```bash
git add src/engine/game.ts src/engine/__tests__/game.test.ts
git commit -m "feat(engine): add story fields to GameState"
```
---
### Task 2: tickGame reports completed actions
**Files:**
- Modify: `src/engine/game.ts`
- Modify: `src/engine/__tests__/game.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
describe('tickGame() completion result', () => {
it('returns the id of each action that completed this tick', () => {
const content = testContent();
const state = createGameState(content);
enqueueAction(state, content, 'forage');
const result = tickGame(state, content, 300);
expect(result.completedActionIds).toEqual(['forage']);
expect(state.activeActionId).toBeNull();
});
it('returns an empty array when nothing completes', () => {
const content = testContent();
const state = createGameState(content);
enqueueAction(state, content, 'forage');
const result = tickGame(state, content, 100);
expect(result.completedActionIds).toEqual([]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test src/engine/__tests__/game.test.ts -t "completion result"`
Expected: FAIL — no return value / wrong shape.
- [ ] **Step 3: Implement return value**
Change signature and track completions in `tickGame`:
```typescript
export interface TickResult {
completedActionIds: string[];
}
export function tickGame(state: GameState, content: Content, tickMs: number): TickResult {
const completedActionIds: string[] = [];
if (!state.activeActionId) return { completedActionIds };
state.actionElapsedMs += tickMs;
while (state.activeActionId) {
const actionId = state.activeActionId;
const action = content.actionsById[actionId];
if (!action) return { completedActionIds };
if (state.actionElapsedMs < action.durationMs) return { completedActionIds };
state.actionElapsedMs -= action.durationMs;
grantYields(state, content, actionId);
completedActionIds.push(actionId);
startNextFromQueue(state, content);
}
return { completedActionIds };
}
```
Refactor: inline `completeActiveAction` body into the loop above (or have `completeActiveAction` return the completed id). Update `applyOfflineProgress` in `save.ts` to ignore the return value:
```typescript
tickGame(state, content, TICK_MS);
```
- [ ] **Step 4: Run engine tests**
Run: `pnpm test src/engine/__tests__/game.test.ts src/engine/__tests__/save.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/engine/game.ts src/engine/__tests__/game.test.ts src/engine/save.ts
git commit -m "feat(engine): tickGame returns completed action ids for story triggers"
```
---
### Task 3: Story content schema
**Files:**
- Create: `src/content/storySchema.ts`
- Create: `src/content/__tests__/storySchema.test.ts`
- [ ] **Step 1: Write the failing tests**
Create `src/content/__tests__/storySchema.test.ts`:
```typescript
import { describe, expect, it } from 'vitest';
import { buildStoryContent } from '../storySchema';
const resourcesById = {
supplies: { id: 'supplies', name: 'Supplies', startAmount: 0 },
coin: { id: 'coin', name: 'Coin', startAmount: 0 },
};
const actionsById = {
scout_path: { id: 'scout_path', name: 'Scout', durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }] },
};
const validNodes = [
{
id: 'boot_intro',
prose: 'You wake at the crossroads.',
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: 'pick_b',
label: 'Low road',
outcomes: [{ type: 'setFlag', flag: 'route_b' }],
targetNodeId: 'route_b_beat',
},
],
},
{ id: 'route_a_beat', prose: 'The high road.' },
{ id: 'route_b_beat', prose: 'The low road.' },
];
describe('buildStoryContent()', () => {
it('indexes nodes and finds boot trigger', () => {
const story = buildStoryContent(validNodes, actionsById, resourcesById);
expect(story.storyNodesById.fork_choice.prose).toContain('Which way');
expect(story.bootTargetNodeId).toBe('boot_intro');
});
it('rejects dangling targetNodeId on choices', () => {
expect(() =>
buildStoryContent(
[{ id: 'n', prose: 'x', choices: [{ id: 'c', label: 'y', outcomes: [], targetNodeId: 'missing' }] }],
actionsById,
resourcesById,
),
).toThrow(/unknown story node/i);
});
it('rejects unknown actionId in actionComplete trigger', () => {
expect(() =>
buildStoryContent(
[
{
id: 't',
prose: '',
triggers: [{ type: 'actionComplete', actionId: 'ghost', targetNodeId: 'boot_intro' }],
},
{ id: 'boot_intro', prose: 'hi' },
],
actionsById,
resourcesById,
),
).toThrow(/unknown action/i);
});
it('requires exactly one boot trigger', () => {
expect(() =>
buildStoryContent([{ id: 'n', prose: 'no boot' }], actionsById, resourcesById),
).toThrow(/boot trigger/i);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm test src/content/__tests__/storySchema.test.ts`
Expected: FAIL — module not found.
- [ ] **Step 3: Implement storySchema.ts**
Create `src/content/storySchema.ts`:
```typescript
import { z } from 'zod';
import type { ActionDef } from './schema';
export const storyOutcomeSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('setFlag'), flag: z.string().min(1) }),
z.object({ type: z.literal('clearFlag'), flag: z.string().min(1) }),
z.object({ type: z.literal('grantResource'), resourceId: z.string().min(1), amount: z.number().positive() }),
z.object({ type: z.literal('consumeResource'), resourceId: z.string().min(1), amount: z.number().positive() }),
z.object({ type: z.literal('log'), text: z.string().min(1) }),
]);
export const choiceRequirementsSchema = z.object({
minResources: z.record(z.string(), z.number().nonnegative()).optional(),
requireStoryFlags: z.array(z.string().min(1)).optional(),
excludeStoryFlags: z.array(z.string().min(1)).optional(),
});
export const storyChoiceSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
requirements: choiceRequirementsSchema.optional(),
outcomes: z.array(storyOutcomeSchema).default([]),
targetNodeId: z.string().min(1),
});
export const storyTriggerSchema = z.object({
type: z.enum(['boot', 'actionComplete', 'minResources']),
actionId: z.string().min(1).optional(),
minResources: z.record(z.string(), z.number().nonnegative()).optional(),
targetNodeId: z.string().min(1),
once: z.boolean().default(true),
});
export const storyNodeSchema = z.object({
id: z.string().min(1),
prose: z.string().min(1),
choices: z.array(storyChoiceSchema).optional(),
triggers: z.array(storyTriggerSchema).optional(),
enterOutcomes: z.array(storyOutcomeSchema).optional(),
});
export type StoryOutcome = z.infer<typeof storyOutcomeSchema>;
export type StoryChoice = z.infer<typeof storyChoiceSchema>;
export type StoryTrigger = z.infer<typeof storyTriggerSchema>;
export type StoryNode = z.infer<typeof storyNodeSchema>;
export interface StoryContent {
storyNodes: StoryNode[];
storyNodesById: Record<string, StoryNode>;
bootTargetNodeId: string;
}
function indexStoryNodes(nodes: StoryNode[]): Record<string, StoryNode> {
const byId: Record<string, StoryNode> = {};
for (const node of nodes) {
if (byId[node.id]) throw new Error(`Duplicate story node id "${node.id}"`);
byId[node.id] = node;
}
return byId;
}
export function buildStoryContent(
rawNodes: unknown[],
actionsById: Record<string, ActionDef>,
resourcesById: Record<string, { id: string }>,
): StoryContent {
const storyNodes = rawNodes.map((n) => storyNodeSchema.parse(n));
const storyNodesById = indexStoryNodes(storyNodes);
let bootTargetNodeId: string | null = null;
for (const node of storyNodes) {
for (const trigger of node.triggers ?? []) {
if (trigger.type === 'boot') {
if (bootTargetNodeId !== null) {
throw new Error('Story graph must have exactly one boot trigger');
}
bootTargetNodeId = trigger.targetNodeId;
}
if (trigger.type === 'actionComplete' && !actionsById[trigger.actionId ?? '']) {
throw new Error(`Story trigger references unknown action "${trigger.actionId}"`);
}
if (trigger.minResources) {
for (const resourceId of Object.keys(trigger.minResources)) {
if (!resourcesById[resourceId]) {
throw new Error(`Story trigger references unknown resource "${resourceId}"`);
}
}
}
if (!storyNodesById[trigger.targetNodeId]) {
throw new Error(`Story trigger references unknown story node "${trigger.targetNodeId}"`);
}
}
for (const choice of node.choices ?? []) {
if (!storyNodesById[choice.targetNodeId]) {
throw new Error(`Story choice references unknown story node "${choice.targetNodeId}"`);
}
for (const outcome of choice.outcomes) {
if (outcome.type === 'grantResource' || outcome.type === 'consumeResource') {
if (!resourcesById[outcome.resourceId]) {
throw new Error(`Story outcome references unknown resource "${outcome.resourceId}"`);
}
}
}
}
for (const outcome of node.enterOutcomes ?? []) {
if (outcome.type === 'grantResource' || outcome.type === 'consumeResource') {
if (!resourcesById[outcome.resourceId]) {
throw new Error(`Story enterOutcome references unknown resource "${outcome.resourceId}"`);
}
}
}
}
if (bootTargetNodeId === null) {
throw new Error('Story graph must have exactly one boot trigger');
}
return { storyNodes, storyNodesById, bootTargetNodeId };
}
```
- [ ] **Step 4: Run tests**
Run: `pnpm test src/content/__tests__/storySchema.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/storySchema.ts src/content/__tests__/storySchema.test.ts
git commit -m "feat(content): add Zod story graph schema and validation"
```
---
### Task 4: Action narrative fields + merged GameContent
**Files:**
- Modify: `src/content/schema.ts`
- Modify: `src/content/index.ts`
- Modify: `src/content/__tests__/schema.test.ts`
- [ ] **Step 1: Write the failing test**
Add to `src/content/__tests__/schema.test.ts`:
```typescript
it('accepts optional storyHint and storyTooltip on actions', () => {
const content = buildContent({
resources: [{ id: 'gold', name: 'Gold' }],
actions: [
{
id: 'forage',
name: 'Forage',
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
storyHint: 'Pick herbs along the trail.',
storyTooltip: 'Yields +1 Gold. Safe choice.',
},
],
});
expect(content.actionsById.forage.storyHint).toBe('Pick herbs along the trail.');
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test src/content/__tests__/schema.test.ts -t "storyHint"`
Expected: FAIL
- [ ] **Step 3: Extend action schema and GameContent export**
In `schema.ts`:
```typescript
export const actionDefSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
durationMs: z.number().positive(),
costs: z.array(resourceAmountSchema).default([]),
yields: z.array(resourceAmountSchema).min(1),
unlock: unlockDefSchema.optional(),
storyHint: z.string().min(1).optional(),
storyTooltip: z.string().min(1).optional(),
});
```
In `index.ts`:
```typescript
import { actionDefs, resourceDefs } from './definitions';
import { buildContent, type Content } from './schema';
import { buildStoryContent, type StoryContent } from './storySchema';
import { storyNodeDefs } from './story';
const base = buildContent({ resources: resourceDefs, actions: actionDefs });
const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesById);
export type GameContent = Content & StoryContent;
export const content: GameContent = { ...base, ...story };
```
Create a minimal placeholder `src/content/story.ts` for now (Task 18 replaces with full stub):
```typescript
export const storyNodeDefs = [
{
id: 'boot_intro',
prose: 'Placeholder boot.',
triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }],
},
];
```
Update engine/content imports: change `Content` to `GameContent` where story APIs need story nodes — or export `GameContent` as the runtime content type. For minimal churn, add `GameContent` in `schema.ts` re-export from index and use `GameContent` in `story.ts` engine module.
- [ ] **Step 4: Run tests**
Run: `pnpm test src/content/__tests__/schema.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/schema.ts src/content/index.ts src/content/story.ts src/content/__tests__/schema.test.ts
git commit -m "feat(content): add action story hints and merge GameContent"
```
---
### Task 5: Story engine — outcomes and enterNode
**Files:**
- Create: `src/engine/story.ts`
- Create: `src/engine/__tests__/story.test.ts`
- [ ] **Step 1: Write the failing tests**
Create `src/engine/__tests__/story.test.ts` with helpers:
```typescript
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema';
import { createGameState } from '../game';
import { applyOutcomes, enterStoryNode, initStory } from '../story';
function gameContent() {
const base = buildContent({
resources: [
{ id: 'supplies', name: 'Supplies', startAmount: 10 },
{ id: 'coin', name: 'Coin', startAmount: 0 },
],
actions: [
{ id: 'scout_path', name: 'Scout', durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }] },
],
});
const story = buildStoryContent(
[
{
id: 'boot_intro',
prose: 'Boot.',
triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }],
enterOutcomes: [{ type: 'grantResource', resourceId: 'coin', amount: 1 }],
},
],
base.actionsById,
base.resourcesById,
);
return { ...base, ...story };
}
describe('enterStoryNode()', () => {
it('sets current node, marks seen, applies enterOutcomes', () => {
const content = gameContent();
const state = createGameState(content);
const events = enterStoryNode(state, content, 'boot_intro');
expect(state.currentStoryNodeId).toBe('boot_intro');
expect(state.seenStoryNodeIds).toContain('boot_intro');
expect(state.resources.coin).toBe(1);
expect(events.length).toBeGreaterThan(0);
});
});
describe('initStory()', () => {
it('leaves currentStoryNodeId empty until triggers run', () => {
const content = gameContent();
const state = createGameState(content);
initStory(state, content);
expect(state.currentStoryNodeId).toBe('');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm test src/engine/__tests__/story.test.ts`
Expected: FAIL
- [ ] **Step 3: Implement outcomes + enterStoryNode + initStory**
Create `src/engine/story.ts`:
```typescript
import type { GameContent } from '../content/index';
import type { StoryOutcome } from '../content/storySchema';
import type { GameState } from './game';
export interface StoryEvent {
kind: 'enter' | 'log';
nodeId: string;
prose: string;
choiceLabel?: string;
}
export function applyOutcomes(
state: GameState,
content: GameContent,
outcomes: StoryOutcome[],
events: StoryEvent[],
): void {
for (const outcome of outcomes) {
switch (outcome.type) {
case 'setFlag':
state.storyFlags[outcome.flag] = true;
break;
case 'clearFlag':
state.storyFlags[outcome.flag] = false;
break;
case 'grantResource':
state.resources[outcome.resourceId] = (state.resources[outcome.resourceId] ?? 0) + outcome.amount;
break;
case 'consumeResource': {
const current = state.resources[outcome.resourceId] ?? 0;
if (current < outcome.amount) {
throw new Error(`Cannot consume ${outcome.amount} ${outcome.resourceId} (have ${current})`);
}
state.resources[outcome.resourceId] = current - outcome.amount;
break;
}
case 'log':
events.push({ kind: 'log', nodeId: state.currentStoryNodeId, prose: outcome.text });
break;
}
}
}
export function enterStoryNode(state: GameState, content: GameContent, nodeId: string): StoryEvent[] {
const node = content.storyNodesById[nodeId];
if (!node) throw new Error(`Unknown story node "${nodeId}"`);
const events: StoryEvent[] = [];
if (!state.seenStoryNodeIds.includes(nodeId)) {
state.seenStoryNodeIds.push(nodeId);
}
state.currentStoryNodeId = nodeId;
applyOutcomes(state, content, node.enterOutcomes ?? [], events);
events.unshift({ kind: 'enter', nodeId, prose: node.prose });
return events;
}
export function initStory(state: GameState, _content: GameContent): void {
state.storyFlags = state.storyFlags ?? {};
state.seenStoryNodeIds = state.seenStoryNodeIds ?? [];
if (!state.currentStoryNodeId) {
state.currentStoryNodeId = '';
}
}
```
Use a type-only import pattern if circular — `GameContent` from `content/index` is fine in engine tests; engine `story.ts` should import `StoryContent & Content` as:
```typescript
import type { GameContent } from '../content/index';
```
If Biome/lint complains about content importing engine, define `StoryContent` + `Content` intersection locally:
```typescript
import type { Content } from '../content/schema';
import type { StoryContent } from '../content/storySchema';
type GameContent = Content & StoryContent;
```
- [ ] **Step 4: Run tests**
Run: `pnpm test src/engine/__tests__/story.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/engine/story.ts src/engine/__tests__/story.test.ts
git commit -m "feat(engine): story outcomes and enterStoryNode"
```
---
### Task 6: Story engine — evaluateTriggers
**Files:**
- Modify: `src/engine/story.ts`
- Modify: `src/engine/__tests__/story.test.ts`
- [ ] **Step 1: Write the failing tests**
Add to `story.test.ts`:
```typescript
import { evaluateTriggers } from '../story';
// extend gameContent() with extra nodes/triggers for trigger tests
describe('evaluateTriggers()', () => {
it('fires boot trigger on boot reason', () => {
const content = gameContent();
const state = createGameState(content);
initStory(state, content);
const { enteredNodeIds } = evaluateTriggers(state, content, { reason: 'boot' });
expect(enteredNodeIds).toEqual(['boot_intro']);
expect(state.currentStoryNodeId).toBe('boot_intro');
});
it('fires actionComplete when scout_path finishes', () => {
const content = gameContentWithActionTrigger(); // helper defined in test file
const state = createGameState(content);
const { enteredNodeIds } = evaluateTriggers(state, content, {
reason: 'actionComplete',
actionId: 'scout_path',
});
expect(enteredNodeIds).toEqual(['scout_aftermath']);
});
it('fires minResources on publish when thresholds met', () => {
const content = gameContentWithThreshold();
const state = createGameState(content);
state.resources.coin = 3;
const { enteredNodeIds } = evaluateTriggers(state, content, { reason: 'publish' });
expect(enteredNodeIds).toEqual(['merchant_flavor']);
});
it('does not re-fire once-only triggers for seen targets', () => {
const content = gameContent();
const state = createGameState(content);
evaluateTriggers(state, content, { reason: 'boot' });
const second = evaluateTriggers(state, content, { reason: 'boot' });
expect(second.enteredNodeIds).toEqual([]);
});
});
```
Implement helpers `gameContentWithActionTrigger` and `gameContentWithThreshold` inline in the test file with minimal node sets.
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm test src/engine/__tests__/story.test.ts -t "evaluateTriggers"`
Expected: FAIL
- [ ] **Step 3: Implement evaluateTriggers**
Add to `story.ts`:
```typescript
export type TriggerContext =
| { reason: 'boot' }
| { reason: 'publish' }
| { reason: 'actionComplete'; actionId: string };
export interface TriggerResult {
enteredNodeIds: string[];
events: StoryEvent[];
}
function meetsMinResources(state: GameState, minResources: Record<string, number>): boolean {
return Object.entries(minResources).every(
([id, min]) => (state.resources[id] ?? 0) >= min,
);
}
function shouldSkipTrigger(state: GameState, trigger: { targetNodeId: string; once: boolean }): boolean {
return trigger.once !== false && state.seenStoryNodeIds.includes(trigger.targetNodeId);
}
export function evaluateTriggers(
state: GameState,
content: GameContent,
ctx: TriggerContext,
): TriggerResult {
const enteredNodeIds: string[] = [];
const events: StoryEvent[] = [];
for (const node of content.storyNodes) {
for (const trigger of node.triggers ?? []) {
if (shouldSkipTrigger(state, trigger)) continue;
let matches = false;
if (ctx.reason === 'boot' && trigger.type === 'boot') matches = true;
if (ctx.reason === 'actionComplete' && trigger.type === 'actionComplete' && trigger.actionId === ctx.actionId) {
matches = true;
}
if (
(ctx.reason === 'publish' || ctx.reason === 'actionComplete') &&
trigger.type === 'minResources' &&
trigger.minResources &&
meetsMinResources(state, trigger.minResources)
) {
matches = true;
}
if (matches) {
enteredNodeIds.push(trigger.targetNodeId);
events.push(...enterStoryNode(state, content, trigger.targetNodeId));
}
}
}
return { enteredNodeIds, events };
}
```
- [ ] **Step 4: Run tests**
Run: `pnpm test src/engine/__tests__/story.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/engine/story.ts src/engine/__tests__/story.test.ts
git commit -m "feat(engine): evaluateTriggers for boot, actionComplete, minResources"
```
---
### Task 7: Story engine — applyChoice and getAvailableChoices
**Files:**
- Modify: `src/engine/story.ts`
- Modify: `src/engine/__tests__/story.test.ts`
- [ ] **Step 1: Write the failing tests**
```typescript
import { applyChoice, getAvailableChoices, getCurrentNode } from '../story';
describe('applyChoice()', () => {
it('applies outcomes and advances on fork', () => {
const content = gameContentWithFork(); // boot + fork_choice + route nodes
const state = createGameState(content);
enterStoryNode(state, content, 'fork_choice');
applyChoice(state, content, 'pick_a');
expect(state.storyFlags.route_a).toBe(true);
expect(state.currentStoryNodeId).toBe('route_a_beat');
});
it('throws when requirements not met', () => {
const content = gameContentWithGatedChoice();
const state = createGameState(content);
enterStoryNode(state, content, 'gated');
expect(() => applyChoice(state, content, 'needs_coin')).toThrow(/requirements/i);
});
});
describe('getAvailableChoices()', () => {
it('hides choices blocked by excludeStoryFlags', () => {
const content = gameContentWithFork();
const state = createGameState(content);
state.storyFlags.route_a = true;
enterStoryNode(state, content, 'fork_choice');
const choices = getAvailableChoices(state, content);
expect(choices.map((c) => c.id)).not.toContain('pick_b_if_excluded');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Expected: FAIL
- [ ] **Step 3: Implement choice APIs**
```typescript
import type { StoryChoice } from '../content/storySchema';
function meetsChoiceRequirements(state: GameState, requirements: StoryChoice['requirements']): boolean {
if (!requirements) return true;
if (requirements.minResources) {
for (const [id, min] of Object.entries(requirements.minResources)) {
if ((state.resources[id] ?? 0) < min) return false;
}
}
if (requirements.requireStoryFlags) {
for (const flag of requirements.requireStoryFlags) {
if (!state.storyFlags[flag]) return false;
}
}
if (requirements.excludeStoryFlags) {
for (const flag of requirements.excludeStoryFlags) {
if (state.storyFlags[flag]) return false;
}
}
return true;
}
export function getCurrentNode(state: GameState, content: GameContent) {
return content.storyNodesById[state.currentStoryNodeId] ?? null;
}
export function getAvailableChoices(state: GameState, content: GameContent): StoryChoice[] {
const node = getCurrentNode(state, content);
if (!node?.choices) return [];
return node.choices.filter((c) => meetsChoiceRequirements(state, c.requirements));
}
export function applyChoice(state: GameState, content: GameContent, choiceId: string): StoryEvent[] {
const node = getCurrentNode(state, content);
if (!node?.choices) throw new Error(`Node "${node?.id}" has no choices`);
const choice = node.choices.find((c) => c.id === choiceId);
if (!choice) throw new Error(`Unknown choice "${choiceId}"`);
if (!meetsChoiceRequirements(state, choice.requirements)) {
throw new Error(`Choice "${choiceId}" requirements not met`);
}
const events: StoryEvent[] = [];
applyOutcomes(state, content, choice.outcomes, events);
events.push(...enterStoryNode(state, content, choice.targetNodeId));
const last = events.find((e) => e.kind === 'enter');
if (last) last.choiceLabel = choice.label;
return events;
}
```
- [ ] **Step 4: Run tests**
Run: `pnpm test src/engine/__tests__/story.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/engine/story.ts src/engine/__tests__/story.test.ts
git commit -m "feat(engine): applyChoice and gated choice filtering"
```
---
### Task 8: Save persistence for story fields
**Files:**
- Modify: `src/engine/save.ts`
- Modify: `src/engine/__tests__/save.test.ts`
- Modify: `src/state/persistence.ts`
- Modify: `src/state/__tests__/persistence.test.ts`
- [ ] **Step 1: Write the failing tests**
In `save.test.ts`:
```typescript
it('snapshots story fields in the save payload', () => {
const state = sampleState();
state.storyFlags = { route_a: true };
state.currentStoryNodeId = 'route_a_beat';
state.seenStoryNodeIds = ['boot_intro', 'route_a_beat'];
const save = createSave(state, 1700);
expect(save.state.storyFlags).toEqual({ route_a: true });
expect(save.state.currentStoryNodeId).toBe('route_a_beat');
expect(save.state.seenStoryNodeIds).toHaveLength(2);
});
```
In `persistence.test.ts`:
```typescript
it('restores story fields on load', async () => {
const content = buildTestGameContent(); // minimal content helper
const backend = createMemoryBackend();
const state = createGameState(content);
state.storyFlags = { route_b: true };
state.currentStoryNodeId = 'fork_choice';
state.seenStoryNodeIds = ['boot_intro'];
await saveGame(state, backend, 1000);
const loaded = await loadGame(content, backend, 1000);
expect(loaded.state.storyFlags.route_b).toBe(true);
expect(loaded.state.currentStoryNodeId).toBe('fork_choice');
});
```
- [ ] **Step 2: Run tests to verify they fail**
Expected: FAIL
- [ ] **Step 3: Extend save schema and persistence**
In `save.ts`:
```typescript
export const gameStateSchema = z.object({
resources: z.record(z.string(), z.number()),
activeActionId: z.string().nullable(),
actionElapsedMs: z.number().nonnegative(),
actionQueue: z.array(z.string()).default([]),
storyFlags: z.record(z.string(), z.boolean()).default({}),
currentStoryNodeId: z.string().default(''),
seenStoryNodeIds: z.array(z.string()).default([]),
});
```
Update `createSave` to copy story fields.
In `persistence.ts` hydration:
```typescript
state = {
resources: { ...base.resources, ...save.state.resources },
activeActionId,
actionElapsedMs: save.state.actionElapsedMs,
actionQueue: [...(save.state.actionQueue ?? [])],
storyFlags: { ...save.state.storyFlags },
currentStoryNodeId: save.state.currentStoryNodeId ?? '',
seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])],
};
```
- [ ] **Step 4: Run tests**
Run: `pnpm test src/engine/__tests__/save.test.ts src/state/__tests__/persistence.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/engine/save.ts src/engine/__tests__/save.test.ts src/state/persistence.ts src/state/__tests__/persistence.test.ts
git commit -m "feat(save): persist story flags and node progress in v1"
```
---
### Task 9: Player preferences module
**Files:**
- Create: `src/state/prefs.ts`
- Create: `src/state/__tests__/prefs.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getPrefs, setPrefs, type GamePrefs } from '../prefs';
describe('prefs', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', {
store: {} as Record<string, string>,
getItem(key: string) { return this.store[key] ?? null; },
setItem(key: string, value: string) { this.store[key] = value; },
});
});
it('returns defaults when localStorage empty', () => {
expect(getPrefs()).toEqual({ storyOpenMode: 'auto', actionDetailMode: 'inline' });
});
it('round-trips updated prefs', () => {
setPrefs({ storyOpenMode: 'manual', actionDetailMode: 'hover' });
expect(getPrefs().storyOpenMode).toBe('manual');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Expected: FAIL
- [ ] **Step 3: Implement prefs.ts**
```typescript
const PREFS_KEY = 'idlegame:prefs:v1';
export type StoryOpenMode = 'auto' | 'choices-only' | 'manual';
export type ActionDetailMode = 'inline' | 'hover' | 'info-button';
export interface GamePrefs {
storyOpenMode: StoryOpenMode;
actionDetailMode: ActionDetailMode;
}
const DEFAULTS: GamePrefs = {
storyOpenMode: 'auto',
actionDetailMode: 'inline',
};
export function getPrefs(): GamePrefs {
if (typeof localStorage === 'undefined') return { ...DEFAULTS };
try {
const raw = localStorage.getItem(PREFS_KEY);
if (!raw) return { ...DEFAULTS };
return { ...DEFAULTS, ...JSON.parse(raw) };
} catch {
return { ...DEFAULTS };
}
}
export function setPrefs(partial: Partial<GamePrefs>): GamePrefs {
const next = { ...getPrefs(), ...partial };
if (typeof localStorage !== 'undefined') {
localStorage.setItem(PREFS_KEY, JSON.stringify(next));
}
return next;
}
```
- [ ] **Step 4: Run test**
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/state/prefs.ts src/state/__tests__/prefs.test.ts
git commit -m "feat(state): localStorage player prefs for story and action display"
```
---
### Task 10: View model — action availability and story fields
**Files:**
- Modify: `src/state/viewModel.ts`
- Modify: `src/state/__tests__/viewModel.test.ts`
- [ ] **Step 1: Write the failing tests**
```typescript
import { getAvailableChoices } from '../../engine/story';
describe('toView() action availability', () => {
it('marks locked actions unavailable with reason', () => {
const content = buildContent({
resources: [{ id: 'coin', name: 'Coin', startAmount: 0 }],
actions: [
{
id: 'locked',
name: 'Locked',
durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { requireStoryFlags: ['route_a'] },
},
],
});
const state = createGameState(content);
const view = toView(state, content);
expect(view.actions[0].available).toBe(false);
expect(view.actions[0].disabledReason).toMatch(/locked/i);
});
it('includes story passage and choices from current node', () => {
const content = /* GameContent with fork node */;
const state = createGameState(content);
enterStoryNode(state, content, 'fork_choice');
const view = toView(state, content);
expect(view.story.currentProse).toContain('Which way');
expect(view.story.choices.length).toBe(2);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Expected: FAIL
- [ ] **Step 3: Extend view model**
Update `viewModel.ts`:
```typescript
import { canAffordAction, canUnlockAction, isActionAvailable } from '../engine/game';
import { getAvailableChoices, getCurrentNode } from '../engine/story';
import type { GameContent } from '../content/index';
export interface ActionView {
id: string;
name: string;
available: boolean;
disabledReason: string | null;
storyHint?: string;
storyTooltip?: string;
costsSummary: string | null;
yieldsSummary: string | null;
}
export interface StoryChoiceView {
id: string;
label: string;
disabled: boolean;
disabledReason: string | null;
}
export interface StoryView {
currentProse: string | null;
choices: StoryChoiceView[];
}
export interface GameView {
resources: ResourceView[];
activeActionId: string | null;
actionName: string | null;
actionProgress: number;
queuedActionIds: string[];
queuedActionNames: string[];
actions: ActionView[];
story: StoryView;
}
function disabledReason(state: GameState, content: GameContent, actionId: string): string | null {
if (!canUnlockAction(state, content, actionId, state.storyFlags)) 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(', ');
}
export function toView(state: GameState, content: GameContent): GameView {
// ... existing resource/progress/queue mapping ...
const actions: ActionView[] = content.actions.map((action) => ({
id: action.id,
name: action.name,
available: isActionAvailable(state, content, action.id),
disabledReason: disabledReason(state, content, action.id),
storyHint: action.storyHint,
storyTooltip: action.storyTooltip,
costsSummary: action.costs.length ? formatResourceList(action.costs, content) : null,
yieldsSummary: formatResourceList(action.yields, content),
}));
const node = getCurrentNode(state, content);
const availableChoices = getAvailableChoices(state, content);
const allChoices = node?.choices ?? [];
const story: StoryView = {
currentProse: node?.prose ?? null,
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',
};
}),
};
return { /* existing fields */, actions, story };
}
```
Fix `isActionAvailable` in `game.ts` to use `state.storyFlags` internally (update signature in Task 1 if not done).
- [ ] **Step 4: Run tests**
Run: `pnpm test src/state/__tests__/viewModel.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/state/viewModel.ts src/state/__tests__/viewModel.test.ts src/engine/game.ts
git commit -m "feat(state): view model action availability and story passage"
```
---
### Task 11: Store story UI state
**Files:**
- Modify: `src/state/store.ts`
- [ ] **Step 1: Extend store interface**
```typescript
export interface StoryLogEntry {
nodeId: string;
prose: string;
choiceLabel?: string;
}
export interface GameStoreState extends GameView {
log: string[];
storyPanelOpen: boolean;
storyHasUnread: boolean;
storyLog: StoryLogEntry[];
prefs: GamePrefs;
setView: (view: GameView) => void;
appendLog: (line: string) => void;
appendStoryLog: (entry: StoryLogEntry) => void;
setStoryPanelOpen: (open: boolean) => void;
setStoryHasUnread: (unread: boolean) => void;
setPrefs: (partial: Partial<GamePrefs>) => void;
}
```
Wire defaults: `storyPanelOpen: false`, `storyHasUnread: false`, `storyLog: []`, `prefs: getPrefs()`.
- [ ] **Step 2: Run typecheck**
Run: `pnpm typecheck`
Expected: FAIL until runtime/UI updated — implement store methods first.
- [ ] **Step 3: Implement store actions**
```typescript
import { getPrefs, setPrefs as persistPrefs } from './prefs';
appendStoryLog: (entry) =>
set((state) => ({ storyLog: [...state.storyLog, entry] })),
setStoryPanelOpen: (open) => set({ storyPanelOpen: open }),
setStoryHasUnread: (unread) => set({ storyHasUnread: unread }),
setPrefs: (partial) => {
const prefs = persistPrefs(partial);
set({ prefs });
},
```
- [ ] **Step 4: Commit**
```bash
git add src/state/store.ts
git commit -m "feat(state): store story panel and prefs UI state"
```
---
### Task 12: Story orchestration module
**Files:**
- Create: `src/state/storyOrchestration.ts`
- Create: `src/state/__tests__/storyOrchestration.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
import { describe, expect, it } from 'vitest';
import { content } from '../../content';
import { createGameState } from '../../engine/game';
import { initStory } from '../../engine/story';
import { processStoryTriggers } from '../storyOrchestration';
describe('processStoryTriggers()', () => {
it('returns entered nodes on boot', () => {
const state = createGameState(content);
initStory(state, content);
const result = processStoryTriggers(state, content, { reason: 'boot' });
expect(result.enteredNodeIds.length).toBeGreaterThan(0);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Expected: FAIL
- [ ] **Step 3: Implement storyOrchestration.ts**
```typescript
import type { GameContent } from '../content/index';
import type { GameState } from '../engine/game';
import { evaluateTriggers, type StoryEvent, type TriggerContext } from '../engine/story';
import type { StoryLogEntry } from './store';
import type { GamePrefs } from './prefs';
export interface StoryUiEffect {
enteredNodeIds: string[];
logEntries: StoryLogEntry[];
shouldOpenPanel: boolean;
eventLogLines: string[];
}
export function storyEventsToLogEntries(events: StoryEvent[]): StoryLogEntry[] {
return events
.filter((e) => e.kind === 'enter')
.map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel }));
}
export function shouldAutoOpenPanel(
prefs: GamePrefs,
nodeId: string,
content: GameContent,
): boolean {
const node = content.storyNodesById[nodeId];
if (!node) return false;
if (prefs.storyOpenMode === 'manual') return false;
if (prefs.storyOpenMode === 'auto') return true;
// choices-only
return (node.choices?.length ?? 0) > 0;
}
export function processStoryTriggers(
state: GameState,
content: GameContent,
ctx: TriggerContext,
prefs: GamePrefs,
): StoryUiEffect {
const { enteredNodeIds, events } = evaluateTriggers(state, content, ctx);
const logEntries = storyEventsToLogEntries(events);
const shouldOpenPanel =
enteredNodeIds.length > 0 &&
enteredNodeIds.some((id) => shouldAutoOpenPanel(prefs, id, content));
const eventLogLines = logEntries.map((e) =>
e.choiceLabel ? `Story: ${e.choiceLabel}` : `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`,
);
return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines };
}
```
- [ ] **Step 4: Run test**
Expected: PASS (requires stub story in content from Task 18 — if boot node only, still passes).
- [ ] **Step 5: Commit**
```bash
git add src/state/storyOrchestration.ts src/state/__tests__/storyOrchestration.test.ts
git commit -m "feat(state): story trigger orchestration and auto-open rules"
```
---
### Task 13: Runtime wiring
**Files:**
- Modify: `src/state/runtime.ts`
- [ ] **Step 1: Wire boot story sequence in `boot()`**
After `loadGame`:
```typescript
import { initStory, applyChoice as engineApplyChoice, enterStoryNode } from '../engine/story';
import { getPrefs } from './prefs';
import { processStoryTriggers, storyEventsToLogEntries, shouldAutoOpenPanel } from './storyOrchestration';
// inside boot(), after state assigned:
initStory(this.state, content);
const prefs = getPrefs();
useGameStore.getState().setPrefs(prefs);
const bootEffect = processStoryTriggers(this.state, content, { reason: 'boot' }, prefs);
this.applyStoryUiEffect(bootEffect);
this.publish();
```
Add private helpers to `GameRuntime`:
```typescript
private applyStoryUiEffect(effect: StoryUiEffect): void {
const store = useGameStore.getState();
for (const entry of effect.logEntries) store.appendStoryLog(entry);
for (const line of effect.eventLogLines) store.appendLog(line);
if (effect.shouldOpenPanel) {
store.setStoryPanelOpen(true);
store.setStoryHasUnread(false);
} else if (effect.enteredNodeIds.length > 0) {
store.setStoryHasUnread(true);
}
}
private runPublishTriggers(): void {
const state = this.state;
if (!state) return;
const prefs = useGameStore.getState().prefs;
const effect = processStoryTriggers(state, content, { reason: 'publish' }, prefs);
this.applyStoryUiEffect(effect);
}
```
- [ ] **Step 2: Wire tick completion triggers**
In `frame`:
```typescript
const result = tickGame(state, content, TICK_MS);
for (const actionId of result.completedActionIds) {
const prefs = useGameStore.getState().prefs;
const effect = processStoryTriggers(state, content, { reason: 'actionComplete', actionId }, prefs);
this.applyStoryUiEffect(effect);
}
// before or after publish interval:
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
this.runPublishTriggers();
this.publish();
}
```
- [ ] **Step 3: Add public runtime commands**
```typescript
applyStoryChoice(choiceId: string): void {
const state = this.state;
if (!state) return;
try {
const events = engineApplyChoice(state, content, choiceId);
const entries = storyEventsToLogEntries(events);
const store = useGameStore.getState();
for (const entry of entries) store.appendStoryLog(entry);
const choiceLabel = entries.at(-1)?.choiceLabel;
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`);
store.setStoryPanelOpen(false);
this.runPublishTriggers();
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed');
}
}
cancelQueuedAction(index: number): void {
const state = this.state;
if (!state) return;
try {
cancelQueuedAction(state, index);
useGameStore.getState().appendLog('Removed queued action.');
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Cancel failed');
}
}
openStoryPanel(): void {
useGameStore.getState().setStoryPanelOpen(true);
useGameStore.getState().setStoryHasUnread(false);
}
closeStoryPanel(): void {
useGameStore.getState().setStoryPanelOpen(false);
}
continueStory(): void {
// passage-only node dismiss
this.closeStoryPanel();
}
```
Update `enqueueAction` to use `isActionAvailable` with story flags and log disabled reason:
```typescript
if (!isActionAvailable(state, content, actionId)) {
const reason = /* from view model helper or inline */;
useGameStore.getState().appendLog(reason ?? 'Cannot enqueue');
return;
}
```
- [ ] **Step 4: Run typecheck and tests**
Run: `pnpm typecheck && pnpm test`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/state/runtime.ts
git commit -m "feat(state): wire story triggers and choices through runtime"
```
---
### Task 14: ActionPanel — queue, cancel, disabled, hints
**Files:**
- Modify: `src/ui/ActionPanel.tsx`
- [ ] **Step 1: Rewrite ActionPanel to consume view.actions and view queue**
```tsx
import { content } from '../content';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
export function ActionPanel() {
const actions = useGameStore((s) => s.actions);
const activeActionId = useGameStore((s) => s.activeActionId);
const actionProgress = useGameStore((s) => s.actionProgress);
const queuedActionIds = useGameStore((s) => s.queuedActionIds);
const queuedActionNames = useGameStore((s) => s.queuedActionNames);
const prefs = useGameStore((s) => s.prefs);
return (
<section aria-label="Actions" className="flex flex-col gap-2">
<h2 className="font-medium text-slate-300 text-sm uppercase tracking-wide">Actions</h2>
{actions.map((action) => {
const isActive = action.id === activeActionId;
return (
<div key={action.id} className="relative">
<button
type="button"
disabled={!action.available && !isActive}
title={prefs.actionDetailMode === 'hover' ? action.storyTooltip : undefined}
onClick={() => gameRuntime.enqueueAction(action.id)}
className={/* disabled: opacity-50; existing progress bar styles */}
>
{/* name, progress, costs/yields summary */}
{prefs.actionDetailMode === 'inline' && action.storyHint ? (
<p className="text-slate-400 text-xs">{action.storyHint}</p>
) : null}
</button>
{prefs.actionDetailMode === 'info-button' && action.storyTooltip ? (
<button type="button" aria-label="Action details" /* popover */></button>
) : null}
</div>
);
})}
{queuedActionNames.length > 0 ? (
<ol aria-label="Action queue" className="mt-2 flex flex-col gap-1">
{queuedActionNames.map((name, index) => (
<li key={queuedActionIds[index]} className="flex items-center justify-between rounded border border-slate-700 px-3 py-2 text-sm">
<span>{name}</span>
<button type="button" aria-label={`Cancel ${name}`} onClick={() => gameRuntime.cancelQueuedAction(index)}></button>
</li>
))}
</ol>
) : null}
</section>
);
}
```
- [ ] **Step 2: Manual smoke**
Run: `pnpm dev` — verify queue list, cancel, disabled styling, inline hints.
- [ ] **Step 3: Commit**
```bash
git add src/ui/ActionPanel.tsx
git commit -m "feat(ui): action queue list, cancel, disabled states, and hints"
```
---
### Task 15: StoryPanel overlay
**Files:**
- Create: `src/ui/StoryPanel.tsx`
- [ ] **Step 1: Implement StoryPanel**
```tsx
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
export function StoryPanel() {
const open = useGameStore((s) => s.storyPanelOpen);
const story = useGameStore((s) => s.story);
const storyLog = useGameStore((s) => s.storyLog);
if (!open) return null;
const hasChoices = story.choices.length > 0;
return (
<div className="fixed inset-0 z-50 flex bg-black/80" role="dialog" aria-modal="true" aria-label="Story">
<div className="flex flex-1 flex-col md:flex-row">
<aside className="hidden max-h-full w-full overflow-y-auto border-slate-700 border-r p-4 md:block md:w-1/3">
<h3 className="mb-2 font-medium text-slate-400 text-xs uppercase">Story log</h3>
<ul className="flex flex-col gap-2 text-slate-300 text-sm">
{storyLog.map((entry, i) => (
<li key={`${entry.nodeId}-${i}`}>
{entry.choiceLabel ? `[${entry.choiceLabel}] ` : null}
{entry.prose}
</li>
))}
</ul>
</aside>
<main className="flex flex-1 flex-col gap-4 p-6">
<p className="text-lg text-slate-100 leading-relaxed">{story.currentProse}</p>
{hasChoices ? (
<div className="flex flex-col gap-2">
{story.choices.map((choice) => (
<button
key={choice.id}
type="button"
disabled={choice.disabled}
onClick={() => gameRuntime.applyStoryChoice(choice.id)}
className="rounded-lg border border-slate-600 px-4 py-3 text-left hover:border-amber-500/60 disabled:opacity-40"
>
{choice.label}
</button>
))}
</div>
) : (
<button type="button" onClick={() => gameRuntime.continueStory()} className="self-start rounded-lg bg-amber-600 px-4 py-2">
Continue
</button>
)}
<button type="button" onClick={() => gameRuntime.closeStoryPanel()} className="self-end text-slate-400 text-sm">
Close
</button>
</main>
</div>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/ui/StoryPanel.tsx
git commit -m "feat(ui): full-screen story panel with VN layout and story log"
```
---
### Task 16: SettingsDrawer and App header
**Files:**
- Create: `src/ui/SettingsDrawer.tsx`
- Modify: `src/ui/App.tsx`
- [ ] **Step 1: Implement SettingsDrawer**
```tsx
import { useGameStore } from '../state/store';
import type { ActionDetailMode, StoryOpenMode } from '../state/prefs';
export function SettingsDrawer() {
const open = useGameStore((s) => s.settingsOpen); // add settingsOpen to store OR use local useState in App
const prefs = useGameStore((s) => s.prefs);
const setPrefs = useGameStore((s) => s.setPrefs);
if (!open) return null;
return (
<div className="rounded-lg border border-slate-700 bg-slate-900 p-4">
<label className="flex flex-col gap-1 text-sm">
Story opens
<select value={prefs.storyOpenMode} onChange={(e) => setPrefs({ storyOpenMode: e.target.value as StoryOpenMode })}>
<option value="auto">Automatically</option>
<option value="choices-only">Choices only</option>
<option value="manual">Manually</option>
</select>
</label>
<label className="mt-3 flex flex-col gap-1 text-sm">
Action details
<select value={prefs.actionDetailMode} onChange={(e) => setPrefs({ actionDetailMode: e.target.value as ActionDetailMode })}>
<option value="inline">Inline subtitles</option>
<option value="hover">Hover tooltips</option>
<option value="info-button">Info button</option>
</select>
</label>
</div>
);
}
```
- [ ] **Step 2: Update App.tsx header**
```tsx
import { StoryPanel } from './StoryPanel';
import { SettingsDrawer } from './SettingsDrawer';
// header: title, Story button (shows badge if storyHasUnread), gear toggle
// subtitle: "M1 playable loop — PR2"
<StoryPanel />
```
Wire Story button: `onClick={() => gameRuntime.openStoryPanel()}` with unread dot when `storyHasUnread`.
- [ ] **Step 3: Commit**
```bash
git add src/ui/SettingsDrawer.tsx src/ui/App.tsx src/state/store.ts
git commit -m "feat(ui): settings drawer and story button in app shell"
```
---
### Task 17: Stub story graph and content pack
**Files:**
- Modify: `src/content/story.ts`
- Modify: `src/content/definitions.ts`
- Create: `src/content/__tests__/story.test.ts`
- [ ] **Step 1: Write integration test for stub branch divergence**
```typescript
import { describe, expect, it } from 'vitest';
import { content } from '../index';
import { createGameState, isActionAvailable } from '../../engine/game';
import { applyChoice, enterStoryNode, evaluateTriggers, initStory } from '../../engine/story';
describe('stub story graph', () => {
it('route A unlocks fortify_camp but not push_onward', () => {
const state = createGameState(content);
initStory(state, content);
evaluateTriggers(state, content, { reason: 'boot' });
enterStoryNode(state, content, 'fork_choice');
applyChoice(state, content, 'pick_a');
expect(state.storyFlags.route_a).toBe(true);
expect(isActionAvailable(state, content, 'fortify_camp')).toBe(true);
expect(isActionAvailable(state, content, 'push_onward')).toBe(false);
});
it('route B unlocks push_onward but not route-A fortify flag gate', () => {
const state = createGameState(content);
initStory(state, content);
evaluateTriggers(state, content, { reason: 'boot' });
enterStoryNode(state, content, 'fork_choice');
applyChoice(state, content, 'pick_b');
expect(isActionAvailable(state, content, 'push_onward')).toBe(true);
});
});
```
- [ ] **Step 2: Replace story.ts with full stub graph**
```typescript
export const storyNodeDefs = [
{
id: 'boot_intro',
prose: '[Stub] You wake at a crossroads camp. Smoke rises from a cold fire pit.',
triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }],
},
{
id: 'fork_choice',
prose: '[Stub] Tracks split. The high road climbs; the low road bends toward the river.',
choices: [
{
id: 'pick_a',
label: 'Take the high road',
outcomes: [
{ type: 'setFlag', flag: 'route_a' },
{ type: 'grantResource', resourceId: 'supplies', amount: 3 },
],
targetNodeId: 'route_a_beat',
},
{
id: 'pick_b',
label: 'Follow the river',
outcomes: [
{ type: 'setFlag', flag: 'route_b' },
{ type: 'grantResource', resourceId: 'coin', amount: 2 },
],
targetNodeId: 'route_b_beat',
},
],
},
{ id: 'route_a_beat', prose: '[Stub] Route A: high ground, extra supplies.' },
{ id: 'route_b_beat', prose: '[Stub] Route B: river trade, extra coin.' },
{
id: 'threshold_listener',
prose: '',
triggers: [{ type: 'minResources', minResources: { coin: 3 }, targetNodeId: 'merchant_flavor' }],
},
{ id: 'merchant_flavor', prose: '[Stub] A merchant remembers your face.' },
{
id: 'scout_listener',
prose: '',
triggers: [{ type: 'actionComplete', actionId: 'scout_path', targetNodeId: 'scout_aftermath' }],
},
{ id: 'scout_aftermath', prose: '[Stub] The path is mapped.' },
];
```
Boot flow: after boot_intro fires, runtime needs to advance player to fork — add enterOutcomes or second boot step. **Fix:** boot trigger targets `boot_intro`; on Continue player stays until we add auto-chain OR boot_intro has no choices and `continueStory` calls `enterStoryNode(state, content, 'fork_choice')` from runtime. Simpler: boot trigger targets `fork_choice` directly OR boot_intro choices empty with `enterOutcomes` that don't auto-advance — **simplest stub fix:** set boot `targetNodeId: 'fork_choice'` and move intro prose to fork's enterOutcomes log, OR chain: boot_intro prose only, player clicks Continue → runtime advances to fork_choice via `continueStory` calling `enterStoryNode(state, content, 'fork_choice')`.
Plan explicit behavior in `continueStory`:
```typescript
continueStory(): void {
const state = this.state;
if (!state) return;
if (state.currentStoryNodeId === 'boot_intro') {
enterStoryNode(state, content, 'fork_choice');
// apply UI effect...
}
this.closeStoryPanel();
this.publish();
}
```
- [ ] **Step 3: Update definitions.ts**
Add `push_onward`:
```typescript
{
id: 'push_onward',
name: 'Push onward',
durationMs: 6000,
costs: [{ resourceId: 'supplies', amount: 2 }],
yields: [{ resourceId: 'coin', amount: 3 }],
unlock: { requireStoryFlags: ['route_b'] },
storyHint: 'Follow the river route.',
storyTooltip: 'Costs 2 Supplies. Yields 3 Coin. Route B only.',
},
```
Update `fortify_camp`:
```typescript
unlock: { minResources: { supplies: 8 }, requireStoryFlags: ['route_a'] },
storyHint: 'Walls for the high road camp.',
storyTooltip: 'Route A only. Costs supplies and coin.',
```
Add hints to other actions similarly.
- [ ] **Step 4: Run tests**
Run: `pnpm test src/content/__tests__/story.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/story.ts src/content/definitions.ts src/content/__tests__/story.test.ts src/state/runtime.ts
git commit -m "feat(content): stub story graph with A/B branch and route-gated actions"
```
---
### Task 18: Architecture doc and verification gate
**Files:**
- Modify: `docs/architecture.md`
- [ ] **Step 1: Update architecture.md**
Add bullets:
```markdown
- `story.ts`: story graph traversal, triggers (boot, actionComplete, minResources), choices, outcomes.
- `storyOrchestration.ts`: evaluates triggers after boot/publish/action completion; maps prefs to panel auto-open.
- Player prefs (`prefs.ts`) in localStorage — not in save v1.
```
- [ ] **Step 2: Run full pre-PR chain**
```powershell
pnpm typecheck
pnpm lint
pnpm test:coverage
pnpm build
```
Expected: all green; `src/engine/` coverage ≥ 80%.
- [ ] **Step 3: Manual playtest checklist**
1. `pnpm dev` — boot story auto-opens (default pref).
2. Continue from boot → fork → pick A vs B → different resources and unlocked actions.
3. Queue 3 actions → cancel middle → order respected.
4. Scout path completes → scout aftermath story fires.
5. Earn 3 coin → merchant flavor fires.
6. Settings → manual story mode → new beats badge only.
7. Reload → story flags, node, queue preserved.
- [ ] **Step 4: Commit**
```bash
git add docs/architecture.md
git commit -m "docs: document story engine and PR2 playable loop"
```
---
### Task 19: Open PR
**Files:** none (git + Gitea)
- [ ] **Step 1: Push branch**
```bash
git checkout -b feat/m1-playable-loop
git push -u origin feat/m1-playable-loop
```
- [ ] **Step 2: Create PR**
Title: `feat(m1): playable loop — story graph, story panel, queue UI`
Body:
```markdown
## Summary
- Story graph engine with hybrid triggers and A/B branch (#6)
- Full-window StoryPanel with VN layout and story log
- ActionPanel queue list, cancel, disabled states, hints/tooltips (#5)
- Player prefs: story-open mode and action-detail mode
- Stub story graph proving route-exclusive actions
## Test plan
- [x] `pnpm typecheck && pnpm lint && pnpm test:coverage && pnpm build`
- [x] Route A vs B → different flags, resources, unlocked actions
- [x] Queue cancel works
- [x] Reload preserves story + queue
- [x] Engine coverage ≥ 80%
Closes #5
Closes #6
```
- [ ] **Step 3: Verify CI green on PR branch**
Expected: Gitea Actions `CI / verify` passes.
---
## Self-review
**Spec coverage:**
| Requirement | Task |
|---|---|
| Hybrid triggers (boot, actionComplete, minResources) | Tasks 6, 12, 13 |
| Threshold on publish + actionComplete | Tasks 6, 13 |
| Story schema + validation | Task 3 |
| GameState story fields | Task 1 |
| applyChoice + gated choices | Task 7 |
| Save v1 story persistence | Task 8 |
| Action hints/tooltips | Tasks 4, 14, 17 |
| Full-window StoryPanel + log | Task 15 |
| Queue UI cancel/disabled | Task 14 |
| Player prefs + Settings | Tasks 9, 16 |
| Stub A/B branch + route actions | Task 17 |
| storyFlags wired to unlocks | Tasks 1, 10, 17 |
| Architecture docs | Task 18 |
| Closes #5, #6 | Task 19 |
**Placeholder scan:** none.
**Type consistency:** `GameContent` used in engine story module and view model; `TickResult.completedActionIds` consumed in runtime; store `GameView` extended consistently in Task 1011.
---
## Execution handoff
Plan complete and saved to `docs/superpowers/plans/2026-06-11-m1-pr2-playable-loop.md`. Two execution options:
**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration
**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach, senpai?