}
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.