[www] staff gifting

This commit is contained in:
Devin Zuczek
2026-09-22 15:24:01 -04:00
parent 533644fcf1
commit f64b04544a
7 changed files with 519 additions and 45 deletions
+84
View File
@@ -1172,7 +1172,11 @@ function PlayerPage({
function StaffPlayerActions({ account, navigate }: { account: PublicAccount; navigate: Navigate }) {
const [tokens, setTokens] = useState('')
const [confirmingClear, setConfirmingClear] = useState(false)
const [customItemId, setCustomItemId] = useState('')
const [xp, setXp] = useState('')
const gift = useAction()
const xpGift = useAction()
const customGift = useAction()
const usernameChange = useAction()
const clearPassword = useAction()
const base = `/api/staff/players/${account.accountId}`
@@ -1218,6 +1222,86 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
{gift.done && <p className="ok">{gift.done}</p>}
</form>
<form
onSubmit={(e) => {
e.preventDefault()
void xpGift.run(async () => {
const amount = Number(xp)
const res = await call<{ level: number; levelsGained: number }>(`${base}/gift-xp`, {
authed: true,
json: { amount },
})
setXp('')
const levels =
res.levelsGained > 0
? ` They went up ${res.levelsGained} level${res.levelsGained === 1 ? '' : 's'}, to ${res.level}.`
: ` They're level ${res.level}.`
return `Sent ${amount.toLocaleString()} XP.${levels}`
})
}}
>
<label>
Gift XP
<span className="staff-gift">
<input
type="number"
min={1}
step={1}
inputMode="numeric"
value={xp}
required
onChange={(e) => setXp(e.target.value)}
/>
<button type="submit" disabled={xpGift.pending}>
{xpGift.pending ? 'Sending…' : 'Send'}
</button>
</span>
<span className="hint">
Arrives as a gift box. Levels it crosses count, but don&apos;t pay their level-up
rewards.
</span>
</label>
{xpGift.error && <p className="error">{xpGift.error}</p>}
{xpGift.done && <p className="ok">{xpGift.done}</p>}
</form>
<form
onSubmit={(e) => {
e.preventDefault()
void customGift.run(async () => {
const res = await call<{ name: string }>(`${base}/gift-custom-item`, {
authed: true,
json: { customAvatarItemId: customItemId.trim() },
})
setCustomItemId('')
return `Sent “${res.name}” to @${account.username}.`
})
}}
>
<label>
Gift custom item
<span className="staff-gift">
<input
type="text"
value={customItemId}
placeholder="Custom avatar item id"
spellCheck={false}
required
onChange={(e) => setCustomItemId(e.target.value)}
/>
<button type="submit" disabled={customGift.pending}>
{customGift.pending ? 'Sending…' : 'Send'}
</button>
</span>
<span className="hint">
Arrives as a gift box, as if bought from the store. Free to them, and the creator
isn&apos;t paid.
</span>
</label>
{customGift.error && <p className="error">{customGift.error}</p>}
{customGift.done && <p className="ok">{customGift.done}</p>}
</form>
<div className="mod-filter-actions staff-actions">
<button
type="submit"
+5
View File
@@ -1568,6 +1568,11 @@ button[type='submit']:disabled {
max-width: 180px;
}
/* A custom item id is a GUID, far wider than a token amount. */
.staff-gift input[type='text'] {
max-width: 360px;
}
.staff-actions {
flex-wrap: wrap;
margin-top: 8px;
+5
View File
@@ -51,6 +51,11 @@ export type Env = SharedHonoEnv & {
* DEFAULT_MAX_TOKEN_GIFT (10,000).
*/
MAX_TOKEN_GIFT?: string | number
/**
* The most XP one staff XP gift can carry (see src/staff.ts), a typo guard like
* MAX_TOKEN_GIFT. Unset means DEFAULT_MAX_XP_GIFT (100).
*/
MAX_XP_GIFT?: string | number
/**
* Service binding to the `auth` worker — how the BFF reaches it, so the browser's real
* IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`).
+251 -45
View File
@@ -1,5 +1,6 @@
import {
addUsernameChange,
addXp,
clearPasswordHash,
createGift,
getAccount,
@@ -15,6 +16,7 @@ import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
// is the same write `api` would do. www owns the ENDPOINTS, not the storage — see the
// module comment below.
import { banEvasionMatch, linkedAccounts } from '../../api/src/bans-db'
import { getCustomAvatarItem } from '../../api/src/custom-avatar-items-db'
import {
banBlockDetails,
banFromReport,
@@ -35,6 +37,7 @@ import {
DEFAULT_STARTING_TOKENS,
ensureStartingBalances,
} from '../../econ/src/balance-db'
import { grantCustomAvatarItem, ownedCustomAvatarItemIds } from '../../econ/src/inventory-custom-db'
// The notification ids and the kick frame's recovered shape, owned by `notify`. Both are
// imported as values/types with no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types'
@@ -46,6 +49,7 @@ import type {
BalanceResponsePayload,
GiftPackagePayload,
ModerationKickPayload,
PlayerProgressionLevelPayload,
} from '../../notify/src/notification-payloads'
import type { App, Env } from './context'
@@ -518,8 +522,26 @@ const COACH_ACCOUNT_ID = 1
*/
export const DEFAULT_MAX_TOKEN_GIFT = 10_000
/** The message on a staff token gift's box. */
const TOKEN_GIFT_MESSAGE = 'A gift from the staff!'
/**
* The most XP one gift can carry when the operator's `MAX_XP_GIFT` is unset — a typo guard,
* as {@link DEFAULT_MAX_TOKEN_GIFT} is. Kept small for now: enough to cross the first few
* levels, where every level to the top costs 15,090 in all (level 1 to 50).
*/
export const DEFAULT_MAX_XP_GIFT = 100
/**
* `GiftContext.Purchased_Gift_A` — what a staff box says it came from.
*
* The client classifies a box by its context and only tests a small set (500–503
* `Purchased_Gift_A`–`D`, 1300 `Friendotron_Gift`); `Default` (0) falls outside it, and a box
* whose contents it won't render shows "cannot display". The TOKEN box is left on 0 because
* it renders as it is — this is the thing being changed, so it is not changed everywhere at
* once.
*/
const GIFT_CONTEXT_PURCHASED_GIFT_A = 500
/** The message on the box a staff gift comes in. */
const STAFF_GIFT_MESSAGE = 'A gift from the staff!'
/** A path's `:id` as a player id, or null when it isn't a positive integer. */
function playerIdParam(c: Context<App>): number | null {
@@ -547,6 +569,83 @@ async function recordPlayerAudit(
}
}
/**
* The stored content of a box staff hand over: from the Coach, as every box the server hands
* over on nobody's behalf is (who really sent it is on the audit row), carrying nothing but
* what `fields` puts in it.
*/
function staffGiftContent(fields: Partial<GiftContent>): GiftContent {
return {
FromPlayerId: COACH_ACCOUNT_ID,
GiftContext: 0,
ConsumableItemDesc: '',
ConsumableCount: 0,
AvatarItemDesc: '',
AvatarItemType: 0,
CurrencyType: 0,
Currency: 0,
Xp: 0,
PackageType: 0,
Message: STAFF_GIFT_MESSAGE,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
GiftRarity: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: null,
...fields,
}
}
/**
* Show a player the box they were just handed: `GiftPackageReceivedImmediate`, the frame
* econ sends for a box the player never clicked for, read off the stored content so the
* frame and `GET /api/avatar/v2/gifts` describe one box. A custom item's box also carries
* `CustomAvatarItemId`, as econ's does — the recovered decoder names no such member and
* drops unknown ones, so it costs nothing if unread. Best-effort: the box is already stored,
* and an offline player meets it on their next read of their gifts.
*/
async function announceGift(
c: Context<App>,
playerId: number,
giftId: number,
content: GiftContent
): Promise<void> {
const frame: GiftPackagePayload = {
Id: giftId,
FromPlayerId: content.FromPlayerId ?? COACH_ACCOUNT_ID,
ConsumableItemDesc: content.ConsumableItemDesc,
AvatarItemType: content.AvatarItemType,
AvatarItemDesc: content.AvatarItemDesc,
EquipmentPrefabName: content.EquipmentPrefabName,
EquipmentModificationGuid: content.EquipmentModificationGuid,
CurrencyType: content.CurrencyType,
Currency: content.Currency,
Xp: content.Xp,
GiftContext: content.GiftContext ?? 0,
GiftRarity: content.GiftRarity,
Message: content.Message,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
NotificationType.GiftPackageReceivedImmediate,
content.CustomAvatarItemId
? { ...frame, CustomAvatarItemId: content.CustomAvatarItemId }
: { ...frame }
)
} catch (err) {
logger.error('failed to announce a staff gift', {
playerId,
giftId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Send a player RecCenterTokens in a gift box.
*
@@ -589,66 +688,173 @@ export async function giftTokensHandler(c: Context<App>) {
startingTokens
)
const content: GiftContent = {
FromPlayerId: COACH_ACCOUNT_ID,
GiftContext: 0,
ConsumableItemDesc: '',
ConsumableCount: 0,
AvatarItemDesc: '',
AvatarItemType: 0,
const content = staffGiftContent({
CurrencyType: CurrencyType.RecCenterTokens,
Currency: amount,
Xp: 0,
PackageType: 0,
Message: TOKEN_GIFT_MESSAGE,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
GiftRarity: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: null,
}
})
const gift = await createGift(c.env.DB, playerId, content)
await recordPlayerAudit(c, 'gift_tokens', { playerId, amount, balance, giftId: gift.id })
logger.info('staff gifted tokens', { moderatorId: staffId(c), playerId, amount })
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
// The balance first, so the box's announcement lands on a total that already includes it.
try {
await hub.notifyPlayer(playerId, NotificationType.StorefrontBalanceUpdate, {
Balance: balance,
CurrencyType: CurrencyType.RecCenterTokens,
Platform: ALL_PLATFORMS,
} satisfies BalanceResponsePayload)
await hub.notifyPlayer(playerId, NotificationType.GiftPackageReceivedImmediate, {
Id: gift.id,
FromPlayerId: content.FromPlayerId ?? COACH_ACCOUNT_ID,
ConsumableItemDesc: content.ConsumableItemDesc,
AvatarItemType: content.AvatarItemType,
AvatarItemDesc: content.AvatarItemDesc,
EquipmentPrefabName: content.EquipmentPrefabName,
EquipmentModificationGuid: content.EquipmentModificationGuid,
CurrencyType: content.CurrencyType,
Currency: content.Currency,
Xp: content.Xp,
GiftContext: content.GiftContext ?? 0,
GiftRarity: content.GiftRarity,
Message: content.Message,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
} satisfies GiftPackagePayload)
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
NotificationType.StorefrontBalanceUpdate,
{
Balance: balance,
CurrencyType: CurrencyType.RecCenterTokens,
Platform: ALL_PLATFORMS,
} satisfies BalanceResponsePayload
)
} catch (err) {
logger.error('failed to announce a staff token gift', {
logger.error('failed to push a staff token gift balance', {
playerId,
giftId: gift.id,
error: err instanceof Error ? err.message : String(err),
})
}
await announceGift(c, playerId, gift.id, content)
return c.json({ playerId, amount, balance, giftId: gift.id })
}
/**
* 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
* pays for; a `PlayerProgressionLevelUpdate` moves the level bar, and the box is announced
* so the player sees what arrived.
*
* Levels crossed do NOT pay their level-up prize boxes: those are rolled from econ's catalog
* (`grantLevelUpGifts`), which lives inside the econ app. The levels themselves are stored.
*/
export async function giftXpHandler(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 amount = Number(body.amount)
if (!Number.isInteger(amount) || amount <= 0) {
return c.json({ error: 'Enter a whole number of XP greater than 0' }, 400)
}
const maxGift = intVar(c.env.MAX_XP_GIFT, DEFAULT_MAX_XP_GIFT)
if (amount > maxGift) {
return c.json({ error: `A gift can carry at most ${maxGift.toLocaleString()} XP` }, 400)
}
if ((await getAccount(c.env.DB, playerId)) === null) {
return c.json({ error: 'No such player' }, 404)
}
const { progression, levelsGained } = await addXp(c.env.DB, playerId, amount)
const content = staffGiftContent({
GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A,
Xp: amount,
})
const gift = await createGift(c.env.DB, playerId, content)
await recordPlayerAudit(c, 'gift_xp', {
playerId,
amount,
level: progression.Level,
levelsGained,
giftId: gift.id,
})
logger.info('staff gifted xp', { moderatorId: staffId(c), playerId, amount, levelsGained })
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
NotificationType.PlayerProgressionLevelUpdate,
{
PlayerId: progression.PlayerId,
Level: progression.Level,
XP: progression.XP,
} satisfies PlayerProgressionLevelPayload
)
} catch (err) {
logger.error('failed to push a staff xp gift progression', {
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
await announceGift(c, playerId, gift.id, content)
return c.json({
playerId,
amount,
level: progression.Level,
xp: progression.XP,
levelsGained,
giftId: gift.id,
})
}
/**
* Give a player a custom avatar item, delivered the way a store purchase delivers one:
* ownership is a row in `inventory_custom` (what `econ`'s owned read serves the item from),
* and the item comes in a gift box naming it by `CustomAvatarItemId` — without the box the
* player is shown nothing. Unlike a purchase nobody pays and the creator is not paid: this is
* a grant, not a sale.
*
* Refused as the store refuses a sale to this player: an unknown item or a DRAFT
* (`Accessibility` 0, visible to its creator alone — handing it out would publish it for
* them), the item's own creator (who owns it already), or a player who already has it.
*/
export async function giftCustomItemHandler(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 { customAvatarItemId?: unknown }
const requested =
typeof body.customAvatarItemId === 'string' ? body.customAvatarItemId.trim() : ''
if (requested === '') return c.json({ error: 'Enter a custom item id' }, 400)
if ((await getAccount(c.env.DB, playerId)) === null) {
return c.json({ error: 'No such player' }, 404)
}
// A GUID's case is not part of its identity, and ids are stored as the export spelled them.
const item =
(await getCustomAvatarItem(c.env.DB, requested)) ??
(await getCustomAvatarItem(c.env.DB, requested.toLowerCase()))
if (item === null || item.Accessibility === 0) {
return c.json({ error: 'No such custom item' }, 404)
}
if (item.CreatorAccountId === playerId) {
return c.json({ error: 'This player created that item, so they already have it' }, 409)
}
const owned = await ownedCustomAvatarItemIds(c.env.DB, playerId, [item.CustomAvatarItemId])
if (owned.has(item.CustomAvatarItemId.toLowerCase())) {
return c.json({ error: 'This player already owns that item' }, 409)
}
await grantCustomAvatarItem(c.env.DB, playerId, item.CustomAvatarItemId)
const content = staffGiftContent({
GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A,
CustomAvatarItemId: item.CustomAvatarItemId,
})
const gift = await createGift(c.env.DB, playerId, content)
await recordPlayerAudit(c, 'gift_custom_item', {
playerId,
customAvatarItemId: item.CustomAvatarItemId,
name: item.Name,
giftId: gift.id,
})
logger.info('staff gifted a custom item', {
moderatorId: staffId(c),
playerId,
customAvatarItemId: item.CustomAvatarItemId,
})
await announceGift(c, playerId, gift.id, content)
return c.json({
playerId,
customAvatarItemId: item.CustomAvatarItemId,
name: item.Name,
giftId: gift.id,
})
}
/**
* Give a player one more username change. The client reads the count off `/account/me`,
* so it shows the next time the player opens the name screen.
+166
View File
@@ -10,6 +10,7 @@ import {
PRESENCE_TTL_SECONDS,
setPresence,
} from '@repo/domain/src/presence-db'
import { getProgression, PROGRESSION_SCHEMA_DDL } from '@repo/domain/src/progression-db'
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
// Banning a player moves them into their own dorm, which is a room (created on demand) with
// a subroom — so those tables have to be here, as they are on the shared database.
@@ -17,6 +18,10 @@ import { ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
import { recordStat, STAT_SCHEMA_DDL } from '@repo/domain/src/stats-db'
import { generateToken } from '@repo/jwt'
import {
createCustomAvatarItem,
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
} from '../../../../api/src/custom-avatar-items-db'
import {
createReport,
getReportById,
@@ -36,6 +41,11 @@ import {
DEFAULT_STARTING_TOKENS,
getBalance,
} from '../../../../econ/src/balance-db'
import {
grantCustomAvatarItem,
INVENTORY_CUSTOM_SCHEMA_DDL,
ownedCustomAvatarItemIds,
} from '../../../../econ/src/inventory-custom-db'
import { discordConfig, parseRoleIds, qualifies } from '../../discord'
import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
@@ -108,6 +118,12 @@ beforeAll(async () => {
// `balance` and `received_gift` are owned by `econ`; a staff token gift writes both.
for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// A custom item gift reads `custom_avatar_item` (owned by `api`) and writes
// `inventory_custom` (owned by `econ`).
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_CUSTOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// And an XP gift writes `progression`.
for (const stmt of PROGRESSION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
@@ -697,6 +713,8 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
]
const writes = [
'/api/staff/players/1/gift-tokens',
'/api/staff/players/1/gift-custom-item',
'/api/staff/players/1/gift-xp',
'/api/staff/players/1/username-changes',
'/api/staff/players/1/clear-password',
]
@@ -1376,3 +1394,151 @@ it('clears a player’s password so they can set a new one in game', async () =>
])
expect((await staffPost('/api/staff/players/8397/clear-password', 8110, {})).status).toBe(404)
})
/** A custom avatar item made by `creatorAccountId`; published unless `accessibility` says. */
const seedCustomItem = (id: string, creatorAccountId: number, accessibility = 1) =>
createCustomAvatarItem(env.DB, {
customAvatarItemId: id,
creatorAccountId,
name: `Shirt ${id.slice(0, 4)}`,
description: '',
price: 300,
baseAvatarItemId: 1,
baseAvatarItemColor: '',
accessibility,
designFilename: 'design.png',
thumbnailImageFilename: 'thumb.png',
})
// Delivered as a purchase delivers one — owned, and boxed by `CustomAvatarItemId` — but nobody
// is charged and the creator is not paid: a grant, not a sale.
it('gifts a player a custom item in a gift box', async () => {
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
await updateAccount(env.DB, 8330, { username: 'Wearer' })
const item = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000001', 8331)
// Upper-cased on purpose: a GUID's case is not part of its identity.
const res = await staffPost('/api/staff/players/8330/gift-custom-item', 8110, {
customAvatarItemId: item.CustomAvatarItemId.toUpperCase(),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { giftId: number; name: string }
expect(body.name).toBe(item.Name)
await expect(ownedCustomAvatarItemIds(env.DB, 8330, [item.CustomAvatarItemId])).resolves.toEqual(
new Set([item.CustomAvatarItemId])
)
const gifts = await getPendingGifts(env.DB, 8330)
expect(gifts).toHaveLength(1)
expect(gifts[0]).toMatchObject({
Id: body.giftId,
FromPlayerId: 1,
CustomAvatarItemId: item.CustomAvatarItemId,
AvatarItemDesc: '',
Currency: 0,
// Purchased_Gift_A: one of the contexts the client's box classifier tests. Default (0)
// is not, and a box it can't classify renders as "cannot display".
GiftContext: 500,
})
// Only the box is announced: no balance moved, the creator's included.
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number
data: Record<string, unknown>
}>
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([[8330, 31]])
expect(frames[0].data).toMatchObject({
Id: body.giftId,
CustomAvatarItemId: item.CustomAvatarItemId,
GiftContext: 500,
})
expect(await auditRows('gift_custom_item', 8330)).toEqual([
{
actor: 8110,
data: {
playerId: 8330,
customAvatarItemId: item.CustomAvatarItemId,
name: item.Name,
giftId: body.giftId,
},
},
])
})
it('refuses a custom item gift the store would refuse', async () => {
await updateAccount(env.DB, 8340, { username: 'Picky' })
const published = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000002', 8341)
const draft = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000003', 8341, 0)
const own = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000004', 8340)
await grantCustomAvatarItem(env.DB, 8340, published.CustomAvatarItemId)
const gift = (customAvatarItemId: unknown, playerId = 8340) =>
staffPost(`/api/staff/players/${playerId}/gift-custom-item`, 8110, { customAvatarItemId })
expect((await gift('')).status).toBe(400)
expect((await gift('no-such-item')).status).toBe(404)
// A draft is visible to its creator alone; handing it out would publish it for them.
expect((await gift(draft.CustomAvatarItemId)).status).toBe(404)
expect((await gift(own.CustomAvatarItemId)).status).toBe(409)
expect((await gift(published.CustomAvatarItemId)).status).toBe(409)
expect((await gift(published.CustomAvatarItemId, 8396)).status).toBe(404)
expect(await getPendingGifts(env.DB, 8340)).toEqual([])
expect(await auditRows('gift_custom_item', 8340)).toEqual([])
})
// Banked and levelled like a game reward's XP: 25 takes a fresh player from level 1 through
// 2 (10) and 3 (10) with 5 into level 3. The bar moves, and the box shows what arrived.
it('gifts a player XP in a gift box', async () => {
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
await updateAccount(env.DB, 8350, { username: 'Climber' })
const res = await staffPost('/api/staff/players/8350/gift-xp', 8110, { amount: 25 })
expect(res.status).toBe(200)
const body = (await res.json()) as { giftId: number }
expect(body).toMatchObject({ level: 3, xp: 5, levelsGained: 2 })
await expect(getProgression(env.DB, 8350)).resolves.toEqual({ PlayerId: 8350, Level: 3, XP: 5 })
const gifts = await getPendingGifts(env.DB, 8350)
expect(gifts).toHaveLength(1)
expect(gifts[0]).toMatchObject({
Id: body.giftId,
FromPlayerId: 1,
Xp: 25,
Currency: 0,
GiftContext: 500,
})
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number | string
data: Record<string, unknown>
}>
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([
[8350, 'PlayerProgressionLevelUpdate'],
[8350, 31],
])
expect(frames[0].data).toEqual({ PlayerId: 8350, Level: 3, XP: 5 })
expect(frames[1].data).toMatchObject({ Id: body.giftId, Xp: 25, GiftContext: 500 })
expect(await auditRows('gift_xp', 8350)).toEqual([
{
actor: 8110,
data: { playerId: 8350, amount: 25, level: 3, levelsGained: 2, giftId: body.giftId },
},
])
})
it('refuses an XP gift that is not a positive whole number, too large, or to nobody', async () => {
await updateAccount(env.DB, 8351, { username: 'Stuck' })
for (const amount of [0, -5, 1.5, 'lots', 101]) {
expect((await staffPost('/api/staff/players/8351/gift-xp', 8110, { amount })).status).toBe(400)
}
expect((await staffPost('/api/staff/players/8395/gift-xp', 8110, { amount: 5 })).status).toBe(404)
await expect(getProgression(env.DB, 8351)).resolves.toEqual({ PlayerId: 8351, Level: 1, XP: 0 })
expect(await auditRows('gift_xp', 8351)).toEqual([])
})
+4
View File
@@ -40,7 +40,9 @@ import {
clearPasswordHandler,
createReportHandler,
getReportHandler,
giftCustomItemHandler,
giftTokensHandler,
giftXpHandler,
linkedAccountsHandler,
playerHistoryHandler,
requireStaff,
@@ -480,6 +482,8 @@ const app = new Hono<App>()
.get('/api/staff/players/:id/linked', linkedAccountsHandler)
// The staff card on a player's profile page — account fixes the game has no endpoint for.
.post('/api/staff/players/:id/gift-tokens', giftTokensHandler)
.post('/api/staff/players/:id/gift-custom-item', giftCustomItemHandler)
.post('/api/staff/players/:id/gift-xp', giftXpHandler)
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)