docs(m1): add PR3 shell UI spec and implementation plans
CI / verify (push) Successful in 1m31s
CI / verify (pull_request) Successful in 1m2s

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/.
This commit is contained in:
ginnoir
2026-06-11 20:01:30 -05:00
parent 57f037b62b
commit 203478268c
8 changed files with 6399 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,694 @@
# M1 PR3 T3.1 — Universal Automation & Recipes 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:** Add universal automation — any action kind becomes automatable after first manual completion — with a separate automation queue, runner precedence (manual > automation > loop), and shareable text recipe export/import referencing content action ids.
**Architecture:** Pure engine modules `automation.ts` and `recipe.ts` own queue execution and text serialization; `manualCompletionCounts` gates unlock; runtime exposes automation UI commands; save v1 schema extended with defaults.
**Tech Stack:** TypeScript strict, Vitest, Zod 4, lz-string (optional compressed recipes), Biome, pnpm, React 19, Zustand.
**Parent spec:** `docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md` §Automation
**Prerequisite plan:** `docs/superpowers/plans/2026-06-11-m1-pr3-t30-shell-ui.md` (T3.0 shell must merge first)
**Branch:** `feat/m1-progression` (continues after T3.0)
**Closes:** Gitea #7 (Automation unlock)
---
## File map
| File | Responsibility |
|---|---|
| `src/engine/game.ts` | `manualCompletionCounts`; hook completion recording in tick/performAction |
| `src/engine/automation.ts` | **Create** — automation queue CRUD, runner, unlock checks |
| `src/engine/recipe.ts` | **Create** — multi-line + single-line parse/serialize/validate |
| `src/engine/__tests__/automation.test.ts` | **Create** — queue, unlock, precedence tests |
| `src/engine/__tests__/recipe.test.ts` | **Create** — round-trip, reject unknown/locked ids |
| `src/engine/save.ts` | Persist `manualCompletionCounts`, `automationQueue` |
| `src/state/viewModel.ts` | `automationUnlocked`, `automationQueueNames` on ActionView |
| `src/state/runtime.ts` | `addToAutomation`, `removeFromAutomation`, `importRecipe`, `exportRecipe` |
| `src/ui/AutomationBar.tsx` | **Create** — queue list, export/import textarea |
| `src/ui/PlayPanel.tsx` | Mount AutomationBar below columns |
| `src/ui/ActionCard.tsx` | Auto toggle when unlocked |
---
### Task 1: manualCompletionCounts + record on completion
**Files:**
- Modify: `src/engine/game.ts`
- Modify: `src/engine/save.ts`
- Modify: `src/engine/__tests__/game.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
describe('manualCompletionCounts', () => {
it('increments when a timed action completes', () => {
const content = testContentWithGroup(); // timed action with group/kind
const state = createGameState(content);
enqueueAction(state, content, 'gather_supplies');
tickGame(state, content, 3000);
expect(state.manualCompletionCounts.gather_supplies).toBe(1);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test src/engine/__tests__/game.test.ts -t "manualCompletionCounts"`
Expected: FAIL — property undefined.
- [ ] **Step 3: Implement recording**
Add to `GameState`:
```typescript
manualCompletionCounts: Record<string, number>;
```
Default `{}` in `createGameState`.
Add helper:
```typescript
export function recordManualCompletion(state: GameState, content: Content, actionId: string): void {
if (!content.actionsById[actionId]) return;
state.manualCompletionCounts[actionId] = (state.manualCompletionCounts[actionId] ?? 0) + 1;
}
```
Call from:
- `tickGame` when pushing to `completedActionIds`
- `executeInstant` after yields
- `executeStoryAction` after `applyChoice`
Update `save.ts`:
```typescript
manualCompletionCounts: z.record(z.string(), z.number()).default({}),
automationQueue: z.array(z.string()).default([]),
```
- [ ] **Step 4: Run tests**
Run: `pnpm test src/engine/__tests__/game.test.ts src/engine/__tests__/save.test.ts`
- [ ] **Step 5: Commit**
```bash
git add src/engine/game.ts src/engine/save.ts src/engine/__tests__/game.test.ts src/engine/__tests__/save.test.ts
git commit -m "feat(engine): track manualCompletionCounts on action complete"
```
---
### Task 2: Automation unlock check
**Files:**
- Create: `src/engine/automation.ts`
- Create: `src/engine/__tests__/automation.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState } from '../game';
import { isAutomationUnlocked, automationUnlockThreshold } from '../automation';
describe('isAutomationUnlocked()', () => {
const content = buildContent({
resources: [{ id: 'supplies', name: 'Supplies' }],
actions: [
{
id: 'gather_supplies',
name: 'Gather',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000,
yields: [{ resourceId: 'supplies', amount: 1 }],
automation: { unlockAfterManualCompletions: 1 },
},
],
});
it('is false before first manual completion', () => {
const state = createGameState(content);
expect(isAutomationUnlocked(state, content, 'gather_supplies')).toBe(false);
});
it('is true after threshold met', () => {
const state = createGameState(content);
state.manualCompletionCounts.gather_supplies = 1;
expect(isAutomationUnlocked(state, content, 'gather_supplies')).toBe(true);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test src/engine/__tests__/automation.test.ts`
Expected: FAIL — module not found.
- [ ] **Step 3: Implement unlock helpers**
Create `src/engine/automation.ts`:
```typescript
import type { Content } from '../content/schema';
import type { GameState } from './game';
export function automationUnlockThreshold(content: Content, actionId: string): number {
return content.actionsById[actionId]?.automation?.unlockAfterManualCompletions ?? 1;
}
export function isAutomationUnlocked(state: GameState, content: Content, actionId: string): boolean {
const threshold = automationUnlockThreshold(content, actionId);
return (state.manualCompletionCounts[actionId] ?? 0) >= threshold;
}
export function addToAutomationQueue(state: GameState, content: Content, actionId: string): void {
if (!content.actionsById[actionId]) {
throw new Error(`Unknown action "${actionId}"`);
}
if (!isAutomationUnlocked(state, content, actionId)) {
throw new Error(`Action "${actionId}" is not automation-unlocked`);
}
if (!state.automationQueue.includes(actionId)) {
state.automationQueue.push(actionId);
}
}
export function removeFromAutomationQueue(state: GameState, index: number): void {
if (index < 0 || index >= state.automationQueue.length) {
throw new RangeError(`Automation queue index ${index} is out of range`);
}
state.automationQueue.splice(index, 1);
}
export function clearAutomationQueue(state: GameState): void {
state.automationQueue.length = 0;
}
```
Add `automationQueue: string[]` to `GameState` (default `[]`).
- [ ] **Step 4: Run tests**
Run: `pnpm test src/engine/__tests__/automation.test.ts`
- [ ] **Step 5: Commit**
```bash
git add src/engine/automation.ts src/engine/game.ts src/engine/__tests__/automation.test.ts
git commit -m "feat(engine): automation unlock and queue CRUD"
```
---
### Task 3: Automation runner + precedence
**Files:**
- Modify: `src/engine/automation.ts`
- Modify: `src/engine/game.ts`
- Modify: `src/engine/__tests__/automation.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
describe('maybeRunAutomation()', () => {
it('starts first affordable automation action when manual queue idle', () => {
const state = createGameState(content);
state.manualCompletionCounts.gather_supplies = 1;
state.automationQueue = ['gather_supplies'];
maybeRunAutomation(state, content);
expect(state.activeActionId).toBe('gather_supplies');
});
it('does not run when manual queue has items', () => {
const state = createGameState(content);
state.actionQueue = ['gather_supplies'];
state.automationQueue = ['gather_supplies'];
state.manualCompletionCounts.gather_supplies = 1;
maybeRunAutomation(state, content);
expect(state.activeActionId).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test src/engine/__tests__/automation.test.ts -t "maybeRunAutomation"`
- [ ] **Step 3: Implement maybeRunAutomation**
```typescript
import { isActionAvailable, type GameState } from './game';
export function maybeRunAutomation(state: GameState, content: Content): void {
if (state.activeActionId !== null || state.actionQueue.length > 0) return;
for (const actionId of state.automationQueue) {
if (!isAutomationUnlocked(state, content, actionId)) continue;
if (!isActionAvailable(state, content, actionId)) continue;
const action = content.actionsById[actionId];
if (!action) continue;
switch (action.kind) {
case 'instant':
// execute inline without queue
break;
case 'timed':
case 'loop':
beginAction(state, content, actionId); // export beginAction or duplicate
return;
case 'story':
executeStoryAction(state, content, actionId);
return;
case 'context':
continue;
default:
continue;
}
}
}
```
**Precedence wiring in `game.ts` `startNextFromQueue`:**
After manual queue exhausts and sets idle:
```typescript
maybeRunAutomation(state, content);
maybeStartLoopAction(state, content);
```
Order: **manual timed completes → automation → loop**.
Refactor `beginAction` to export if needed (or add `startActionById` internal export).
For automation instant actions: execute inline in runner, re-advance automation index.
- [ ] **Step 4: Run automation + game tests**
Run: `pnpm test src/engine`
- [ ] **Step 5: Commit**
```bash
git add src/engine/automation.ts src/engine/game.ts src/engine/__tests__/automation.test.ts
git commit -m "feat(engine): automation runner with manual-first precedence"
```
---
### Task 4: Recipe export/import
**Files:**
- Create: `src/engine/recipe.ts`
- Create: `src/engine/__tests__/recipe.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState } from '../game';
import { exportRecipe, importRecipe, RECIPE_HEADER_V1 } from '../recipe';
describe('recipe export/import', () => {
const content = buildContent({
resources: [{ id: 'supplies', name: 'Supplies' }],
actions: [
{
id: 'gather_supplies',
name: 'Gather',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
{
id: 'rest',
name: 'Rest',
kind: 'loop',
group: { id: 'camp_loop', label: 'Camp' },
durationMs: 1000,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
],
});
it('round-trips multi-line format', () => {
const state = createGameState(content);
state.manualCompletionCounts = { gather_supplies: 1, rest: 1 };
state.automationQueue = ['gather_supplies', 'rest'];
const text = exportRecipe(state, content, { name: 'Camp loop' });
expect(text).toContain(RECIPE_HEADER_V1);
expect(text).toContain('gather_supplies');
const fresh = createGameState(content);
fresh.manualCompletionCounts = { gather_supplies: 1, rest: 1 };
importRecipe(fresh, content, text);
expect(fresh.automationQueue).toEqual(['gather_supplies', 'rest']);
});
it('rejects unknown action ids', () => {
const state = createGameState(content);
expect(() =>
importRecipe(state, content, `${RECIPE_HEADER_V1}\nnot_real`),
).toThrow(/unknown/i);
});
it('rejects locked action ids', () => {
const state = createGameState(content);
const text = `${RECIPE_HEADER_V1}\ngather_supplies`;
expect(() => importRecipe(state, content, text)).toThrow(/locked/i);
});
it('parses single-line alias', () => {
const state = createGameState(content);
state.manualCompletionCounts.gather_supplies = 1;
importRecipe(state, content, `${RECIPE_HEADER_V1}:gather_supplies`);
expect(state.automationQueue).toEqual(['gather_supplies']);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test src/engine/__tests__/recipe.test.ts`
- [ ] **Step 3: Implement recipe.ts**
Create `src/engine/recipe.ts`:
```typescript
import type { Content } from '../content/schema';
import type { GameState } from './game';
import { clearAutomationQueue, isAutomationUnlocked, addToAutomationQueue } from './automation';
export const RECIPE_HEADER_V1 = 'idlegame-recipe/v1';
const ACTION_ID_RE = /^[a-z][a-z0-9_]*$/;
export interface RecipeMeta {
name?: string;
}
export function exportRecipe(state: GameState, content: Content, meta?: RecipeMeta): string {
const lines = [RECIPE_HEADER_V1];
if (meta?.name) lines.push(`# name: ${meta.name}`);
for (const id of state.automationQueue) {
if (content.actionsById[id]) lines.push(id);
}
return lines.join('\n');
}
export function parseRecipeLines(text: string): { name?: string; actionIds: string[] } {
const trimmed = text.trim();
if (trimmed.startsWith(`${RECIPE_HEADER_V1}:`)) {
const ids = trimmed.slice(RECIPE_HEADER_V1.length + 1).split(',').map((s) => s.trim()).filter(Boolean);
return { actionIds: ids };
}
const lines = trimmed.split(/\r?\n/);
if (lines[0]?.trim() !== RECIPE_HEADER_V1) {
throw new Error(`Invalid recipe header; expected "${RECIPE_HEADER_V1}"`);
}
let name: string | undefined;
const actionIds: string[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i]?.trim() ?? '';
if (!line) continue;
if (line.startsWith('# name:')) {
name = line.slice('# name:'.length).trim();
continue;
}
if (line.startsWith('#')) continue;
if (!ACTION_ID_RE.test(line)) {
throw new Error(`Invalid action id "${line}"`);
}
actionIds.push(line);
}
return { name, actionIds };
}
export function importRecipe(state: GameState, content: Content, text: string): { name?: string } {
const { name, actionIds } = parseRecipeLines(text);
const unknown = actionIds.filter((id) => !content.actionsById[id]);
if (unknown.length > 0) {
throw new Error(`Unknown action ids: ${unknown.join(', ')}`);
}
const locked = actionIds.filter((id) => !isAutomationUnlocked(state, content, id));
if (locked.length > 0) {
throw new Error(`Automation locked for: ${locked.join(', ')}`);
}
clearAutomationQueue(state);
for (const id of actionIds) {
addToAutomationQueue(state, content, id);
}
return { name };
}
```
- [ ] **Step 4: Run tests**
Run: `pnpm test src/engine/__tests__/recipe.test.ts`
- [ ] **Step 5: Commit**
```bash
git add src/engine/recipe.ts src/engine/__tests__/recipe.test.ts
git commit -m "feat(engine): automation recipe export and import"
```
---
### Task 5: View model automation fields
**Files:**
- Modify: `src/state/viewModel.ts`
- Modify: `src/state/__tests__/viewModel.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
it('marks automationUnlocked on actions after manual completion', () => {
state.manualCompletionCounts.gather_supplies = 1;
const view = toView(state, content);
const action = view.actionColumns
.flatMap((c) => c.groups)
.flatMap((g) => g.actions)
.find((a) => a.id === 'gather_supplies');
expect(action?.automationUnlocked).toBe(true);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test src/state/__tests__/viewModel.test.ts -t "automationUnlocked"`
- [ ] **Step 3: Extend ActionView**
```typescript
export interface ActionView {
// ...existing
automationUnlocked: boolean;
inAutomationQueue: boolean;
loopEnabled: boolean;
}
```
Map using `isAutomationUnlocked`, `state.automationQueue.includes(id)`, `state.enabledLoopActionIds[id]`.
Add to `GameView`:
```typescript
automationQueueIds: string[];
automationQueueNames: string[];
```
- [ ] **Step 4: Run tests**
Run: `pnpm test src/state/__tests__/viewModel.test.ts`
- [ ] **Step 5: Commit**
```bash
git add src/state/viewModel.ts src/state/__tests__/viewModel.test.ts
git commit -m "feat(state): automation fields on action view model"
```
---
### Task 6: Runtime automation commands
**Files:**
- Modify: `src/state/runtime.ts`
- [ ] **Step 1: Add runtime methods**
```typescript
toggleAutomation(actionId: string): void {
const state = this.state;
if (!state) return;
try {
if (state.automationQueue.includes(actionId)) {
const idx = state.automationQueue.indexOf(actionId);
removeFromAutomationQueue(state, idx);
} else {
addToAutomationQueue(state, content, actionId);
}
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Automation failed');
}
}
exportAutomationRecipe(): string {
const state = this.state;
if (!state) return '';
return exportRecipe(state, content);
}
importAutomationRecipe(text: string): void {
const state = this.state;
if (!state) return;
try {
const meta = importRecipe(state, content, text);
useGameStore.getState().appendLog(meta.name ? `Imported recipe: ${meta.name}` : 'Imported recipe.');
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Import failed');
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/state/runtime.ts
git commit -m "feat(state): runtime automation and recipe commands"
```
---
### Task 7: AutomationBar UI
**Files:**
- Create: `src/ui/AutomationBar.tsx`
- Modify: `src/ui/PlayPanel.tsx`
- Modify: `src/ui/ActionCard.tsx`
- [ ] **Step 1: Create AutomationBar**
```tsx
export function AutomationBar() {
const names = useGameStore((s) => s.automationQueueNames);
const [importText, setImportText] = useState('');
return (
<section aria-label="Automation" className="mt-4 rounded-lg border border-slate-700 p-3">
<h2 className="mb-2 font-medium text-slate-300 text-sm uppercase tracking-wide">Automation</h2>
{names.length > 0 ? (
<ol className="mb-2 text-slate-300 text-sm">
{names.map((name, i) => (
<li key={name}>{i + 1}. {name}</li>
))}
</ol>
) : (
<p className="mb-2 text-slate-500 text-sm">No automated processes.</p>
)}
<div className="flex gap-2">
<button type="button" onClick={() => navigator.clipboard.writeText(gameRuntime.exportAutomationRecipe())}>
Copy recipe
</button>
</div>
<textarea
className="mt-2 w-full rounded border border-slate-600 bg-slate-900 p-2 text-slate-200 text-xs"
rows={4}
placeholder="Paste recipe text…"
value={importText}
onChange={(e) => setImportText(e.target.value)}
/>
<button type="button" className="mt-1" onClick={() => { gameRuntime.importAutomationRecipe(importText); setImportText(''); }}>
Import recipe
</button>
</section>
);
}
```
- [ ] **Step 2: ActionCard auto toggle**
When `action.automationUnlocked`, show small "Auto" button calling `gameRuntime.toggleAutomation(action.id)`; highlight when `action.inAutomationQueue`.
- [ ] **Step 3: Mount in PlayPanel**
Add `<AutomationBar />` below column grid.
- [ ] **Step 4: Browser smoke**
1. Complete gather manually once → Auto button appears.
2. Add to automation → runs when idle.
3. Export recipe → paste in fresh session (with unlocks) → import restores queue.
- [ ] **Step 5: Commit**
```bash
git add src/ui/AutomationBar.tsx src/ui/PlayPanel.tsx src/ui/ActionCard.tsx
git commit -m "feat(ui): automation bar with recipe export/import"
```
---
### Task 8: Integration verify
- [ ] **Step 1: Run pre-PR chain**
```powershell
pnpm typecheck
pnpm lint
pnpm test:coverage
pnpm build
```
Expected: all green; engine ≥80%.
- [ ] **Step 2: Commit if doc touch needed**
Update `docs/architecture.md` §Automation with recipe format example.
```bash
git add docs/architecture.md
git commit -m "docs: document automation queue and recipe format"
```
---
## Spec coverage self-review
| Spec requirement | Task |
|---|---|
| Universal automation after first manual completion | Tasks 12 |
| Story actions automatable after first perform | Task 1 records story completions |
| Separate automation queue | Tasks 23 |
| Precedence manual > automation > loop | Task 3 |
| Multi-line recipe format | Task 4 |
| Single-line alias | Task 4 |
| Reject unknown/locked ids on import | Task 4 |
| Export/import UI | Task 7 |
| Offline automation | Task 3 runner uses same tickGame path |
| Compressed recipes | Optional — defer unless queue >20 steps |
## Placeholder scan
No TBD/TODO. All steps include paths and code.
@@ -0,0 +1,341 @@
# M1 PR2 — Playable Loop Design Spec
> **Status:** Approved by ginnoir (brainstorm 2026-06-11).
> **Branch:** `feat/m1-playable-loop` off `main`.
> **Closes:** Gitea #5 (Action queue UI), #6 (Story graph and first branch).
> **Parent plan:** `docs/plans/2026-06-11-m1-vertical-slice.md` §PR2.
## Summary
PR2 turns the PR1 queue engine into the first **playable loop**: a hybrid story graph (choices, action-complete beats, boot, resource thresholds), a full-window visual-novel story panel with running story log, upgraded action queue UI with narrative context (subtitles/tooltips), and player preferences for story-open and action-detail modes. Stub prose and placeholder names prove branch mechanics; real content replaces them in PR3.
## What we decided (brainstorm)
| Topic | Decision |
|---|---|
| Story pacing | **Hybrid** — manual choices, action-complete beats, boot intro, resource thresholds |
| Threshold checks | On **action completion** and on **publish** (~10 fps) |
| Stub A/B branch | **Flags + resources + route-exclusive action unlocks** |
| Action context | `storyHint` subtitle + `storyTooltip` with mechanics and consequences |
| Story UI | **Full-window overlay** (VN layout + running story log), not inline strip |
| Story open behavior | **Player-configurable**, default **auto on new beats**; modes: auto / choices-only / manual |
| Action detail display | **Inline subtitle default**; hover on desktop; configurable (inline / hover / info-button) |
| PR2 scope | **Mechanics + minimal Settings drawer**; full mobile layout pass deferred to PR4 (#10) |
| Architecture | **Approach 1** — pure engine triggers + thin UI/prefs in state layer |
## Architecture
```
src/engine/story.ts traverse, applyChoice, evaluateTriggers, outcomes
src/engine/game.ts + storyFlags, currentStoryNodeId, seenStoryNodeIds
src/engine/save.ts persist story fields in SAVE_VERSION=1 (.default())
src/content/storySchema.ts Zod story node / choice / trigger / outcome defs
src/content/story.ts stub ~6-node graph
src/content/schema.ts + action.storyHint, action.storyTooltip
src/state/storyOrchestration.ts evaluateTriggers after boot, publish, actionComplete
src/state/prefs.ts localStorage: storyOpenMode, actionDetailMode
src/state/viewModel.ts + story view, action availability, hints
src/state/runtime.ts wire orchestration, applyChoice, cancelQueue
src/ui/StoryPanel.tsx full-screen overlay, VN passage, choices, story log
src/ui/ActionPanel.tsx queue list, cancel, disabled, subtitles/tooltips
src/ui/SettingsDrawer.tsx two preference selects
```
**Boundaries (unchanged from M0):**
- `src/engine/` — pure TS; no React, DOM, localStorage, wall clock.
- `src/state/` — orchestration, prefs, RAF, persistence.
- `src/ui/` — renders view model; calls runtime commands only.
## Story schema
### StoryNode
```typescript
StoryNode {
id: string
prose: string
choices?: StoryChoice[]
triggers?: StoryTrigger[]
enterOutcomes?: StoryOutcome[]
}
```
### StoryChoice
```typescript
StoryChoice {
id: string
label: string
requirements?: {
minResources?: Record<string, number>
requireStoryFlags?: string[]
excludeStoryFlags?: string[]
}
outcomes: StoryOutcome[]
targetNodeId: string
}
```
### StoryTrigger
```typescript
StoryTrigger {
type: 'boot' | 'actionComplete' | 'minResources'
actionId?: string
minResources?: Record<string, number>
targetNodeId: string
once?: boolean // default true
}
```
### StoryOutcome
| Type | Effect |
|---|---|
| `setFlag` | `storyFlags[id] = true` |
| `clearFlag` | `storyFlags[id] = false` |
| `grantResource` | increase resource |
| `consumeResource` | decrease resource (validated) |
| `log` | narrative line appended to story log (no state change) |
Outcomes on a choice or `enterOutcomes` apply atomically before advancing.
### Action narrative fields
```typescript
action.storyHint?: string // one-line subtitle (inline mode)
action.storyTooltip?: string // longer detail: yields, costs, consequences
```
Tooltip copy may reference flags/choices textually (authoring); engine does not parse natural language.
### Content validation
At `buildContent` / story load:
- All `targetNodeId` references resolve.
- Trigger `actionId` values exist in `actionsById`.
- Resource ids in outcomes/triggers exist.
- Graph has exactly one boot trigger entry point.
- Invalid graphs throw at load (dev-time), matching action schema behavior.
## GameState extensions
```typescript
interface GameState {
// existing: resources, activeActionId, actionElapsedMs, actionQueue
storyFlags: Record<string, boolean>
currentStoryNodeId: string
seenStoryNodeIds: string[]
}
```
**Save v1** (no version bump; PR4 ships formal v1→v2 migration):
```typescript
storyFlags: z.record(z.string(), z.boolean()).default({})
currentStoryNodeId: z.string().default('') // hydrated to entry on first boot if empty
seenStoryNodeIds: z.array(z.string()).default([])
```
## Engine API (`src/engine/story.ts`)
| Function | Purpose |
|---|---|
| `initStory(state, content)` | Resolve entry node from boot trigger; set `currentStoryNodeId` |
| `evaluateTriggers(state, content, ctx)` | ctx: `{ reason: 'boot' \| 'publish' \| 'actionComplete', actionId? }`; returns `{ enteredNodes: string[], events: StoryEvent[] }` |
| `applyChoice(state, content, choiceId)` | Validate requirements, apply outcomes, advance, append to `seenStoryNodeIds` |
| `getAvailableChoices(state, content, nodeId)` | Filter choices by requirements |
| `getCurrentNode(state, content)` | Lookup helper |
**Trigger evaluation rules:**
1. **`boot`** — fires once on `initStory` if target not seen (when `once !== false`).
2. **`actionComplete`** — fires when `ctx.reason === 'actionComplete'` and `ctx.actionId` matches.
3. **`minResources`** — fires when all thresholds met; checked on `publish` and after `actionComplete`.
4. A trigger does not re-fire if `targetNodeId` is in `seenStoryNodeIds` and `once !== false`.
5. Entering a node applies `enterOutcomes`, adds id to `seenStoryNodeIds`, emits events.
**Integration with actions:** `game.ts` `isActionAvailable` receives `state.storyFlags` (remove PR1 default `{}` placeholder at call sites).
## Stub story graph
~6 nodes, one A/B fork, placeholder prose:
```text
boot_intro
└─ fork_choice ─┬─ route_a_beat → unlocks fortify_camp (requireStoryFlags: route_a)
└─ route_b_beat → unlocks push_onward stub action (requireStoryFlags: route_b)
threshold_beat (minResources coin ≥ 3) → merchant_flavor
actionComplete(scout_path) → scout_aftermath
```
**Route outcomes:**
- Route A: `setFlag route_a`, grant supplies, unlock `fortify_camp`.
- Route B: `setFlag route_b`, grant coin, unlock new stub action `push_onward`.
Add `push_onward` to `definitions.ts` with `requireStoryFlags: ['route_b']`.
## Runtime orchestration
```text
boot → loadGame → initStory → evaluateTriggers(boot) → publish
each frame:
tickGame → [if action completed this tick] evaluateTriggers(actionComplete)
publish (≥10fps) → evaluateTriggers(publish)
if new node:
appendStoryLog
open StoryPanel per prefs.storyOpenMode
optionally mirror one line to event log
applyChoice → engine → appendStoryLog → publish → close/minimize panel
cancelQueuedAction → engine → publish
```
`src/state/storyOrchestration.ts` owns the trigger call sequence; runtime invokes it.
## Player preferences (`src/state/prefs.ts`)
Stored in `localStorage` (not save v1):
| Key | Values | Default |
|---|---|---|
| `storyOpenMode` | `auto` \| `choices-only` \| `manual` | `auto` |
| `actionDetailMode` | `inline` \| `hover` \| `info-button` | `inline` |
**SettingsDrawer** — gear icon in header; two `<select>` controls; changes apply immediately.
### Story open modes
| Mode | Behavior |
|---|---|
| `auto` | Open overlay on every new node |
| `choices-only` | Auto-open only when node has choices; else unread badge |
| `manual` | Never auto-open; **Story** button shows unread badge |
## UI
### App shell
Order unchanged: header (title + settings gear + story button) → ResourceBar → ActionPanel → EventLog.
### StoryPanel (overlay)
- `fixed inset-0 z-50`, dark backdrop.
- **Main:** current prose; choice buttons; "Continue" on passage-only nodes.
- **Story log:** collapsible sidebar (desktop) or bottom sheet (mobile) — append-only `{ nodeId, prose, choiceLabel? }`.
- Close/minimize respects open mode; choices require selection before dismiss when auto-opened for a choice node.
### ActionPanel
- Button per action with progress bar on active (existing).
- **Queue list:** ordered names, cancel (✕) per index.
- **Disabled** when locked or unaffordable; click logs reason (no enqueue).
- **Inline:** `storyHint` under action name.
- **Hover (desktop):** tooltip with `storyTooltip`.
- **Info-button:** ⓘ opens tooltip popover.
Availability derived from view model (`available`, `disabledReason`).
### View model additions
```typescript
actions: {
id, name, available, disabledReason,
storyHint?, storyTooltip?,
costsSummary?, yieldsSummary?
}[]
story: {
isOpen, hasUnread,
currentProse, choices: { id, label, disabled, disabledReason }[],
log: { nodeId, prose, choiceLabel? }[]
}
```
## Error handling
| Failure | Behavior |
|---|---|
| Invalid story content at load | Throw during `buildContent` (dev-time) |
| Unknown node/choice id in engine API | Throw (dev-time) |
| Choice requirements not met | `applyChoice` throws; UI disables button + shows reason |
| Unaffordable enqueue | Existing pattern — catch in runtime, log to event log |
| Missing prefs in localStorage | Use defaults |
| Save missing story fields | `.default()` on schema — backward compatible with PR1 saves |
## Testing
### Engine (required, ≥80% coverage on `src/engine/`)
- Linear node traversal and `seenStoryNodeIds`.
- Branch by choice; requirements gate choices.
- Each trigger type: boot, actionComplete, minResources.
- Threshold fires on resource change via outcomes.
- Outcomes: flags, grant/consume resources.
- `once: false` vs default once-only triggers.
- Invalid graph rejected at content load.
### Content schema
- Valid stub graph builds.
- Dangling `targetNodeId` throws.
- Unknown action id in trigger throws.
### State
- Orchestration fires boot trigger on load.
- `storyFlags` wired into action availability in view model.
- Prefs round-trip localStorage.
### UI (browser smoke — Antigravity or manual)
- Queue 2+ actions, cancel one, order respected.
- Route A vs B → different flags, resources, unlocked actions.
- Story panel opens per default auto mode.
- Settings change story-open mode behavior.
- Reload preserves story state + queue.
### Pre-PR verification chain
```powershell
pnpm typecheck
pnpm lint
pnpm test:coverage
pnpm build
```
## Out of scope (PR2)
- Real opening-arc prose (PR3 / Phase 0 gate).
- Automation unlock (PR3 #7).
- Prestige reset (PR3 #8).
- Save v2 migration and export/import UX (PR4 #9).
- Full mobile layout pass (PR4 #10) — basic overlay responsiveness only.
- Playwright E2E.
- Additional prefs beyond the two PR2 toggles.
## AI tool routing (from parent plan)
| Task | Tool |
|---|---|
| T2.1T2.2 Story engine + schema | Codex |
| T2.3T2.5 UI + stub graph + browser verify | Antigravity |
| Ginnoir review / tweaks | Cursor |
## Success criteria (PR2 merge)
1. Player can queue/cancel actions with visible queue and disabled states.
2. Full-window story panel shows stub VN passage and choices.
3. Completing route A vs B produces different flags, resources, and unlocked actions.
4. Action subtitles/tooltips show narrative + mechanical hints.
5. Settings drawer toggles story-open and action-detail modes.
6. Reload preserves story position, flags, and queue.
7. CI green; engine coverage ≥80%.
---
*Brainstorm approved 2026-06-11. Next step: invoke `writing-plans` for task-level implementation plan.*
@@ -0,0 +1,367 @@
# M1 PR3 — Shell UI & Action Column Design Spec
> **Status:** Approved by ginnoir (brainstorm 2026-06-11).
> **Branch:** `feat/m1-progression` off `main` (first chunk before automation/prestige/content).
> **Supersedes (partially):** PR2 story overlay UX in `docs/superpowers/specs/2026-06-11-m1-pr2-playable-loop-design.md` §Story UI.
> **Parent plan:** `docs/plans/2026-06-11-m1-vertical-slice.md` §PR3.
## Summary
PR3 opens with a **shell refactor** that fixes PR2 UX debt before progression/content land: replace the full-screen story overlay with a three-region layout (nav rail / center panel / right rail), reorganize Play into **behavior-type action columns** with collapsible theme groups, move **all story forks to actions** (Story tab is read-only prose + branching tree), and establish **universal automation** with **shareable text recipes** referencing content action ids.
## What we decided (brainstorm)
| Topic | Decision |
|---|---|
| Timing | **First chunk of PR3** — shell lands at start of `feat/m1-progression`, then T3.1 automation, T3.2 prestige, T3.3 content |
| Story choices | **Actions only** — Story tab has no choice buttons |
| Story tab | **Split pane:** branching tree (larger, ~60%) + long-form prose log (~40%); tree left, log right |
| App shell | **Left nav** (Play, Story, Settings, About) → **center** active panel → **right rail** on Play (resources + inventory + optional event log) |
| Action layout | **Columns by behavior kind**, collapsible **theme/purpose groups** inside each column |
| Column order | **Instant → Loop → Timed → Story → Context** (Timed centered as primary column) |
| Loop vs automation | **Loop** = idle filler while playing (queue empty, nothing timed active). **Automation** = hands-off repeat queue including offline |
| Automation scope | **Universal** — instant, loop, timed, story, context all automatable **after first manual completion** on that action |
| Story automation | Same rule — first time at a fork is conscious; after performing a branch once, that story action can be automated on repeat runs |
| Automation sharing | Recipes **export/import as text**; steps reference stable **content action ids** |
| Event log | **Optional** on Play right rail (collapsible + pref to hide; default visible) |
| Mobile | Column stacking + responsive Story split deferred to PR4 (#10) |
| Approach | **Shell first, typed columns second** within PR3 opener (T3.0) |
## Problem statement (PR2 debt)
1. **Readability** — full-screen story overlay (`bg-black/80`) ghosts underlying UI; hard to read.
2. **Redundant choice paths** — story panel choice buttons and Play actions both visible; unclear authority.
3. **Structural mismatch** — single centered column does not match intended Chronicle-style shell (nav / main / sidebar).
4. **Flat action list** — no distinction between instant, idle loop, timed queue, story decisions, or context switches.
## App shell
```
┌──────────┬─────────────────────────────┬──────────────┐
│ Nav rail │ Center (active panel) │ Right rail │
│ │ │ (Play only) │
│ ● Play │ Play → action columns │ Resources │
│ Story │ Story → tree │ prose log │ Inventory * │
│ Settings │ Event log † │
│ About │ Settings / About → content │ │
└──────────┴─────────────────────────────┴──────────────┘
* Inventory placeholder = resource list until item system exists
† Collapsible; pref to hide (default: visible)
```
### Nav rail
- Fixed left column; icons + labels on desktop, icons-only acceptable on narrow widths until PR4.
- **Story unread badge** when new beats arrive (replaces overlay auto-open as primary signal).
- `storyOpenMode` pref remapped: `auto` switches to Story nav or pulses badge instead of opening overlay.
### Center panel
Swaps content by active nav item. No full-screen modal for story.
### Right rail (Play only)
- **Resources** — always visible.
- **Inventory** — resource list placeholder in M1; item grid later.
- **Event log** — collapsible section; `showEventLog` pref (default `true`).
### Removed
- `StoryPanel` full-screen overlay with choice buttons.
- Header Story / Settings buttons replaced by nav rail (Settings may retain drawer or become full center panel — implementer picks cleaner fit).
## Play panel — action columns
### Column order (left → right)
| # | Column | `kind` | Role |
|---|---|---|---|
| 1 | Instant | `instant` | One-shot interactions: buy, sell, trade |
| 2 | Loop | `loop` | Idle upkeep between tasks (rest, passive recovery) |
| 3 | **Timed** | `timed` | Primary gameplay; manual queue + progress bars |
| 4 | Story | `story` | Narrative forks and hard decisions |
| 5 | Context | `context` | Area changes, combat entry, context switches |
Timed column is visually central. Narrow viewports: horizontal scroll with Timed near viewport center.
### Groups within columns
Each action belongs to a **group** (content-defined theme/purpose):
```typescript
group: {
id: string // e.g. 'camp', 'tavern', 'buy'
label: string // e.g. 'Camp activities', 'Buy'
}
```
- Groups render as **collapsible sections** inside their kind column.
- Examples:
- **Instant:** Buy, Sell, Trade
- **Loop:** Camp activities, Travel activities
- **Timed:** Library, Tavern, Camp (location-themed)
- **Story:** Arc-specific decision groups
- **Context:** Region / combat entry groups
- Collapse state stored in `GamePrefs.collapsedActionGroups: Record<string, boolean>`.
### Action card UX (unchanged from PR2 where applicable)
- `storyHint` inline subtitle (default) / hover / info-button per pref.
- `storyTooltip` for mechanics and fork consequences.
- **Story-kind actions** get distinct styling (amber fork badge, border) so diverging paths are obvious in Play.
## Action kinds — engine behavior
### `instant`
- Click → validate afford/unlock → apply costs/yields immediately.
- No queue slot, no duration.
### `timed`
- Current PR1/PR2 behavior: `durationMs`, manual `actionQueue`, progress bar.
- Primary manual play column.
### `loop`
- Runs only when **`activeActionId` is null** and **manual `actionQueue` is empty**.
- Player enables/disables per loop action (toggle); priority order when multiple enabled (content `loopPriority` number, lower first).
- On completion while still idle, immediately repeats same loop action.
- Respects costs; stops if unaffordable.
- **Does not run offline** — idle-present behavior only.
### `story`
- Represents one graph choice. Content field `storyChoiceId` links to `StoryChoice.id`.
- On execute → `applyChoice(state, content, storyChoiceId)` → sibling story actions hide via flags/requirements.
- First visit: only available choices shown; player picks consciously.
- After first manual completion of that action id: eligible for automation queue.
### `context`
- Switches active game context (location, combat scene, etc.).
- M1: stub until opening arc requires it; schema + column chrome ship in T3.0.
## Automation (T3.1 — universal)
### Philosophy
Once the player has **completed an action manually at least once**, they may add it to an **automation queue** that repeats hands-off, **including offline** (within existing offline cap).
Applies to **all kinds** including `story` (after that specific branch was taken once). New/unseen branches remain manual until first completion.
Prestige/knowledge catch-up (T3.2) treats previously cleared routes as already completed for automation unlock purposes — same knowledge model as story fast-forward.
### Queues
| Queue | Purpose |
|---|---|
| `actionQueue` | Manual timed queue (player actively planning) |
| `automationQueue` | Hands-off repeat pipeline |
Automation runner executes when manual queue is idle (exact precedence documented in engine; automation fills gaps like a background worker, distinct from loop-kind idle actions — both may need priority rules: **manual > automation > loop**).
### Automation recipe export/import
Recipes are **shareable text** referencing stable **content action ids** (not display names).
**Canonical multi-line format (v1):**
```text
idlegame-recipe/v1
# name: Early camp grind
gather_supplies
scout_path
rest_briefly
```
- Line 1: magic header with version (`idlegame-recipe/v1`).
- Optional `# name:` comment for human label (ignored by parser if malformed).
- One action id per non-empty, non-comment line.
- Lines must match `/^[a-z][a-z0-9_]*$/` (same id convention as content defs).
**Single-line alias (optional parser support):**
```text
idlegame-recipe/v1:gather_supplies,scout_path,rest_briefly
```
**Compressed variant (long recipes):** same payload JSON + `lz-string` URI encoding as saves (`toRecipeExportString` / `fromRecipeExportString`) — optional in T3.1 if recipes exceed ~20 steps; multi-line text remains the default share format.
**Import validation:**
1. Parse version; reject unknown versions explicitly.
2. Every id must exist in `content.actionsById`.
3. Every id must be **automation-unlocked** for the current player state (`manualCompletionCounts[id] >= threshold`).
4. On failure: user-visible error listing unknown or locked ids — no partial apply unless ginnoir opts in later.
**Export:** serializes current `automationQueue` (or named saved recipe slot if added later) to multi-line text; copy-to-clipboard in Settings or Play automation UI.
### State fields
```typescript
manualCompletionCounts: Record<string, number> // default {}; gates automation
automationQueue: string[] // action ids
enabledLoopActionIds: Record<string, boolean> // loop toggles
```
Persist in save payload (PR3 adds fields; PR4 may bump `SAVE_VERSION` to 2 with migration).
## Story tab
### Layout
Split pane, resizable on desktop:
```
┌──────────────────────────┬─────────────────────┐
│ Branching tree (~60%) │ Prose log (~40%) │
│ │ │
│ ○ boot_intro │ [Full passage text │
│ ├─● route_a │ for selected or │
│ │ └─ merchant │ latest node] │
│ └─○ route_b (dimmed) │ │
│ │ Scrollable history │
└──────────────────────────┴─────────────────────┘
```
- **Tree (primary surface):** built from story graph + `seenStoryNodeIds` / `storyFlags`. Taken paths emphasized; untaken branches dimmed. Click node → prose log scrolls to that beat. Larger pane because the tree needs manipulation room.
- **Prose log (secondary):** full passage text, not truncated. Choice labels inline: `[You chose: Take the high road]`.
- **No choice buttons.**
### Story map data
- Tree nodes mirror `storyNodesById` edges via choices + triggers.
- Visibility: seen nodes solid; future/locked dimmed or hidden per design.
- M1 T3.0 may ship minimal tree (indented list) before polish; structure must support real graph.
### Unread / navigation
- New story beats → Story nav badge.
- `storyOpenMode: auto` → switch to Story tab or highlight badge (no overlay).
## Content schema changes
```typescript
actionKindSchema = z.enum(['instant', 'loop', 'timed', 'story', 'context'])
actionGroupSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
})
actionDefSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
kind: actionKindSchema.default('timed'),
group: actionGroupSchema,
durationMs: z.number().positive().optional(), // required for timed + loop
loopPriority: z.number().int().nonnegative().optional(),
costs: ...,
yields: ...,
unlock: ...,
storyHint: ...,
storyTooltip: ...,
storyChoiceId: z.string().min(1).optional(), // required when kind === 'story'
contextId: z.string().min(1).optional(), // required when kind === 'context'
automation: z.object({
unlockAfterManualCompletions: z.number().int().positive().default(1),
}).optional(),
})
```
**Validation rules:**
- `timed` and `loop` require `durationMs`.
- `instant`, `story`, `context` must not require duration for execution (engine enforces).
- `story` requires valid `storyChoiceId` referencing a choice in the loaded story graph.
- Column placement derived from `kind` (not a separate column field).
### Stub content migration (T3.0)
| Current action | New kind | Group example |
|---|---|---|
| gather_supplies | timed | Camp |
| scout_path | timed | Travel |
| trade_at_camp | timed | Camp |
| fortify_camp | timed | Camp (route A) |
| push_onward | timed | Travel (route B) |
| rest_briefly | loop | Camp activities |
| *(new)* pick_high_road | story | Fork |
| *(new)* follow_river | story | Fork |
Remove parallel choice buttons from story graph UI; fork choices exist only as story-kind actions.
## Architecture
```
src/ui/AppShell.tsx nav rail + center + right rail layout
src/ui/NavRail.tsx
src/ui/PlayPanel.tsx action column grid
src/ui/ActionColumn.tsx one kind column
src/ui/ActionGroup.tsx collapsible group
src/ui/StoryView.tsx split tree + prose log (replaces overlay StoryPanel)
src/ui/StoryTree.tsx
src/ui/StoryProseLog.tsx
src/ui/RightRail.tsx resources, inventory placeholder, event log
src/ui/AutomationBar.tsx automation queue UI + export/import (T3.1)
src/content/schema.ts + kind, group, storyChoiceId, contextId, automation
src/engine/game.ts instant execute, loop idle runner, completion counts
src/engine/automation.ts automation queue runner, recipe parse/serialize (new)
src/engine/recipe.ts recipe export/import validation (new)
src/state/viewModel.ts column/group projection, automation eligibility
src/state/runtime.ts nav commands, remove overlay; story action → applyChoice
src/state/prefs.ts + collapsedActionGroups, showEventLog
```
**Boundaries unchanged:** engine pure TS; UI calls runtime only.
## PR3 task order (revised opener)
| Task | Deliverable | Gitea |
|---|---|---|
| **T3.0** | App shell, action columns, story-via-actions, Story tab split, remove overlay | UI gate |
| **T3.1** | Universal automation + automation queue + recipe export/import | #7 |
| **T3.2** | Prestige + knowledge catch-up for automation/story | #8 |
| **T3.3** | Opening arc content on new shell | #11 |
## Testing
### Engine
- Instant action applies costs/yields without queue.
- Loop runs when manual queue idle; stops when timed work queued.
- Priority: manual timed > automation > loop (document exact rules in tests).
- Story action executes `applyChoice`; siblings become unavailable.
- `manualCompletionCounts` gates automation; story actions unlock after first perform.
- Recipe round-trip: export → import → identical queue; reject unknown ids; reject locked ids.
### UI / view model
- Actions projected into correct column and group.
- Group collapse prefs persist.
- Story tab: tree click selects prose; no choice buttons rendered.
- Nav badge on unread story.
### Regression
- Remove/update PR2 overlay tests and story choice button tests.
- Existing queue/tick/story graph tests adapted for action-kind story forks.
## Out of scope (T3.0)
- Polished graph visualization (minimal tree OK).
- Item inventory (resource list placeholder).
- Combat mechanics (context column stub).
- Mobile responsive column stack (PR4 #10).
- Save v2 migration (PR4 #9) — PR3 adds new state fields with defaults in v1 payload until bump.
## PR2 supersession note
PR2 spec locked full-window overlay with in-panel choices. This spec **intentionally replaces** that UX. Engine story graph (`applyChoice`, triggers, flags) remains; only presentation and choice surface move to Play actions + Story read-only tab.
## Open questions (none blocking)
All brainstorm decisions resolved 2026-06-11.