mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -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 }) {
|
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't spare it.
|
tokens back, skipping anyone who can'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
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user