mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[econ] another attempt to fix client balance
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 buyer’s resulting',
|
'`inventory_invention`, and returns the invention alongside the buyer’s 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 buyer’s negative, the creator’s 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,
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user