mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[www] token drop performance improvement
This commit is contained in:
+96
-34
@@ -44,6 +44,7 @@ import {
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
ensureStartingBalances,
|
||||
getBalance,
|
||||
payTokenGifts,
|
||||
spendCurrency,
|
||||
} from '../../econ/src/balance-db'
|
||||
// The item catalog and the two inventories a skin or a consumable gift writes, all `econ`'s.
|
||||
@@ -71,6 +72,7 @@ import type {
|
||||
ModerationKickPayload,
|
||||
PlayerProgressionLevelPayload,
|
||||
} from '../../notify/src/notification-payloads'
|
||||
import type { PlayerNotification } from '../../notify/src/notifications-hub'
|
||||
import type { App, Env } from './context'
|
||||
|
||||
/**
|
||||
@@ -658,12 +660,11 @@ function staffGiftContent(fields: Partial<GiftContent>): GiftContent {
|
||||
* drops unknown ones, so it costs nothing if unread. Best-effort: the box is already stored,
|
||||
* and an offline player meets it on their next read of their gifts.
|
||||
*/
|
||||
async function announceGift(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
/** The `GiftPackageReceivedImmediate` frame announcing a stored box — see {@link announceGift}. */
|
||||
function giftFrame(
|
||||
giftId: number,
|
||||
content: GiftContent
|
||||
): Promise<void> {
|
||||
): GiftPackagePayload & { CustomAvatarItemId?: string } {
|
||||
const frame: GiftPackagePayload = {
|
||||
Id: giftId,
|
||||
FromPlayerId: content.FromPlayerId ?? COACH_ACCOUNT_ID,
|
||||
@@ -682,13 +683,22 @@ async function announceGift(
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
}
|
||||
return content.CustomAvatarItemId
|
||||
? { ...frame, CustomAvatarItemId: content.CustomAvatarItemId }
|
||||
: frame
|
||||
}
|
||||
|
||||
async function announceGift(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
giftId: number,
|
||||
content: GiftContent
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
content.CustomAvatarItemId
|
||||
? { ...frame, CustomAvatarItemId: content.CustomAvatarItemId }
|
||||
: { ...frame }
|
||||
{ ...giftFrame(giftId, content) }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to announce a staff gift', {
|
||||
@@ -852,6 +862,74 @@ async function sendTokens(
|
||||
return { balance, giftId: gift.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link sendTokens} for a crowd — the room gift, the online drop and the role drop, which
|
||||
* pay the same amount in the same box to everyone on a list. The one-player path is five
|
||||
* D1 round trips and two hub calls; done in series for a full server that was a request of
|
||||
* ~1,750 subrequests, a minute or more long, and past D1's 1,000-queries-an-invocation cap
|
||||
* somewhere around 200 players — the drop then died partway, with the first players paid
|
||||
* and the rest not, and no audit row. This banks the boxes through econ's batched
|
||||
* `payTokenGifts` and announces the lot in ONE hub RPC.
|
||||
*
|
||||
* Per player it is exactly the one-player gift: the signup grant seeded first, the balance
|
||||
* moved, a box stored, then a balance frame (the RESULTING total, into the -2 bucket) and
|
||||
* the box announced. A negative amount is a debit guarded per row — `amount >= ?` in the
|
||||
* UPDATE, so nobody is overdrawn — and a player who can't afford it is SKIPPED (no box, no
|
||||
* frame) rather than failing the rest, as the one-player gift returns null. `paid` and
|
||||
* `skipped` keep the order of `playerIds`.
|
||||
*
|
||||
* The frames are best-effort, as everywhere: the tokens and boxes are stored before the hub
|
||||
* is called, and a player who was offline meets both on their next sign-in.
|
||||
*/
|
||||
async function sendTokensToMany(
|
||||
c: Context<App>,
|
||||
playerIds: readonly number[],
|
||||
amount: number,
|
||||
startingTokens: number,
|
||||
message: string = STAFF_GIFT_MESSAGE
|
||||
): Promise<{ paid: number[]; skipped: number[] }> {
|
||||
const content = staffGiftContent({
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Currency: amount,
|
||||
Message: message,
|
||||
})
|
||||
const { paid, skipped } = await payTokenGifts(
|
||||
c.env.DB,
|
||||
playerIds.map((accountId) => ({ accountId, amount, content })),
|
||||
startingTokens
|
||||
)
|
||||
|
||||
// The balance first, so the box's announcement lands on a total that already includes
|
||||
// it — the same two frames, in the same order, as sendTokens.
|
||||
const frames: PlayerNotification[] = paid.flatMap((p) => [
|
||||
{
|
||||
playerId: p.accountId,
|
||||
notificationType: NotificationType.StorefrontBalanceUpdate,
|
||||
data: {
|
||||
Balance: p.balance,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Platform: ALL_PLATFORMS,
|
||||
} satisfies BalanceResponsePayload,
|
||||
},
|
||||
{
|
||||
playerId: p.accountId,
|
||||
notificationType: NotificationType.GiftPackageReceivedImmediate,
|
||||
data: { ...giftFrame(p.giftId, p.content) },
|
||||
},
|
||||
])
|
||||
if (frames.length > 0) {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayers(frames)
|
||||
} catch (err) {
|
||||
logger.error('failed to announce a bulk staff token gift', {
|
||||
paidCount: paid.length,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
return { paid: paid.map((p) => p.accountId), skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tokens to EVERYONE standing in a room right now — every instance of it at once.
|
||||
*
|
||||
@@ -861,10 +939,9 @@ async function sendTokens(
|
||||
* has lapsed is already gone from it.
|
||||
*
|
||||
* Each player is paid exactly as the one-player gift pays them — the same box, with the same
|
||||
* optional `message` on it — one after another rather than at once: this is a handful of writes per player, and a busy room would otherwise open a
|
||||
* hundred at a time. A player a negative amount can't be taken from is SKIPPED rather than
|
||||
* failing the room — the ones who could afford it have already been debited by then — and the
|
||||
* response says who was missed.
|
||||
* optional `message` on it — in batches, through {@link sendTokensToMany}. A player a
|
||||
* negative amount can't be taken from is SKIPPED rather than failing the room — the ones who
|
||||
* could afford it are debited regardless — and the response says who was missed.
|
||||
*/
|
||||
export async function giftRoomTokensHandler(c: Context<App>) {
|
||||
const roomId = Number(c.req.param('roomId'))
|
||||
@@ -885,13 +962,7 @@ export async function giftRoomTokensHandler(c: Context<App>) {
|
||||
}
|
||||
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
const paid: number[] = []
|
||||
const skipped: number[] = []
|
||||
for (const playerId of playerIds) {
|
||||
const sent = await sendTokens(c, playerId, amount, startingTokens, message)
|
||||
if (sent === null) skipped.push(playerId)
|
||||
else paid.push(playerId)
|
||||
}
|
||||
const { paid, skipped } = await sendTokensToMany(c, playerIds, amount, startingTokens, message)
|
||||
|
||||
await recordPlayerAudit(c, 'gift_tokens_room', { roomId, amount, message, paid, skipped })
|
||||
logger.info('staff gifted tokens to a room', {
|
||||
@@ -911,8 +982,9 @@ export async function giftRoomTokensHandler(c: Context<App>) {
|
||||
* The audience is every unexpired `presence` row, lobby included (see `getOnlinePlayerIds`),
|
||||
* read when the button is pressed: the same population the coach broadcast reaches and the
|
||||
* status page counts. Each player is paid exactly as the one-player and room gifts pay them
|
||||
* (banked, then a balance frame and an announced box), one after another for the same reason
|
||||
* the room gift is — a full server is a lot of writes, and they should not all open at once.
|
||||
* (banked, then a balance frame and an announced box), in batches through
|
||||
* {@link sendTokensToMany}: a full server is a lot of writes, and paid one player at a time
|
||||
* the drop outran D1's per-invocation query cap and died partway.
|
||||
*
|
||||
* Narrower than the other gifts on purpose: the amount is POSITIVE and capped at
|
||||
* {@link MAX_TOKEN_DROP} per player, whatever `MAX_TOKEN_GIFT` says — a stray zero here is
|
||||
@@ -943,13 +1015,8 @@ export async function giftOnlineTokensHandler(c: Context<App>) {
|
||||
if (playerIds.length === 0) return c.json({ error: 'Nobody is online right now' }, 404)
|
||||
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
const paid: number[] = []
|
||||
for (const playerId of playerIds) {
|
||||
// A positive amount never comes back null: nothing to skip.
|
||||
if ((await sendTokens(c, playerId, amount, startingTokens, message)) !== null) {
|
||||
paid.push(playerId)
|
||||
}
|
||||
}
|
||||
// A positive amount skips nobody.
|
||||
const { paid } = await sendTokensToMany(c, playerIds, amount, startingTokens, message)
|
||||
|
||||
await recordPlayerAudit(c, 'gift_tokens_online', { amount, message, paid })
|
||||
logger.info('staff dropped tokens on everyone online', {
|
||||
@@ -1015,13 +1082,8 @@ export async function giftRoleTokensHandler(c: Context<App>) {
|
||||
}
|
||||
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
const paid: number[] = []
|
||||
for (const playerId of playerIds) {
|
||||
// A positive amount never comes back null: nothing to skip.
|
||||
if ((await sendTokens(c, playerId, amount, startingTokens, message)) !== null) {
|
||||
paid.push(playerId)
|
||||
}
|
||||
}
|
||||
// A positive amount skips nobody.
|
||||
const { paid } = await sendTokensToMany(c, playerIds, amount, startingTokens, message)
|
||||
|
||||
await recordPlayerAudit(c, 'gift_tokens_role', { roleId, amount, message, paid })
|
||||
logger.info('staff dropped tokens on a discord role', {
|
||||
|
||||
@@ -52,7 +52,9 @@ import {
|
||||
BALANCE_SCHEMA_DDL,
|
||||
CurrencyType,
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
ensureStartingBalances,
|
||||
getBalance,
|
||||
spendCurrency,
|
||||
} from '../../../../econ/src/balance-db'
|
||||
import { CATALOG_SCHEMA_DDL } from '../../../../econ/src/catalog-db'
|
||||
import { CONSUMABLE_SCHEMA_DDL, getConsumables } from '../../../../econ/src/consumables-db'
|
||||
@@ -1770,6 +1772,7 @@ it('drops tokens on everyone online, lobby included, with the message on the box
|
||||
`UPDATE presence SET data = json_set(data, '$.expiresAt', 1) WHERE account_id = 8383`
|
||||
).run()
|
||||
|
||||
await clearHub()
|
||||
const res = await devPost('/api/staff/online/gift-tokens', 8110, {
|
||||
amount: 100,
|
||||
message: 'Thanks for playing!',
|
||||
@@ -1781,6 +1784,7 @@ it('drops tokens on everyone online, lobby included, with the message on the box
|
||||
expect(body.paid).toEqual(expect.arrayContaining([8380, 8381, 8382]))
|
||||
expect(body.paid).not.toContain(8383)
|
||||
|
||||
const frames = await hubFrames()
|
||||
for (const playerId of [8380, 8381, 8382]) {
|
||||
await expect(
|
||||
getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
@@ -1788,7 +1792,23 @@ it('drops tokens on everyone online, lobby included, with the message on the box
|
||||
const gifts = await getPendingGifts(env.DB, playerId)
|
||||
expect(gifts).toHaveLength(1)
|
||||
expect(gifts[0]).toMatchObject({ Currency: 100, Message: 'Thanks for playing!' })
|
||||
// The same two frames the one-player gift sends, in the same order: the resulting
|
||||
// total into the -2 bucket, then the box it came in — every player's own.
|
||||
const own = frames.filter((f) => f.playerId === playerId)
|
||||
expect(own.map((f) => f.notificationType)).toEqual([61, 31])
|
||||
expect(own[0].data).toEqual({
|
||||
Balance: DEFAULT_STARTING_TOKENS + 100,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Platform: -2,
|
||||
})
|
||||
expect(own[1].data).toMatchObject({
|
||||
Id: gifts[0]!.Id,
|
||||
Currency: 100,
|
||||
Message: 'Thanks for playing!',
|
||||
BalanceType: -2,
|
||||
})
|
||||
}
|
||||
expect(frames.filter((f) => f.playerId === 8383)).toEqual([])
|
||||
expect(await getPendingGifts(env.DB, 8383)).toEqual([])
|
||||
|
||||
const { results } = await env.DB.prepare(
|
||||
@@ -1813,6 +1833,115 @@ it('refuses a token drop over the cap, without a message, or with nobody online'
|
||||
expect((await drop({ amount: 1_000, message: 'hi' })).status).toBe(404)
|
||||
})
|
||||
|
||||
// The drop pays in rounds of 30 (three bound values a player, 100 a statement) and announces
|
||||
// the lot in one hub call. More players than one round holds, with a balance row already
|
||||
// present for some and none for the rest: everyone is paid once, in presence order, each
|
||||
// box has its own id, and the hub gets exactly two frames a player — nobody doubled by a
|
||||
// round boundary and nobody dropped past it.
|
||||
it('drops tokens on more players than one round pays, each exactly once', async () => {
|
||||
await env.DB.prepare('DELETE FROM presence').run()
|
||||
const players = Array.from({ length: 70 }, (_, i) => 8600 + i)
|
||||
for (const accountId of players) {
|
||||
await setPresence(env.DB, {
|
||||
accountId,
|
||||
roomInstance: null,
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 0,
|
||||
platform: 4,
|
||||
appVersion: 'test',
|
||||
})
|
||||
}
|
||||
// Half already hold a balance row (one touched by a prior read); the rest get the signup
|
||||
// grant seeded by the drop itself. Both must land on grant + drop.
|
||||
for (const accountId of players.filter((id) => id % 2 === 0)) {
|
||||
await getBalance(env.DB, accountId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
}
|
||||
|
||||
await clearHub()
|
||||
const res = await devPost('/api/staff/online/gift-tokens', 8110, {
|
||||
amount: 7,
|
||||
message: 'Round and round',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { paid: number[] }
|
||||
expect(body.paid).toEqual(players)
|
||||
|
||||
const { results } = await env.DB.prepare(
|
||||
`SELECT account_id AS accountId, amount FROM balance
|
||||
WHERE currency_type = ?1 AND account_id BETWEEN 8600 AND 8669 ORDER BY account_id`
|
||||
)
|
||||
.bind(CurrencyType.RecCenterTokens)
|
||||
.all<{ accountId: number; amount: number }>()
|
||||
expect(results.map((r) => r.accountId)).toEqual(players)
|
||||
expect(new Set(results.map((r) => r.amount))).toEqual(new Set([DEFAULT_STARTING_TOKENS + 7]))
|
||||
|
||||
const frames = await hubFrames()
|
||||
expect(frames).toHaveLength(players.length * 2)
|
||||
const boxIds = new Set<number>()
|
||||
for (const playerId of players) {
|
||||
const own = frames.filter((f) => f.playerId === playerId)
|
||||
expect(own.map((f) => f.notificationType)).toEqual([61, 31])
|
||||
expect(own[0].data.Balance).toBe(DEFAULT_STARTING_TOKENS + 7)
|
||||
const gifts = await getPendingGifts(env.DB, playerId)
|
||||
expect(gifts).toHaveLength(1)
|
||||
expect(own[1].data.Id).toBe(gifts[0]!.Id)
|
||||
boxIds.add(gifts[0]!.Id)
|
||||
}
|
||||
expect(boxIds.size).toBe(players.length)
|
||||
})
|
||||
|
||||
// A negative room gift is a debit guarded per player: whoever can't cover it is skipped —
|
||||
// no debit, no box, no frame — and the ones who can are still charged.
|
||||
it('skips the players a room debit would overdraw, and charges the rest', async () => {
|
||||
const inRoom = async (accountId: number) =>
|
||||
setPresence(env.DB, {
|
||||
accountId,
|
||||
roomInstance: { roomId: 7730, roomInstanceId: 77301 },
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 0,
|
||||
platform: 4,
|
||||
appVersion: 'test',
|
||||
})
|
||||
await inRoom(8480)
|
||||
await inRoom(8481)
|
||||
await inRoom(8482)
|
||||
// 8481 has already spent most of their grant.
|
||||
await ensureStartingBalances(env.DB, 8481, DEFAULT_STARTING_TOKENS)
|
||||
expect(
|
||||
await spendCurrency(
|
||||
env.DB,
|
||||
8481,
|
||||
CurrencyType.RecCenterTokens,
|
||||
DEFAULT_STARTING_TOKENS - 10,
|
||||
DEFAULT_STARTING_TOKENS
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
await clearHub()
|
||||
const res = await devPost('/api/staff/rooms/7730/gift-tokens', 8110, { amount: -100 })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
roomId: 7730,
|
||||
amount: -100,
|
||||
paid: [8480, 8482],
|
||||
skipped: [8481],
|
||||
})
|
||||
for (const playerId of [8480, 8482]) {
|
||||
await expect(
|
||||
getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).resolves.toBe(DEFAULT_STARTING_TOKENS - 100)
|
||||
expect(await getPendingGifts(env.DB, playerId)).toHaveLength(1)
|
||||
}
|
||||
await expect(
|
||||
getBalance(env.DB, 8481, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).resolves.toBe(10)
|
||||
expect(await getPendingGifts(env.DB, 8481)).toEqual([])
|
||||
const frames = await hubFrames()
|
||||
expect(frames.map((f) => f.playerId)).toEqual([8480, 8480, 8482, 8482])
|
||||
})
|
||||
|
||||
// The role drop's audience is the Discord links that record the role — offline included,
|
||||
// since that is the point — and nobody else: a link holding other roles, a link holding none,
|
||||
// a player with no link at all. The same Discord identity on two accounts pays both.
|
||||
|
||||
Reference in New Issue
Block a user