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 purchasedOffers set tracking one-time special-offer purchases (transitional, flagged for server move).
  • The PurchaseResult interface 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_PACKS and SPECIAL_OFFERS (and the GemPack / SpecialOffer types) from ../data/economy.
  • useSupporterStore to check isSupporter() and isPaid() before mutating membership.
  • The supplied currentDailyClaimed and dailyLimit arguments for claimAdTicket.
  • Internal purchasedOffers set for one-time-offer gating and getAvailableOffers filtering.

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(...) and earnPullTickets(...) 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() and upgradePaid() 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 purchasedOffers set 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; upgradeSupporterPaid is a fake-IAP transition.
  • Does not enforce the daily ad-ticket limit on its own state — the caller passes in currentDailyClaimed and dailyLimit.
  • Does not log telemetry, emit events, or notify UI directly; callers consume PurchaseResult synchronously 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) from buyGemPack.
  • Sync-status signal: usePlayerStore’s syncStatus cycles syncingsynced (success) or syncingerror (RPC throw) during buyGemPack.
  • Wallet snapshot signal: server returns Record<string, number> mapped to { gems, credits }, with missing fields coerced to 0.

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 purchasedOffers set is a module-scoped singleton; it resets on page reload and is not shared between tabs. Documented TODO to move to server.
  • buyGemPack is 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 incremental earnGems, ensuring the client matches the server even if local state drifted.
  • Defensive zero-coercion (?? 0) on walletSnapshot.gems and walletSnapshot.credits guards against partial RPC responses.
  • Errors from invokeRpc are caught, narrowed via instanceof Error, and surfaced through PurchaseResult.error rather than thrown — callers never need a try/catch.
  • getGemPacks returns a spread copy to prevent callers from mutating the underlying GEM_PACKS data table.
  • No event bus, no observer pattern: the shop service is a thin imperative facade over stores and the RPC client.