HomeResolveController

PURPOSE

Hub-screen controller that drives the 'resolve' phase of a reward batch. Sequentially animates one AnimatedBar per ProgressDelta, then advances the orchestrator. Render-null outside 'resolve'. Mounted inside HubScreen only.

OWNS

  • Local React state activeBarIndex: number — index of the bar currently animating; -1 means idle / not rendering.
  • sortedDeltas ref — frozen copy of activeBatch.progress sorted by TRACK_ORDER, snapshot at resolve start.
  • Module-local TRACK_ORDER: ProgressTrack[] (currently ['journey']) and the trackSortKey helper.
  • Module-local defaultGetXpForLevel curve passed to every AnimatedBar.
  • Module-local trackColor(track) mapping (per-track bar color).
  • Container <div> styling (padding: '8px 16px').

READS FROM

  • useRewardQueueStorephase, activeBatch, surfaceReady (selector subscriptions); getState().activeBatch inside handleBarComplete to read the live batch without resubscribing.
  • ProgressDelta / ProgressTrack from ../data/reward-types (type-only).
  • activeBatch.progress — source of bar definitions. Reads delta.track, delta.trackId, and (via AnimatedBar) delta.before / delta.after. Does NOT read live store progression values.

PUSHES TO

  • useDisplayOverrideStore.getState().releaseProgress(progressKey(track, trackId)) — released once per bar as it completes.
  • useRewardQueueStore’s surfaceReady() — called once on mount to drain deferred resolve backlog.
  • advanceOrFinish(batch, 'resolve') from ./RewardOrchestrator — called when all bars finish, OR immediately if activeBatch.progress is empty.
  • <AnimatedBar /> children — one per delta, with onComplete wired only on the bar whose index equals activeBarIndex; preceding bars receive skip={true} so they render in their final state without re-animating.

DOES NOT

  • Does not mutate activeBatch or store-side progression values directly.
  • Does not animate concurrently — bars fill sequentially in TRACK_ORDER.
  • Does not handle the 'milestones' or 'rewards' phases — those are sibling phases reached only via advanceOrFinish.
  • Does not render when phase !== 'resolve', when activeBatch is null, or when activeBarIndex < 0.
  • Does not acquire display overrides — only releases them. Acquisition happens upstream when the batch enters the queue.
  • Does not compute XP curves from live data — uses the flat defaultGetXpForLevel placeholder.
  • Does not own the lifecycle of 'journey'-class progression — only the visual reveal.

Signals

SignalSourceEffect
MountReactCalls surfaceReady() once.
phase / activeBatch changeZustand selectorRe-sorts deltas and sets activeBarIndex = 0, or -1 if not in resolve. Empty progress array short-circuits to advanceOrFinish.
AnimatedBar.onCompleteActive child bar onlyhandleBarComplete — releases that track’s override, increments activeBarIndex, or finishes via advanceOrFinish(batch, 'resolve') when no bars remain.

Entry points

  • Exported function component HomeResolveController() — only public surface.
  • Mounted by HubScreen. No props.

Pattern notes

  • Sequential bars via index gating. Every delta renders an AnimatedBar, but onComplete is bound to a no-op for non-active bars. Bars at idx < activeBarIndex get skip={true} (final-state, no animation); the bar at idx === activeBarIndex is the one that ticks; bars beyond are present but inert. This keeps mount identity stable across the sequence — the key is `${delta.track}-${delta.trackId ?? ''}`.
  • Snapshot-then-iterate. Deltas are copied into a ref at resolve start ([...activeBatch.progress].sort(...)) so later store mutations to activeBatch.progress cannot reshape the in-flight sequence.
  • Imperative store read inside the callback. handleBarComplete reads useRewardQueueStore.getState().activeBatch rather than the closed-over selector value, so the callback always sees the current batch and isn’t invalidated by stale closures.
  • Empty-progress fast path. A batch with progress.length === 0 is forwarded to advanceOrFinish without rendering any bars — keeps resolve from blocking the queue.
  • Release-only contract on displayOverrideStore. Acquire happens elsewhere; this controller is the canonical release site for 'resolve'-phase progress overrides.
  • TRACK_ORDER is extensible. Unknown tracks sort to 999 (end of list) so adding a track without updating order still renders deterministically at the tail.

EXTRACT-CANDIDATE: The pattern “render N children sequentially, gate onComplete to one at a time, skip earlier ones” recurs in any phase controller that consumes a batch list (resolve, milestones, rewards). Worth lifting to a useSequentialReveal(items, onAllDone) helper if a second controller adopts it.

EXTRACT-CANDIDATE: defaultGetXpForLevel is a flat 100-XP-per-level placeholder marked for replacement by real progression curves. When the real curve lands it should live in a shared progression module (data table + lookup) rather than inline in this file — gameplay numbers belong on a balance page, not in a UI controller.

EXTRACT-CANDIDATE: trackColor maps ProgressTrack → CSS color. As soon as a second track ships, this should move next to the ProgressTrack type (or to a small track-presentation.ts) so the UI palette has one canonical source.