mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[www] add message to staff boxes
This commit is contained in:
@@ -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.
|
||||
</span>
|
||||
</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.done && <p className="ok">{gift.done}</p>}
|
||||
</form>
|
||||
@@ -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.
|
||||
</span>
|
||||
</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>}
|
||||
{done && <p className="ok">{done}</p>}
|
||||
</form>
|
||||
|
||||
+38
-15
@@ -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<App>) {
|
||||
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<App>, 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<App>) {
|
||||
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<App>) {
|
||||
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<App>) {
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user