[econ] another attempt to fix client balance

This commit is contained in:
Devin Zuczek
2026-08-11 12:26:05 -04:00
parent 7df783302b
commit efbd7936db
4 changed files with 58 additions and 101 deletions
+10
View File
@@ -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 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
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 - 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
+8 -6
View File
@@ -97,9 +97,9 @@ export function startingBalances(
* 100+ members are the "not purchased" kinds, split by whether they may be spent * 100+ members are the "not purchased" kinds, split by whether they may be spent
* player-to-player. * player-to-player.
* *
* We sell nothing, so only two of these are ever on the wire from us: balances read back * We sell nothing, so only one of these is ever on the wire from us: balances read back as
* as `NonPurchasedNotUsableInP2P` (see `ALL_PLATFORMS`) and a storefront purchase reports * `NonPurchasedNotUsableInP2P` (see `ALL_PLATFORMS`). The rest is recorded for when a frame
* `RecNet`. The rest is recorded for when a frame from a real capture has to be read. * from a real capture has to be read.
*/ */
export const Platform = { export const Platform = {
NonPurchasedNotUsableInP2P: -2, 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 * but never derives the balance from it, so a wrong value here is cosmetic, not a wrong
* number on screen. * number on screen.
* *
* `CommercePurchase` (1400) is a storefront buy — what `buyItem` sends. Kept whole because * Nothing here sends one today: the `StorefrontBalanceUpdate` frames this worker pushes
* the reasons a balance moves (challenges, level-ups, creator payouts, manual grants) are * carry only `{ Balance, CurrencyType, BalanceType }`, and the purchase paths push no frame
* paths this worker will grow into, and the client already has a name for each. * 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 = { export const BalanceAddType = {
Invalid: 0, Invalid: 0,
+29 -69
View File
@@ -31,14 +31,12 @@ import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db' import { getAvatar, setAvatar } from './avatar-db'
import { import {
ALL_PLATFORMS, ALL_PLATFORMS,
BalanceAddType,
creditCurrency, creditCurrency,
CurrencyType, CurrencyType,
DEFAULT_STARTING_TOKENS, DEFAULT_STARTING_TOKENS,
ensureStartingBalances, ensureStartingBalances,
getBalance, getBalance,
isSpendable, isSpendable,
Platform,
spendCurrency, spendCurrency,
} from './balance-db' } from './balance-db'
import { 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<App>,
accountId: number,
currencyType: number,
delta: number,
balance: number
): Promise<void> {
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 * Push a StorefrontBalanceUpdate to a player after their balance changes, mirroring the
* reference's * reference's
* `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`. * `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. * waiting for a `GET /balance` re-fetch.
* *
* `Balance` is the CHANGE — negative for a debit, positive for a payout — not the * `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, * 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 * 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 * 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 * once per change and never re-send it as a "refresh".
* `pushBalancePurchase` instead, whose `Balance` IS the total. *
* 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 * `BalanceType` is -2 (account-wide, all platforms). Best-effort: a hub failure is logged
* and swallowed, since the balance change has already committed. * and swallowed, since the balance change has already committed.
@@ -1750,8 +1713,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. Pushes a StorefrontBalancePurchase socket frame whose', 'price), not the new total. No balance socket frame is pushed: the buyer is the caller,',
'`Balance` is the RESULTING total (`Delta` is log-only), which the client shows as-is.', '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(' '), ].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'),
@@ -1839,15 +1803,13 @@ const app = new Hono<App>({ strict: false })
message message
) )
// Push the spend over the socket so the buyer's client updates the shown total // NO balance frame is pushed here, deliberately. The buyer is the caller: they get
// immediately — the buyer (`id`) is who was charged, in the currency they spent. A // the debit from the response below (and re-read `GET /balance`), and the client ADDS
// purchase sends StorefrontBalancePurchase, whose `Balance` is the RESULTING total // any StorefrontBalance* frame on top of that — including StorefrontBalancePurchase,
// read back from the DB (`Delta` is log-only), so a frame that arrives late, twice or // which is additive like the rest despite carrying a `Delta` field. Pushing the
// alongside a `GET /balance` still lands the client on the balance we hold. The // resulting total doubled the shown balance (17,500 900 read 33,200 = 16,600 twice);
// additive StorefrontBalanceUpdate this used to send could not: two of them, or one // pushing the change debited it twice. Only a player who is NOT reading this response
// crossing a re-fetch, drifted the shown total off the backend's. Best-effort. // needs a frame — see the invention creator's payout in buyInvention.
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 // 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
@@ -1917,9 +1879,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, both players get a StorefrontBalanceUpdate push carrying', 'balance. When tokens moved, the CREATOR gets a StorefrontBalanceUpdate push carrying',
'their CHANGE (the buyers negative, the creators positive), which the client adds to', 'their payout, which their client adds to the balance it is showing. The buyer gets no',
'the balance it is showing — unlike this response body, which replaces it.', 'push: this response body already replaces the balance their client shows.',
'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,
@@ -2022,13 +1984,11 @@ const app = new Hono<App>({ strict: false })
// 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). The socket frame below is the other way round: the HTTP // grant, as everywhere else). That total REPLACES the balance the buyer's client is
// body REPLACES the shown balance, the push ADDS to it. // 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) 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({ return c.json({
BalanceUpdateResponse: { BalanceUpdateResponse: {
Balance: balance, Balance: balance,
+11 -26
View File
@@ -777,24 +777,12 @@ 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 StorefrontBalancePurchase, NOT the additive StorefrontBalanceUpdate: // A purchase pushes NO balance frame. The buyer is the caller: they apply the change
// `Balance` is the RESULTING total (10000 - 450), which the client shows as-is, and // from the body above, and the client ADDS any StorefrontBalance* frame on top of it —
// `Delta`/`BalanceAddType` are log-only — the client never subtracts `Delta` itself. // StorefrontBalancePurchase included, despite its `Delta` field. Pushing the resulting
// Sending the change here would leave the client showing 9550 less than it should. // total is what made a live 17,500-token player read 33,200 after spending 900 (16,600
expect(await drainFrames()).toEqual([ // twice over); pushing the change would debit them twice instead.
{ 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,
},
},
])
// 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`, {
@@ -1143,20 +1131,17 @@ 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])
// Both sides get a socket frame carrying their CHANGE, not their new total: the client // Only the CREATOR gets a socket frame, carrying their CHANGE rather than their new
// ADDS what it receives to the balance it is showing, so a total would have the creator // total: the client ADDS what it receives to the balance it is showing, so a total would
// reading their own balance plus the payout. Equal and opposite, like the ledger. // 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([ expect(await drainFrames()).toEqual([
{ {
accountId: 999, accountId: 999,
notificationType: NotificationType.StorefrontBalanceUpdate, notificationType: NotificationType.StorefrontBalanceUpdate,
payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 }, payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
}, },
{
accountId: 51,
notificationType: NotificationType.StorefrontBalanceUpdate,
payload: { Balance: -250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
},
]) ])
}) })