PURPOSE
Zustand store that serializes reward presentation. Two parallel surfaces coexist: a legacy popup queue (sequential RewardItem overlays for simple grants like prologue beats) and the new phase-driven RewardBatch pipeline that drives RewardOrchestrator and HomeResolveController through reveal → collect → await_surface → resolve → milestones. Acts as the single declarative state for both surfaces — timers, animation promises, and skip logic live in the orchestrator, never in this store.
OWNS
queue: RewardItem[]— popup queue, FIFO after priority-sort on enqueue.isShowing: boolean— popup overlay visibility flag; true while queue is non-empty.phase: RewardPhase— pipeline state machine; one ofidle | reveal | collect | await_surface | resolve | milestones. Starts atidle.activeBatch: RewardBatch | null— the batch currently being presented through the phase pipeline;nullwhen idle.RewardItemshape:{ id, label, icon, amount, priority, source }.idauto-generated via_idCounter(module-level, monotonic, prefixrq_).REWARD_PRIORITYconstant — sort weights{ gems: 6, tickets: 5, ship: 4, credits: 1 }. Higher wins.PHASE_ORDERmodule constant — linear advancement order['reveal','collect','await_surface','resolve','milestones'].makeRewardItem(label, icon, amount, priority, source)helper that auto-IDs aRewardItem.
READS FROM
zustand—createfactory.../data/reward-types—RewardBatchandRewardPhasetypes only. No runtime dependency.
PUSHES TO
- Nothing direct. The store is purely declarative state. Consumers subscribe via
useRewardQueueStoreselectors:RewardPopup(insrc/metagame/components/) renders the front ofqueueand callsdismiss()on tap or auto-dismiss timeout.RewardOrchestrator(insrc/starship-survivors/components/) watchesphase+activeBatchand drives phase transitions viasetPhase/finishCurrent/deferCurrentResolve.HomeResolveController(hub-only) watches forphase === 'resolve', animates progress bars, callssurfaceReady()on mount.reward-finalizers.tscallsenqueue(batch)after committing domain mutations and locking display overrides.
DOES NOT
- Does not run timers, animations, or async work. The orchestrator owns the runtime.
- Does not persist anything. State is in-memory only; resets on reload.
- Does not coalesce or dedupe items in the popup
queue— every call toenqueue(items[])appends after priority-sort. - Does not check item content or batch source — sorting is by
priorityfield only; phase advancement is byPHASE_ORDERonly. - Does not drive the resolve phase logic —
resolveis handled byHomeResolveController; the orchestrator’scase 'resolve'is a no-op. - Does not currently implement deferred-resolve backlog:
surfaceReady()is a stub no-op, anddeferCurrentResolve()discardsactiveBatchand returns toidlerather than queuing for later replay. - Does not enforce that a popup queue and an active batch are mutually exclusive — both surfaces can be active simultaneously.
Signals
enqueue(items)is polymorphic on its argument:RewardItem[]— sorts by priority (highest first) and appends toqueue; setsisShowing: true. Empty array is a no-op.RewardBatch— setsactiveBatchand forcesphase: 'reveal'. Skips the popup queue entirely.
dismiss()slices the front ofqueueand recomputesisShowingfrom remaining length — oncequeueempties, the overlay hides on the next dismiss.current()returns the front item ornull— selector-style read, not a subscription target.finishCurrent()clearsactiveBatchand resetsphasetoidle.resolveNextPhase(batch, currentPhase)is a pure module function returning the next phase ornullif the batch is exhausted. Used byRewardOrchestrator.advanceOrFinish.
Entry points
useRewardQueueStore— Zustand hook; the only export consumers use to read or mutate state.enqueue(items)— sole admission point.reward-finalizers.tsis the canonical caller forRewardBatch; legacy/prologue paths passRewardItem[].dismiss()— called byRewardPopupon tap or auto-dismiss.clear()— drops the entire popup queue; hides overlay.setPhase(phase)/finishCurrent()/deferCurrentResolve()— pipeline transitions called byRewardOrchestratorandHomeResolveController.surfaceReady()— called byHomeResolveControlleron hub mount (currently no-op).makeRewardItem(...)— used byPrologueScreenand similar simple grant sites that constructRewardItemdirectly.REWARD_PRIORITY,resolveNextPhase,RewardItem— exported for external consumers.
Pattern notes
- Two coexisting surfaces by design: legacy popup queue for simple immediate grants, phase pipeline for the new batch-presentation flow. Both share the store but never interact.
enqueuepolymorphism viaArray.isArray—RewardItem[]vsRewardBatchis the discriminator.- Module-level
_idCounteris the sole source ofRewardItem.id— IDs are unique within a session but not across reloads. - The phase state machine is declarative-only: the orchestrator reads it, runs side effects, then writes the next state. The store never advances itself.
PHASE_ORDERis the single source of phase sequence;resolveNextPhaseis the only function that interprets it.await_surfaceanddefer_until_hub(theRewardBatch.presentation.resolvePolicy) are the seams for deferring resolve animations untilHubScreenis mounted — but the backlog/replay machinery is stubbed (surfaceReadyno-op,deferCurrentResolvedrops the batch).- No subscription/middleware (persist, devtools) attached at the store level.
- Crash-on-bad-data does not apply here: unknown phases or empty batches fall through silently — phase advancement returns
nullfor out-of-range indices and finishes the batch.