mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 23:21:30 -07:00
[econ] maybe fix up balance mismatch on purchases
This commit is contained in:
@@ -13,7 +13,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The currencies the client knows about (its `CurrencyType` enum). The client sends
|
* The currencies the client knows about (its `CurrencyType` enum, obfuscated
|
||||||
|
* `GKPEKOLBBJL` — which lists every member below except `RoomInventoryItem`). The client sends
|
||||||
* these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is
|
* these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is
|
||||||
* RecCenterTokens — so the values are fixed by the client, not by us.
|
* RecCenterTokens — so the values are fixed by the client, not by us.
|
||||||
*
|
*
|
||||||
@@ -90,10 +91,73 @@ export function startingBalances(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `Platform` in the client's balance DTO. -2 is "all platforms" — we don't track
|
* The client's `Platform` enum (obfuscated `DEKBHBDENBA`) — WHERE a balance came from,
|
||||||
* per-platform wallets (real RecNet did, for platform-purchased tokens).
|
* 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 two of these are ever on the wire from us: balances read back
|
||||||
|
* as `NonPurchasedNotUsableInP2P` (see `ALL_PLATFORMS`) and a storefront purchase reports
|
||||||
|
* `RecNet`. The rest is recorded for when a frame from a real capture has to be read.
|
||||||
*/
|
*/
|
||||||
export const ALL_PLATFORMS = -2
|
export const Platform = {
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* `CommercePurchase` (1400) is a storefront buy — what `buyItem` sends. Kept whole because
|
||||||
|
* the reasons a balance moves (challenges, level-ups, creator payouts, manual grants) are
|
||||||
|
* paths this worker will grow into, and the client already has a name for each.
|
||||||
|
*/
|
||||||
|
export const BalanceAddType = {
|
||||||
|
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[] = [
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ 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 {
|
||||||
@@ -222,6 +224,50 @@ 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
|
||||||
@@ -233,7 +279,8 @@ async function pushConsumableAdded(
|
|||||||
* 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".
|
* once per change and never re-send it as a "refresh". A spend goes through
|
||||||
|
* `pushBalancePurchase` instead, whose `Balance` IS the total.
|
||||||
*
|
*
|
||||||
* `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.
|
||||||
@@ -1703,8 +1750,8 @@ 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 StorefrontBalanceUpdate socket frame carrying the',
|
'price), not the new total. Pushes a StorefrontBalancePurchase socket frame whose',
|
||||||
'same change, which the client ADDS to the balance it is showing.',
|
'`Balance` is the RESULTING total (`Delta` is log-only), which the client shows as-is.',
|
||||||
].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'),
|
||||||
@@ -1792,11 +1839,15 @@ const app = new Hono<App>({ strict: false })
|
|||||||
message
|
message
|
||||||
)
|
)
|
||||||
|
|
||||||
// Push the debit over the socket so the buyer's client updates the shown total
|
// Push the spend over the socket so the buyer's client updates the shown total
|
||||||
// immediately — the buyer (`id`) is who was charged, in the currency they spent. The
|
// immediately — the buyer (`id`) is who was charged, in the currency they spent. A
|
||||||
// frame carries the CHANGE, so a purchase is negative. Best-effort; the HTTP response
|
// purchase sends StorefrontBalancePurchase, whose `Balance` is the RESULTING total
|
||||||
// carries the same change either way.
|
// read back from the DB (`Delta` is log-only), so a frame that arrives late, twice or
|
||||||
await pushBalanceUpdate(c, id, currencyType as number, -price.Price)
|
// alongside a `GET /balance` still lands the client on the balance we hold. The
|
||||||
|
// additive StorefrontBalanceUpdate this used to send could not: two of them, or one
|
||||||
|
// crossing a re-fetch, drifted the shown total off the backend's. Best-effort.
|
||||||
|
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
|
||||||
|
await pushBalancePurchase(c, id, currencyType as number, -price.Price, newBalance)
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -777,13 +777,22 @@ describe('econ endpoints', () => {
|
|||||||
expect(gift.AvatarItemDesc).not.toBe('')
|
expect(gift.AvatarItemDesc).not.toBe('')
|
||||||
expect(gift.Id).toBeGreaterThan(0)
|
expect(gift.Id).toBeGreaterThan(0)
|
||||||
|
|
||||||
// The socket frame carries the same change the response does — the client adds it to
|
// A purchase pushes StorefrontBalancePurchase, NOT the additive StorefrontBalanceUpdate:
|
||||||
// the balance it is showing, so the resulting total here would double-count the 9550.
|
// `Balance` is the RESULTING total (10000 - 450), which the client shows as-is, and
|
||||||
|
// `Delta`/`BalanceAddType` are log-only — the client never subtracts `Delta` itself.
|
||||||
|
// Sending the change here would leave the client showing 9550 less than it should.
|
||||||
expect(await drainFrames()).toEqual([
|
expect(await drainFrames()).toEqual([
|
||||||
{
|
{
|
||||||
accountId: 20,
|
accountId: 20,
|
||||||
notificationType: NotificationType.StorefrontBalanceUpdate,
|
notificationType: NotificationType.StorefrontBalancePurchase,
|
||||||
payload: { Balance: -450, CurrencyType: 2, BalanceType: -2 },
|
payload: {
|
||||||
|
// 1400 = CommercePurchase, 4 = RecNet (the store, not the buyer's device).
|
||||||
|
BalanceAddType: 1400,
|
||||||
|
Delta: -450,
|
||||||
|
Balance: 9550,
|
||||||
|
Platform: 4,
|
||||||
|
CurrencyType: 2,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user