mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-28 00:18:58 -07:00
[www] Fix room badge grants and profile badge lists (#148)
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
getOutfits,
|
getOutfits,
|
||||||
getPendingGifts,
|
getPendingGifts,
|
||||||
getPlayerIdsInRoom,
|
getPlayerIdsInRoom,
|
||||||
|
getPresence,
|
||||||
grantInvention,
|
grantInvention,
|
||||||
levelReward,
|
levelReward,
|
||||||
levelsReached,
|
levelsReached,
|
||||||
@@ -117,6 +118,8 @@ import {
|
|||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
form,
|
form,
|
||||||
GameRewardRequest,
|
GameRewardRequest,
|
||||||
|
GrantRoomBadgeRequest,
|
||||||
|
GrantRoomBadgeResponse,
|
||||||
HeldRoomKeyDto,
|
HeldRoomKeyDto,
|
||||||
InfluencerIdsResponse,
|
InfluencerIdsResponse,
|
||||||
IsOnWishlistBulkRequest,
|
IsOnWishlistBulkRequest,
|
||||||
@@ -294,6 +297,28 @@ function unauthorized(c: Context<App>) {
|
|||||||
return c.body(null, 401)
|
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
|
* 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.
|
* 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
|
// 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
|
// 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
|
// the whole `CustomAvatarItem` record out of the item table. The client downloads these
|
||||||
|
|||||||
@@ -38,13 +38,14 @@ export async function grantCustomAvatarItem(
|
|||||||
accountId: number,
|
accountId: number,
|
||||||
customAvatarItemId: string,
|
customAvatarItemId: string,
|
||||||
now: Date = new Date()
|
now: Date = new Date()
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
await db
|
const { meta } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT OR IGNORE INTO inventory_custom (account_id, custom_avatar_item_id, acquired_at) VALUES (?1, ?2, ?3)'
|
'INSERT OR IGNORE INTO inventory_custom (account_id, custom_avatar_item_id, acquired_at) VALUES (?1, ?2, ?3)'
|
||||||
)
|
)
|
||||||
.bind(accountId, customAvatarItemId, now.toISOString())
|
.bind(accountId, customAvatarItemId, now.toISOString())
|
||||||
.run()
|
.run()
|
||||||
|
return meta.changes > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -117,6 +117,18 @@ export const CustomAvatarItemsResponse = z.object({
|
|||||||
TotalResults: z.int(),
|
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,
|
* 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
|
* so this is the complimentary subscription reported by a caller whose token carries
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
PROGRESSION_SCHEMA_DDL,
|
PROGRESSION_SCHEMA_DDL,
|
||||||
RECEIVED_GIFT_SCHEMA_DDL,
|
RECEIVED_GIFT_SCHEMA_DDL,
|
||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
|
setPresence,
|
||||||
updateAccount,
|
updateAccount,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { PlatformType } from '@repo/domain/src/enums'
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
@@ -596,6 +597,134 @@ describe('econ endpoints', () => {
|
|||||||
expect(res.status).toBe(404)
|
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 () => {
|
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`)
|
const anon = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`)
|
||||||
expect(anon.status).toBe(401)
|
expect(anon.status).toBe(401)
|
||||||
@@ -8068,6 +8197,7 @@ describe('econ endpoints', () => {
|
|||||||
'GET /api/subscriptionseasons/v1/seasons/current',
|
'GET /api/subscriptionseasons/v1/seasons/current',
|
||||||
'GET /api/ugcPurchasables/v1/items/room/{roomId}',
|
'GET /api/ugcPurchasables/v1/items/room/{roomId}',
|
||||||
'GET /econ/customAvatarItems/v1/owned',
|
'GET /econ/customAvatarItems/v1/owned',
|
||||||
|
'GET /econ/customAvatarItems/v1/ownedby/{accountId}',
|
||||||
'GET /econ/roomEconConfig/{roomId}',
|
'GET /econ/roomEconConfig/{roomId}',
|
||||||
'GET /econ/roomGiftDropShops/room/{roomId}',
|
'GET /econ/roomGiftDropShops/room/{roomId}',
|
||||||
'GET /econ/roomInventory/room/{roomId}',
|
'GET /econ/roomInventory/room/{roomId}',
|
||||||
@@ -8116,6 +8246,7 @@ describe('econ endpoints', () => {
|
|||||||
'POST /api/storefronts/v3/buyInvention',
|
'POST /api/storefronts/v3/buyInvention',
|
||||||
'POST /api/timedCurrencyGrants/update',
|
'POST /api/timedCurrencyGrants/update',
|
||||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||||
|
'POST /circuitsRoomRewards/grantRoomBadge',
|
||||||
'PUT /api/equipment/v1/update',
|
'PUT /api/equipment/v1/update',
|
||||||
'PUT /api/roomconsumables/v1/roomConsumable',
|
'PUT /api/roomconsumables/v1/roomConsumable',
|
||||||
'PUT /api/roomconsumables/v1/roomConsumable/{id}/consume',
|
'PUT /api/roomconsumables/v1/roomConsumable/{id}/consume',
|
||||||
|
|||||||
@@ -1819,7 +1819,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void itemGift.run(async () => {
|
void itemGift.run(async () => {
|
||||||
const res = await call<{
|
const res = await call<{
|
||||||
kind: 'custom_item' | 'skin' | 'consumable'
|
kind: 'custom_item' | 'badge' | 'skin' | 'consumable'
|
||||||
name: string
|
name: string
|
||||||
prefabName?: string
|
prefabName?: string
|
||||||
owned?: number
|
owned?: number
|
||||||
@@ -1829,6 +1829,8 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
})
|
})
|
||||||
setItemId('')
|
setItemId('')
|
||||||
switch (res.kind) {
|
switch (res.kind) {
|
||||||
|
case 'badge':
|
||||||
|
return `Granted the badge “${res.name}” to @${account.username}.`
|
||||||
case 'skin':
|
case 'skin':
|
||||||
return `Sent the skin “${res.name}” (${res.prefabName}) to @${account.username}.`
|
return `Sent the skin “${res.name}” (${res.prefabName}) to @${account.username}.`
|
||||||
case 'consumable':
|
case 'consumable':
|
||||||
@@ -1840,12 +1842,12 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label>
|
<label>
|
||||||
Gift item
|
Gift item or badge
|
||||||
<span className="staff-gift">
|
<span className="staff-gift">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={itemId}
|
value={itemId}
|
||||||
placeholder="Custom item, skin or consumable id"
|
placeholder="Badge, custom item, skin or consumable id"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
required
|
required
|
||||||
onChange={(e) => setItemId(e.target.value)}
|
onChange={(e) => setItemId(e.target.value)}
|
||||||
@@ -1855,10 +1857,11 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span className="hint">
|
<span className="hint">
|
||||||
A custom avatar item’s guid, a skin’s modification guid, a consumable’s item desc,
|
A badge or custom avatar item’s guid, a skin’s modification guid, a consumable’s
|
||||||
or a store id — the kind is worked out from the id. Arrives as a gift box, as if
|
item desc, or a store id — the kind is worked out from the id. Arrives as a gift
|
||||||
bought from the store; free to them, and a creator isn’t paid. Consumables stack:
|
box, as if bought from the store; free to them, and a creator isn’t paid.
|
||||||
send again for another.
|
Consumables stack: send again for another. Badges must be published and can be
|
||||||
|
granted while the player is offline.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{itemGift.error && <p className="error">{itemGift.error}</p>}
|
{itemGift.error && <p className="error">{itemGift.error}</p>}
|
||||||
|
|||||||
@@ -1413,7 +1413,12 @@ async function giftCustomItem(
|
|||||||
return c.json({ error: 'This player already owns that item' }, 409)
|
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({
|
const content = staffGiftContent({
|
||||||
GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A,
|
GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A,
|
||||||
CustomAvatarItemId: item.CustomAvatarItemId,
|
CustomAvatarItemId: item.CustomAvatarItemId,
|
||||||
@@ -1439,7 +1444,7 @@ async function giftCustomItem(
|
|||||||
await announceGift(c, playerId, gift.id, content)
|
await announceGift(c, playerId, gift.id, content)
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
kind: 'custom_item',
|
kind: item.OutfitType === 1000 ? 'badge' : 'custom_item',
|
||||||
playerId,
|
playerId,
|
||||||
customAvatarItemId: item.CustomAvatarItemId,
|
customAvatarItemId: item.CustomAvatarItemId,
|
||||||
name: item.Name,
|
name: item.Name,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { generateToken } from '@repo/jwt'
|
|||||||
import {
|
import {
|
||||||
createCustomAvatarItem,
|
createCustomAvatarItem,
|
||||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||||
|
importCustomAvatarItem,
|
||||||
} from '../../../../api/src/custom-avatar-items-db'
|
} from '../../../../api/src/custom-avatar-items-db'
|
||||||
import {
|
import {
|
||||||
createReport,
|
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 () => {
|
it('refuses a custom item gift the store would refuse', async () => {
|
||||||
await updateAccount(env.DB, 8340, { username: 'Picky' })
|
await updateAccount(env.DB, 8340, { username: 'Picky' })
|
||||||
const published = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000002', 8341)
|
const published = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000002', 8341)
|
||||||
|
|||||||
Reference in New Issue
Block a user