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 a Record<T, number> weight table.
  • pickHull() — uniformly random hull class from HULL_CLASSES.
  • executePull(_bannerId, pityCount) — single client-side roll producing a stub PullV4Result (unlock/XP/star fields are zeroed and patched after server response) and an incremented pity count.
  • PullV4Result interface — per-pull detail with shipId, unlocked, xpGained, oldXp/newXp, oldStar/newStar, rarity, wasPity.
  • PaymentMethod ('tickets' | 'gems'), PullRequest (bannerId, count: 1 | 10, payment), PullResponse (success, results, v4Results, newShips, error?).
  • PullRpcResponse interface — shape of the perform_pull RPC 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 a PullResponse.
  • getBannerPity(bannerId) — pity reader off playerStore.
  • loadPity(bannerId, count) — deprecated pity writer that delegates to playerStore.setPity.

READS FROM

  • ../data/shipsHULL_CLASSES array, ShipRarity type.
  • ../data/pull-configBANNERS map (banner validation), SHIP_RARITY_RATES (weighted rarity table), PullPullResult legacy result shape.
  • ../data/ship-progressionstarFromXp(xp) to derive star levels.
  • usePlayerStorepity map (per-banner counter) and setSyncStatus/setPity controls.
  • useWalletStore — current wallet/tickets state, mutated via replaceFromSnapshot and recordPull.
  • useInventoryStorecurrentXp(shipId), isUnlocked(shipId), grantShipPull(shipId).
  • @metagame/services/supabaseinvokeRpc for the perform_pull RPC call.
  • @metagame/services/analyticstrackEvent, V4_PULL_UNLOCK, V4_PULL_XP_GAIN constants.

PUSHES TO

  • playerStoresetSyncStatus('syncing' | 'synced' | 'error') bracketing the RPC, and setPity(pity) with the canonical server pity map.
  • walletStorereplaceFromSnapshot({ gems, credits }, tickets) from RPC wallet/tickets, then recordPull(count).
  • inventoryStore — one grantShipPull(shipId) per rolled result; the returned unlocked flag is folded back into the matching PullV4Result.
  • Analytics — V4_PULL_UNLOCK per newly unlocked hull (shipId, rarity), or V4_PULL_XP_GAIN per XP gain (shipId, oldXp, newXp, starUp boolean).
  • Supabase — perform_pull RPC with p_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; pickHull is uniform over HULL_CLASSES.
  • Does not deduct currency client-side; wallet/tickets/pity are replaced wholesale from the RPC snapshot.
  • Does not implement true pity branching in executePullwasPity is hardcoded false and pityCount is 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 error and propagates as { success: false, error }.
  • Does not consume PullV4Result in the legacy pull-animation path; results are also down-mapped to PullPullResult for backward compatibility (TODO Track-0b).

Signals

  • playerStore.setSyncStatus transitions: 'syncing' before RPC, 'synced' after inventory reconciliation, 'error' on caught exception.
  • walletStore.recordPull(count) fires once per successful pull batch.
  • Telemetry events V4_PULL_UNLOCK and V4_PULL_XP_GAIN (mutually exclusive per result) — starUp derived from newStar > oldStar.
  • Invalid bannerId produces 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 legacy results: PullPullResult[] (for the existing animation code) and the v4 v4Results: PullV4Result[] (with XP/star deltas and unlock flags), plus newShips: string[] listing first-pull hulls.
  • getBannerPity(bannerId) — UI reader for the per-banner pity counter.
  • loadPity(bannerId, count) — deprecated; prefer reading/writing playerStore.pity directly.

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 executePull is discarded once setPity(rpcResponse.pity) runs.
  • Pre-pull XP snapshot: localXp[shipId] shadows inventoryStore.currentXp within a batch so that rolling the same hull multiple times in a single 10-pull produces correct sequential oldXp/newXp deltas and the reveal engine can show per-pull star transitions.
  • Server-canonical naming: server speaks credits; replaceFromSnapshot is fed { gems: rpcResponse.wallet.gems ?? 0, credits: rpcResponse.wallet.credits ?? 0 }.
  • weightedRoll falls back to the last key if floating-point drift leaves roll > 0 after 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; the TODO Track-0b comment marks it for removal once the animation consumes PullV4Result directly.
  • oldStar is forced to 0 for first-pull unlocks (wasUnlocked === false) so the reveal engine can distinguish “unlocked from zero” from “star-up from existing”.