RewardBatch FSM

A RewardBatch is the unit of reward presentation in Starship Survivors. Every reward source (arcade run, mission collect, pull, journey chest) is funneled through a finalizer that builds a single batch, enqueues it, and then a phase runner drives that batch through a fixed state machine: idle → reveal → collect → await_surface → resolve → milestones.

The orchestrator never inspects the source of a batch — it reads PresentationHints and walks the FSM. This is the seam that lets pull, run, mission, and chest rewards share one presentation pipeline.

The batch

Defined in data/reward-types.ts. Every batch carries:

  • batchId — unique handle for dedupe + phase-run keying.
  • source — string tag ('pull', 'arcade_run', 'journey_chest', etc.). Used only to pick a custom presenter; the orchestrator never branches on it.
  • presentation: PresentationHints — how to present the batch. Three fields:
    • revealMode: 'default' | 'custom' | 'none'
    • collectMode: 'default' | 'custom' | 'none'
    • resolvePolicy: 'immediate' | 'defer_until_hub'
  • counters: CounterDelta[] — currency before/after pairs (credits, gems, pull tickets). Drive fly-to-target animations and counter settle.
  • progress: ProgressDelta[] — track snapshots (currently only 'journey'). Each delta carries before / after xp+level snapshots and optional thresholdsCrossed IDs that trigger pause-and-pop bar animations.
  • milestones: Milestone[] — chip labels with importance: 'minor' | 'major' that fire after resolve.

isNoOpBatch() short-circuits batches with zero counters, zero progress, and zero milestones — finalizers discard them silently before enqueue.

The phases

Defined as RewardPhase in data/reward-types.ts, run by components/RewardOrchestrator.tsx:

  • idle — no active batch. Orchestrator is quiet.
  • reveal — delegates to presenter.presentReveal(batch). Default presenter spawns cards, flips with burst, settles with punch (staggered 50ms per card). Pull presenter is a pass-through because the pull engine owns its own reveal flow.
  • collect — delegates to presenter.presentCollect(batch). Default presenter flies grant cards to wallet anchors and releases display overrides so counters settle to after-values. After collect resolves, the orchestrator releases each counter override via displayOverrideStore.releaseCounter().
  • await_surface — a holding phase. If the hub isn’t mounted when a batch needs resolve, the orchestrator calls deferCurrentResolve(), moves the batch to deferredResolves, and starts the next batch. Resumed later when the hub mounts.
  • resolve — bar fills. Handled by HomeResolveController, not the orchestrator. The controller watches for the resolve phase, animates progress[] bars, and calls advanceOrFinish() when done.
  • milestones — sequential holdAndSettle(150) → showChip(label, tone) → holdAndSettle(180) per milestone. Tone is 'premium' for major, 'info' for minor.

Transitions go through setPhase(), finishCurrent(), or deferCurrentResolve() on rewardQueueStore. The orchestrator’s runningPhaseRef keyed on ${batchId}:${phase} prevents double-execution if React re-runs the effect.

Presentation hints drive the orchestrator

The orchestrator never asks “is this a pull?” or “is this a run?” — it asks the batch’s PresentationHints:

  • revealMode: 'none' or collectMode: 'none' → the orchestrator skips that phase entirely.
  • revealMode: 'custom' or collectMode: 'custom'getPresenter() returns a source-specific presenter (currently only pullPresenter, which is a no-op pass-through because the pull engine handles its own animations).
  • resolvePolicy: 'defer_until_hub' → in advanceOrFinish(), if the next phase is resolve and the hub isn’t mounted, the orchestrator calls deferCurrentResolve() instead of setPhase('resolve').
  • resolvePolicy: 'immediate' → resolve runs as soon as the controller picks it up.

This is what lets a chest opened mid-mission defer its journey-bar fill until the player lands back at the hub, while still showing reveal + collect immediately in the chest UI.

Finalizers are the only entry points

services/reward-finalizers.ts is the commit layer. Callers replace direct store mutations with one finalizer call per reward source:

  • finalizeRunRewards() — arcade run end. The finalize_run Supabase RPC has already committed wallet changes, so the finalizer just snapshots before, snapshots after, calls describeRunRewards(), locks display overrides, and enqueues.
  • finalizeMissionRewards() — mission collect. Takes a commitFn that performs the wallet/inventory mutations between the before-snapshot and the describe step.
  • finalizePullRewards() — pull commit. Server RPC already mutated state, so this reconstructs before-ownership by subtracting pullResponse.newShips from current ownership. Display overrides are NOT locked because pull uses revealMode: 'custom'.
  • finalizeChestRewards() — journey chests. Same pattern as mission: snapshot, commitFn, snapshot, describe, lock, enqueue.

Every finalizer follows the same six-step ritual: capture before-snapshot, lock display overrides to before-state values, commit the domain action, capture after-snapshot, call a pure describer to build the RewardBatch, enqueue (or discard if no-op).

The describer is pure — all state access happens in the finalizer. Display overrides are locked BEFORE the domain commit so UI components keep showing before-values during the theatrical reveal.

Presenters

services/reward-presenters.ts is the strategy layer. getPresenter(batch) returns one of:

  • defaultPresenter — card spawn + flip + burst → fly-to-target + counter settle. Used for every batch with revealMode: 'default' / collectMode: 'default'. Currently stubbed; methods resolve immediately until the visual implementation lands.
  • pullPresenter — pass-through. Pull engine owns its own reveal + collect lifecycle externally. Acknowledged transitional debt — long-term, pull-engine’s reveal should be extracted into a proper presenter adapter.

The contract is strict: presenters do per-phase work and resolve when their animations finish. They never advance the FSM — that’s the orchestrator’s job.

Cross-references

  • Batch + phase types: src/starship-survivors/data/reward-types.ts
  • Finalizers (commit layer): src/starship-survivors/services/reward-finalizers.ts
  • Presenters (strategy layer): src/starship-survivors/services/reward-presenters.ts
  • Phase runner: src/starship-survivors/components/RewardOrchestrator.tsx
  • Resolve phase controller: src/starship-survivors/components/HomeResolveController.tsx
  • Queue store (declarative state): src/starship-survivors/stores/rewardQueueStore.ts
  • Display override locking: src/starship-survivors/stores/displayOverrideStore.ts
  • Pure describers: src/starship-survivors/services/reward-describers.ts