diff --git a/.env.example b/.env.example
index 88d8aec..06401ec 100644
--- a/.env.example
+++ b/.env.example
@@ -159,6 +159,10 @@ RECFLARE_DOMAIN=rec.example.com
# staff can give in total.
# RECFLARE_MAX_TOKEN_GIFT=10000
+# The same guard for one XP gift from that staff card (`www`). Every level to the top costs
+# 15,090 XP in all (level 1 to 50).
+# RECFLARE_MAX_XP_GIFT=100
+
# Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare
# Turnstile widget, and both of that widget's keys live in the shared Secrets Store
# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets.
diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx
index 47298ad..019f0e0 100644
--- a/apps/www/src/client/App.tsx
+++ b/apps/www/src/client/App.tsx
@@ -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 &&
{gift.done}
}
+
+
+
+
): 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 {
+ 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,
+ playerId: number,
+ giftId: number,
+ content: GiftContent
+): Promise {
+ 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) {
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) {
+ 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) {
+ 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.
diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts
index 5efcc53..6793fb7 100644
--- a/apps/www/src/test/integration/api.test.ts
+++ b/apps/www/src/test/integration/api.test.ts
@@ -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
+ }>
+ 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
+ }>
+ 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([])
+})
diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts
index 72d61ab..5fcd356 100644
--- a/apps/www/src/www.app.ts
+++ b/apps/www/src/www.app.ts
@@ -40,7 +40,9 @@ import {
clearPasswordHandler,
createReportHandler,
getReportHandler,
+ giftCustomItemHandler,
giftTokensHandler,
+ giftXpHandler,
linkedAccountsHandler,
playerHistoryHandler,
requireStaff,
@@ -480,6 +482,8 @@ const app = new Hono()
.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)