[www] add message to staff boxes

This commit is contained in:
Devin Zuczek
2026-09-22 23:35:28 -04:00
parent b81974476e
commit 907b6efe15
3 changed files with 128 additions and 17 deletions
+28 -2
View File
@@ -1172,6 +1172,7 @@ function PlayerPage({
*/ */
function StaffPlayerActions({ account, navigate }: { account: PublicAccount; navigate: Navigate }) { function StaffPlayerActions({ account, navigate }: { account: PublicAccount; navigate: Navigate }) {
const [tokens, setTokens] = useState('') const [tokens, setTokens] = useState('')
const [tokenMessage, setTokenMessage] = useState('')
const [confirmingClear, setConfirmingClear] = useState(false) const [confirmingClear, setConfirmingClear] = useState(false)
const [customItemId, setCustomItemId] = useState('') const [customItemId, setCustomItemId] = useState('')
const [xp, setXp] = useState('') const [xp, setXp] = useState('')
@@ -1195,9 +1196,10 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
const amount = Number(tokens) const amount = Number(tokens)
const res = await call<{ balance: number }>(`${base}/gift-tokens`, { const res = await call<{ balance: number }>(`${base}/gift-tokens`, {
authed: true, authed: true,
json: { amount }, json: { amount, message: tokenMessage.trim() },
}) })
setTokens('') setTokens('')
setTokenMessage('')
const what = const what =
amount < 0 amount < 0
? `Took ${Math.abs(amount).toLocaleString()} tokens back` ? `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. they have. Zero sends an empty box and moves nothing.
</span> </span>
</label> </label>
<label>
Gift box message
<input
type="text"
value={tokenMessage}
maxLength={256}
placeholder="A gift from the staff!"
onChange={(e) => setTokenMessage(e.target.value)}
/>
<span className="hint">Optional. Blank sends the default.</span>
</label>
{gift.error && <p className="error">{gift.error}</p>} {gift.error && <p className="error">{gift.error}</p>}
{gift.done && <p className="ok">{gift.done}</p>} {gift.done && <p className="ok">{gift.done}</p>}
</form> </form>
@@ -2188,6 +2201,7 @@ function PublicRoomView({
*/ */
function StaffRoomTokens({ room }: { room: OwnedRoom }) { function StaffRoomTokens({ room }: { room: OwnedRoom }) {
const [tokens, setTokens] = useState('') const [tokens, setTokens] = useState('')
const [message, setMessage] = useState('')
const { pending, error, done, run } = useAction() const { pending, error, done, run } = useAction()
return ( return (
@@ -2200,9 +2214,10 @@ function StaffRoomTokens({ room }: { room: OwnedRoom }) {
const amount = Number(tokens) const amount = Number(tokens)
const res = await call<{ paid: number[]; skipped: number[] }>( const res = await call<{ paid: number[]; skipped: number[] }>(
`/api/staff/rooms/${room.RoomId}/gift-tokens`, `/api/staff/rooms/${room.RoomId}/gift-tokens`,
{ authed: true, json: { amount } } { authed: true, json: { amount, message: message.trim() } }
) )
setTokens('') setTokens('')
setMessage('')
const n = res.paid.length const n = res.paid.length
const missed = const missed =
res.skipped.length > 0 res.skipped.length > 0
@@ -2231,6 +2246,17 @@ function StaffRoomTokens({ room }: { room: OwnedRoom }) {
tokens back, skipping anyone who can&apos;t spare it. tokens back, skipping anyone who can&apos;t spare it.
</span> </span>
</label> </label>
<label>
Gift box message
<input
type="text"
value={message}
maxLength={256}
placeholder="A gift from the staff!"
onChange={(e) => setMessage(e.target.value)}
/>
<span className="hint">Optional. Blank sends the default.</span>
</label>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>} {done && <p className="ok">{done}</p>}
</form> </form>
+38 -15
View File
@@ -554,8 +554,8 @@ export const DEFAULT_MAX_XP_GIFT = 100
*/ */
export const MAX_TOKEN_DROP = 1_000 export const MAX_TOKEN_DROP = 1_000
/** The longest message a token drop's box may carry — the same cap a client message has. */ /** The longest message a staff token box may carry — the same cap a client message has. */
const MAX_TOKEN_DROP_MESSAGE = 256 const MAX_GIFT_MESSAGE = 256
/** /**
* `GiftContext.Purchased_Gift_A` — what a staff box says it came from. * `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 * `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. * 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 * 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 * 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. * 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<App>) {
const playerId = playerIdParam(c) const playerId = playerIdParam(c)
if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400) 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 amount = Number(body.amount)
const refusal = tokenAmountRefusal(c, amount) const refusal = tokenAmountRefusal(c, amount)
if (refusal !== null) return c.json({ error: refusal }, 400) 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) { if ((await getAccount(c.env.DB, playerId)) === null) {
return c.json({ error: 'No such player' }, 404) return c.json({ error: 'No such player' }, 404)
} }
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) 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) { if (sent === null) {
const held = await getBalance(c.env.DB, playerId, CurrencyType.RecCenterTokens, startingTokens) const held = await getBalance(c.env.DB, playerId, CurrencyType.RecCenterTokens, startingTokens)
return c.json({ error: `They only have ${held.toLocaleString()} tokens to take` }, 400) 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', { logger.info(amount < 0 ? 'staff took tokens back' : 'staff gifted tokens', {
moderatorId: staffId(c), moderatorId: staffId(c),
playerId, playerId,
@@ -735,6 +738,22 @@ function tokenAmountRefusal(c: Context<App>, amount: number): string | null {
return 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 * 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 * 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 * button is pressed. Someone who arrives a second later gets nothing; someone whose presence
* has lapsed is already gone from it. * has lapsed is already gone from it.
* *
* Each player is paid exactly as the one-player gift pays them, one after another rather than * Each player is paid exactly as the one-player gift pays them — the same box, with the same
* at once: this is a handful of writes per player, and a busy room would otherwise open a * 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 * 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 * failing the room — the ones who could afford it have already been debited by then — and the
* response says who was missed. * response says who was missed.
@@ -828,10 +847,12 @@ export async function giftRoomTokensHandler(c: Context<App>) {
return c.json({ error: 'A numeric room id is required' }, 400) 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 amount = Number(body.amount)
const refusal = tokenAmountRefusal(c, amount) const refusal = tokenAmountRefusal(c, amount)
if (refusal !== null) return c.json({ error: refusal }, 400) 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) const playerIds = await getPlayerIdsInRoom(c.env.DB, roomId)
if (playerIds.length === 0) { if (playerIds.length === 0) {
@@ -842,12 +863,12 @@ export async function giftRoomTokensHandler(c: Context<App>) {
const paid: number[] = [] const paid: number[] = []
const skipped: number[] = [] const skipped: number[] = []
for (const playerId of playerIds) { 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) if (sent === null) skipped.push(playerId)
else paid.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', { logger.info('staff gifted tokens to a room', {
moderatorId: staffId(c), moderatorId: staffId(c),
roomId, roomId,
@@ -885,11 +906,13 @@ export async function giftOnlineTokensHandler(c: Context<App>) {
400 400
) )
} }
const message = typeof body.message === 'string' ? body.message.trim() : '' // Required here, where the other gifts fall back to the staff default: a server-wide drop
if (message === '') return c.json({ error: 'Enter the message for the gift box' }, 400) // with nothing to say about itself is a mistake more often than not.
if (message.length > MAX_TOKEN_DROP_MESSAGE) { if (typeof body.message !== 'string' || body.message.trim() === '') {
return c.json({ error: `Keep the message under ${MAX_TOKEN_DROP_MESSAGE} characters` }, 400) 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) const playerIds = await getOnlinePlayerIds(c.env.DB)
if (playerIds.length === 0) return c.json({ error: 'Nobody is online right now' }, 404) if (playerIds.length === 0) return c.json({ error: 'Nobody is online right now' }, 404)
+62
View File
@@ -1340,6 +1340,7 @@ it('gifts a player tokens in a gift box', async () => {
data: { data: {
playerId: 8300, playerId: 8300,
amount: 500, amount: 500,
message: 'A gift from the staff!',
balance: DEFAULT_STARTING_TOKENS + 500, balance: DEFAULT_STARTING_TOKENS + 500,
giftId: body.giftId, giftId: body.giftId,
}, },
@@ -1395,6 +1396,7 @@ it('takes tokens back on a negative gift, and cannot overdraw', async () => {
data: { data: {
playerId: 8360, playerId: 8360,
amount: -400, amount: -400,
message: 'A gift from the staff!',
balance: DEFAULT_STARTING_TOKENS - 400, balance: DEFAULT_STARTING_TOKENS - 400,
giftId: body.giftId, 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({ expect(JSON.parse(results[0].data)).toEqual({
roomId: 7700, roomId: 7700,
amount: 250, amount: 250,
message: 'A gift from the staff!',
paid: [8370, 8371, 8372], paid: [8370, 8371, 8372],
skipped: [], skipped: [],
}) })
@@ -1477,6 +1480,65 @@ it('refuses a room gift with nobody in the room, and a bad amount', async () =>
).toBe(400) ).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 // 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 // player sat in the lobby with no instance at all — and the box carries the operator's message
// rather than the staff default. // rather than the staff default.