mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] token drop functionality
This commit is contained in:
@@ -3068,6 +3068,10 @@ function Dashboard({
|
||||
{ id: 'coach', label: 'Coach message', render: () => <CoachMessageForm /> },
|
||||
]
|
||||
: []),
|
||||
// Narrower than the two above: the drop mints tokens, and www's route is developer-only.
|
||||
...(isDeveloper()
|
||||
? [{ id: 'tokens', label: 'Token drop', render: () => <TokenDropForm /> }]
|
||||
: []),
|
||||
]
|
||||
const [active, setActive] = useState(sections[0].id)
|
||||
const current = sections.find((s) => s.id === active) ?? sections[0]
|
||||
@@ -3355,6 +3359,84 @@ function MaintenanceForm() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The most a token drop carries per player — a mirror of www's `MAX_TOKEN_DROP`, which is
|
||||
* the one that decides. Not imported: the staff module pulls hono and the JWT code into the
|
||||
* page bundle for one number.
|
||||
*/
|
||||
const MAX_TOKEN_DROP = 1_000
|
||||
|
||||
/**
|
||||
* Developer-only: give every player online right now the same number of tokens, in a gift
|
||||
* box carrying the operator's message — the server-wide cousin of the room page's staff gift.
|
||||
*
|
||||
* The audience is read by the server when the button is pressed, so it is whoever is on at
|
||||
* that moment, lobby included; the button says as much. The cap is the server's
|
||||
* (`MAX_TOKEN_DROP`, 1,000 each) and is repeated on the input so a typo is caught before the
|
||||
* round trip — the server is still the one that refuses.
|
||||
*/
|
||||
function TokenDropForm() {
|
||||
const [message, setMessage] = useState('')
|
||||
const [tokens, setTokens] = useState('')
|
||||
const { pending, error, done, run } = useAction()
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Token drop</h2>
|
||||
<p className="muted">
|
||||
Give everyone online right now the same number of tokens. Each player gets a gift box with
|
||||
your message on it; a player who isn't signed in when you press the button gets
|
||||
nothing. Up to {MAX_TOKEN_DROP.toLocaleString()} tokens each.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void run(async () => {
|
||||
const amount = Number(tokens)
|
||||
const res = await call<{ paid: number[] }>('/api/staff/online/gift-tokens', {
|
||||
authed: true,
|
||||
json: { amount, message: message.trim() },
|
||||
})
|
||||
setTokens('')
|
||||
setMessage('')
|
||||
const n = res.paid.length
|
||||
return `Sent ${amount.toLocaleString()} tokens to ${n} online player${n === 1 ? '' : 's'}.`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Gift box message
|
||||
<textarea
|
||||
value={message}
|
||||
rows={3}
|
||||
maxLength={256}
|
||||
placeholder="Thanks for playing this weekend!"
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Tokens per player
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_TOKEN_DROP}
|
||||
step={1}
|
||||
value={tokens}
|
||||
required
|
||||
onChange={(e) => setTokens(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{done && <p className="ok">{done}</p>}
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? 'Sending…' : 'Send to everyone online'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the account's username — the name used to sign in, here and in the game.
|
||||
*
|
||||
|
||||
+72
-1
@@ -4,6 +4,7 @@ import {
|
||||
clearPasswordHash,
|
||||
createGift,
|
||||
getAccount,
|
||||
getOnlinePlayerIds,
|
||||
getPlayerIdsInRoom,
|
||||
getPresences,
|
||||
movePlayerToDorm,
|
||||
@@ -545,6 +546,17 @@ export const DEFAULT_MAX_TOKEN_GIFT = 10_000
|
||||
*/
|
||||
export const DEFAULT_MAX_XP_GIFT = 100
|
||||
|
||||
/**
|
||||
* The most a token DROP — the gift to everyone online at once — can carry per player. Fixed
|
||||
* rather than an operator knob like `MAX_TOKEN_GIFT`, and far under it: this one is multiplied
|
||||
* by however many players are on, and the drop is meant for a small thank-you to the whole
|
||||
* server, not a payout.
|
||||
*/
|
||||
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
|
||||
|
||||
/**
|
||||
* `GiftContext.Purchased_Gift_A` — what a staff box says it came from.
|
||||
*
|
||||
@@ -735,12 +747,16 @@ function tokenAmountRefusal(c: Context<App>, amount: number): string | null {
|
||||
*
|
||||
* The signup grant is seeded first, as econ does before every credit: `creditCurrency`
|
||||
* upserts the row, so a never-touched balance would otherwise start from this gift.
|
||||
*
|
||||
* `message` is what the box says; the staff default unless the caller wrote one (the drop
|
||||
* to everyone online does).
|
||||
*/
|
||||
async function sendTokens(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
amount: number,
|
||||
startingTokens: number
|
||||
startingTokens: number,
|
||||
message: string = STAFF_GIFT_MESSAGE
|
||||
): Promise<{ balance: number; giftId: number } | null> {
|
||||
await ensureStartingBalances(c.env.DB, playerId, startingTokens)
|
||||
if (amount < 0) {
|
||||
@@ -767,6 +783,7 @@ async function sendTokens(
|
||||
const content = staffGiftContent({
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Currency: amount,
|
||||
Message: message,
|
||||
})
|
||||
const gift = await createGift(c.env.DB, playerId, content)
|
||||
|
||||
@@ -841,6 +858,60 @@ export async function giftRoomTokensHandler(c: Context<App>) {
|
||||
return c.json({ roomId, amount, paid, skipped })
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tokens to EVERYONE ONLINE right now — the server-wide drop behind the account page's
|
||||
* "Token drop" tab, beside the coach broadcast and the maintenance notice.
|
||||
*
|
||||
* The audience is every unexpired `presence` row, lobby included (see `getOnlinePlayerIds`),
|
||||
* read when the button is pressed: the same population the coach broadcast reaches and the
|
||||
* status page counts. Each player is paid exactly as the one-player and room gifts pay them
|
||||
* (banked, then a balance frame and an announced box), one after another for the same reason
|
||||
* the room gift is — a full server is a lot of writes, and they should not all open at once.
|
||||
*
|
||||
* Narrower than the other gifts on purpose: the amount is POSITIVE and capped at
|
||||
* {@link MAX_TOKEN_DROP} per player, whatever `MAX_TOKEN_GIFT` says — a stray zero here is
|
||||
* multiplied by everyone online, and there is no way to take a drop back. The message on the
|
||||
* box is the operator's, required, since the box is what the players actually see of it.
|
||||
*/
|
||||
export async function giftOnlineTokensHandler(c: Context<App>) {
|
||||
const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown; message?: unknown }
|
||||
const amount = Number(body.amount)
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
return c.json({ error: 'Enter a whole number of tokens greater than 0' }, 400)
|
||||
}
|
||||
if (amount > MAX_TOKEN_DROP) {
|
||||
return c.json(
|
||||
{ error: `A token drop can carry at most ${MAX_TOKEN_DROP.toLocaleString()} tokens each` },
|
||||
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)
|
||||
}
|
||||
|
||||
const playerIds = await getOnlinePlayerIds(c.env.DB)
|
||||
if (playerIds.length === 0) return c.json({ error: 'Nobody is online right now' }, 404)
|
||||
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
const paid: number[] = []
|
||||
for (const playerId of playerIds) {
|
||||
// A positive amount never comes back null: nothing to skip.
|
||||
if ((await sendTokens(c, playerId, amount, startingTokens, message)) !== null) {
|
||||
paid.push(playerId)
|
||||
}
|
||||
}
|
||||
|
||||
await recordPlayerAudit(c, 'gift_tokens_online', { amount, message, paid })
|
||||
logger.info('staff dropped tokens on everyone online', {
|
||||
moderatorId: staffId(c),
|
||||
amount,
|
||||
paidCount: paid.length,
|
||||
})
|
||||
return c.json({ amount, message, paid })
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a player XP, in a gift box — the way a game reward pays its XP. The XP is banked
|
||||
* here (the box, like every box, grants nothing when opened) and spent on any levels it now
|
||||
|
||||
@@ -720,7 +720,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
|
||||
'/api/staff/players/1/username-changes',
|
||||
'/api/staff/players/1/clear-password',
|
||||
]
|
||||
writes.push('/api/staff/rooms/1/gift-tokens')
|
||||
writes.push('/api/staff/rooms/1/gift-tokens', '/api/staff/online/gift-tokens')
|
||||
for (const path of writes) {
|
||||
expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401)
|
||||
const res = await SELF.fetch(`https://example.com${path}`, {
|
||||
@@ -1477,6 +1477,75 @@ it('refuses a room gift with nobody in the room, and a bad amount', async () =>
|
||||
).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.
|
||||
it('drops tokens on everyone online, lobby included, with the message on the box', async () => {
|
||||
const online = async (
|
||||
accountId: number,
|
||||
roomInstance: { roomId: number; roomInstanceId: number } | null
|
||||
) =>
|
||||
setPresence(env.DB, {
|
||||
accountId,
|
||||
roomInstance,
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 0,
|
||||
platform: 4,
|
||||
appVersion: 'test',
|
||||
})
|
||||
await online(8380, { roomId: 7710, roomInstanceId: 77201 })
|
||||
await online(8381, { roomId: 7711, roomInstanceId: 77202 })
|
||||
await online(8382, null)
|
||||
// Expired presence is offline: the row is still there until the sweep, but they left.
|
||||
await online(8383, { roomId: 7710, roomInstanceId: 77201 })
|
||||
await env.DB.prepare(
|
||||
`UPDATE presence SET data = json_set(data, '$.expiresAt', 1) WHERE account_id = 8383`
|
||||
).run()
|
||||
|
||||
const res = await devPost('/api/staff/online/gift-tokens', 8110, {
|
||||
amount: 100,
|
||||
message: 'Thanks for playing!',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { amount: number; message: string; paid: number[] }
|
||||
expect(body.amount).toBe(100)
|
||||
expect(body.message).toBe('Thanks for playing!')
|
||||
expect(body.paid).toEqual(expect.arrayContaining([8380, 8381, 8382]))
|
||||
expect(body.paid).not.toContain(8383)
|
||||
|
||||
for (const playerId of [8380, 8381, 8382]) {
|
||||
await expect(
|
||||
getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).resolves.toBe(DEFAULT_STARTING_TOKENS + 100)
|
||||
const gifts = await getPendingGifts(env.DB, playerId)
|
||||
expect(gifts).toHaveLength(1)
|
||||
expect(gifts[0]).toMatchObject({ Currency: 100, Message: 'Thanks for playing!' })
|
||||
}
|
||||
expect(await getPendingGifts(env.DB, 8383)).toEqual([])
|
||||
|
||||
const { results } = await env.DB.prepare(
|
||||
`SELECT player_id, data FROM audit_log WHERE action = 'gift_tokens_online'`
|
||||
).all<{ player_id: number; data: string }>()
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].player_id).toBe(8110)
|
||||
expect(JSON.parse(results[0].data)).toMatchObject({ amount: 100, message: 'Thanks for playing!' })
|
||||
})
|
||||
|
||||
// The drop is narrower than the other gifts: positive only, capped at 1,000 each whatever
|
||||
// MAX_TOKEN_GIFT allows, and the box needs a message — that is what the players see of it.
|
||||
it('refuses a token drop over the cap, without a message, or with nobody online', async () => {
|
||||
const drop = (body: unknown) => devPost('/api/staff/online/gift-tokens', 8110, body)
|
||||
expect((await drop({ amount: 1_001, message: 'hi' })).status).toBe(400)
|
||||
expect((await drop({ amount: 0, message: 'hi' })).status).toBe(400)
|
||||
expect((await drop({ amount: -5, message: 'hi' })).status).toBe(400)
|
||||
expect((await drop({ amount: 10, message: ' ' })).status).toBe(400)
|
||||
expect((await drop({ amount: 10 })).status).toBe(400)
|
||||
// An empty server is a 404 rather than a silent success, as an empty room is.
|
||||
await env.DB.prepare('DELETE FROM presence').run()
|
||||
expect((await drop({ amount: 1_000, message: 'hi' })).status).toBe(404)
|
||||
})
|
||||
|
||||
// Zero is a real gift here: nothing moves, and the player gets an empty box.
|
||||
it('sends an empty box on a zero token gift', async () => {
|
||||
await updateAccount(env.DB, 8361, { username: 'Emptyhanded' })
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
createReportHandler,
|
||||
getReportHandler,
|
||||
giftCustomItemHandler,
|
||||
giftOnlineTokensHandler,
|
||||
giftRoomTokensHandler,
|
||||
giftTokensHandler,
|
||||
giftXpHandler,
|
||||
@@ -489,6 +490,8 @@ const app = new Hono<App>()
|
||||
.post('/api/staff/players/:id/gift-xp', requireDeveloper, giftXpHandler)
|
||||
// Everyone standing in a room, across all of its instances.
|
||||
.post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler)
|
||||
// The account page's token drop: the same gift to everyone online at once.
|
||||
.post('/api/staff/online/gift-tokens', requireDeveloper, giftOnlineTokensHandler)
|
||||
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
|
||||
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user