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/.
2082 lines
63 KiB
Markdown
2082 lines
63 KiB
Markdown
# 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 10–11.
|
||
|
||
---
|
||
|
||
## 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?
|