mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[www] room token fun
This commit is contained in:
@@ -2155,7 +2155,12 @@ function PublicRoomView({
|
|||||||
|
|
||||||
{/* Staff only, and cosmetic: hidden for everyone else, but `rooms` checks the token's
|
{/* Staff only, and cosmetic: hidden for everyone else, but `rooms` checks the token's
|
||||||
role itself on the DELETE. */}
|
role itself on the DELETE. */}
|
||||||
{isAdmin() && <StaffTakedown room={room} onTakenDown={onTakenDown} />}
|
{isAdmin() && (
|
||||||
|
<>
|
||||||
|
<StaffRoomTokens room={room} />
|
||||||
|
<StaffTakedown room={room} onTakenDown={onTakenDown} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2166,6 +2171,63 @@ function PublicRoomView({
|
|||||||
* steps — a plain card, then the same accent-bordered confirm the ban form uses — since
|
* steps — a plain card, then the same accent-bordered confirm the ban form uses — since
|
||||||
* this is the one thing on the site that can't be undone.
|
* this is the one thing on the site that can't be undone.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Pay everyone standing in the room, across every instance of it, from the room's own page.
|
||||||
|
* The audience is read when the button is pressed, so it is whoever is in there at that
|
||||||
|
* moment — there is nothing to pick and nobody to look up.
|
||||||
|
*/
|
||||||
|
function StaffRoomTokens({ room }: { room: OwnedRoom }) {
|
||||||
|
const [tokens, setTokens] = useState('')
|
||||||
|
const { pending, error, done, run } = useAction()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Staff</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void run(async () => {
|
||||||
|
const amount = Number(tokens)
|
||||||
|
const res = await call<{ paid: number[]; skipped: number[] }>(
|
||||||
|
`/api/staff/rooms/${room.RoomId}/gift-tokens`,
|
||||||
|
{ authed: true, json: { amount } }
|
||||||
|
)
|
||||||
|
setTokens('')
|
||||||
|
const n = res.paid.length
|
||||||
|
const missed =
|
||||||
|
res.skipped.length > 0
|
||||||
|
? ` ${res.skipped.length} couldn't spare it and were skipped.`
|
||||||
|
: ''
|
||||||
|
return `Sent ${amount.toLocaleString()} tokens to ${n} player${n === 1 ? '' : 's'} in ^${room.Name}.${missed}`
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Gift tokens to everyone here
|
||||||
|
<span className="staff-gift">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={1}
|
||||||
|
value={tokens}
|
||||||
|
required
|
||||||
|
onChange={(e) => setTokens(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
{pending ? 'Sending…' : 'Send'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span className="hint">
|
||||||
|
Everyone in the room right now, across all of its instances. A negative amount takes
|
||||||
|
tokens back, skipping anyone who can't spare it.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{done && <p className="ok">{done}</p>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) {
|
function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) {
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const { pending, error, run } = useAction()
|
const { pending, error, run } = useAction()
|
||||||
@@ -2173,7 +2235,7 @@ function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: ()
|
|||||||
if (!confirming) {
|
if (!confirming) {
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Staff</h2>
|
<h2>Delete room</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Deleting a room removes it for everyone: the room, its subrooms and their saves, and its
|
Deleting a room removes it for everyone: the room, its subrooms and their saves, and its
|
||||||
picture. Photos players took in it stay on their profiles.
|
picture. Photos players took in it stay on their profiles.
|
||||||
|
|||||||
+102
-30
@@ -4,6 +4,7 @@ import {
|
|||||||
clearPasswordHash,
|
clearPasswordHash,
|
||||||
createGift,
|
createGift,
|
||||||
getAccount,
|
getAccount,
|
||||||
|
getPlayerIdsInRoom,
|
||||||
getPresences,
|
getPresences,
|
||||||
movePlayerToDorm,
|
movePlayerToDorm,
|
||||||
writeAuditLog,
|
writeAuditLog,
|
||||||
@@ -673,23 +674,62 @@ export async function giftTokensHandler(c: Context<App>) {
|
|||||||
|
|
||||||
const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown }
|
const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown }
|
||||||
const amount = Number(body.amount)
|
const amount = Number(body.amount)
|
||||||
if (!Number.isInteger(amount)) {
|
const refusal = tokenAmountRefusal(c, amount)
|
||||||
return c.json({ error: 'Enter a whole number of tokens, positive or negative' }, 400)
|
if (refusal !== null) return c.json({ error: refusal }, 400)
|
||||||
}
|
|
||||||
const maxGift = intVar(c.env.MAX_TOKEN_GIFT, DEFAULT_MAX_TOKEN_GIFT)
|
|
||||||
if (Math.abs(amount) > maxGift) {
|
|
||||||
return c.json({ error: `A gift can carry at most ${maxGift.toLocaleString()} tokens` }, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed the signup grant first, as econ does before every credit: `creditCurrency` upserts,
|
|
||||||
// and a never-touched balance would otherwise start from this gift instead of the grant.
|
|
||||||
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)
|
||||||
|
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 })
|
||||||
|
logger.info(amount < 0 ? 'staff took tokens back' : 'staff gifted tokens', {
|
||||||
|
moderatorId: staffId(c),
|
||||||
|
playerId,
|
||||||
|
amount,
|
||||||
|
})
|
||||||
|
return c.json({ playerId, amount, ...sent })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why this token amount can't be sent, or null when it can. Shared by the one-player gift
|
||||||
|
* and the room-wide one so they agree on what an amount is: any whole number within the
|
||||||
|
* operator's cap, in either direction (see {@link sendTokens} for what each sign does).
|
||||||
|
*/
|
||||||
|
function tokenAmountRefusal(c: Context<App>, amount: number): string | null {
|
||||||
|
if (!Number.isInteger(amount)) return 'Enter a whole number of tokens, positive or negative'
|
||||||
|
const maxGift = intVar(c.env.MAX_TOKEN_GIFT, DEFAULT_MAX_TOKEN_GIFT)
|
||||||
|
if (Math.abs(amount) > maxGift) {
|
||||||
|
return `A gift can carry at most ${maxGift.toLocaleString()} tokens`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* negative amount is more than they hold (nothing is changed, and no box is minted).
|
||||||
|
*
|
||||||
|
* A POSITIVE amount credits, as econ's own faucets do. A NEGATIVE one debits through the
|
||||||
|
* same guarded spend a purchase uses, so it cannot overdraw. ZERO moves nothing. All three
|
||||||
|
* mint a box — an empty or negative one has never been seen in the client, and finding out
|
||||||
|
* is the point.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
async function sendTokens(
|
||||||
|
c: Context<App>,
|
||||||
|
playerId: number,
|
||||||
|
amount: number,
|
||||||
|
startingTokens: number
|
||||||
|
): Promise<{ balance: number; giftId: number } | null> {
|
||||||
await ensureStartingBalances(c.env.DB, playerId, startingTokens)
|
await ensureStartingBalances(c.env.DB, playerId, startingTokens)
|
||||||
// A NEGATIVE amount takes tokens back, through the same guarded debit a purchase spends
|
|
||||||
// with — so it can't overdraw, and a player who can't afford it keeps what they have.
|
|
||||||
if (amount < 0) {
|
if (amount < 0) {
|
||||||
const spent = await spendCurrency(
|
const spent = await spendCurrency(
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
@@ -698,18 +738,8 @@ export async function giftTokensHandler(c: Context<App>) {
|
|||||||
-amount,
|
-amount,
|
||||||
startingTokens
|
startingTokens
|
||||||
)
|
)
|
||||||
if (!spent) {
|
if (!spent) return 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)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Zero moves nothing and still sends a box — an empty one, for the same reason a negative
|
|
||||||
// one is allowed: nobody has seen what the client draws for it.
|
|
||||||
const balance =
|
const balance =
|
||||||
amount > 0
|
amount > 0
|
||||||
? await creditCurrency(
|
? await creditCurrency(
|
||||||
@@ -727,13 +757,6 @@ export async function giftTokensHandler(c: Context<App>) {
|
|||||||
})
|
})
|
||||||
const gift = await createGift(c.env.DB, playerId, content)
|
const gift = await createGift(c.env.DB, playerId, content)
|
||||||
|
|
||||||
await recordPlayerAudit(c, 'gift_tokens', { playerId, amount, balance, giftId: gift.id })
|
|
||||||
logger.info(amount < 0 ? 'staff took tokens back' : 'staff gifted tokens', {
|
|
||||||
moderatorId: staffId(c),
|
|
||||||
playerId,
|
|
||||||
amount,
|
|
||||||
})
|
|
||||||
|
|
||||||
// The balance first, so the box's announcement lands on a total that already includes it.
|
// The balance first, so the box's announcement lands on a total that already includes it.
|
||||||
try {
|
try {
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
@@ -752,8 +775,57 @@ export async function giftTokensHandler(c: Context<App>) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
await announceGift(c, playerId, gift.id, content)
|
await announceGift(c, playerId, gift.id, content)
|
||||||
|
return { balance, giftId: gift.id }
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ playerId, amount, balance, giftId: gift.id })
|
/**
|
||||||
|
* Send tokens to EVERYONE standing in a room right now — every instance of it at once.
|
||||||
|
*
|
||||||
|
* The room is the audience, not one session: `getPlayerIdsInRoom` is the live, unexpired
|
||||||
|
* presence for that room across its instances, deduplicated, and it is read at the moment the
|
||||||
|
* 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
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export async function giftRoomTokensHandler(c: Context<App>) {
|
||||||
|
const roomId = Number(c.req.param('roomId'))
|
||||||
|
if (!Number.isInteger(roomId) || roomId <= 0) {
|
||||||
|
return c.json({ error: 'A numeric room id is required' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown }
|
||||||
|
const amount = Number(body.amount)
|
||||||
|
const refusal = tokenAmountRefusal(c, amount)
|
||||||
|
if (refusal !== null) return c.json({ error: refusal }, 400)
|
||||||
|
|
||||||
|
const playerIds = await getPlayerIdsInRoom(c.env.DB, roomId)
|
||||||
|
if (playerIds.length === 0) {
|
||||||
|
return c.json({ error: 'Nobody is in that room right now' }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||||
|
const paid: number[] = []
|
||||||
|
const skipped: number[] = []
|
||||||
|
for (const playerId of playerIds) {
|
||||||
|
const sent = await sendTokens(c, playerId, amount, startingTokens)
|
||||||
|
if (sent === null) skipped.push(playerId)
|
||||||
|
else paid.push(playerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordPlayerAudit(c, 'gift_tokens_room', { roomId, amount, paid, skipped })
|
||||||
|
logger.info('staff gifted tokens to a room', {
|
||||||
|
moderatorId: staffId(c),
|
||||||
|
roomId,
|
||||||
|
amount,
|
||||||
|
paidCount: paid.length,
|
||||||
|
skippedCount: skipped.length,
|
||||||
|
})
|
||||||
|
return c.json({ roomId, amount, paid, skipped })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -718,6 +718,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/username-changes',
|
||||||
'/api/staff/players/1/clear-password',
|
'/api/staff/players/1/clear-password',
|
||||||
]
|
]
|
||||||
|
writes.push('/api/staff/rooms/1/gift-tokens')
|
||||||
for (const path of writes) {
|
for (const path of writes) {
|
||||||
expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401)
|
expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401)
|
||||||
const res = await SELF.fetch(`https://example.com${path}`, {
|
const res = await SELF.fetch(`https://example.com${path}`, {
|
||||||
@@ -1407,6 +1408,68 @@ it('takes tokens back on a negative gift, and cannot overdraw', async () => {
|
|||||||
expect(await auditRows('gift_tokens', 8360)).toHaveLength(1)
|
expect(await auditRows('gift_tokens', 8360)).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The room is the audience, not one session: everyone standing in any instance of it, read
|
||||||
|
// at the moment the button is pressed.
|
||||||
|
it('sends tokens to everyone in a room, across its instances', async () => {
|
||||||
|
const inRoom = async (accountId: number, roomId: number, roomInstanceId: number) =>
|
||||||
|
setPresence(env.DB, {
|
||||||
|
accountId,
|
||||||
|
roomInstance: { roomId, roomInstanceId },
|
||||||
|
statusVisibility: 0,
|
||||||
|
deviceClass: 0,
|
||||||
|
vrMovementMode: 0,
|
||||||
|
platform: 4,
|
||||||
|
appVersion: 'test',
|
||||||
|
})
|
||||||
|
// Two instances of room 7700, plus a player in a different room who must not be paid.
|
||||||
|
await inRoom(8370, 7700, 77101)
|
||||||
|
await inRoom(8371, 7700, 77101)
|
||||||
|
await inRoom(8372, 7700, 77102)
|
||||||
|
await inRoom(8373, 7701, 77103)
|
||||||
|
|
||||||
|
const res = await staffPost('/api/staff/rooms/7700/gift-tokens', 8110, { amount: 250 })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
roomId: 7700,
|
||||||
|
amount: 250,
|
||||||
|
paid: [8370, 8371, 8372],
|
||||||
|
skipped: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const playerId of [8370, 8371, 8372]) {
|
||||||
|
await expect(
|
||||||
|
getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||||
|
).resolves.toBe(DEFAULT_STARTING_TOKENS + 250)
|
||||||
|
expect(await getPendingGifts(env.DB, playerId)).toHaveLength(1)
|
||||||
|
}
|
||||||
|
// The player in the other room is untouched — no box, and no balance row touched.
|
||||||
|
expect(await getPendingGifts(env.DB, 8373)).toEqual([])
|
||||||
|
|
||||||
|
const { results } = await env.DB.prepare(
|
||||||
|
`SELECT player_id, data FROM audit_log
|
||||||
|
WHERE action = 'gift_tokens_room' AND json_extract(data, '$.roomId') = 7700`
|
||||||
|
).all<{ player_id: number; data: string }>()
|
||||||
|
expect(results).toHaveLength(1)
|
||||||
|
expect(results[0].player_id).toBe(8110)
|
||||||
|
expect(JSON.parse(results[0].data)).toEqual({
|
||||||
|
roomId: 7700,
|
||||||
|
amount: 250,
|
||||||
|
paid: [8370, 8371, 8372],
|
||||||
|
skipped: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// An empty room is a 404 rather than a silent success: a staffer who pressed the button
|
||||||
|
// expects somebody to have been paid.
|
||||||
|
it('refuses a room gift with nobody in the room, and a bad amount', async () => {
|
||||||
|
expect((await staffPost('/api/staff/rooms/7799/gift-tokens', 8110, { amount: 10 })).status).toBe(
|
||||||
|
404
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
(await staffPost('/api/staff/rooms/7700/gift-tokens', 8110, { amount: 10_001 })).status
|
||||||
|
).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
// Zero is a real gift here: nothing moves, and the player gets an empty box.
|
// 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 () => {
|
it('sends an empty box on a zero token gift', async () => {
|
||||||
await updateAccount(env.DB, 8361, { username: 'Emptyhanded' })
|
await updateAccount(env.DB, 8361, { username: 'Emptyhanded' })
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
createReportHandler,
|
createReportHandler,
|
||||||
getReportHandler,
|
getReportHandler,
|
||||||
giftCustomItemHandler,
|
giftCustomItemHandler,
|
||||||
|
giftRoomTokensHandler,
|
||||||
giftTokensHandler,
|
giftTokensHandler,
|
||||||
giftXpHandler,
|
giftXpHandler,
|
||||||
linkedAccountsHandler,
|
linkedAccountsHandler,
|
||||||
@@ -484,6 +485,8 @@ const app = new Hono<App>()
|
|||||||
.post('/api/staff/players/:id/gift-tokens', giftTokensHandler)
|
.post('/api/staff/players/:id/gift-tokens', giftTokensHandler)
|
||||||
.post('/api/staff/players/:id/gift-custom-item', giftCustomItemHandler)
|
.post('/api/staff/players/:id/gift-custom-item', giftCustomItemHandler)
|
||||||
.post('/api/staff/players/:id/gift-xp', giftXpHandler)
|
.post('/api/staff/players/:id/gift-xp', giftXpHandler)
|
||||||
|
// Everyone standing in a room, across all of its instances.
|
||||||
|
.post('/api/staff/rooms/:roomId/gift-tokens', giftRoomTokensHandler)
|
||||||
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
|
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
|
||||||
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user