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/.
12 KiB
M1 PR2 — Playable Loop Design Spec
Status: Approved by ginnoir (brainstorm 2026-06-11).
Branch:feat/m1-playable-loopoffmain.
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
StoryNode {
id: string
prose: string
choices?: StoryChoice[]
triggers?: StoryTrigger[]
enterOutcomes?: StoryOutcome[]
}
StoryChoice
StoryChoice {
id: string
label: string
requirements?: {
minResources?: Record<string, number>
requireStoryFlags?: string[]
excludeStoryFlags?: string[]
}
outcomes: StoryOutcome[]
targetNodeId: string
}
StoryTrigger
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
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
targetNodeIdreferences resolve. - Trigger
actionIdvalues exist inactionsById. - 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
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):
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:
boot— fires once oninitStoryif target not seen (whenonce !== false).actionComplete— fires whenctx.reason === 'actionComplete'andctx.actionIdmatches.minResources— fires when all thresholds met; checked onpublishand afteractionComplete.- A trigger does not re-fire if
targetNodeIdis inseenStoryNodeIdsandonce !== false. - Entering a node applies
enterOutcomes, adds id toseenStoryNodeIds, 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:
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, unlockfortify_camp. - Route B:
setFlag route_b, grant coin, unlock new stub actionpush_onward.
Add push_onward to definitions.ts with requireStoryFlags: ['route_b'].
Runtime orchestration
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:
storyHintunder action name. - Hover (desktop): tooltip with
storyTooltip. - Info-button: ⓘ opens tooltip popover.
Availability derived from view model (available, disabledReason).
View model additions
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: falsevs default once-only triggers.- Invalid graph rejected at content load.
Content schema
- Valid stub graph builds.
- Dangling
targetNodeIdthrows. - Unknown action id in trigger throws.
State
- Orchestration fires boot trigger on load.
storyFlagswired 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
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)
- Player can queue/cancel actions with visible queue and disabled states.
- Full-window story panel shows stub VN passage and choices.
- Completing route A vs B produces different flags, resources, and unlocked actions.
- Action subtitles/tooltips show narrative + mechanical hints.
- Settings drawer toggles story-open and action-detail modes.
- Reload preserves story position, flags, and queue.
- CI green; engine coverage ≥80%.
Brainstorm approved 2026-06-11. Next step: invoke writing-plans for task-level implementation plan.