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/.
342 lines
12 KiB
Markdown
342 lines
12 KiB
Markdown
# 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.1–T2.2 Story engine + schema | Codex |
|
||
| T2.3–T2.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.*
|