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 activeRewardBatch. - The milestones sequence runner (
runMilestones) — sequentialholdAndSettle+showChipper milestone, tone derived frommilestone.importance. advanceOrFinish(batch, currentPhase)— exported transition helper that callsresolveNextPhase, then eithersetPhase(next),deferCurrentResolve(), orfinishCurrent()+releaseAll().- Re-entry guard:
runningPhaseRefkeyed by${batchId}:${phase}prevents double-execution of the same phase. - Cancellation:
cancelRefflipped totrueon unmount; checked after everyawaitinsiderunPhaseandrunMilestones.
READS FROM
useRewardQueueStore(selectorss.phase,s.activeBatch; imperativesetPhase,finishCurrent,deferCurrentResolveviagetState()).resolveNextPhase(batch, currentPhase)from../stores/rewardQueueStorefor phase transitions.useDisplayOverrideStore.getState()forreleaseCounter(counter)per-counter aftercollect, andreleaseAll()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)andholdAndSettle(ms)from../fx/juice.RewardBatch,RewardPhasetypes from../data/reward-types.
PUSHES TO
rewardQueueStore:setPhase(next),finishCurrent(),deferCurrentResolve().displayOverrideStore:releaseCounter(delta.counter)aftercollect,releaseAll()on batch finish.- FX layer: presenter
presentReveal/presentCollect(which render into#fx-*layer divs), plusshowChip/holdAndSettlefor milestones.
DOES NOT
- Render anything — returns
null. All visuals happen inside presenters / FX-layer divs. - Check reward
sourcetype. Routing is byPresentationHints(presenter selection) andbatch.presentation.resolvePolicy. - Run the
resolvephase. That is driven byHomeResolveController; the orchestrator’sresolvecase is a no-op (the controller calls back intoadvanceOrFinishwhen 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
| Phase | Action |
|---|---|
reveal | await presenter.presentReveal(batch) (if defined) → advanceOrFinish |
collect | await presenter.presentCollect(batch) → release each batch.counters[i].counter override → advanceOrFinish |
await_surface | deferCurrentResolve() — hub not mounted, park batch on deferredResolves and start next |
resolve | No-op here — HomeResolveController drives bar fills and calls advanceOrFinish when done |
milestones | runMilestones(batch, cancelRef) then finishCurrent() |
idle | Clear runningPhaseRef, return |
advanceOrFinish branches:
next === null→releaseAll()thenfinishCurrent().next === 'resolve'andbatch.presentation.resolvePolicy === 'defer_until_hub'→deferCurrentResolve().- Otherwise →
setPhase(next).
Entry points
<RewardOrchestrator />— single instance mounted at app root inLayout.tsx.export function advanceOrFinish(batch, currentPhase)— called byHomeResolveControllerafter 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
awaitis followed by anif (cancelRef.current) returncheck 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 tofalseat 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.presentationhints — keeps new reward sources from requiring orchestrator edits. - Milestone tone mapping.
importance === 'major'→'premium'chip tone, otherwise'info'. SurroundingholdAndSettlewaits gate the chip cadence.
EXTRACT-CANDIDATE
- Cancel-token pattern. The
cancelRef+ after-awaitcheck 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 auseCancelToken()hook infx/orlib/. - Phase-key re-entry guard. The
${id}:${state}running-key pattern would also apply to any future state-machine driver mounted at root.