- 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)