From fd30182f3d0de6f3b4e30f7e6197b21a43953229 Mon Sep 17 00:00:00 2001 From: dukedotdev Date: Tue, 27 Oct 2026 23:14:10 -0400 Subject: [PATCH] [www] Fix room badge grants and profile badge lists (#148) --- apps/econ/src/econ.app.ts | 118 +++++++++++++++++++ apps/econ/src/inventory-custom-db.ts | 5 +- apps/econ/src/openapi.ts | 12 ++ apps/econ/src/test/integration/api.test.ts | 131 +++++++++++++++++++++ apps/www/src/client/App.tsx | 17 +-- apps/www/src/staff.ts | 9 +- apps/www/src/test/integration/api.test.ts | 72 +++++++++++ 7 files changed, 353 insertions(+), 11 deletions(-) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index c71cec4..ed7e3f8 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -13,6 +13,7 @@ import { getOutfits, getPendingGifts, getPlayerIdsInRoom, + getPresence, grantInvention, levelReward, levelsReached, @@ -117,6 +118,8 @@ import { ErrorResponse, form, GameRewardRequest, + GrantRoomBadgeRequest, + GrantRoomBadgeResponse, HeldRoomKeyDto, InfluencerIdsResponse, IsOnWishlistBulkRequest, @@ -294,6 +297,28 @@ function unauthorized(c: Context) { return c.body(null, 401) } +/** The badge tab reads another account's inventory, filtered before paging. */ +async function customAvatarItemsPage(c: Context, accountId: number, callerId: number) { + const owned = await getOwnedCustomAvatarItems(c.env.DB, accountId) + const outfitType = c.req.query('outfitType') + const visible = owned.filter( + (item) => + (accountId === callerId || item.Accessibility !== 0) && + (outfitType === undefined || item.OutfitType === Number(outfitType)) + ) + const pageNumber = (value: string | undefined, fallback: number) => { + const parsed = Number(value ?? fallback) + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback + } + const skip = pageNumber(c.req.query('skip'), 0) + const take = Math.min(pageNumber(c.req.query('take'), 1000), 1000) + const page = visible.slice(skip, skip + take) + const Results = isQuestAssetTarget(c.req.query('unityAssetTarget')) + ? page.map(toQuestCustomAvatarItem) + : page + return c.json({ Results, TotalResults: visible.length }) +} + /** * The `Error` a wishlist write answers when its body names no item. Shown to the player * verbatim behind the client's “Wishlist error: ” prefix, so it is a sentence, not a code. @@ -3061,6 +3086,99 @@ const app = new Hono({ strict: false }) } ) + .get( + '/econ/customAvatarItems/v1/ownedby/:accountId{[0-9]+}', + describeRoute({ + tags: ['Avatar'], + summary: 'Custom avatar items owned by an account', + description: + 'Lists owned and created items, filtered by outfitType and paged by skip/take. ' + + 'TotalResults is the filtered count before paging. Other accounts see published items only. ' + + 'Honors unityAssetTarget as the caller-owned inventory does.', + security: AUTHED, + responses: { + 200: json(CustomAvatarItemsResponse, 'The owned items'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return customAvatarItemsPage(c, Number(c.req.param('accountId')), id) + } + ) + .post( + '/circuitsRoomRewards/grantRoomBadge', + describeRoute({ + tags: ['Avatar'], + summary: 'Award a room badge from a circuit', + description: + 'Grants a published room badge to a player in the caller’s live room instance. ' + + 'The badge must belong to that room. ResponseCode is 0 on a new grant, 1 if already owned, ' + + 'or 2 on an invalid grant. A new grant creates and announces a gift package.', + security: AUTHED, + requestBody: jsonBody(GrantRoomBadgeRequest, 'The badge, player and room instance'), + responses: { + 200: json(GrantRoomBadgeResponse, 'The circuit result'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const failed = (ErrorMessage: string) => c.json({ ResponseCode: 2, ErrorMessage }) + const parsed = GrantRoomBadgeRequest.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) return failed('Invalid room badge request') + const body = parsed.data + const [badge] = await getCustomAvatarItems(c.env.DB, [ + body.BadgeCustomAvatarItemId.toLowerCase(), + ]) + const roomId = (badge?.CustomBadgeMetadata as { RoomId?: number } | null)?.RoomId + if ( + !badge || + badge.OutfitType !== 1000 || + badge.Accessibility === 0 || + badge.CurrentSaves.length === 0 || + !roomId + ) + return failed('No published room badge') + type BadgeInstance = { roomId: number; roomInstanceId: number } + const [caller, target] = await Promise.all([ + getPresence(c.env.DB, id), + getPresence(c.env.DB, body.TargetAccountId), + ]) + if ( + caller?.roomInstance?.roomId !== roomId || + caller.roomInstance.roomInstanceId !== body.RoomInstanceId || + target?.roomInstance?.roomId !== roomId || + target.roomInstance.roomInstanceId !== body.RoomInstanceId + ) { + return failed('Players must be in the badge’s room instance') + } + const owned = await ownedCustomAvatarItemIds(c.env.DB, body.TargetAccountId, [ + badge.CustomAvatarItemId, + ]) + if (badge.CreatorAccountId === body.TargetAccountId || owned.size > 0) { + return c.json({ ResponseCode: 1, ErrorMessage: null }) + } + // The insert decides the winner of concurrent grants, so only one box is created. + if ( + !(await grantCustomAvatarItem(c.env.DB, body.TargetAccountId, badge.CustomAvatarItemId)) + ) { + return c.json({ ResponseCode: 1, ErrorMessage: null }) + } + const content = toCustomGiftContent( + badge, + censorSwears(body.GrantMessage ?? ''), + COACH_ACCOUNT_ID, + null + ) + const box = await createGift(c.env.DB, body.TargetAccountId, content) + await pushCustomGiftReceived(c, body.TargetAccountId, box.id, content) + return c.json({ ResponseCode: 0, ErrorMessage: null }) + } + ) + // The player's owned custom avatar items. [Authorize]; the paginated envelope. What the // caller has BOUGHT through bulkpurchase (`inventory_custom`) plus what they CREATED, each // the whole `CustomAvatarItem` record out of the item table. The client downloads these diff --git a/apps/econ/src/inventory-custom-db.ts b/apps/econ/src/inventory-custom-db.ts index 288b0fe..95d0a33 100644 --- a/apps/econ/src/inventory-custom-db.ts +++ b/apps/econ/src/inventory-custom-db.ts @@ -38,13 +38,14 @@ export async function grantCustomAvatarItem( accountId: number, customAvatarItemId: string, now: Date = new Date() -): Promise { - await db +): Promise { + const { meta } = await db .prepare( 'INSERT OR IGNORE INTO inventory_custom (account_id, custom_avatar_item_id, acquired_at) VALUES (?1, ?2, ?3)' ) .bind(accountId, customAvatarItemId, now.toISOString()) .run() + return meta.changes > 0 } /** diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index fbd6528..4964a23 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -117,6 +117,18 @@ export const CustomAvatarItemsResponse = z.object({ TotalResults: z.int(), }) +export const GrantRoomBadgeRequest = z.object({ + TargetAccountId: z.int().positive(), + BadgeCustomAvatarItemId: z.uuid(), + RoomInstanceId: z.int().positive(), + GrantMessage: z.string().nullable().optional(), +}) + +export const GrantRoomBadgeResponse = z.object({ + ResponseCode: z.union([z.literal(0), z.literal(1), z.literal(2)]), + ErrorMessage: z.string().nullable(), +}) + /** * A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one, * so this is the complimentary subscription reported by a caller whose token carries diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 83f8d78..07cfb9a 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -19,6 +19,7 @@ import { PROGRESSION_SCHEMA_DDL, RECEIVED_GIFT_SCHEMA_DDL, ROOM_SCHEMA_DDL, + setPresence, updateAccount, } from '@repo/domain' import { PlatformType } from '@repo/domain/src/enums' @@ -596,6 +597,134 @@ describe('econ endpoints', () => { expect(res.status).toBe(404) }) + test('room badges grant once from a live circuit and appear on another player’s badge tab', async () => { + const seed = await createCustomAvatarItem(env.DB, { + customAvatarItemId: crypto.randomUUID(), + creatorAccountId: 801, + name: 'Room badge', + description: '', + price: 0, + baseAvatarItemId: 1, + baseAvatarItemColor: '#fff', + accessibility: 1, + designFilename: 'badge.png', + thumbnailImageFilename: 'badge.png', + }) + const badge = await importCustomAvatarItem(env.DB, { + ...seed, + OutfitType: 1000, + CustomBadgeMetadata: { RoomId: 989891 }, + CurrentSaves: [ + { + CustomAvatarItemSaveId: 1, + CustomAvatarItemId: seed.CustomAvatarItemId, + UnityAssetId: crypto.randomUUID(), + BodyType: 0, + OutfitType: 1000, + QAState: 0, + CreatedAt: seed.CreatedAt, + ModifiedAt: seed.ModifiedAt, + Description: null, + ThumbnailFileName: 'badge.png', + AdditionalConfiguration: '{}', + UnityAsset: 'badge.png', + UnityAssetHash: '', + UnityAsset2: null, + UnityAsset2Hash: null, + }, + ], + }) + const presence = async (accountId: number, roomInstanceId = 891) => + setPresence(env.DB, { + accountId, + roomInstance: { roomId: 989891, roomInstanceId }, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 0, + platform: 0, + appVersion: '20260323.14', + }) + await presence(802) + await presence(803) + const grant = async (overrides = {}, authenticated = true): Promise => + exports.default.fetch(`${ORIGIN}/circuitsRoomRewards/grantRoomBadge`, { + method: 'POST', + headers: { + ...(authenticated ? await bearer('802') : {}), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + TargetAccountId: 803, + BadgeCustomAvatarItemId: badge.CustomAvatarItemId.toUpperCase(), + RoomInstanceId: 891, + GrantMessage: 'Well done!', + ...overrides, + }), + }) + const list = async (query = '', authenticated = true): Promise => + exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/ownedby/803${query}`, { + headers: authenticated ? await bearer('802') : {}, + }) + expect((await grant({}, false)).status).toBe(401) + expect((await list('', false)).status).toBe(401) + expect(await (await list('?outfitType=1000')).json()).toEqual({ Results: [], TotalResults: 0 }) + expect(await (await grant({ RoomInstanceId: 892 })).json()).toMatchObject({ ResponseCode: 2 }) + await presence(803, 892) + expect(await (await grant()).json()).toMatchObject({ ResponseCode: 2 }) + await presence(803) + expect( + await (await grant({ BadgeCustomAvatarItemId: crypto.randomUUID() })).json() + ).toMatchObject({ ResponseCode: 2 }) + const grants = await Promise.all([grant(), grant()]) + expect( + (await Promise.all(grants.map((r) => r.json() as Promise<{ ResponseCode: number }>))) + .map((r) => r.ResponseCode) + .sort((a, b) => a - b) + ).toEqual([0, 1]) + expect(await (await grant()).json()).toEqual({ ResponseCode: 1, ErrorMessage: null }) + const gifts = await getPendingGifts(env.DB, 803) + expect(gifts).toHaveLength(1) + expect(gifts[0]!).toMatchObject({ + CustomAvatarItemId: badge.CustomAvatarItemId, + Message: 'Well done!', + }) + await grantCustomAvatarItem(env.DB, 803, seed.CustomAvatarItemId) + const shirt = await createCustomAvatarItem(env.DB, { + customAvatarItemId: crypto.randomUUID(), + creatorAccountId: 801, + name: 'Shirt', + description: '', + price: 0, + baseAvatarItemId: 1, + baseAvatarItemColor: '#fff', + accessibility: 1, + designFilename: 'shirt.png', + thumbnailImageFilename: 'shirt.png', + }) + await grantCustomAvatarItem(env.DB, 803, shirt.CustomAvatarItemId) + const draft = await importCustomAvatarItem(env.DB, { + ...badge, + CustomAvatarItemId: crypto.randomUUID(), + Accessibility: 0, + }) + await grantCustomAvatarItem(env.DB, 803, draft.CustomAvatarItemId) + expect( + await (await grant({ BadgeCustomAvatarItemId: draft.CustomAvatarItemId })).json() + ).toMatchObject({ ResponseCode: 2 }) + expect(await (await list('?outfitType=1000&skip=0&take=1000')).json()).toEqual({ + Results: [{ ...badge, PurchaseInfo: null }], + TotalResults: 1, + }) + expect(await (await list('?outfitType=1000&skip=1&take=1')).json()).toEqual({ + Results: [], + TotalResults: 1, + }) + expect(await (await list('?outfitType=1000&unityAssetTarget=2')).json()).toMatchObject({ + Results: [{ CurrentSaves: [{ UnityAsset: 'badge.png' }] }], + TotalResults: 1, + }) + }) + test('GET /econ/customAvatarItems/v1/owned 401s without a token, lists what the caller bought', async () => { const anon = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`) expect(anon.status).toBe(401) @@ -8068,6 +8197,7 @@ describe('econ endpoints', () => { 'GET /api/subscriptionseasons/v1/seasons/current', 'GET /api/ugcPurchasables/v1/items/room/{roomId}', 'GET /econ/customAvatarItems/v1/owned', + 'GET /econ/customAvatarItems/v1/ownedby/{accountId}', 'GET /econ/roomEconConfig/{roomId}', 'GET /econ/roomGiftDropShops/room/{roomId}', 'GET /econ/roomInventory/room/{roomId}', @@ -8116,6 +8246,7 @@ describe('econ endpoints', () => { 'POST /api/storefronts/v3/buyInvention', 'POST /api/timedCurrencyGrants/update', 'POST /api/ugcPurchasables/v1/items/bulk', + 'POST /circuitsRoomRewards/grantRoomBadge', 'PUT /api/equipment/v1/update', 'PUT /api/roomconsumables/v1/roomConsumable', 'PUT /api/roomconsumables/v1/roomConsumable/{id}/consume', diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 232150d..a141348 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -1819,7 +1819,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav e.preventDefault() void itemGift.run(async () => { const res = await call<{ - kind: 'custom_item' | 'skin' | 'consumable' + kind: 'custom_item' | 'badge' | 'skin' | 'consumable' name: string prefabName?: string owned?: number @@ -1829,6 +1829,8 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav }) setItemId('') switch (res.kind) { + case 'badge': + return `Granted the badge “${res.name}” to @${account.username}.` case 'skin': return `Sent the skin “${res.name}” (${res.prefabName}) to @${account.username}.` case 'consumable': @@ -1840,12 +1842,12 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav }} > {itemGift.error &&

{itemGift.error}

} diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index e92023d..80dc504 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -1413,7 +1413,12 @@ async function giftCustomItem( return c.json({ error: 'This player already owns that item' }, 409) } - await grantCustomAvatarItem(c.env.DB, playerId, item.CustomAvatarItemId) + if (item.OutfitType === 1000 && item.CurrentSaves.length === 0) { + return c.json({ error: 'That badge has no published image' }, 400) + } + if (!(await grantCustomAvatarItem(c.env.DB, playerId, item.CustomAvatarItemId))) { + return c.json({ error: 'This player already owns that item' }, 409) + } const content = staffGiftContent({ GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A, CustomAvatarItemId: item.CustomAvatarItemId, @@ -1439,7 +1444,7 @@ async function giftCustomItem( await announceGift(c, playerId, gift.id, content) return c.json({ - kind: 'custom_item', + kind: item.OutfitType === 1000 ? 'badge' : 'custom_item', playerId, customAvatarItemId: item.CustomAvatarItemId, name: item.Name, diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index b8e106e..3a597c3 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -37,6 +37,7 @@ import { generateToken } from '@repo/jwt' import { createCustomAvatarItem, SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL, + importCustomAvatarItem, } from '../../../../api/src/custom-avatar-items-db' import { createReport, @@ -2637,6 +2638,77 @@ it('gifts a player a custom item in a gift box', async () => { ]) }) +it('grants a published room badge through profile gifting without room presence, once', async () => { + await clearHub() + await updateAccount(env.DB, 8530, { username: 'BadgeRecipient' }) + const seed = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000030', 8531) + const badge = await importCustomAvatarItem(env.DB, { + ...seed, + Name: 'Room Champion', + OutfitType: 1000, + CustomBadgeMetadata: { RoomId: 12345 }, + CurrentSaves: [ + { + CustomAvatarItemSaveId: 1, + CustomAvatarItemId: seed.CustomAvatarItemId, + UnityAssetId: crypto.randomUUID(), + BodyType: 0, + OutfitType: 1000, + QAState: 0, + CreatedAt: seed.CreatedAt, + ModifiedAt: seed.ModifiedAt, + Description: null, + ThumbnailFileName: 'badge.png', + AdditionalConfiguration: '{}', + UnityAsset: 'badge.png', + UnityAssetHash: '', + UnityAsset2: null, + UnityAsset2Hash: null, + }, + ], + }) + const responses = await Promise.all([ + giftItem(8530, badge.CustomAvatarItemId.toUpperCase(), 'Congratulations!'), + giftItem(8530, badge.CustomAvatarItemId, 'Congratulations!'), + ]) + expect(responses.map((r) => r.status).sort((a, b) => a - b)).toEqual([200, 409]) + const body = await responses.find((r) => r.status === 200)!.json() + expect(body).toMatchObject({ kind: 'badge', name: 'Room Champion' }) + await expect(ownedCustomAvatarItemIds(env.DB, 8530, [badge.CustomAvatarItemId])).resolves.toEqual( + new Set([badge.CustomAvatarItemId]) + ) + const gifts = await getPendingGifts(env.DB, 8530) + expect(gifts).toHaveLength(1) + expect(gifts[0]).toMatchObject({ + CustomAvatarItemId: badge.CustomAvatarItemId, + AvatarItemType: 0, + GiftContext: 500, + Message: 'Congratulations!', + }) + const frames = await hubFrames() + expect(frames).toHaveLength(1) + expect(frames[0]).toMatchObject({ + playerId: 8530, + notificationType: 31, + data: { CustomAvatarItemId: badge.CustomAvatarItemId, Message: 'Congratulations!' }, + }) + expect(await auditRows('gift_custom_item', 8530)).toHaveLength(1) + expect((await giftItem(8530, badge.CustomAvatarItemId)).status).toBe(409) + const draft = await importCustomAvatarItem(env.DB, { + ...badge, + CustomAvatarItemId: crypto.randomUUID(), + Accessibility: 0, + }) + expect((await giftItem(8530, draft.CustomAvatarItemId)).status).toBe(404) + const unbuilt = await importCustomAvatarItem(env.DB, { + ...badge, + CustomAvatarItemId: crypto.randomUUID(), + CurrentSaves: [], + }) + expect((await giftItem(8530, unbuilt.CustomAvatarItemId)).status).toBe(400) + expect(await getPendingGifts(env.DB, 8530)).toHaveLength(1) +}) + 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)