[www] Fix room badge grants and profile badge lists (#148)

This commit is contained in:
dukedotdev
2026-10-27 23:14:10 -04:00
committed by GitHub
parent 1d55ae8b80
commit fd30182f3d
7 changed files with 353 additions and 11 deletions
+118
View File
@@ -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<App>) {
return c.body(null, 401)
}
/** The badge tab reads another account's inventory, filtered before paging. */
async function customAvatarItemsPage(c: Context<App>, 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<App>({ 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<BadgeInstance>(c.env.DB, id),
getPresence<BadgeInstance>(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
+3 -2
View File
@@ -38,13 +38,14 @@ export async function grantCustomAvatarItem(
accountId: number,
customAvatarItemId: string,
now: Date = new Date()
): Promise<void> {
await db
): Promise<boolean> {
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
}
/**
+12
View File
@@ -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
+131
View File
@@ -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<Response> =>
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<Response> =>
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',