RewardOrchestrator

PURPOSE

App-root phase runner for reward presentation. Mounted at app root via Layout.tsx, always present. Watches rewardQueueStore.{phase, activeBatch} and runs the corresponding side-effects (timers, animation awaits, FX calls, display-override releases) for the active phase. The store stays purely declarative; all imperative async/timer work lives here.

OWNS

  • The phase-runner switch (runPhase) that dispatches per-phase logic for an active RewardBatch.
  • The milestones sequence runner (runMilestones) — sequential holdAndSettle + showChip per milestone, tone derived from milestone.importance.
  • advanceOrFinish(batch, currentPhase) — exported transition helper that calls resolveNextPhase, then either setPhase(next), deferCurrentResolve(), or finishCurrent() + releaseAll().
  • Re-entry guard: runningPhaseRef keyed by ${batchId}:${phase} prevents double-execution of the same phase.
  • Cancellation: cancelRef flipped to true on unmount; checked after every await inside runPhase and runMilestones.

READS FROM

  • useRewardQueueStore (selectors s.phase, s.activeBatch; imperative setPhase, finishCurrent, deferCurrentResolve via getState()).
  • resolveNextPhase(batch, currentPhase) from ../stores/rewardQueueStore for phase transitions.
  • useDisplayOverrideStore.getState() for releaseCounter(counter) per-counter after collect, and releaseAll() on batch finish.
  • getPresenter(batch) from ../services/reward-presenters — looks up the presenter by batch shape (no source-type check here).
  • showChip(label, undefined, tone) and holdAndSettle(ms) from ../fx/juice.
  • RewardBatch, RewardPhase types from ../data/reward-types.

PUSHES TO

  • rewardQueueStore: setPhase(next), finishCurrent(), deferCurrentResolve().
  • displayOverrideStore: releaseCounter(delta.counter) after collect, releaseAll() on batch finish.
  • FX layer: presenter presentReveal / presentCollect (which render into #fx-* layer divs), plus showChip / holdAndSettle for milestones.

DOES NOT

  • Render anything — returns null. All visuals happen inside presenters / FX-layer divs.
  • Check reward source type. Routing is by PresentationHints (presenter selection) and batch.presentation.resolvePolicy.
  • Run the resolve phase. That is driven by HomeResolveController; the orchestrator’s resolve case is a no-op (the controller calls back into advanceOrFinish when its bar fills complete).
  • Hold timers or animation promises in the store. The store is purely declarative; all async lives in this file.
  • Mutate batch — phase data flows through store transitions only.

Signals

PhaseAction
revealawait presenter.presentReveal(batch) (if defined) → advanceOrFinish
collectawait presenter.presentCollect(batch) → release each batch.counters[i].counter override → advanceOrFinish
await_surfacedeferCurrentResolve() — hub not mounted, park batch on deferredResolves and start next
resolveNo-op here — HomeResolveController drives bar fills and calls advanceOrFinish when done
milestonesrunMilestones(batch, cancelRef) then finishCurrent()
idleClear runningPhaseRef, return

advanceOrFinish branches:

  • next === nullreleaseAll() then finishCurrent().
  • next === 'resolve' and batch.presentation.resolvePolicy === 'defer_until_hub'deferCurrentResolve().
  • Otherwise → setPhase(next).

Entry points

  • <RewardOrchestrator /> — single instance mounted at app root in Layout.tsx.
  • export function advanceOrFinish(batch, currentPhase) — called by HomeResolveController after the resolve phase completes externally.

Pattern notes

  • Phase-key guard. runningPhaseRef.current = "${batchId}:${phase}" ensures the effect’s body runs at most once per (batch, phase) pair, even if the effect re-fires for unrelated reasons.
  • Cancel-after-await. Every await is followed by an if (cancelRef.current) return check so unmount mid-animation does not push state into a freed store path.
  • Cleanup on unmount only. The cancel ref is set in an unmount cleanup (useEffect(() => () => { cancelRef.current = true; }, [])) and reset to false at the top of each new phase run — batch changes do not abort an in-flight phase, only unmount does.
  • Imperative store reads via getState() inside the async runner avoid re-subscribing each phase change and let the runner read the latest action references.
  • Source-blind routing. The orchestrator deliberately ignores reward source type and dispatches via getPresenter(batch) + batch.presentation hints — keeps new reward sources from requiring orchestrator edits.
  • Milestone tone mapping. importance === 'major''premium' chip tone, otherwise 'info'. Surrounding holdAndSettle waits gate the chip cadence.

EXTRACT-CANDIDATE

  • Cancel-token pattern. The cancelRef + after-await check is a generic React-async cancel idiom. If a second app-root async runner appears (e.g., a future tutorial/intro orchestrator), pull it into a useCancelToken() hook in fx/ or lib/.
  • Phase-key re-entry guard. The ${id}:${state} running-key pattern would also apply to any future state-machine driver mounted at root.