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

12 KiB
Raw Blame History

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

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 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

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:

  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:

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

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

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

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.