PURPOSE
V4 banner pull service. Client rolls hulls locally (rarity + uniform hull pick), the perform_pull Supabase RPC validates wallet + pity and persists ship rows, and the local inventory is then advanced via grantShipPull (first pull unlocks the hull, subsequent pulls grant +1 XP). Rarity remains on the wire as a UI label only — v4 stats are not rarity-scaled.
OWNS
weightedRoll<T>(rates)— generic weighted random selection over aRecord<T, number>weight table.pickHull()— uniformly random hull class fromHULL_CLASSES.executePull(_bannerId, pityCount)— single client-side roll producing a stubPullV4Result(unlock/XP/star fields are zeroed and patched after server response) and an incremented pity count.PullV4Resultinterface — per-pull detail withshipId,unlocked,xpGained,oldXp/newXp,oldStar/newStar,rarity,wasPity.PaymentMethod('tickets' | 'gems'),PullRequest(bannerId,count: 1 | 10,payment),PullResponse(success,results,v4Results,newShips,error?).PullRpcResponseinterface — shape of theperform_pullRPC payload (success,wallet,tickets,pity,new_entities).executePullPull(request)— public async orchestrator that rolls, calls the RPC, reconciles wallet/pity/inventory, emits telemetry, and returns aPullResponse.getBannerPity(bannerId)— pity reader offplayerStore.loadPity(bannerId, count)— deprecated pity writer that delegates toplayerStore.setPity.
READS FROM
../data/ships—HULL_CLASSESarray,ShipRaritytype.../data/pull-config—BANNERSmap (banner validation),SHIP_RARITY_RATES(weighted rarity table),PullPullResultlegacy result shape.../data/ship-progression—starFromXp(xp)to derive star levels.usePlayerStore—pitymap (per-banner counter) andsetSyncStatus/setPitycontrols.useWalletStore— current wallet/tickets state, mutated viareplaceFromSnapshotandrecordPull.useInventoryStore—currentXp(shipId),isUnlocked(shipId),grantShipPull(shipId).@metagame/services/supabase—invokeRpcfor theperform_pullRPC call.@metagame/services/analytics—trackEvent,V4_PULL_UNLOCK,V4_PULL_XP_GAINconstants.
PUSHES TO
playerStore—setSyncStatus('syncing' | 'synced' | 'error')bracketing the RPC, andsetPity(pity)with the canonical server pity map.walletStore—replaceFromSnapshot({ gems, credits }, tickets)from RPCwallet/tickets, thenrecordPull(count).inventoryStore— onegrantShipPull(shipId)per rolled result; the returnedunlockedflag is folded back into the matchingPullV4Result.- Analytics —
V4_PULL_UNLOCKper newly unlocked hull (shipId,rarity), orV4_PULL_XP_GAINper XP gain (shipId,oldXp,newXp,starUpboolean). - Supabase —
perform_pullRPC withp_banner_id,p_count,p_payment_type,p_results: [{ type: 'ship', entityId, rarity }].
DOES NOT
- Does not scale ship stats by rarity; rarity is a wire-level UI label only.
- Does not maintain per-rarity hull pools;
pickHullis uniform overHULL_CLASSES. - Does not deduct currency client-side; wallet/tickets/pity are replaced wholesale from the RPC snapshot.
- Does not implement true pity branching in
executePull—wasPityis hardcodedfalseandpityCountis incremented locally without crossing any threshold. - Does not animate or render reveals; it returns data only and leaves animation to the caller.
- Does not retry on RPC failure — any thrown error sets sync status to
errorand propagates as{ success: false, error }. - Does not consume
PullV4Resultin the legacy pull-animation path; results are also down-mapped toPullPullResultfor backward compatibility (TODO Track-0b).
Signals
playerStore.setSyncStatustransitions:'syncing'before RPC,'synced'after inventory reconciliation,'error'on caught exception.walletStore.recordPull(count)fires once per successful pull batch.- Telemetry events
V4_PULL_UNLOCKandV4_PULL_XP_GAIN(mutually exclusive per result) —starUpderived fromnewStar > oldStar. - Invalid
bannerIdproduces an immediate{ success: false, error: 'Invalid banner' }with no sync-status mutation and no RPC call.
Entry points
executePullPull(request)— called by the shop / banner UI to perform a 1× or 10× pull. Returns both the legacyresults: PullPullResult[](for the existing animation code) and the v4v4Results: PullV4Result[](with XP/star deltas and unlock flags), plusnewShips: string[]listing first-pull hulls.getBannerPity(bannerId)— UI reader for the per-banner pity counter.loadPity(bannerId, count)— deprecated; prefer reading/writingplayerStore.pitydirectly.
Pattern notes
- Client-rolls / server-validates split: rolls are local for snappy UI, but pity and wallet are canonical from the server response — the local pity increment inside
executePullis discarded oncesetPity(rpcResponse.pity)runs. - Pre-pull XP snapshot:
localXp[shipId]shadowsinventoryStore.currentXpwithin a batch so that rolling the same hull multiple times in a single 10-pull produces correct sequentialoldXp/newXpdeltas and the reveal engine can show per-pull star transitions. - Server-canonical naming: server speaks
credits;replaceFromSnapshotis fed{ gems: rpcResponse.wallet.gems ?? 0, credits: rpcResponse.wallet.credits ?? 0 }. weightedRollfalls back to the last key if floating-point drift leavesroll > 0after consuming all weights — defensive boundary against rates summing to slightly less than 1.- Result shape down-mapping (
v4Results → results) is an intentional compat layer for the legacy pull-animation code path; theTODO Track-0bcomment marks it for removal once the animation consumesPullV4Resultdirectly. oldStaris forced to0for first-pull unlocks (wasUnlocked === false) so the reveal engine can distinguish “unlocked from zero” from “star-up from existing”.