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