diff --git a/CLAUDE.md b/CLAUDE.md index 2977988..5ba82b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,16 @@ inconsistency here without checking the client first. the owner whether to load the latest or the published version and resolves it from the `/subrooms/:sid/saves` list — the matchmake call is identical either way. Don't make this server-side: it would put two people in one instance on different versions. +- Every `StorefrontBalance*` socket frame (`econ` → `notify` hub) is ADDITIVE: the client + ADDS the frame's `Balance` to the total it is already showing. That includes + `StorefrontBalancePurchase`, whose `Delta`/`BalanceAddType` fields make it look like an + idempotent "here is your new total" frame — it isn't, and the client never applies + `Delta` itself. So never send a total, and never push a frame to the player who is + reading the HTTP response for the same change: they apply both. A storefront purchase + (`/api/storefronts/v2/buyItem`) therefore pushes NOTHING — the buyer applies the body's + `Balance` (the negated price) — and `buyInvention` pushes only the CREATOR's payout, not + the buyer's debit. Pushing the resulting total on a buy showed 33,200 tokens to a player + who spent 900 of 17,500 (the correct 16,600, twice); pushing the change debits twice. - Accessibility is sent as the `RoomAccessibility` enum NAME on `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members diff --git a/apps/econ/src/balance-db.ts b/apps/econ/src/balance-db.ts index 8fd4e77..242d6e2 100644 --- a/apps/econ/src/balance-db.ts +++ b/apps/econ/src/balance-db.ts @@ -97,9 +97,9 @@ export function startingBalances( * 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. + * We sell nothing, so only one of these is ever on the wire from us: balances read back as + * `NonPurchasedNotUsableInP2P` (see `ALL_PLATFORMS`). The rest is recorded for when a frame + * from a real capture has to be read. */ export const Platform = { NonPurchasedNotUsableInP2P: -2, @@ -130,9 +130,11 @@ export const ALL_PLATFORMS: number = Platform.NonPurchasedNotUsableInP2P * 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. + * Nothing here sends one today: the `StorefrontBalanceUpdate` frames this worker pushes + * carry only `{ Balance, CurrencyType, BalanceType }`, and the purchase paths push no frame + * at all (see `pushBalanceUpdate` in econ.app.ts). Recorded because the reasons a balance + * moves (challenges, level-ups, creator payouts, manual grants) are paths this worker will + * grow into, and for reading a frame out of a real capture. */ export const BalanceAddType = { Invalid: 0, diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index c11c019..122360b 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -31,14 +31,12 @@ 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 { @@ -224,63 +222,28 @@ 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 * `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`. - * The client applies it to the shown balance so a purchase reflects immediately, without + * The client applies it to the shown balance so a change reflects immediately, without * waiting for a `GET /balance` re-fetch. * * `Balance` is the CHANGE — negative for a debit, positive for a payout — not the * 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". A spend goes through - * `pushBalancePurchase` instead, whose `Balance` IS the total. + * once per change and never re-send it as a "refresh". + * + * Every StorefrontBalance* frame is additive this way, StorefrontBalancePurchase included + * — it is NOT the idempotent "here is your new total" frame it looks like. Sending the + * total on a purchase doubled the buyer's balance on screen (17,500 − 900 spent showed + * 33,200: the correct 16,600 twice over), which is why the purchase paths below push + * nothing to the buyer at all. + * + * So: a frame goes to a player whose client is NOT reading this response — the invention + * creator collecting a payout. The caller learns their own new balance from the HTTP body + * and must not also be pushed one, or they apply both. * * `BalanceType` is -2 (account-wide, all platforms). Best-effort: a hub failure is logged * and swallowed, since the balance change has already committed. @@ -1750,8 +1713,9 @@ 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 StorefrontBalancePurchase socket frame whose', - '`Balance` is the RESULTING total (`Delta` is log-only), which the client shows as-is.', + 'price), not the new total. No balance socket frame is pushed: the buyer is the caller,', + 'and the client ADDS any StorefrontBalance* frame on top of the change it already', + 'applied from this body — pushing the total here doubled the balance on screen.', ].join(' '), security: AUTHED, requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'), @@ -1839,15 +1803,13 @@ const app = new Hono({ strict: false }) message ) - // 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) + // NO balance frame is pushed here, deliberately. The buyer is the caller: they get + // the debit from the response below (and re-read `GET /balance`), and the client ADDS + // any StorefrontBalance* frame on top of that — including StorefrontBalancePurchase, + // which is additive like the rest despite carrying a `Delta` field. Pushing the + // resulting total doubled the shown balance (17,500 − 900 read 33,200 = 16,600 twice); + // pushing the change debited it twice. Only a player who is NOT reading this response + // needs a frame — see the invention creator's payout in buyInvention. // 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 @@ -1917,9 +1879,9 @@ const app = new Hono({ strict: false }) 'its stored `Price`, debits the buyer and pays the creator that price in', 'RecCenterTokens (a free invention moves nothing), records ownership in', '`inventory_invention`, and returns the invention alongside the buyer’s resulting', - 'balance. When tokens moved, both players get a StorefrontBalanceUpdate push carrying', - 'their CHANGE (the buyer’s negative, the creator’s positive), which the client adds to', - 'the balance it is showing — unlike this response body, which replaces it.', + 'balance. When tokens moved, the CREATOR gets a StorefrontBalanceUpdate push carrying', + 'their payout, which their client adds to the balance it is showing. The buyer gets no', + 'push: this response body already replaces the balance their client shows.', 'A GET because that is how the client sends it.', ].join(' '), security: AUTHED, @@ -2022,13 +1984,11 @@ const app = new Hono({ strict: false }) // Unlike buyItem — whose `Balance` is the change applied — the reference server // answers this one with the RESULTING total (a first read seeds the buyer's starting - // grant, as everywhere else). The socket frame below is the other way round: the HTTP - // body REPLACES the shown balance, the push ADDS to it. + // grant, as everywhere else). That total REPLACES the balance the buyer's client is + // showing, which is why the buyer gets no socket frame: a StorefrontBalance* push is + // ADDED to what the client shows, so one here would debit them a second time on + // screen. The creator, whose client never sees this response, is pushed above. const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens) - // A free invention moved nothing, so there is no change to push for it. - if (price > 0) { - await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, -price) - } return c.json({ BalanceUpdateResponse: { Balance: balance, diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 5674175..e192956 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -777,24 +777,12 @@ describe('econ endpoints', () => { expect(gift.AvatarItemDesc).not.toBe('') expect(gift.Id).toBeGreaterThan(0) - // 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.StorefrontBalancePurchase, - payload: { - // 1400 = CommercePurchase, 4 = RecNet (the store, not the buyer's device). - BalanceAddType: 1400, - Delta: -450, - Balance: 9550, - Platform: 4, - CurrencyType: 2, - }, - }, - ]) + // A purchase pushes NO balance frame. The buyer is the caller: they apply the change + // from the body above, and the client ADDS any StorefrontBalance* frame on top of it — + // StorefrontBalancePurchase included, despite its `Delta` field. Pushing the resulting + // total is what made a live 17,500-token player read 33,200 after spending 900 (16,600 + // twice over); pushing the change would debit them twice instead. + expect(await drainFrames()).toEqual([]) // The balance endpoint reflects the debit (this is the resulting total, 10000 - 450). const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { @@ -1143,20 +1131,17 @@ describe('econ endpoints', () => { ).toBe(DEFAULT_STARTING_TOKENS + 250) expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9]) - // Both sides get a socket frame carrying their CHANGE, not their new total: the client - // ADDS what it receives to the balance it is showing, so a total would have the creator - // reading their own balance plus the payout. Equal and opposite, like the ledger. + // Only the CREATOR gets a socket frame, carrying their CHANGE rather than their new + // total: the client ADDS what it receives to the balance it is showing, so a total would + // have them reading their own balance plus the payout. The buyer gets none — the + // response body already replaced the balance their client shows, and a frame on top of + // it would debit them twice on screen. expect(await drainFrames()).toEqual([ { accountId: 999, notificationType: NotificationType.StorefrontBalanceUpdate, payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 }, }, - { - accountId: 51, - notificationType: NotificationType.StorefrontBalanceUpdate, - payload: { Balance: -250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 }, - }, ]) })