PURPOSE
Beta-launch shop service. Handles gem-pack top-ups via the authoritative server RPC, plus transitional local-wallet flows for special offers, Supporter Club membership, and daily ad-ticket claims. Bridges shop UI intents to wallet/supporter stores and the Supabase backend.
OWNS
- The in-memory
purchasedOffersset tracking one-time special-offer purchases (transitional, flagged for server move). - The
PurchaseResultinterface used as the return contract for every shop action. - Purchase-flow orchestration: pack/offer lookup, eligibility checks, server invocation, wallet snapshot reconciliation, sync-status transitions.
READS FROM
GEM_PACKSandSPECIAL_OFFERS(and theGemPack/SpecialOffertypes) from../data/economy.useSupporterStoreto checkisSupporter()andisPaid()before mutating membership.- The supplied
currentDailyClaimedanddailyLimitarguments forclaimAdTicket. - Internal
purchasedOffersset for one-time-offer gating andgetAvailableOffersfiltering.
PUSHES TO
invokeRpc('grant_fake_gems', { p_pack_id })— server-authoritative gem grant.useWalletStore.getState().replaceFromSnapshot({ gems, credits })after a successful pack purchase.useWalletStore.getState().earnGems(...)andearnPullTickets(...)for special-offer contents and ad-ticket claims (transitional local grants).usePlayerStore.getState().setSyncStatus('syncing' | 'synced' | 'error')around the gem-pack RPC call.useSupporterStore.getState().joinFree()andupgradePaid()for Supporter Club transitions.purchasedOffers.add(offerId)when a one-time offer is consumed.
DOES NOT
- Does not award gems client-side for gem-pack purchases — the server is the source of truth and the wallet is replaced from the RPC snapshot.
- Does not persist the
purchasedOffersset across reloads or to the server (in-memory only; flagged TODO). - Does not validate pack prices, currencies, or real-money payment flows; gem packs are fake-IAP top-ups in beta.
- Does not handle Supporter Club paid-tier real payments;
upgradeSupporterPaidis a fake-IAP transition. - Does not enforce the daily ad-ticket limit on its own state — the caller passes in
currentDailyClaimedanddailyLimit. - Does not log telemetry, emit events, or notify UI directly; callers consume
PurchaseResultsynchronously or via the returned promise. - Does not retry failed RPC calls.
Signals
- Return shape:
PurchaseResult = { success: boolean; error?: string }from every purchase/claim/join function. - Error strings:
'Pack not found','Offer not found','Already purchased','Already a supporter','Already paid supporter','Daily limit reached', plus pass-through of RPC error messages (or'Purchase failed'fallback) frombuyGemPack. - Sync-status signal:
usePlayerStore’ssyncStatuscyclessyncing→synced(success) orsyncing→error(RPC throw) duringbuyGemPack. - Wallet snapshot signal: server returns
Record<string, number>mapped to{ gems, credits }, with missing fields coerced to0.
Entry points
buyGemPack(packId: string): Promise<PurchaseResult>— async, server-authoritative gem grant.buySpecialOffer(offerId: string): PurchaseResult— sync, local wallet grant (transitional).joinSupporterFree(): PurchaseResult— sync, free Supporter Club join.upgradeSupporterPaid(): PurchaseResult— sync, fake-IAP paid upgrade.claimAdTicket(currentDailyClaimed: number, dailyLimit: number): PurchaseResult— sync, grants one pull ticket if under daily limit.isOfferPurchased(offerId: string): boolean— query helper for one-time-offer state.getGemPacks(): GemPack[]— returns a defensive copy of all gem packs.getAvailableOffers(): SpecialOffer[]— returns offers filtered to exclude one-time offers already purchased.
Pattern notes
- Authority split: gem-pack purchases use the server RPC + snapshot replacement pattern (canonical), while special offers, Supporter Club, and ad tickets still mutate the wallet/supporter stores directly. Comment in source flags this as transitional.
- The
purchasedOffersset is a module-scoped singleton; it resets on page reload and is not shared between tabs. Documented TODO to move to server. buyGemPackis the only async entry point; all others are synchronous because they currently touch only local stores.- Wallet reconciliation after a pack purchase uses
replaceFromSnapshot(full overwrite) rather than incrementalearnGems, ensuring the client matches the server even if local state drifted. - Defensive zero-coercion (
?? 0) onwalletSnapshot.gemsandwalletSnapshot.creditsguards against partial RPC responses. - Errors from
invokeRpcare caught, narrowed viainstanceof Error, and surfaced throughPurchaseResult.errorrather than thrown — callers never need a try/catch. getGemPacksreturns a spread copy to prevent callers from mutating the underlyingGEM_PACKSdata table.- No event bus, no observer pattern: the shop service is a thin imperative facade over stores and the RPC client.