From 7376f2706fbe62a932b62a65920d31f4696e274d Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 22 Sep 2026 15:34:22 -0400 Subject: [PATCH] [www] I award you no points --- apps/www/src/client/App.tsx | 14 +++-- apps/www/src/staff.ts | 61 +++++++++++++++---- apps/www/src/test/integration/api.test.ts | 73 ++++++++++++++++++++++- 3 files changed, 130 insertions(+), 18 deletions(-) diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 019f0e0..23efe11 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -1194,7 +1194,13 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav json: { amount }, }) setTokens('') - return `Sent ${amount.toLocaleString()} tokens. @${account.username} now has ${res.balance.toLocaleString()}.` + const what = + amount < 0 + ? `Took ${Math.abs(amount).toLocaleString()} tokens back` + : amount === 0 + ? 'Sent an empty box' + : `Sent ${amount.toLocaleString()} tokens` + return `${what}. @${account.username} now has ${res.balance.toLocaleString()}.` }) }} > @@ -1203,9 +1209,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav setTokens(e.target.value)} @@ -1215,7 +1219,9 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav - Arrives as a gift box, and is added to their balance right away. + Arrives as a gift box, and is added to their balance right away. A negative amount takes + tokens back instead — it still sends a box, and it can't take more than they have. + Zero sends an empty box and moves nothing. {gift.error &&

