From 95e752513ae2f478acc83e1b2b460fce9d59ff73 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 24 Sep 2026 16:16:51 -0400 Subject: [PATCH] [www] add more staff gift options --- CLAUDE.md | 15 +- apps/api/src/openapi.ts | 2 +- apps/api/src/routes/avatar.ts | 3 +- apps/econ/src/econ.app.ts | 16 +- apps/www/src/client/App.tsx | 118 +++++---- apps/www/src/staff.ts | 268 +++++++++++++++++-- apps/www/src/test/integration/api.test.ts | 304 ++++++++++++++++++++-- apps/www/src/www.app.ts | 5 +- 8 files changed, 607 insertions(+), 124 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d1e0109..db431f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -260,12 +260,15 @@ inconsistency here without checking the client first. refresh relationship state: sent alone, the request "worked" server-side and the target saw nothing. Same pattern as the cheer. - A gift box's `AvatarItemType` (`econ`: the stored box in `GET /api/avatar/v2/gifts`, `buyItem`'s - `BalanceUpdates[].Data`, `bulkpurchase`'s `GiftPackage`, and the `GiftPackageReceived*` frame) - must be NULL for anything that is not an avatar item — a consumable, a skin, a box. The client - routes the box on that field before it reads what else the box names, so a 0 sends it looking - for avatar item type 0 and it fails with "can't find avatar item". The 2025 store lists every - non-avatar drop with `AvatarItemType: null` for this reason; `boxAvatarItemType` applies it on - every surface, so don't coalesce it with `?? 0` anywhere a box is built. + `BalanceUpdates[].Data`, `bulkpurchase`'s `GiftPackage`, and the `GiftPackageReceived*` frame; + `www`'s staff boxes; `api`'s `gifts/generate`) must be NULL for anything that is not an avatar + item — a consumable, a skin, a box, tokens, XP — and a NUMBER (the type, or 0) when the box holds + one, whether a baked item by `AvatarItemDesc` or a custom item by `CustomAvatarItemId`. The + client routes the box on that field before it reads what else the box names, so a 0 sends it + looking for avatar item type 0 and it fails with "can't find avatar item". The 2025 store lists + every non-avatar drop with `AvatarItemType: null` for this reason; `boxAvatarItemType` applies + it on every surface, so don't coalesce it with `?? 0` anywhere a box is built. This holds for + every worker that mints a box, not only `econ`. - Accessibility is sent as the `RoomAccessibility` enum NAME on `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 8039c34..193116c 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -848,7 +848,7 @@ export const GeneratedGift = z.object({ ConsumableItemDesc: z.string(), AvatarItemDesc: z.string(), FriendlyName: z.string(), - AvatarItemType: z.int(), + AvatarItemType: z.int().nullable().describe('Null — a token box holds no avatar item'), EquipmentPrefabName: z.string(), EquipmentModificationGuid: z.string(), CurrencyType: z.int(), diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index a5e438f..d31219d 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -372,7 +372,8 @@ export const avatarRoutes = new Hono({ strict: false }) ConsumableItemDesc: '', AvatarItemDesc: '', FriendlyName: '', - AvatarItemType: 0, + // A token box holds no avatar item: null, or the client goes looking for type 0. + AvatarItemType: null, EquipmentPrefabName: '', EquipmentModificationGuid: '', CurrencyType: 2, diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 6230c55..6fe5295 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -1100,18 +1100,24 @@ function truncateGiftMessage(message: string): string { * own context — a box the server handed over on nobody's behalf. */ /** - * `AvatarItemType` as a gift box carries it: the drop's own for an avatar item, NULL for - * anything else — a consumable, a skin, a box, a token bundle. + * `AvatarItemType` as a gift box carries it: the drop's own (0 when it names none) for an + * AVATAR ITEM — a baked one by `AvatarItemDesc` or a custom one by `CustomAvatarItemId` — and + * NULL for anything else: a consumable, a skin, a box, a token bundle. * * The client routes a box by this field before it looks at what else the box names: a 0 * reads as "an avatar item of type 0", and a consumable or skin box sent with 0 fails with * "can't find avatar item" because there is no such item to find. The 2025 store lists every * non-avatar drop with `AvatarItemType: null` for exactly that reason, and this is what the - * old `?? 0` coalescing used to erase. Applied at every surface a box is serialized on — the - * stored box, both purchase responses and the hub frame — so they cannot disagree. + * old `?? 0` coalescing used to erase. A custom item's box is the other way round: it needs + * the number, as the bag's guid-keyed box ({@link toCustomGiftContent}) sends it. Applied at + * every surface a box is serialized on — the stored box, both purchase responses and the hub + * frame — so they cannot disagree. */ function boxAvatarItemType(drop: StoreGiftDrop): number | null { - return drop.AvatarItemDesc !== '' ? (drop.AvatarItemType ?? 0) : null + const isAvatarItem = + drop.AvatarItemDesc !== '' || + (typeof drop.CustomAvatarItemId === 'string' && drop.CustomAvatarItemId !== '') + return isAvatarItem ? (drop.AvatarItemType ?? 0) : null } function toGiftContent( diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 4754f7f..3fece29 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -1198,15 +1198,35 @@ function PlayerPage({ * `audit_log`. Clearing a password asks twice, since it logs the player out of password * sign-in until they set a new one; the rest are one click. */ +/** The optional message on the box a staff gift comes in — one field, shared by every gift below it. */ +function GiftMessageField({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( + + ) +} + function StaffPlayerActions({ account, navigate }: { account: PublicAccount; navigate: Navigate }) { const [tokens, setTokens] = useState('') - const [tokenMessage, setTokenMessage] = useState('') + // One message for every box sent from this card; it stays put between sends. + const [message, setMessage] = useState('') const [confirmingClear, setConfirmingClear] = useState(false) - const [customItemId, setCustomItemId] = useState('') + const [itemId, setItemId] = useState('') const [xp, setXp] = useState('') const gift = useAction() const xpGift = useAction() - const customGift = useAction() + const itemGift = useAction() const usernameChange = useAction() const clearPassword = useAction() const base = `/api/staff/players/${account.accountId}` @@ -1217,6 +1237,8 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav {/* The gifts are developer-only; www refuses them to moderators. */} {isDeveloper() && ( <> + +
{ e.preventDefault() @@ -1224,10 +1246,9 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav const amount = Number(tokens) const res = await call<{ balance: number }>(`${base}/gift-tokens`, { authed: true, - json: { amount, message: tokenMessage.trim() }, + json: { amount, message: message.trim() }, }) setTokens('') - setTokenMessage('') const what = amount < 0 ? `Took ${Math.abs(amount).toLocaleString()} tokens back` @@ -1258,17 +1279,6 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav they have. Zero sends an empty box and moves nothing. - {gift.error &&

{gift.error}

} {gift.done &&

{gift.done}

}
@@ -1280,7 +1290,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav const amount = Number(xp) const res = await call<{ level: number; levelsGained: number }>(`${base}/gift-xp`, { authed: true, - json: { amount }, + json: { amount, message: message.trim() }, }) setXp('') const levels = @@ -1319,38 +1329,52 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
{ e.preventDefault() - void customGift.run(async () => { - const res = await call<{ name: string }>(`${base}/gift-custom-item`, { + void itemGift.run(async () => { + const res = await call<{ + kind: 'custom_item' | 'skin' | 'consumable' + name: string + prefabName?: string + owned?: number + }>(`${base}/gift-item`, { authed: true, - json: { customAvatarItemId: customItemId.trim() }, + json: { itemId: itemId.trim(), message: message.trim() }, }) - setCustomItemId('') - return `Sent “${res.name}” to @${account.username}.` + setItemId('') + switch (res.kind) { + case 'skin': + return `Sent the skin “${res.name}” (${res.prefabName}) to @${account.username}.` + case 'consumable': + return `Sent one “${res.name}” to @${account.username}. They now have ${(res.owned ?? 1).toLocaleString()}.` + default: + return `Sent the custom item “${res.name}” to @${account.username}.` + } }) }} > - {customGift.error &&

{customGift.error}

} - {customGift.done &&

{customGift.done}

} + {itemGift.error &&

{itemGift.error}

} + {itemGift.done &&

{itemGift.done}

}
)} @@ -2094,7 +2118,8 @@ function RoomPage({ * A room as anyone sees it: the same hero the owner's page draws, with the creator linked * to their profile in place of the upload control, then the room's public facts and the * photos players took in it — read-only, since nothing here is the viewer's to change. - * Staff also see its subrooms; the owner gets them on their own page instead. + * No subroom list: a visitor has no use for the room's internals, and the owner gets them + * (with the scene-data tool) on their own page instead. */ function PublicRoomView({ room, @@ -2108,7 +2133,6 @@ function PublicRoomView({ const [creator, setCreator] = useState<{ username: string; displayName: string } | null>(null) const [photos, setPhotos] = useState(null) const created = new Date(room.CreatedAt) - const subRooms = room.SubRooms ?? [] useEffect(() => { setCreator(null) @@ -2178,32 +2202,6 @@ function PublicRoomView({ )} - {/* Staff only: a visitor has no use for the room's internals. The owner sees them on - their own page (RoomDetail), never this one. */} - {isAdmin() && ( -
-

Subrooms

- {subRooms.length === 0 ? ( -

This room has no subrooms.

- ) : ( -
    - {subRooms.map((sub) => ( -
  • -
    - {sub.Name} - -
    -

    - Up to {sub.MaxPlayers} player{sub.MaxPlayers === 1 ? '' : 's'} - {sub.IsSandbox ? ' · sandbox' : ''} -

    -
  • - ))} -
- )} -
- )} - {/* Staff only, and cosmetic: hidden for everyone else, but `rooms` checks the token's role itself on the DELETE. */} {isAdmin() && ( diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index 53cab06..282ce20 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -41,6 +41,15 @@ import { getBalance, spendCurrency, } from '../../econ/src/balance-db' +// The item catalog and the two inventories a skin or a consumable gift writes, all `econ`'s. +import { + CatalogKind, + getCatalogItem, + getCatalogItemById, + toCatalogSkin, +} from '../../econ/src/catalog-db' +import { countConsumable, grantConsumable } from '../../econ/src/consumables-db' +import { getEquipment, grantEquipment } from '../../econ/src/equipment-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. @@ -48,7 +57,9 @@ import { NotificationType } from '../../notify/src/notification-types' import type { Context, MiddlewareHandler } from 'hono' import type { GiftContent } from '@repo/domain' +import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db' import type { ReportRow, ReportSearch } from '../../api/src/reports-db' +import type { CatalogRow } from '../../econ/src/catalog-db' import type { BalanceResponsePayload, GiftPackagePayload, @@ -554,7 +565,7 @@ export const DEFAULT_MAX_XP_GIFT = 100 */ export const MAX_TOKEN_DROP = 1_000 -/** The longest message a staff token box may carry — the same cap a client message has. */ +/** The longest message a staff box may carry — the same cap a client message has. */ const MAX_GIFT_MESSAGE = 256 /** @@ -601,6 +612,10 @@ 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. + * + * `AvatarItemType` is NULL: no staff box holds an avatar item, and the client routes a box on + * that field before reading the rest — a 0 sends it after "avatar item type 0", which fails + * with "can't find avatar item". Same rule as econ's `boxAvatarItemType`. */ function staffGiftContent(fields: Partial): GiftContent { return { @@ -609,7 +624,7 @@ function staffGiftContent(fields: Partial): GiftContent { ConsumableItemDesc: '', ConsumableCount: 0, AvatarItemDesc: '', - AvatarItemType: 0, + AvatarItemType: null, CurrencyType: 0, Currency: 0, Xp: 0, @@ -739,7 +754,7 @@ function tokenAmountRefusal(c: Context, amount: number): string | null { } /** - * The message a token box should carry, from the body's optional `message`: the staffer's + * The message a staff 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. @@ -948,7 +963,7 @@ 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 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 XP greater than 0' }, 400) @@ -957,6 +972,8 @@ export async function giftXpHandler(c: Context) { if (amount > maxGift) { return c.json({ error: `A gift can carry at most ${maxGift.toLocaleString()} XP` }, 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) { return c.json({ error: 'No such player' }, 404) } @@ -965,12 +982,14 @@ export async function giftXpHandler(c: Context) { const content = staffGiftContent({ GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A, Xp: amount, + Message: message, }) const gift = await createGift(c.env.DB, playerId, content) await recordPlayerAudit(c, 'gift_xp', { playerId, amount, + message, level: progression.Level, levelsGained, giftId: gift.id, @@ -1006,35 +1025,123 @@ export async function giftXpHandler(c: Context) { } /** - * 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. + * How many of a consumable one staff gift carries — one, as a store purchase grants one + * (econ's `CONSUMABLE_GRANT_COUNT`). Consumables stack, so more is more gifts. */ -export async function giftCustomItemHandler(c: Context) { +const CONSUMABLE_GIFT_COUNT = 1 + +/** + * What an item gift's id resolved to. `avatar_item` is a catalog row nothing here can grant; + * `ambiguous` is an id found in BOTH tables, which nothing here can pick between. + */ +type GiftableItem = + | { kind: 'custom_item'; item: CustomAvatarItem } + | { kind: 'skin'; row: CatalogRow } + | { kind: 'consumable'; row: CatalogRow } + | { kind: 'avatar_item'; row: CatalogRow } + | { kind: 'ambiguous' } + +/** + * Resolve the one id the staff card takes to whatever it names, looking in BOTH tables an + * item can live in: `custom_avatar_item` by guid, in either case (a GUID's case is not its + * identity; ids are stored as the export spelled them), and `catalog` by KEY — a skin's + * `ModificationGuid` or a consumable's `ConsumableItemDesc`, spelled as the export spells it + * (the short pre-GUID ids are case-sensitive, so nothing is folded there) — or, for a bare + * number, by `catalog_id`, the store's `PurchasableItemId`. + * + * A UUID could be anything, so neither lookup is skipped on the strength of the other: an id + * that answers in both tables is `ambiguous` rather than whichever was asked first. In + * practice they don't collide — no catalog key is a bare number, and a skin and a consumable + * never share a key — but the check is what makes that a fact rather than an assumption. + * + * A DRAFT custom item (`Accessibility` 0, visible to its creator alone — handing it out would + * publish it for them) is treated as unknown, exactly as the store would refuse to sell it. + */ +async function findGiftableItem(db: D1Database, requested: string): Promise { + const [custom, byKey] = await Promise.all([ + getCustomAvatarItem(db, requested).then( + (item) => item ?? getCustomAvatarItem(db, requested.toLowerCase()) + ), + getCatalogItem(db, requested), + ]) + const item = custom !== null && custom.Accessibility !== 0 ? custom : null + const row = + byKey ?? (/^\d{1,9}$/.test(requested) ? await getCatalogItemById(db, Number(requested)) : null) + + if (item !== null && row !== null) return { kind: 'ambiguous' } + if (item !== null) return { kind: 'custom_item', item } + if (row === null) return null + switch (row.kind) { + case CatalogKind.Skin: + return { kind: 'skin', row } + case CatalogKind.Consumable: + return { kind: 'consumable', row } + case CatalogKind.AvatarItem: + return { kind: 'avatar_item', row } + default: + return null + } +} + +/** + * Give a player an item — a custom avatar item, an equipment skin or a consumable — by any + * one of their ids, delivered the way a store purchase delivers it: ownership in the inventory + * `econ` reads that kind from, and a gift box that names it, without which the player is + * shown nothing. Nobody pays, and a custom item's creator is not paid: a grant, not a sale. + * Which kind the id turned out to be is on the reply as `kind`, and the audit row is filed + * per kind (`gift_custom_item`, `gift_skin`, `gift_consumable`). + * + * A baked avatar item's id is refused outright: nothing here grants one yet, and a 404 would + * send the staffer checking an id that is right. + */ +export async function giftItemHandler(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) + const body = (await c.req.json().catch(() => ({}))) as { itemId?: unknown; message?: unknown } + const requested = typeof body.itemId === 'string' ? body.itemId.trim() : '' + if (requested === '') return c.json({ error: 'Enter an item id' }, 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) { 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) + const found = await findGiftableItem(c.env.DB, requested) + if (found === null) return c.json({ error: 'No such item' }, 404) + switch (found.kind) { + case 'custom_item': + return await giftCustomItem(c, playerId, found.item, message) + case 'skin': + return await giftSkin(c, playerId, found.row, message) + case 'consumable': + return await giftConsumable(c, playerId, found.row, message) + case 'avatar_item': + return c.json( + { + error: + 'That is a baked avatar item; only custom items, skins and consumables can be gifted', + }, + 400 + ) + case 'ambiguous': + return c.json({ error: 'That id names both a custom item and a catalog item' }, 409) } +} + +/** + * A custom avatar item: ownership is a row in `inventory_custom` (what `econ`'s owned read + * serves the item from), and the box names it by `CustomAvatarItemId`. + * + * Refused as the store refuses a sale to this player: the item's own creator (who owns it + * already), or a player who already has it. + */ +async function giftCustomItem( + c: Context, + playerId: number, + item: CustomAvatarItem, + message: string +) { if (item.CreatorAccountId === playerId) { return c.json({ error: 'This player created that item, so they already have it' }, 409) } @@ -1047,6 +1154,10 @@ export async function giftCustomItemHandler(c: Context) { const content = staffGiftContent({ GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A, CustomAvatarItemId: item.CustomAvatarItemId, + // 0, not the null the other staff boxes carry: a custom item IS an avatar item, and this + // matches the box econ mints for a bought one, which is the shape seen to open. + AvatarItemType: 0, + Message: message, }) const gift = await createGift(c.env.DB, playerId, content) @@ -1054,6 +1165,7 @@ export async function giftCustomItemHandler(c: Context) { playerId, customAvatarItemId: item.CustomAvatarItemId, name: item.Name, + message, giftId: gift.id, }) logger.info('staff gifted a custom item', { @@ -1064,6 +1176,7 @@ export async function giftCustomItemHandler(c: Context) { await announceGift(c, playerId, gift.id, content) return c.json({ + kind: 'custom_item', playerId, customAvatarItemId: item.CustomAvatarItemId, name: item.Name, @@ -1071,6 +1184,113 @@ export async function giftCustomItemHandler(c: Context) { }) } +/** + * An equipment skin: ownership is a row in `equipment` (what `GET /api/equipment/v2/getUnlocked` + * serves), and the box names it by `EquipmentPrefabName`/`EquipmentModificationGuid` with + * `AvatarItemType` NULL — a 0 there sends the client looking for an avatar item that does not + * exist, and the box fails to open. + * + * Refused for a player who already owns it: owning a skin is boolean, so a second grant would + * hand them a box with nothing new in it. + */ +async function giftSkin(c: Context, playerId: number, row: CatalogRow, message: string) { + const skin = toCatalogSkin(row) + const owned = await getEquipment(c.env.DB, playerId) + if (owned.some((eq) => eq.ModificationGuid === skin.ModificationGuid)) { + return c.json({ error: 'This player already owns that skin' }, 409) + } + + // The same DTO a purchase grants (econ's `toEquipment`), built from the catalog's view. + await grantEquipment(c.env.DB, playerId, { + ModificationGuid: skin.ModificationGuid, + PrefabName: skin.PrefabName, + FriendlyName: skin.FriendlyName, + Tooltip: skin.Tooltip ?? '', + Rarity: skin.Rarity, + PlatformMask: -1, + Favorited: false, + }) + const content = staffGiftContent({ + GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A, + EquipmentPrefabName: skin.PrefabName, + EquipmentModificationGuid: skin.ModificationGuid, + GiftRarity: skin.Rarity, + Message: message, + }) + const gift = await createGift(c.env.DB, playerId, content) + + await recordPlayerAudit(c, 'gift_skin', { + playerId, + modificationGuid: skin.ModificationGuid, + prefabName: skin.PrefabName, + name: skin.FriendlyName, + message, + giftId: gift.id, + }) + logger.info('staff gifted a skin', { + moderatorId: staffId(c), + playerId, + modificationGuid: skin.ModificationGuid, + }) + await announceGift(c, playerId, gift.id, content) + + return c.json({ + kind: 'skin', + playerId, + modificationGuid: skin.ModificationGuid, + prefabName: skin.PrefabName, + name: skin.FriendlyName, + giftId: gift.id, + }) +} + +/** + * One of a consumable: a fresh `consumable` row (they stack, so there is no "already owns" + * refusal — a second gift is a second one), and a box naming it by `ConsumableItemDesc` with + * `AvatarItemType` NULL, as a skin's is. The box also carries the granted row's id and the + * player's count BEFORE this one, which is what lets opening it fire an accurate + * `ConsumableMappingAdded`. + */ +async function giftConsumable(c: Context, playerId: number, row: CatalogRow, message: string) { + const preExisting = await countConsumable(c.env.DB, playerId, row.item_key) + const mappingId = await grantConsumable(c.env.DB, playerId, row.item_key, CONSUMABLE_GIFT_COUNT) + const content = staffGiftContent({ + GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A, + ConsumableItemDesc: row.item_key, + ConsumableCount: CONSUMABLE_GIFT_COUNT, + ConsumableMappingId: mappingId, + ConsumablePreExistingCount: preExisting, + GiftRarity: row.rarity, + Message: message, + }) + const gift = await createGift(c.env.DB, playerId, content) + + await recordPlayerAudit(c, 'gift_consumable', { + playerId, + consumableItemDesc: row.item_key, + name: row.friendly_name, + count: CONSUMABLE_GIFT_COUNT, + message, + giftId: gift.id, + }) + logger.info('staff gifted a consumable', { + moderatorId: staffId(c), + playerId, + consumableItemDesc: row.item_key, + }) + await announceGift(c, playerId, gift.id, content) + + return c.json({ + kind: 'consumable', + playerId, + consumableItemDesc: row.item_key, + name: row.friendly_name, + count: CONSUMABLE_GIFT_COUNT, + owned: preExisting + CONSUMABLE_GIFT_COUNT, + 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 d33cbee..d5427c0 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -41,6 +41,9 @@ import { DEFAULT_STARTING_TOKENS, getBalance, } from '../../../../econ/src/balance-db' +import { CATALOG_SCHEMA_DDL } from '../../../../econ/src/catalog-db' +import { CONSUMABLE_SCHEMA_DDL, getConsumables } from '../../../../econ/src/consumables-db' +import { EQUIPMENT_SCHEMA_DDL, getEquipment } from '../../../../econ/src/equipment-db' import { grantCustomAvatarItem, INVENTORY_CUSTOM_SCHEMA_DDL, @@ -115,6 +118,11 @@ beforeAll(async () => { // `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() + // A skin or consumable gift resolves its item in `catalog` and writes `equipment` or + // `consumable` — all three `econ`'s. + for (const stmt of CATALOG_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of CONSUMABLE_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() }) @@ -715,7 +723,7 @@ 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-item', '/api/staff/players/1/gift-xp', '/api/staff/players/1/username-changes', '/api/staff/players/1/clear-password', @@ -1316,6 +1324,8 @@ it('gifts a player tokens in a gift box', async () => { FromPlayerId: 1, CurrencyType: CurrencyType.RecCenterTokens, Currency: 500, + // No staff box holds an avatar item; a 0 here sends the client after one. + AvatarItemType: null, }) const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{ @@ -1680,21 +1690,49 @@ const seedCustomItem = (id: string, creatorAccountId: number, accessibility = 1) thumbnailImageFilename: 'thumb.png', }) +/** A catalog row — a skin (with `prefabName`), a consumable or an avatar item — as `runx catalog load` writes one. */ +const seedCatalogRow = ( + itemKey: string, + catalogId: number, + kind: 'skin' | 'consumable' | 'avatar_item', + friendlyName: string, + prefabName: string | null = null +) => + env.DB.prepare( + `INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, prefab_name) + VALUES (?1, ?2, ?3, ?4, NULL, 3, -1, ?5)` + ) + .bind(itemKey, catalogId, kind, friendlyName, prefabName) + .run() + +/** Every frame the hub was handed, after clearing it. */ +const hubFrames = async () => + (await ( + await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/all') + ).json()) as Array<{ + playerId: number + notificationType: number + data: Record + }> +const clearHub = () => + env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/all', { method: 'DELETE' }) + +/** The one item gift, by whichever id. */ +const giftItem = (playerId: number, itemId: unknown, message?: string) => + devPost(`/api/staff/players/${playerId}/gift-item`, 8110, { itemId, message }) + // 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 clearHub() 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 devPost('/api/staff/players/8330/gift-custom-item', 8110, { - customAvatarItemId: item.CustomAvatarItemId.toUpperCase(), - }) + const res = await giftItem(8330, item.CustomAvatarItemId.toUpperCase()) expect(res.status).toBe(200) - const body = (await res.json()) as { giftId: number; name: string } - expect(body.name).toBe(item.Name) + const body = (await res.json()) as { kind: string; giftId: number; name: string } + expect(body).toMatchObject({ kind: 'custom_item', name: item.Name }) await expect(ownedCustomAvatarItemIds(env.DB, 8330, [item.CustomAvatarItemId])).resolves.toEqual( new Set([item.CustomAvatarItemId]) @@ -1706,18 +1744,17 @@ it('gifts a player a custom item in a gift box', async () => { FromPlayerId: 1, CustomAvatarItemId: item.CustomAvatarItemId, AvatarItemDesc: '', + // As econ boxes a bought custom item — an avatar item, so not the null the others carry. + AvatarItemType: 0, Currency: 0, + Message: 'A gift from the staff!', // 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 - }> + const frames = await hubFrames() expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([[8330, 31]]) expect(frames[0].data).toMatchObject({ Id: body.giftId, @@ -1732,6 +1769,7 @@ it('gifts a player a custom item in a gift box', async () => { playerId: 8330, customAvatarItemId: item.CustomAvatarItemId, name: item.Name, + message: 'A gift from the staff!', giftId: body.giftId, }, }, @@ -1745,21 +1783,212 @@ it('refuses a custom item gift the store would refuse', async () => { const own = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000004', 8340) await grantCustomAvatarItem(env.DB, 8340, published.CustomAvatarItemId) - const gift = (customAvatarItemId: unknown, playerId = 8340) => - devPost(`/api/staff/players/${playerId}/gift-custom-item`, 8110, { customAvatarItemId }) - - expect((await gift('')).status).toBe(400) - expect((await gift('no-such-item')).status).toBe(404) + expect((await giftItem(8340, '')).status).toBe(400) + expect((await giftItem(8340, 7)).status).toBe(400) + expect((await giftItem(8340, published.CustomAvatarItemId, 'x'.repeat(257))).status).toBe(400) + expect((await giftItem(8340, '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 giftItem(8340, draft.CustomAvatarItemId)).status).toBe(404) + expect((await giftItem(8340, own.CustomAvatarItemId)).status).toBe(409) + expect((await giftItem(8340, published.CustomAvatarItemId)).status).toBe(409) + expect((await giftItem(8396, published.CustomAvatarItemId)).status).toBe(404) expect(await getPendingGifts(env.DB, 8340)).toEqual([]) expect(await auditRows('gift_custom_item', 8340)).toEqual([]) }) +// Delivered as a purchase delivers one — an `equipment` row, and a box keyed by the prefab and +// guid with `AvatarItemType` NULL (a 0 sends the client after an avatar item that isn't there). +it('gifts a player a skin in a gift box', async () => { + await clearHub() + await updateAccount(env.DB, 8420, { username: 'Armed' }) + await seedCatalogRow('0dM2SfqGR0SmtO5ufTWfUQ', 91001, 'skin', 'Bow Skin (Dryad)', '[Bow]') + await seedCatalogRow( + 'a1b2c3d4-0000-4000-8000-000000000001', + 91002, + 'skin', + 'Pen Skin', + '[MakerPen]' + ) + + // By key, as the export spells it — the short ids are case-sensitive, so nothing is folded. + let res = await giftItem(8420, '0dM2SfqGR0SmtO5ufTWfUQ', 'Nice shot') + expect(res.status).toBe(200) + const body = (await res.json()) as { + kind: string + giftId: number + name: string + prefabName: string + } + expect(body).toMatchObject({ kind: 'skin', name: 'Bow Skin (Dryad)', prefabName: '[Bow]' }) + + expect(await getEquipment(env.DB, 8420)).toEqual([ + { + ModificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ', + PrefabName: '[Bow]', + FriendlyName: 'Bow Skin (Dryad)', + Tooltip: '', + Rarity: 3, + PlatformMask: -1, + Favorited: false, + }, + ]) + const gifts = await getPendingGifts(env.DB, 8420) + expect(gifts).toHaveLength(1) + expect(gifts[0]).toMatchObject({ + Id: body.giftId, + FromPlayerId: 1, + EquipmentPrefabName: '[Bow]', + EquipmentModificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ', + AvatarItemType: null, + AvatarItemDesc: '', + GiftRarity: 3, + GiftContext: 500, + Message: 'Nice shot', + }) + const frames = await hubFrames() + expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([[8420, 31]]) + expect(frames[0].data).toMatchObject({ + Id: body.giftId, + EquipmentModificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ', + AvatarItemType: null, + GiftContext: 500, + }) + expect(await auditRows('gift_skin', 8420)).toEqual([ + { + actor: 8110, + data: { + playerId: 8420, + modificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ', + prefabName: '[Bow]', + name: 'Bow Skin (Dryad)', + message: 'Nice shot', + giftId: body.giftId, + }, + }, + ]) + + // Or by the store's numeric id. + res = await giftItem(8420, '91002') + expect(res.status).toBe(200) + expect((await getEquipment(env.DB, 8420)).map((eq) => eq.ModificationGuid)).toEqual([ + '0dM2SfqGR0SmtO5ufTWfUQ', + 'a1b2c3d4-0000-4000-8000-000000000001', + ]) + + // Owning a skin is boolean: a second gift would be a box with nothing new in it. + expect((await giftItem(8420, 'skin-owned-8420', 'x'.repeat(257))).status).toBe(400) + expect((await giftItem(8420, '0dM2SfqGR0SmtO5ufTWfUQ')).status).toBe(409) + expect((await giftItem(8420, '99999999')).status).toBe(404) + expect((await giftItem(8394, '0dM2SfqGR0SmtO5ufTWfUQ')).status).toBe(404) + expect(await getEquipment(env.DB, 8420)).toHaveLength(2) + expect(await getPendingGifts(env.DB, 8420)).toHaveLength(2) +}) + +// A consumable stacks: each gift is a fresh row, and the box carries that row's id and the +// count the player had before it, which is what opening the box reports. +it('gifts a player a consumable in a gift box, and again', async () => { + await clearHub() + await updateAccount(env.DB, 8430, { username: 'Hungry' }) + await seedCatalogRow('0DsHcPhR_Eybn53UxKJhJw', 91010, 'consumable', 'Supreme Pizza') + + let res = await giftItem(8430, '0DsHcPhR_Eybn53UxKJhJw') + expect(res.status).toBe(200) + const first = (await res.json()) as { + kind: string + giftId: number + name: string + count: number + owned: number + } + expect(first).toMatchObject({ kind: 'consumable', name: 'Supreme Pizza', count: 1, owned: 1 }) + + let owned = await getConsumables(env.DB, 8430) + expect(owned).toHaveLength(1) + expect(owned[0]).toMatchObject({ ConsumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw', Count: 1 }) + let gifts = await getPendingGifts(env.DB, 8430) + expect(gifts).toHaveLength(1) + expect(gifts[0]).toMatchObject({ + Id: first.giftId, + FromPlayerId: 1, + ConsumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw', + ConsumableCount: 1, + ConsumableMappingId: owned[0].Ids[0], + ConsumablePreExistingCount: 0, + AvatarItemType: null, + EquipmentModificationGuid: '', + GiftRarity: 3, + GiftContext: 500, + Message: 'A gift from the staff!', + }) + const frames = await hubFrames() + expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([[8430, 31]]) + expect(frames[0].data).toMatchObject({ + Id: first.giftId, + ConsumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw', + AvatarItemType: null, + }) + expect(await auditRows('gift_consumable', 8430)).toEqual([ + { + actor: 8110, + data: { + playerId: 8430, + consumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw', + name: 'Supreme Pizza', + count: 1, + message: 'A gift from the staff!', + giftId: first.giftId, + }, + }, + ]) + + // Again, by the store's id this time: a second instance, and the box knows they had one. + res = await giftItem(8430, '91010', 'Seconds?') + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ kind: 'consumable', owned: 2 }) + owned = await getConsumables(env.DB, 8430) + expect(owned).toHaveLength(1) + expect(owned[0]).toMatchObject({ Count: 2 }) + expect(owned[0].Ids).toHaveLength(2) + gifts = await getPendingGifts(env.DB, 8430) + expect(gifts).toHaveLength(2) + expect(gifts.map((g) => g.ConsumablePreExistingCount ?? -1).sort((a, b) => a - b)).toEqual([0, 1]) + expect(gifts.map((g) => g.Message).sort()).toEqual(['A gift from the staff!', 'Seconds?']) + + expect((await giftItem(8430, '0DsHcPhR_Eybn53UxKJhJw', 'x'.repeat(257))).status).toBe(400) + expect((await giftItem(8393, '0DsHcPhR_Eybn53UxKJhJw')).status).toBe(404) + expect(await getConsumables(env.DB, 8430)).toEqual(owned) +}) + +// One id field, so the id decides the kind — and a UUID could be anything, so BOTH tables are +// asked. A baked avatar item is a real row nothing here can grant, and an id in both tables +// is nobody's to pick between. +it('refuses an item gift for a baked avatar item, or an id that names two things', async () => { + await updateAccount(env.DB, 8440, { username: 'Ambiguous' }) + await seedCatalogRow('Hat,0,0,', 91020, 'avatar_item', 'A Hat') + const twin = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000005', 8441) + await seedCatalogRow(twin.CustomAvatarItemId, 91021, 'skin', 'Twin Skin', '[Bow]') + + let res = await giftItem(8440, 'Hat,0,0,') + expect(res.status).toBe(400) + expect(((await res.json()) as { error: string }).error).toMatch(/baked avatar item/) + res = await giftItem(8440, '91020') + expect(res.status).toBe(400) + + res = await giftItem(8440, twin.CustomAvatarItemId) + expect(res.status).toBe(409) + expect(((await res.json()) as { error: string }).error).toMatch(/both/) + // The catalog id names only the skin, so that way round it goes through. + res = await giftItem(8440, '91021') + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ kind: 'skin', name: 'Twin Skin' }) + + expect(await getPendingGifts(env.DB, 8440)).toHaveLength(1) + await expect(ownedCustomAvatarItemIds(env.DB, 8440, [twin.CustomAvatarItemId])).resolves.toEqual( + new Set() + ) +}) + // 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 () => { @@ -1767,7 +1996,10 @@ it('gifts a player XP in a gift box', async () => { await hub().fetch('http://do/all', { method: 'DELETE' }) await updateAccount(env.DB, 8350, { username: 'Climber' }) - const res = await devPost('/api/staff/players/8350/gift-xp', 8110, { amount: 25 }) + const res = await devPost('/api/staff/players/8350/gift-xp', 8110, { + amount: 25, + message: 'Keep climbing!', + }) expect(res.status).toBe(200) const body = (await res.json()) as { giftId: number } expect(body).toMatchObject({ level: 3, xp: 5, levelsGained: 2 }) @@ -1780,7 +2012,9 @@ it('gifts a player XP in a gift box', async () => { FromPlayerId: 1, Xp: 25, Currency: 0, + AvatarItemType: null, GiftContext: 500, + Message: 'Keep climbing!', }) const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{ @@ -1793,12 +2027,24 @@ it('gifts a player XP in a gift box', async () => { [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(frames[1].data).toMatchObject({ + Id: body.giftId, + Xp: 25, + GiftContext: 500, + Message: 'Keep climbing!', + }) expect(await auditRows('gift_xp', 8350)).toEqual([ { actor: 8110, - data: { playerId: 8350, amount: 25, level: 3, levelsGained: 2, giftId: body.giftId }, + data: { + playerId: 8350, + amount: 25, + message: 'Keep climbing!', + level: 3, + levelsGained: 2, + giftId: body.giftId, + }, }, ]) }) @@ -1808,6 +2054,14 @@ it('refuses an XP gift that is not a positive whole number, too large, or to nob for (const amount of [0, -5, 1.5, 'lots', 101]) { expect((await devPost('/api/staff/players/8351/gift-xp', 8110, { amount })).status).toBe(400) } + expect( + ( + await devPost('/api/staff/players/8351/gift-xp', 8110, { + amount: 5, + message: 'x'.repeat(257), + }) + ).status + ).toBe(400) expect((await devPost('/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 2aadb25..702d2ee 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -40,7 +40,7 @@ import { clearPasswordHandler, createReportHandler, getReportHandler, - giftCustomItemHandler, + giftItemHandler, giftOnlineTokensHandler, giftRoomTokensHandler, giftTokensHandler, @@ -486,7 +486,8 @@ const app = new Hono() // The staff card on a player's profile page — account fixes the game has no endpoint for. // The gifts create value from nothing, so they are developer-only, not all staff. .post('/api/staff/players/:id/gift-tokens', requireDeveloper, giftTokensHandler) - .post('/api/staff/players/:id/gift-custom-item', requireDeveloper, giftCustomItemHandler) + // One id field for a custom avatar item, a skin or a consumable; the kind is resolved here. + .post('/api/staff/players/:id/gift-item', requireDeveloper, giftItemHandler) .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)