[econ] finally fix client balances after purchase

This commit is contained in:
Devin Zuczek
2026-08-11 13:10:31 -04:00
parent 4fb1c901b4
commit c2adc1ffbb
4 changed files with 202 additions and 138 deletions
+22 -10
View File
@@ -121,16 +121,28 @@ inconsistency here without checking the client first.
the owner whether to load the latest or the published version and resolves it from the 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 `/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. 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 - A balance lives in a `(CurrencyType, Platform)` BUCKET and the client shows the SUM of the
ADDS the frame's `Balance` to the total it is already showing. That includes buckets, so `Platform` is a balance's identity, not a label. This server uses exactly one
`StorefrontBalancePurchase`, whose `Delta`/`BalanceAddType` fields make it look like an bucket per currency — `ALL_PLATFORMS`, -2 `NonPurchasedNotUsableInP2P` — and every surface
idempotent "here is your new total" frame — it isn't, and the client never applies must name it: the balance DTO (`econ`: `GET /api/storefronts/v4/balance/:type`), the
`Delta` itself. So never send a total, and never push a frame to the player who is `BalanceType` the storefront bodies echo, and the `Platform` on every `StorefrontBalance*`
reading the HTTP response for the same change: they apply both. A storefront purchase socket frame. Two traps, which produced two "balance doubling" bugs that both looked like
(`/api/storefronts/v2/buyItem`) therefore pushes NOTHING — the buyer applies the body's the frames being additive when they are not:
`Balance` (the negated price) — and `buyInvention` pushes only the CREATOR's payout, not - Each frame SETS the bucket it names to an absolute value — `Balance` is the RESULTING
the buyer's debit. Pushing the resulting total on a buy showed 33,200 tokens to a player TOTAL, never the change (`StorefrontBalancePurchase`'s `Delta`/`BalanceAddType` are
who spent 900 of 17,500 (the correct 16,600, twice); pushing the change debits twice. display-only; the client logs them and stores `Balance` outright). Send a change and the
balance becomes that change. Being absolute, a frame is idempotent: re-sending one, or
racing a `GET /balance`, cannot drift the total, so the player reading the HTTP response
for the same change gets a frame too.
- The bucket key on the wire is `Platform`. The client's property is named `BalanceType`
but carries a `[DataMember]` rename, and its decoder drops unknown members silently, so
a frame saying `BalanceType` lands in `Platform` 0 (`SteamPurchased`) and adds a phantom
balance to the real one — 10,000 tokens + a 250 reward read 20,250. Sending a real-but-
different platform does the same: `Platform: RecNet` on a buy showed 34,100 to a player
who spent 900 of 17,500, then 33,200 once the body's -900 reached the true bucket.
The payload shapes are recovered from the client's own decoder in
`apps/notify/src/notification-payloads.ts` — build frames against those interfaces (econ
does) so a renamed key fails the build instead of silently vanishing on the wire.
- Accessibility is sent as the `RoomAccessibility` enum NAME on - Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
+16 -66
View File
@@ -1,3 +1,5 @@
import { BalancePlatform } from '../../notify/src/notification-payloads'
/** /**
* Currency balances on the shared `recflare` D1 database. * Currency balances on the shared `recflare` D1 database.
* *
@@ -91,75 +93,23 @@ export function startingBalances(
} }
/** /**
* The client's `Platform` enum (obfuscated `DEKBHBDENBA`) — WHERE a balance came from, * The ONE balance bucket this server uses: `NonPurchasedNotUsableInP2P` (-2).
* 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 one of these is ever on the wire from us: balances read back as * The client keys a balance by `(CurrencyType, Platform)` and shows the SUM of the buckets,
* `NonPurchasedNotUsableInP2P` (see `ALL_PLATFORMS`). The rest is recorded for when a frame * so which Platform a balance is reported under is not cosmetic — it is the bucket's
* from a real capture has to be read. * identity. Everything we hand out is minted rather than bought, and we track no
*/ * per-platform wallets (real RecNet did, for tokens paid for on each store), so one
export const Platform = { * account-wide bucket per currency answers for all of them.
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.
* *
* Nothing here sends one today: the `StorefrontBalanceUpdate` frames this worker pushes * Every surface that names the bucket must name THIS one: the balance DTO's `Platform`, the
* carry only `{ Balance, CurrencyType, BalanceType }`, and the purchase paths push no frame * `BalanceType` the storefront HTTP bodies echo, and the `Platform` on every
* at all (see `pushBalanceUpdate` in econ.app.ts). Recorded because the reasons a balance * `StorefrontBalance*` socket frame. Naming a second one there invents a balance the client
* moves (challenges, level-ups, creator payouts, manual grants) are paths this worker will * adds to the real total — see the frame rule in econ.app.ts.
* grow into, and for reading a frame out of a real capture. *
* The enum itself lives in the notify worker's `notification-payloads.ts`, recovered from
* the client's decoder, rather than being duplicated here.
*/ */
export const BalanceAddType = { export const ALL_PLATFORMS: BalancePlatform = BalancePlatform.NonPurchasedNotUsableInP2P
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. */ /** Schema DDL (mirror of migrations 0001_balance.sql) — also used to build the table in tests. */
export const BALANCE_SCHEMA_DDL: string[] = [ export const BALANCE_SCHEMA_DDL: string[] = [
+122 -50
View File
@@ -20,8 +20,11 @@ import { validateAndGetAccountId } from '@repo/jwt'
// Imported directly rather than copied: these are plain D1 helpers with no bindings of // Imported directly rather than copied: these are plain D1 helpers with no bindings of
// their own, and buyInvention has to read the very rows `api` writes. // their own, and buyInvention has to read the very rows `api` writes.
import { getInventionById, toSaveResult } from '../../api/src/inventions-db' import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported // The notification-type ids the hub carries, and the payload shapes recovered from the
// as a value — the enum has no runtime dependencies. // client's own decoder (both owned by the `notify` worker). Imported rather than copied so
// the frames this worker builds are typed by the shapes the client actually parses — a
// wrong or renamed key (see the `Platform`/`BalanceType` trap) fails the build here.
import { BalanceAddType } from '../../notify/src/notification-payloads'
import { NotificationType } from '../../notify/src/notification-types' import { NotificationType } from '../../notify/src/notification-types'
import adCarouselItems from '../static/ad-carousel-items.json' import adCarouselItems from '../static/ad-carousel-items.json'
import defaultAvatarItems from '../static/default-avatar-items.json' import defaultAvatarItems from '../static/default-avatar-items.json'
@@ -87,6 +90,10 @@ import { claimReward } from './reward-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { GiftContent, Progression, StoredGift, XpGrant } from '@repo/domain' import type { GiftContent, Progression, StoredGift, XpGrant } from '@repo/domain'
import type {
BalanceResponsePayload,
PurchaseBalanceModificationPayload,
} from '../../notify/src/notification-payloads'
import type { Avatar } from './avatar-db' import type { Avatar } from './avatar-db'
import type { ConsumeResult } from './consumables-db' import type { ConsumeResult } from './consumables-db'
import type { App } from './context' import type { App } from './context'
@@ -223,46 +230,63 @@ async function pushConsumableAdded(
} }
/** /**
* Push a StorefrontBalanceUpdate to a player after their balance changes, mirroring the * THE BALANCE-FRAME RULE, which both balance bugs came from getting wrong.
* reference's
* `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`.
* 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 * The client holds a balance PER `(CurrencyType, Platform)` bucket and shows the SUM of the
* resulting total. The client ADDS what it receives to the balance it is already showing, * buckets. Every `StorefrontBalance*` frame is an absolute SET of the one bucket it names —
* so sending the total made a 10,000-token player who earned 250 read 20,250: their own * not a change to apply — so:
* 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".
* *
* Every StorefrontBalance* frame is additive this way, StorefrontBalancePurchase included * 1. `Balance` is the RESULTING TOTAL. Sending the change sets the bucket TO that change.
* — it is NOT the idempotent "here is your new total" frame it looks like. Sending the * 2. The bucket key on the wire is `Platform`. The client's property is called
* total on a purchase doubled the buyer's balance on screen (17,500 900 spent showed * `BalanceType` but carries a `[DataMember]` rename, and its decoder drops unknown
* 33,200: the correct 16,600 twice over), which is why the purchase paths below push * members in silence — so a frame that says `BalanceType` lands in `Platform` 0,
* nothing to the buyer at all. * `SteamPurchased`, and creates a SECOND bucket that is added to the real one forever.
* 3. That bucket must be the same one `GET /api/storefronts/v4/balance/:type` reports,
* `ALL_PLATFORMS`. One account-wide bucket per currency is the whole model here; a
* frame naming any other Platform is a phantom balance, not a per-store nicety.
* *
* So: a frame goes to a player whose client is NOT reading this response — the invention * Both live bugs were rule 2 or 3, and both looked like the frame being "additive":
* creator collecting a payout. The caller learns their own new balance from the HTTP body * - A player who earned 250 on 10,000 read 20,250 — `BalanceType: -2` was dropped, so the
* and must not also be pushed one, or they apply both. * total landed in a phantom Steam bucket beside the real one.
* - A player who spent 900 of 17,500 read 34,100, then 33,200 once the purchase response's
* -900 reached the real bucket — same phantom bucket, this time from `Platform: RecNet`.
* Neither was additivity: the totals were right, the bucket was wrong. Frames as specified
* here are idempotent, so re-sending one or racing a `GET /balance` cannot drift the total.
* *
* `BalanceType` is -2 (account-wide, all platforms). Best-effort: a hub failure is logged * See apps/notify/src/notification-payloads.ts for the payload shapes this is recovered
* and swallowed, since the balance change has already committed. * from — the interfaces there type these calls, so a wrong key is now a build error.
*/
/**
* Push a StorefrontBalanceUpdate (61) — "your balance in this bucket is now X" — after a
* player's balance changes for a reason that is not their own purchase. `balance` is their
* resulting TOTAL in that currency, per the rule above.
*
* A player who is reading the HTTP response for the same change gets this too: it sets the
* bucket to the same total the body reports, so the two agree rather than compound. Pushing
* it is what saves them a `GET /balance` re-fetch.
*
* Best-effort: a hub failure is logged and swallowed, since the change has already committed.
*/ */
async function pushBalanceUpdate( async function pushBalanceUpdate(
c: Context<App>, c: Context<App>,
accountId: number, accountId: number,
currencyType: number, currencyType: number,
change: number balance: number
): Promise<void> { ): Promise<void> {
// `satisfies` rather than a type annotation: the hub takes a Record<string, unknown>, and
// an interface (unlike an inferred object type) has no implicit index signature to match
// it. This still checks every key against the shape the client's decoder parses.
const payload = {
Balance: balance,
CurrencyType: currencyType,
Platform: ALL_PLATFORMS,
} satisfies BalanceResponsePayload
try { try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
accountId, accountId,
NotificationType.StorefrontBalanceUpdate, NotificationType.StorefrontBalanceUpdate,
{ payload
Balance: change,
CurrencyType: currencyType,
BalanceType: ALL_PLATFORMS,
}
) )
} catch (err) { } catch (err) {
logger.error('failed to push StorefrontBalanceUpdate notification', { logger.error('failed to push StorefrontBalanceUpdate notification', {
@@ -272,6 +296,45 @@ async function pushBalanceUpdate(
} }
} }
/**
* Push a StorefrontBalancePurchase (62) — the frame the reference sends when the balance
* moved because the player BOUGHT something, as opposed to the plain update above. Same
* absolute-set semantics: `balance` is the resulting total.
*
* `Delta` (the negated price) and `BalanceAddType` are display/telemetry only — the client
* logs them and then stores `Balance` outright, so a correct `Delta` beside a stale
* `Balance` still leaves the player's balance wrong. `Platform` is `ALL_PLATFORMS`, NOT
* `RecNetPurchased`: it has to name the bucket `GET /balance` reports, and sending RecNet
* here is exactly what doubled a buyer's tokens on screen. Best-effort, as above.
*/
async function pushBalancePurchase(
c: Context<App>,
accountId: number,
currencyType: number,
delta: number,
balance: number
): Promise<void> {
const payload = {
BalanceAddType: BalanceAddType.CommercePurchase,
Delta: delta,
Balance: balance,
Platform: ALL_PLATFORMS,
CurrencyType: currencyType,
} satisfies PurchaseBalanceModificationPayload
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
accountId,
NotificationType.StorefrontBalancePurchase,
payload
)
} catch (err) {
logger.error('failed to push StorefrontBalancePurchase notification', {
accountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/** /**
* Project a stored avatar into the public render subset returned by * Project a stored avatar into the public render subset returned by
* `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar * `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar
@@ -1713,9 +1776,9 @@ const app = new Hono<App>({ strict: false })
'still matches, debits the buyer atomically, grants the item (into the inventory or', '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', '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', 'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
'price), not the new total. No balance socket frame is pushed: the buyer is the caller,', 'price), not the new total. Pushes a StorefrontBalancePurchase socket frame that SETS the',
'and the client ADDS any StorefrontBalance* frame on top of the change it already', 'buyers account-wide bucket to the RESULTING total, so the frame, this body and a',
'applied from this body — pushing the total here doubled the balance on screen.', '`GET /balance` re-fetch all agree (`Delta` there is display-only).',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'), requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
@@ -1803,13 +1866,13 @@ const app = new Hono<App>({ strict: false })
message message
) )
// NO balance frame is pushed here, deliberately. The buyer is the caller: they get // Push the spend to the buyer (`id` — the caller is who was charged) so their client
// the debit from the response below (and re-read `GET /balance`), and the client ADDS // updates without waiting for a `GET /balance` re-fetch. StorefrontBalancePurchase
// any StorefrontBalance* frame on top of that — including StorefrontBalancePurchase, // SETS the account-wide bucket to the resulting total read back from D1, so it agrees
// which is additive like the rest despite carrying a `Delta` field. Pushing the // with both the response body below and any re-fetch instead of compounding with them
// resulting total doubled the shown balance (17,500 900 read 33,200 = 16,600 twice); // — see the frame rule above pushBalanceUpdate. Best-effort.
// pushing the change debited it twice. Only a player who is NOT reading this response const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
// needs a frame — see the invention creator's payout in buyInvention. await pushBalancePurchase(c, id, currencyType as number, -price.Price, newBalance)
// The response mirrors a captured real buyItem: `Balance` is the change applied (the // 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 // negated price), not the resulting balance (the client reads its new total from
@@ -1879,9 +1942,9 @@ const app = new Hono<App>({ strict: false })
'its stored `Price`, debits the buyer and pays the creator that price in', 'its stored `Price`, debits the buyer and pays the creator that price in',
'RecCenterTokens (a free invention moves nothing), records ownership in', 'RecCenterTokens (a free invention moves nothing), records ownership in',
'`inventory_invention`, and returns the invention alongside the buyers resulting', '`inventory_invention`, and returns the invention alongside the buyers resulting',
'balance. When tokens moved, the CREATOR gets a StorefrontBalanceUpdate push carrying', 'balance. When tokens moved, both players get a socket push carrying their RESULTING',
'their payout, which their client adds to the balance it is showing. The buyer gets no', 'total — the buyer a StorefrontBalancePurchase, the CREATOR a StorefrontBalanceUpdate —',
'push: this response body already replaces the balance their client shows.', 'which sets the account-wide bucket their client shows, agreeing with this body.',
'A GET because that is how the client sends it.', 'A GET because that is how the client sends it.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
@@ -1969,26 +2032,35 @@ const app = new Hono<App>({ strict: false })
// creator who had never touched their balance would otherwise have the row created // creator who had never touched their balance would otherwise have the row created
// here and lose their starting tokens forever. // here and lose their starting tokens forever.
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens) await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
await creditCurrency( const creatorBalance = await creditCurrency(
c.env.DB, c.env.DB,
invention.CreatorPlayerId, invention.CreatorPlayerId,
CurrencyType.RecCenterTokens, CurrencyType.RecCenterTokens,
price, price,
startingTokens startingTokens
) )
// The creator is a different, probably-online player: push the payout so a sale // The creator is a different, probably-online player with no response to read:
// lands on their shown balance without a re-fetch. Positive, because the frame // push the sale so it lands on their shown balance without a re-fetch. The frame
// carries the change. Best-effort, as everywhere. // carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
await pushBalanceUpdate(c, invention.CreatorPlayerId, CurrencyType.RecCenterTokens, price) // sending the payout would set their whole balance to it. A plain update rather
// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
await pushBalanceUpdate(
c,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
creatorBalance
)
} }
// Unlike buyItem — whose `Balance` is the change applied — the reference server // 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 // answers this one with the RESULTING total (a first read seeds the buyer's starting
// grant, as everywhere else). That total REPLACES the balance the buyer's client is // grant, as everywhere else). The buyer's frame carries that same total, so the body
// showing, which is why the buyer gets no socket frame: a StorefrontBalance* push is // and the push land the client on one number.
// 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) const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no purchase to report.
if (price > 0) {
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
}
return c.json({ return c.json({
BalanceUpdateResponse: { BalanceUpdateResponse: {
Balance: balance, Balance: balance,
+42 -12
View File
@@ -777,12 +777,27 @@ describe('econ endpoints', () => {
expect(gift.AvatarItemDesc).not.toBe('') expect(gift.AvatarItemDesc).not.toBe('')
expect(gift.Id).toBeGreaterThan(0) expect(gift.Id).toBeGreaterThan(0)
// A purchase pushes NO balance frame. The buyer is the caller: they apply the change // A purchase pushes StorefrontBalancePurchase, which SETS one (CurrencyType, Platform)
// from the body above, and the client ADDS any StorefrontBalance* frame on top of it — // bucket to an absolute value: `Balance` is the resulting total (10000 - 450) and `Delta`
// StorefrontBalancePurchase included, despite its `Delta` field. Pushing the resulting // is display-only. The bucket key is `Platform`, and it MUST be the -2 the balance
// total is what made a live 17,500-token player read 33,200 after spending 900 (16,600 // endpoint reports below — the client sums its buckets, so a frame naming any other
// twice over); pushing the change would debit them twice instead. // platform (or spelling the key `BalanceType`, which the client's decoder drops) invents
expect(await drainFrames()).toEqual([]) // a second balance beside the real one. That is what showed a live player 34,100 tokens
// after spending 900 of 17,500, then 33,200 once the body's -900 landed.
expect(await drainFrames()).toEqual([
{
accountId: 20,
notificationType: NotificationType.StorefrontBalancePurchase,
payload: {
// 1400 = CommercePurchase; -2 = NonPurchasedNotUsableInP2P, the only bucket we use.
BalanceAddType: 1400,
Delta: -450,
Balance: 9550,
Platform: -2,
CurrencyType: 2,
},
},
])
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 450). // 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`, { const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
@@ -1131,16 +1146,31 @@ describe('econ endpoints', () => {
).toBe(DEFAULT_STARTING_TOKENS + 250) ).toBe(DEFAULT_STARTING_TOKENS + 250)
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9]) expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9])
// Only the CREATOR gets a socket frame, carrying their CHANGE rather than their new // Both sides get a frame carrying their RESULTING TOTAL, into the same -2 bucket the
// total: the client ADDS what it receives to the balance it is showing, so a total would // balance endpoint reports — a StorefrontBalance* push SETS that bucket, so sending the
// have them reading their own balance plus the payout. The buyer gets none — the // change (250 / -250) would set their whole balance to it. The creator sold, so theirs is
// response body already replaced the balance their client shows, and a frame on top of // a plain update; the buyer bought, so theirs is a purchase frame with a display-only
// it would debit them twice on screen. // `Delta`. Note the key is `Platform`: the client renames `BalanceType` away and drops it.
expect(await drainFrames()).toEqual([ expect(await drainFrames()).toEqual([
{ {
accountId: 999, accountId: 999,
notificationType: NotificationType.StorefrontBalanceUpdate, notificationType: NotificationType.StorefrontBalanceUpdate,
payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 }, payload: {
Balance: DEFAULT_STARTING_TOKENS + 250,
CurrencyType: CurrencyType.RecCenterTokens,
Platform: -2,
},
},
{
accountId: 51,
notificationType: NotificationType.StorefrontBalancePurchase,
payload: {
BalanceAddType: 1400,
Delta: -250,
Balance: DEFAULT_STARTING_TOKENS - 250,
Platform: -2,
CurrencyType: CurrencyType.RecCenterTokens,
},
}, },
]) ])
}) })