PURPOSE
Zustand store that tracks the S.I.G. Storefront bonus-bar progression. A 0–100 progress bar advances by one point per pull. Crossing each multiple of 20 (the 20, 40, 60, 80, 100 milestones) queues a bonus reward of rarity tier 1–5 respectively. Once the bar reaches 100 and all queued rewards have been drained, the bar resets to 0.
OWNS
bonusPoints: number— current bonus bar value, clamped to the range 0–100.pendingBonusRewards: number[]— FIFO queue of unclaimed milestone rewards. Each entry is a rarity tier in the range 1–5.
READS FROM
Nothing. The store is self-contained. All inputs arrive through its own method parameters (advanceBonus(amount)).
PUSHES TO
Nothing directly. The store exposes state and methods; consumers (the shop screen and pull engine) read state, call methods, and drive their own UI/reward flow off the returned milestone-tier values.
DOES NOT
- Does not grant or spawn the reward itself —
advanceBonusonly returns the tier that was crossed and appends topendingBonusRewards; consumers must callshiftPendingRewardand apply the reward. - Does not auto-reset on reaching 100 — the bar stays at 100 until
resetIfFullis called and the pending queue is empty. - Does not persist across sessions — state is in-memory only; no save/load, no localStorage, no Supabase mirror.
- Does not subscribe to or emit external events.
- Does not enforce that
amountis positive or integer — callers pass1per pull by contract. - Does not allow advancement past 100; once
bonusPoints >= 100,advanceBonusshort-circuits and returns null.
Signals
The store has no event/signal channel. The only out-of-band signal is the return value of advanceBonus:
- Returns the crossed milestone tier (
1–5) when a multiple of 20 is crossed by this call. - Returns
nullwhen no milestone was crossed, when the bar is already at 100, or when the crossed milestone falls outside the 1–5 range.
shiftPendingReward returns the next queued tier or null if the queue is empty.
Entry points
useBonusStore— Zustand hook, used asuseBonusStore(s => s.bonusPoints)for reactive reads, oruseBonusStore.getState()for one-shot access from non-React code.advanceBonus(amount: number) => number | null— advance the bar; returns crossed milestone tier or null.shiftPendingReward() => number | null— pop the front of the pending-reward queue.resetIfFull() => void— resetbonusPointsto 0 only when the bar is at 100 and the pending queue is empty.getPoints() => number— non-subscribing read of current points.getPendingCount() => number— non-subscribing read of pending-reward count.
Consumers in code: src/metagame/screens/ShopScreen.tsx reads bonusPoints reactively, calls advanceBonus(1) per pull reveal, drains rewards via shiftPendingReward, and calls resetIfFull after the queue empties.
Pattern notes
- Milestone detection uses
Math.floor(points / 20)on both the old and new values; a milestone fires whenevernewMilestone > oldMilestone. The inline comment notes this replaced an earlierold % 20 === 0check that was too strict and could miss milestones on partial resets. - The milestone tier range is hard-clamped to
[1, 5]insideadvanceBonus, so tiers outside that range are silently dropped even if the math would produce them. - The store uses Zustand’s
createdirectly with no middleware (nopersist, nodevtools, nosubscribeWithSelector). setcalls always include the full updatedbonusPointsvalue to keep the state and the queue update atomic in the milestone-crossing branch.Math.min(state.bonusPoints + amount, 100)is the only place the 100 ceiling is enforced; callers should not pre-clamp.- The store is stateless across game sessions — see the “DOES NOT” note on persistence. Bonus progress is per-session by design.