{gift.error}

} diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index ef95452..25f69ba 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -36,6 +36,8 @@ import { CurrencyType, DEFAULT_STARTING_TOKENS, ensureStartingBalances, + getBalance, + spendCurrency, } from '../../econ/src/balance-db' import { grantCustomAvatarItem, ownedCustomAvatarItemIds } from '../../econ/src/inventory-custom-db' // The notification ids and the kick frame's recovered shape, owned by `notify`. Both are @@ -647,7 +649,8 @@ async function announceGift( } /** - * Send a player RecCenterTokens in a gift box. + * Send a player RecCenterTokens in a gift box — or, with a NEGATIVE amount, take some back. + * * * The same shape as econ's own server-handed currency (a game reward's Laser Tag tickets): * the balance is CREDITED here — opening a box only deletes it, it grants nothing — then a @@ -658,6 +661,11 @@ async function announceGift( * * Both frames are best-effort: the tokens are banked by the time they go out, and an offline * player meets the box in `GET /api/avatar/v2/gifts` and the balance on their next read. + * + * A negative amount debits through the same guarded spend a purchase uses, so it cannot + * overdraw: a player who can't afford it is refused and keeps what they have. Zero moves + * nothing at all. Both still mint a box, carrying that `Currency` — nobody has seen what the + * client makes of an empty or a negative one, and finding out is the point. */ export async function giftTokensHandler(c: Context) { const playerId = playerIdParam(c) @@ -665,11 +673,11 @@ export async function giftTokensHandler(c: Context) { const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown } const amount = Number(body.amount) - if (!Number.isInteger(amount) || amount <= 0) { - return c.json({ error: 'Enter a whole number of tokens greater than 0' }, 400) + if (!Number.isInteger(amount)) { + return c.json({ error: 'Enter a whole number of tokens, positive or negative' }, 400) } const maxGift = intVar(c.env.MAX_TOKEN_GIFT, DEFAULT_MAX_TOKEN_GIFT) - if (amount > maxGift) { + if (Math.abs(amount) > maxGift) { return c.json({ error: `A gift can carry at most ${maxGift.toLocaleString()} tokens` }, 400) } if ((await getAccount(c.env.DB, playerId)) === null) { @@ -680,13 +688,38 @@ export async function giftTokensHandler(c: Context) { // and a never-touched balance would otherwise start from this gift instead of the grant. const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) await ensureStartingBalances(c.env.DB, playerId, startingTokens) - const balance = await creditCurrency( - c.env.DB, - playerId, - CurrencyType.RecCenterTokens, - amount, - startingTokens - ) + // A NEGATIVE amount takes tokens back, through the same guarded debit a purchase spends + // with — so it can't overdraw, and a player who can't afford it keeps what they have. + if (amount < 0) { + const spent = await spendCurrency( + c.env.DB, + playerId, + CurrencyType.RecCenterTokens, + -amount, + startingTokens + ) + if (!spent) { + const held = await getBalance( + c.env.DB, + playerId, + CurrencyType.RecCenterTokens, + startingTokens + ) + return c.json({ error: `They only have ${held.toLocaleString()} tokens to take` }, 400) + } + } + // Zero moves nothing and still sends a box — an empty one, for the same reason a negative + // one is allowed: nobody has seen what the client draws for it. + const balance = + amount > 0 + ? await creditCurrency( + c.env.DB, + playerId, + CurrencyType.RecCenterTokens, + amount, + startingTokens + ) + : await getBalance(c.env.DB, playerId, CurrencyType.RecCenterTokens, startingTokens) const content = staffGiftContent({ CurrencyType: CurrencyType.RecCenterTokens, @@ -695,7 +728,11 @@ export async function giftTokensHandler(c: Context) { const gift = await createGift(c.env.DB, playerId, content) await recordPlayerAudit(c, 'gift_tokens', { playerId, amount, balance, giftId: gift.id }) - logger.info('staff gifted tokens', { moderatorId: staffId(c), playerId, amount }) + logger.info(amount < 0 ? 'staff took tokens back' : 'staff gifted tokens', { + moderatorId: staffId(c), + playerId, + amount, + }) // The balance first, so the box's announcement lands on a total that already includes it. try { diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 6793fb7..691fdcd 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -1339,9 +1339,11 @@ it('gifts a player tokens in a gift box', async () => { ]) }) -it('refuses a token gift that is not a positive whole number, too large, or to nobody', async () => { +it('refuses a token gift that is not a whole number, too large, or to nobody', async () => { await updateAccount(env.DB, 8301, { username: 'NotGifted' }) - for (const amount of [0, -5, 1.5, 'lots', 10_001]) { + // A fraction and a word are not amounts, and the cap binds in both directions, since a + // negative gift takes tokens away. Zero is allowed — see the empty box below. + for (const amount of [1.5, 'lots', 10_001, -10_001]) { const res = await staffPost('/api/staff/players/8301/gift-tokens', 8110, { amount }) expect(res.status).toBe(400) } @@ -1352,6 +1354,73 @@ it('refuses a token gift that is not a positive whole number, too large, or to n expect(await auditRows('gift_tokens', 8301)).toEqual([]) }) +// A negative gift takes tokens back — through the same guarded debit a purchase spends with, +// so it cannot overdraw. It still mints a box, carrying the negative amount: what the client +// makes of one is the thing being found out. +it('takes tokens back on a negative gift, and cannot overdraw', async () => { + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + await hub().fetch('http://do/all', { method: 'DELETE' }) + await updateAccount(env.DB, 8360, { username: 'Indebted' }) + + const res = await staffPost('/api/staff/players/8360/gift-tokens', 8110, { amount: -400 }) + expect(res.status).toBe(200) + const body = (await res.json()) as { balance: number; giftId: number } + expect(body.balance).toBe(DEFAULT_STARTING_TOKENS - 400) + await expect( + getBalance(env.DB, 8360, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).resolves.toBe(DEFAULT_STARTING_TOKENS - 400) + + const gifts = await getPendingGifts(env.DB, 8360) + expect(gifts).toHaveLength(1) + expect(gifts[0]).toMatchObject({ Id: body.giftId, Currency: -400 }) + + // The balance frame carries the resulting total, as it does for a credit. + const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{ + notificationType: number + data: Record + }> + expect(frames[0].data).toMatchObject({ Balance: DEFAULT_STARTING_TOKENS - 400 }) + + expect(await auditRows('gift_tokens', 8360)).toEqual([ + { + actor: 8110, + data: { + playerId: 8360, + amount: -400, + balance: DEFAULT_STARTING_TOKENS - 400, + giftId: body.giftId, + }, + }, + ]) + + // More than they hold changes nothing: no debit, no box, no audit row beyond the first. + // Under the cap, so this is the BALANCE refusing it and not the typo guard (they hold + // DEFAULT_STARTING_TOKENS - 400 by now). + const tooMuch = await staffPost('/api/staff/players/8360/gift-tokens', 8110, { + amount: -(DEFAULT_STARTING_TOKENS - 100), + }) + expect(tooMuch.status).toBe(400) + await expect( + getBalance(env.DB, 8360, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).resolves.toBe(DEFAULT_STARTING_TOKENS - 400) + expect(await getPendingGifts(env.DB, 8360)).toHaveLength(1) + expect(await auditRows('gift_tokens', 8360)).toHaveLength(1) +}) + +// Zero is a real gift here: nothing moves, and the player gets an empty box. +it('sends an empty box on a zero token gift', async () => { + await updateAccount(env.DB, 8361, { username: 'Emptyhanded' }) + const res = await staffPost('/api/staff/players/8361/gift-tokens', 8110, { amount: 0 }) + expect(res.status).toBe(200) + const body = (await res.json()) as { balance: number; giftId: number } + expect(body.balance).toBe(DEFAULT_STARTING_TOKENS) + + const gifts = await getPendingGifts(env.DB, 8361) + expect(gifts).toHaveLength(1) + expect(gifts[0]).toMatchObject({ Id: body.giftId, Currency: 0 }) + expect(await auditRows('gift_tokens', 8361)).toMatchObject([{ data: { amount: 0 } }]) +}) + // An account that has never renamed itself stores no count and is read as the default 3, so // the first grant makes it 4 rather than 1. it('adds a username change on top of the default', async () => {