diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 23efe11..317804e 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -2155,7 +2155,12 @@ function PublicRoomView({ {/* Staff only, and cosmetic: hidden for everyone else, but `rooms` checks the token's role itself on the DELETE. */} - {isAdmin() && } + {isAdmin() && ( + <> + + + + )} ) } @@ -2166,6 +2171,63 @@ function PublicRoomView({ * steps — a plain card, then the same accent-bordered confirm the ban form uses — since * this is the one thing on the site that can't be undone. */ +/** + * Pay everyone standing in the room, across every instance of it, from the room's own page. + * The audience is read when the button is pressed, so it is whoever is in there at that + * moment — there is nothing to pick and nobody to look up. + */ +function StaffRoomTokens({ room }: { room: OwnedRoom }) { + const [tokens, setTokens] = useState('') + const { pending, error, done, run } = useAction() + + return ( +
+

Staff

+
{ + e.preventDefault() + void run(async () => { + const amount = Number(tokens) + const res = await call<{ paid: number[]; skipped: number[] }>( + `/api/staff/rooms/${room.RoomId}/gift-tokens`, + { authed: true, json: { amount } } + ) + setTokens('') + const n = res.paid.length + const missed = + res.skipped.length > 0 + ? ` ${res.skipped.length} couldn't spare it and were skipped.` + : '' + return `Sent ${amount.toLocaleString()} tokens to ${n} player${n === 1 ? '' : 's'} in ^${room.Name}.${missed}` + }) + }} + > + + {error &&

{error}

} + {done &&

{done}

} +
+
+ ) +} + function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) { const [confirming, setConfirming] = useState(false) const { pending, error, run } = useAction() @@ -2173,7 +2235,7 @@ function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () if (!confirming) { return (
-

Staff

+

Delete room

Deleting a room removes it for everyone: the room, its subrooms and their saves, and its picture. Photos players took in it stay on their profiles. diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index 25f69ba..28d7420 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -4,6 +4,7 @@ import { clearPasswordHash, createGift, getAccount, + getPlayerIdsInRoom, getPresences, movePlayerToDorm, writeAuditLog, @@ -673,23 +674,62 @@ 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)) { - 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 (Math.abs(amount) > maxGift) { - return c.json({ error: `A gift can carry at most ${maxGift.toLocaleString()} tokens` }, 400) - } + const refusal = tokenAmountRefusal(c, amount) + if (refusal !== null) return c.json({ error: refusal }, 400) if ((await getAccount(c.env.DB, playerId)) === null) { return c.json({ error: 'No such player' }, 404) } - // Seed the signup grant first, as econ does before every credit: `creditCurrency` upserts, - // 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) + const sent = await sendTokens(c, playerId, amount, startingTokens) + if (sent === null) { + const held = await getBalance(c.env.DB, playerId, CurrencyType.RecCenterTokens, startingTokens) + return c.json({ error: `They only have ${held.toLocaleString()} tokens to take` }, 400) + } + + await recordPlayerAudit(c, 'gift_tokens', { playerId, amount, ...sent }) + logger.info(amount < 0 ? 'staff took tokens back' : 'staff gifted tokens', { + moderatorId: staffId(c), + playerId, + amount, + }) + return c.json({ playerId, amount, ...sent }) +} + +/** + * Why this token amount can't be sent, or null when it can. Shared by the one-player gift + * and the room-wide one so they agree on what an amount is: any whole number within the + * operator's cap, in either direction (see {@link sendTokens} for what each sign does). + */ +function tokenAmountRefusal(c: Context, amount: number): string | null { + if (!Number.isInteger(amount)) return 'Enter a whole number of tokens, positive or negative' + const maxGift = intVar(c.env.MAX_TOKEN_GIFT, DEFAULT_MAX_TOKEN_GIFT) + if (Math.abs(amount) > maxGift) { + return `A gift can carry at most ${maxGift.toLocaleString()} tokens` + } + return null +} + +/** + * Move one player's token balance by `amount`, box it and tell them — the whole of a token + * gift, for one player. Returns their resulting balance and the box's id, or null when a + * negative amount is more than they hold (nothing is changed, and no box is minted). + * + * A POSITIVE amount credits, as econ's own faucets do. A NEGATIVE one debits through the + * same guarded spend a purchase uses, so it cannot overdraw. ZERO moves nothing. All three + * mint a box — an empty or negative one has never been seen in the client, and finding out + * is the point. + * + * The signup grant is seeded first, as econ does before every credit: `creditCurrency` + * upserts the row, so a never-touched balance would otherwise start from this gift. + */ +async function sendTokens( + c: Context, + playerId: number, + amount: number, + startingTokens: number +): Promise<{ balance: number; giftId: number } | null> { await ensureStartingBalances(c.env.DB, playerId, 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, @@ -698,18 +738,8 @@ export async function giftTokensHandler(c: Context) { -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) - } + if (!spent) return null } - // 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( @@ -727,13 +757,6 @@ 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(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 { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( @@ -752,8 +775,57 @@ export async function giftTokensHandler(c: Context) { }) } await announceGift(c, playerId, gift.id, content) + return { balance, giftId: gift.id } +} - return c.json({ playerId, amount, balance, giftId: gift.id }) +/** + * Send tokens to EVERYONE standing in a room right now — every instance of it at once. + * + * The room is the audience, not one session: `getPlayerIdsInRoom` is the live, unexpired + * presence for that room across its instances, deduplicated, and it is read at the moment the + * button is pressed. Someone who arrives a second later gets nothing; someone whose presence + * has lapsed is already gone from it. + * + * Each player is paid exactly as the one-player gift pays them, 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. + */ +export async function giftRoomTokensHandler(c: Context) { + const roomId = Number(c.req.param('roomId')) + if (!Number.isInteger(roomId) || roomId <= 0) { + return c.json({ error: 'A numeric room id is required' }, 400) + } + + const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown } + const amount = Number(body.amount) + const refusal = tokenAmountRefusal(c, amount) + if (refusal !== null) return c.json({ error: refusal }, 400) + + const playerIds = await getPlayerIdsInRoom(c.env.DB, roomId) + if (playerIds.length === 0) { + return c.json({ error: 'Nobody is in that room right now' }, 404) + } + + 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) + if (sent === null) skipped.push(playerId) + else paid.push(playerId) + } + + await recordPlayerAudit(c, 'gift_tokens_room', { roomId, amount, paid, skipped }) + logger.info('staff gifted tokens to a room', { + moderatorId: staffId(c), + roomId, + amount, + paidCount: paid.length, + skippedCount: skipped.length, + }) + return c.json({ roomId, amount, paid, skipped }) } /** diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 691fdcd..5123d0b 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -718,6 +718,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy '/api/staff/players/1/username-changes', '/api/staff/players/1/clear-password', ] + writes.push('/api/staff/rooms/1/gift-tokens') for (const path of writes) { expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401) const res = await SELF.fetch(`https://example.com${path}`, { @@ -1407,6 +1408,68 @@ it('takes tokens back on a negative gift, and cannot overdraw', async () => { expect(await auditRows('gift_tokens', 8360)).toHaveLength(1) }) +// The room is the audience, not one session: everyone standing in any instance of it, read +// at the moment the button is pressed. +it('sends tokens to everyone in a room, across its instances', async () => { + const inRoom = async (accountId: number, roomId: number, roomInstanceId: number) => + setPresence(env.DB, { + accountId, + roomInstance: { roomId, roomInstanceId }, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 0, + platform: 4, + appVersion: 'test', + }) + // Two instances of room 7700, plus a player in a different room who must not be paid. + await inRoom(8370, 7700, 77101) + await inRoom(8371, 7700, 77101) + await inRoom(8372, 7700, 77102) + await inRoom(8373, 7701, 77103) + + const res = await staffPost('/api/staff/rooms/7700/gift-tokens', 8110, { amount: 250 }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + roomId: 7700, + amount: 250, + paid: [8370, 8371, 8372], + skipped: [], + }) + + for (const playerId of [8370, 8371, 8372]) { + await expect( + getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).resolves.toBe(DEFAULT_STARTING_TOKENS + 250) + expect(await getPendingGifts(env.DB, playerId)).toHaveLength(1) + } + // The player in the other room is untouched — no box, and no balance row touched. + expect(await getPendingGifts(env.DB, 8373)).toEqual([]) + + const { results } = await env.DB.prepare( + `SELECT player_id, data FROM audit_log + WHERE action = 'gift_tokens_room' AND json_extract(data, '$.roomId') = 7700` + ).all<{ player_id: number; data: string }>() + expect(results).toHaveLength(1) + expect(results[0].player_id).toBe(8110) + expect(JSON.parse(results[0].data)).toEqual({ + roomId: 7700, + amount: 250, + paid: [8370, 8371, 8372], + skipped: [], + }) +}) + +// An empty room is a 404 rather than a silent success: a staffer who pressed the button +// expects somebody to have been paid. +it('refuses a room gift with nobody in the room, and a bad amount', async () => { + expect((await staffPost('/api/staff/rooms/7799/gift-tokens', 8110, { amount: 10 })).status).toBe( + 404 + ) + expect( + (await staffPost('/api/staff/rooms/7700/gift-tokens', 8110, { amount: 10_001 })).status + ).toBe(400) +}) + // 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' }) diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index 5fcd356..43b1df6 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -41,6 +41,7 @@ import { createReportHandler, getReportHandler, giftCustomItemHandler, + giftRoomTokensHandler, giftTokensHandler, giftXpHandler, linkedAccountsHandler, @@ -484,6 +485,8 @@ const app = new Hono() .post('/api/staff/players/:id/gift-tokens', giftTokensHandler) .post('/api/staff/players/:id/gift-custom-item', giftCustomItemHandler) .post('/api/staff/players/:id/gift-xp', giftXpHandler) + // Everyone standing in a room, across all of its instances. + .post('/api/staff/rooms/:roomId/gift-tokens', giftRoomTokensHandler) .post('/api/staff/players/:id/username-changes', addUsernameChangeHandler) .post('/api/staff/players/:id/clear-password', clearPasswordHandler)