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

342 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M1 PR2 — Playable Loop 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.*