From 907b6efe1552cd02d922c7817e69ceb48abf524a Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 22 Sep 2026 23:35:28 -0400 Subject: [PATCH] [www] add message to staff boxes --- apps/www/src/client/App.tsx | 30 ++++++++++- apps/www/src/staff.ts | 53 +++++++++++++------ apps/www/src/test/integration/api.test.ts | 62 +++++++++++++++++++++++ 3 files changed, 128 insertions(+), 17 deletions(-) diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 7842aff..2c73b5c 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -1172,6 +1172,7 @@ function PlayerPage({ */ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; navigate: Navigate }) { const [tokens, setTokens] = useState('') + const [tokenMessage, setTokenMessage] = useState('') const [confirmingClear, setConfirmingClear] = useState(false) const [customItemId, setCustomItemId] = useState('') const [xp, setXp] = useState('') @@ -1195,9 +1196,10 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav const amount = Number(tokens) const res = await call<{ balance: number }>(`${base}/gift-tokens`, { authed: true, - json: { amount }, + json: { amount, message: tokenMessage.trim() }, }) setTokens('') + setTokenMessage('') const what = amount < 0 ? `Took ${Math.abs(amount).toLocaleString()} tokens back` @@ -1228,6 +1230,17 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav they have. Zero sends an empty box and moves nothing. + {gift.error &&

{gift.error}

} {gift.done &&

{gift.done}

} @@ -2188,6 +2201,7 @@ function PublicRoomView({ */ function StaffRoomTokens({ room }: { room: OwnedRoom }) { const [tokens, setTokens] = useState('') + const [message, setMessage] = useState('') const { pending, error, done, run } = useAction() return ( @@ -2200,9 +2214,10 @@ function StaffRoomTokens({ room }: { room: OwnedRoom }) { const amount = Number(tokens) const res = await call<{ paid: number[]; skipped: number[] }>( `/api/staff/rooms/${room.RoomId}/gift-tokens`, - { authed: true, json: { amount } } + { authed: true, json: { amount, message: message.trim() } } ) setTokens('') + setMessage('') const n = res.paid.length const missed = res.skipped.length > 0 @@ -2231,6 +2246,17 @@ function StaffRoomTokens({ room }: { room: OwnedRoom }) { tokens back, skipping anyone who can't spare it. + {error &&

{error}

} {done &&

{done}

} diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index 819b453..53cab06 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -554,8 +554,8 @@ export const DEFAULT_MAX_XP_GIFT = 100 */ export const MAX_TOKEN_DROP = 1_000 -/** The longest message a token drop's box may carry — the same cap a client message has. */ -const MAX_TOKEN_DROP_MESSAGE = 256 +/** The longest message a staff token box may carry — the same cap a client message has. */ +const MAX_GIFT_MESSAGE = 256 /** * `GiftContext.Purchased_Gift_A` — what a staff box says it came from. @@ -683,7 +683,8 @@ async function announceGift( * `StorefrontBalanceUpdate` carrying the resulting total sets the client's bucket, and the box * is stored and announced with `GiftPackageReceivedImmediate` so the player sees what arrived. * The box is from the Coach, as every box the server hands over on nobody's behalf is; who - * really sent it is on the audit row. + * really sent it is on the audit row. It says what the body's optional `message` says, or + * the staff default when there is none. * * 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. @@ -697,22 +698,24 @@ export async function giftTokensHandler(c: Context) { const playerId = playerIdParam(c) if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400) - const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown } + const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown; message?: unknown } const amount = Number(body.amount) const refusal = tokenAmountRefusal(c, amount) if (refusal !== null) return c.json({ error: refusal }, 400) + const message = giftMessage(body) + if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 400) if ((await getAccount(c.env.DB, playerId)) === null) { return c.json({ error: 'No such player' }, 404) } const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) - const sent = await sendTokens(c, playerId, amount, startingTokens) + const sent = await sendTokens(c, playerId, amount, startingTokens, message) 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 }) + await recordPlayerAudit(c, 'gift_tokens', { playerId, amount, message, ...sent }) logger.info(amount < 0 ? 'staff took tokens back' : 'staff gifted tokens', { moderatorId: staffId(c), playerId, @@ -735,6 +738,22 @@ function tokenAmountRefusal(c: Context, amount: number): string | null { return null } +/** + * The message a token box should carry, from the body's optional `message`: the staffer's + * own words, trimmed, or {@link STAFF_GIFT_MESSAGE} when they wrote none. `undefined` means + * a message was written but is too long to send — the caller refuses rather than truncating + * what the player is going to read. + */ +function giftMessage(body: { message?: unknown }): string | undefined { + const message = typeof body.message === 'string' ? body.message.trim() : '' + if (message === '') return STAFF_GIFT_MESSAGE + if (message.length > MAX_GIFT_MESSAGE) return undefined + return message +} + +/** The refusal {@link giftMessage} stands for when it answers `undefined`. */ +const GIFT_MESSAGE_TOO_LONG = `Keep the message under ${MAX_GIFT_MESSAGE} characters` + /** * 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 @@ -816,8 +835,8 @@ async function sendTokens( * 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 + * 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. @@ -828,10 +847,12 @@ export async function giftRoomTokensHandler(c: Context) { return c.json({ error: 'A numeric room id is required' }, 400) } - const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown } + const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown; message?: unknown } const amount = Number(body.amount) const refusal = tokenAmountRefusal(c, amount) if (refusal !== null) return c.json({ error: refusal }, 400) + const message = giftMessage(body) + if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 400) const playerIds = await getPlayerIdsInRoom(c.env.DB, roomId) if (playerIds.length === 0) { @@ -842,12 +863,12 @@ export async function giftRoomTokensHandler(c: Context) { const paid: number[] = [] const skipped: number[] = [] for (const playerId of playerIds) { - const sent = await sendTokens(c, playerId, amount, startingTokens) + const sent = await sendTokens(c, playerId, amount, startingTokens, message) if (sent === null) skipped.push(playerId) else paid.push(playerId) } - await recordPlayerAudit(c, 'gift_tokens_room', { roomId, amount, paid, skipped }) + await recordPlayerAudit(c, 'gift_tokens_room', { roomId, amount, message, paid, skipped }) logger.info('staff gifted tokens to a room', { moderatorId: staffId(c), roomId, @@ -885,11 +906,13 @@ export async function giftOnlineTokensHandler(c: Context) { 400 ) } - const message = typeof body.message === 'string' ? body.message.trim() : '' - if (message === '') return c.json({ error: 'Enter the message for the gift box' }, 400) - if (message.length > MAX_TOKEN_DROP_MESSAGE) { - return c.json({ error: `Keep the message under ${MAX_TOKEN_DROP_MESSAGE} characters` }, 400) + // Required here, where the other gifts fall back to the staff default: a server-wide drop + // with nothing to say about itself is a mistake more often than not. + if (typeof body.message !== 'string' || body.message.trim() === '') { + return c.json({ error: 'Enter the message for the gift box' }, 400) } + const message = giftMessage(body) + if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 400) const playerIds = await getOnlinePlayerIds(c.env.DB) if (playerIds.length === 0) return c.json({ error: 'Nobody is online right now' }, 404) diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index a776d1d..d33cbee 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -1340,6 +1340,7 @@ it('gifts a player tokens in a gift box', async () => { data: { playerId: 8300, amount: 500, + message: 'A gift from the staff!', balance: DEFAULT_STARTING_TOKENS + 500, giftId: body.giftId, }, @@ -1395,6 +1396,7 @@ it('takes tokens back on a negative gift, and cannot overdraw', async () => { data: { playerId: 8360, amount: -400, + message: 'A gift from the staff!', balance: DEFAULT_STARTING_TOKENS - 400, giftId: body.giftId, }, @@ -1461,6 +1463,7 @@ it('sends tokens to everyone in a room, across its instances', async () => { expect(JSON.parse(results[0].data)).toEqual({ roomId: 7700, amount: 250, + message: 'A gift from the staff!', paid: [8370, 8371, 8372], skipped: [], }) @@ -1477,6 +1480,65 @@ it('refuses a room gift with nobody in the room, and a bad amount', async () => ).toBe(400) }) +// The box says what the staffer wrote, when they wrote something: the one-player and room +// gifts take the same optional `message` the drop requires. Blank keeps the staff default, +// and one too long to send is refused rather than cut. +it('puts a custom message on a one-player and a room token gift', async () => { + await updateAccount(env.DB, 8362, { username: 'Messaged' }) + let res = await devPost('/api/staff/players/8362/gift-tokens', 8110, { + amount: 50, + message: ' Well played! ', + }) + expect(res.status).toBe(200) + expect(await getPendingGifts(env.DB, 8362)).toMatchObject([ + { Currency: 50, Message: 'Well played!' }, + ]) + expect(await auditRows('gift_tokens', 8362)).toMatchObject([ + { data: { message: 'Well played!' } }, + ]) + + res = await devPost('/api/staff/players/8362/gift-tokens', 8110, { amount: 5, message: '' }) + expect(res.status).toBe(200) + expect((await getPendingGifts(env.DB, 8362)).map((g) => g.Message)).toEqual([ + 'Well played!', + 'A gift from the staff!', + ]) + expect( + ( + await devPost('/api/staff/players/8362/gift-tokens', 8110, { + amount: 5, + message: 'x'.repeat(257), + }) + ).status + ).toBe(400) + + await setPresence(env.DB, { + accountId: 8363, + roomInstance: { roomId: 7720, roomInstanceId: 77301 }, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 0, + platform: 4, + appVersion: 'test', + }) + res = await devPost('/api/staff/rooms/7720/gift-tokens', 8110, { + amount: 25, + message: 'Thanks for coming!', + }) + expect(res.status).toBe(200) + expect(await getPendingGifts(env.DB, 8363)).toMatchObject([ + { Currency: 25, Message: 'Thanks for coming!' }, + ]) + expect( + ( + await devPost('/api/staff/rooms/7720/gift-tokens', 8110, { + amount: 25, + message: 'x'.repeat(257), + }) + ).status + ).toBe(400) +}) + // Everyone online is the audience — a player in a room, a player in another room and a // player sat in the lobby with no instance at all — and the box carries the operator's message // rather than the staff default.