From a012b5165ab76c6356f77d1004c7e6725dfd7f1e Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 11 Aug 2026 10:51:00 -0400 Subject: [PATCH] [econ] maybe fix up balance mismatch on purchases --- apps/econ/src/balance-db.ts | 72 ++++++++++++++++++++-- apps/econ/src/econ.app.ts | 67 +++++++++++++++++--- apps/econ/src/test/integration/api.test.ts | 17 +++-- 3 files changed, 140 insertions(+), 16 deletions(-) diff --git a/apps/econ/src/balance-db.ts b/apps/econ/src/balance-db.ts index e7b9991..8fd4e77 100644 --- a/apps/econ/src/balance-db.ts +++ b/apps/econ/src/balance-db.ts @@ -13,7 +13,8 @@ */ /** - * The currencies the client knows about (its `CurrencyType` enum). The client sends + * The currencies the client knows about (its `CurrencyType` enum, obfuscated + * `GKPEKOLBBJL` — which lists every member below except `RoomInventoryItem`). The client sends * these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is * RecCenterTokens — so the values are fixed by the client, not by us. * @@ -90,10 +91,73 @@ export function startingBalances( } /** - * `Platform` in the client's balance DTO. -2 is "all platforms" — we don't track - * per-platform wallets (real RecNet did, for platform-purchased tokens). + * The client's `Platform` enum (obfuscated `DEKBHBDENBA`) — WHERE a balance came from, + * carried on the balance DTO and on the `StorefrontBalance*` socket frames. The store + * platforms (Steam … Pico) mean tokens bought with real money there; the negative and + * 100+ members are the "not purchased" kinds, split by whether they may be spent + * player-to-player. + * + * We sell nothing, so only two of these are ever on the wire from us: balances read back + * as `NonPurchasedNotUsableInP2P` (see `ALL_PLATFORMS`) and a storefront purchase reports + * `RecNet`. The rest is recorded for when a frame from a real capture has to be read. */ -export const ALL_PLATFORMS = -2 +export const Platform = { + NonPurchasedNotUsableInP2P: -2, + NonPurchasedDefault: -1, + Steam: 0, + Oculus: 1, + PlayStation: 2, + Microsoft: 3, + RecNet: 4, + IOS: 5, + GooglePlay: 6, + Pico: 8, + PlayStationNonPurchasedP2P: 100, + NonPlayStationNonPurchasedP2P: 101, + NonPurchasedEarnedByP2P: 1000, +} as const + +/** + * `Platform` in the client's balance DTO: -2, `NonPurchasedNotUsableInP2P`. We don't track + * per-platform wallets (real RecNet did, for platform-purchased tokens), and everything we + * hand out is minted rather than bought, so one account-wide balance answers for all of them. + */ +export const ALL_PLATFORMS: number = Platform.NonPurchasedNotUsableInP2P + +/** + * The client's `BalanceAddType` enum (obfuscated `EPJJLKAOOLD`) — WHY a balance changed, + * tagged onto a `StorefrontBalance*` frame. Log-only: the client shows/records the reason + * but never derives the balance from it, so a wrong value here is cosmetic, not a wrong + * number on screen. + * + * `CommercePurchase` (1400) is a storefront buy — what `buyItem` sends. Kept whole because + * the reasons a balance moves (challenges, level-ups, creator payouts, manual grants) are + * paths this worker will grow into, and the client already has a name for each. + */ +export const BalanceAddType = { + Invalid: 0, + DirectBalanceWithMultiplier: 1, + FromGiftBox: 2, + NUXChallenge: 10, + AllNUXChallenges: 11, + DailyChallenge: 100, + AllDailyChallenges: 101, + FinishActivity: 200, + RecRoyaleMatchFinished: 250, + ChecklistCredit: 303, + WonGame: 1000, + LostGame: 1001, + WonGameRateLimited: 1002, + WonGamePartial: 1003, + LevelUp: 1100, + Registered: 1200, + CreatorReward: 1300, + CommercePurchase: 1400, + CommercePurchaseRevoked: 1401, + Manual_Refund: 2000, + Manual_Thanks: 2010, + Manual_Apology: 2020, +} as const /** Schema DDL (mirror of migrations 0001_balance.sql) — also used to build the table in tests. */ export const BALANCE_SCHEMA_DDL: string[] = [ diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index cf042b3..c11c019 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -31,12 +31,14 @@ import weeklyChallenge from '../static/weekly-challenge.json' import { getAvatar, setAvatar } from './avatar-db' import { ALL_PLATFORMS, + BalanceAddType, creditCurrency, CurrencyType, DEFAULT_STARTING_TOKENS, ensureStartingBalances, getBalance, isSpendable, + Platform, spendCurrency, } from './balance-db' import { @@ -222,6 +224,50 @@ async function pushConsumableAdded( } } +/** + * Push a StorefrontBalancePurchase to the buyer after a purchase settles — the frame the + * reference sends for a spend, as opposed to the StorefrontBalanceUpdate it sends for a + * plain balance change. + * + * `Balance` is ABSOLUTE — the resulting total — and is the only field that moves the + * client's state. `Delta` and `BalanceAddType` are log-only: the client does NOT subtract + * `Delta` from what it is showing. That makes this frame idempotent, unlike + * `pushBalanceUpdate` below, and is why the purchase path uses it: an additive frame that + * raced a `GET /balance` re-fetch (or arrived twice) left the client showing a total the + * backend never had. + * + * `Platform` is `RecNet` — the store the tokens were spent in, not the buyer's device (the + * JWT carries no device, and we sell nothing per-platform) — and `CurrencyType` says which + * wallet the total belongs to. Best-effort: a hub failure is logged and swallowed, since + * the spend has already committed. + */ +async function pushBalancePurchase( + c: Context, + accountId: number, + currencyType: number, + delta: number, + balance: number +): Promise { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + accountId, + NotificationType.StorefrontBalancePurchase, + { + BalanceAddType: BalanceAddType.CommercePurchase, + Delta: delta, + Balance: balance, + Platform: Platform.RecNet, + CurrencyType: currencyType, + } + ) + } catch (err) { + logger.error('failed to push StorefrontBalancePurchase notification', { + accountId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + /** * Push a StorefrontBalanceUpdate to a player after their balance changes, mirroring the * reference's @@ -233,7 +279,8 @@ async function pushConsumableAdded( * resulting total. The client ADDS what it receives to the balance it is already showing, * so sending the total made a 10,000-token player who earned 250 read 20,250: their own * balance plus the new total. That also makes this frame non-idempotent, so push exactly - * once per change and never re-send it as a "refresh". + * once per change and never re-send it as a "refresh". A spend goes through + * `pushBalancePurchase` instead, whose `Balance` IS the total. * * `BalanceType` is -2 (account-wide, all platforms). Best-effort: a hub failure is logged * and swallowed, since the balance change has already committed. @@ -1703,8 +1750,8 @@ const app = new Hono({ strict: false }) 'still matches, debits the buyer atomically, grants the item (into the inventory or', 'consumable table), and returns a gift box. A `Gift` block routes the item to another', 'player, but the caller always pays. `Balance` in the response is the CHANGE (negated', - 'price), not the new total. Pushes a StorefrontBalanceUpdate socket frame carrying the', - 'same change, which the client ADDS to the balance it is showing.', + 'price), not the new total. Pushes a StorefrontBalancePurchase socket frame whose', + '`Balance` is the RESULTING total (`Delta` is log-only), which the client shows as-is.', ].join(' '), security: AUTHED, requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'), @@ -1792,11 +1839,15 @@ const app = new Hono({ strict: false }) message ) - // Push the debit over the socket so the buyer's client updates the shown total - // immediately — the buyer (`id`) is who was charged, in the currency they spent. The - // frame carries the CHANGE, so a purchase is negative. Best-effort; the HTTP response - // carries the same change either way. - await pushBalanceUpdate(c, id, currencyType as number, -price.Price) + // Push the spend over the socket so the buyer's client updates the shown total + // immediately — the buyer (`id`) is who was charged, in the currency they spent. A + // purchase sends StorefrontBalancePurchase, whose `Balance` is the RESULTING total + // read back from the DB (`Delta` is log-only), so a frame that arrives late, twice or + // alongside a `GET /balance` still lands the client on the balance we hold. The + // additive StorefrontBalanceUpdate this used to send could not: two of them, or one + // crossing a re-fetch, drifted the shown total off the backend's. Best-effort. + const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens) + await pushBalancePurchase(c, id, currencyType as number, -price.Price, newBalance) // The response mirrors a captured real buyItem: `Balance` is the change applied (the // negated price), not the resulting balance (the client reads its new total from diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 7b09a40..5674175 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -777,13 +777,22 @@ describe('econ endpoints', () => { expect(gift.AvatarItemDesc).not.toBe('') expect(gift.Id).toBeGreaterThan(0) - // The socket frame carries the same change the response does — the client adds it to - // the balance it is showing, so the resulting total here would double-count the 9550. + // A purchase pushes StorefrontBalancePurchase, NOT the additive StorefrontBalanceUpdate: + // `Balance` is the RESULTING total (10000 - 450), which the client shows as-is, and + // `Delta`/`BalanceAddType` are log-only — the client never subtracts `Delta` itself. + // Sending the change here would leave the client showing 9550 less than it should. expect(await drainFrames()).toEqual([ { accountId: 20, - notificationType: NotificationType.StorefrontBalanceUpdate, - payload: { Balance: -450, CurrencyType: 2, BalanceType: -2 }, + notificationType: NotificationType.StorefrontBalancePurchase, + payload: { + // 1400 = CommercePurchase, 4 = RecNet (the store, not the buyer's device). + BalanceAddType: 1400, + Delta: -450, + Balance: 9550, + Platform: 4, + CurrencyType: 2, + }, }, ])