[2025] unstable

This commit is contained in:
Devin Zuczek
2026-08-13 01:05:55 -04:00
committed by devin
parent 551585179a
commit ec52985ef2
32 changed files with 1396 additions and 109 deletions
+93 -15
View File
@@ -7,11 +7,13 @@ import {
consumeGift,
createGift,
getGift,
getOutfits,
getPendingGifts,
grantInvention,
levelReward,
levelsReached,
ownsInvention,
setOutfit,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
@@ -29,6 +31,7 @@ import { NotificationType } from '../../notify/src/notification-types'
import adCarouselItems from '../static/ad-carousel-items.json'
import defaultAvatarItems from '../static/default-avatar-items.json'
import defaultAvatar from '../static/default-avatar.json'
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
import myProgress from '../static/my-progress.json'
import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
@@ -54,9 +57,10 @@ import {
grantConsumable,
} from './consumables-db'
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
import { getInventory, grantItem } from './inventory-db'
import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db'
import {
AUTHED,
AvatarItemV4Dto,
AvatarV2Dto,
BalanceEntry,
BuyInventionResponse,
@@ -64,6 +68,9 @@ import {
BuyItemResponse,
ChallengeProgressRequest,
ChallengeProgressResponse,
ChecklistCompleteResponse,
ChecklistEntry,
CompleteChecklistRequest,
ConsumeConsumableRequest,
ConsumeEnvelope,
ConsumeGiftRequest,
@@ -85,11 +92,10 @@ import {
UpdateObjectiveRequest,
UpdateObjectiveResponse,
} from './openapi'
import { getOutfits, setOutfit } from './outfit-db'
import { claimReward } from './reward-db'
import type { Context } from 'hono'
import type { GiftContent, Progression, StoredGift, XpGrant } from '@repo/domain'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type {
BalanceResponsePayload,
PurchaseBalanceModificationPayload,
@@ -99,7 +105,6 @@ import type { ConsumeResult } from './consumables-db'
import type { App } from './context'
import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db'
import type { Outfit } from './outfit-db'
/**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
@@ -1160,6 +1165,22 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
}
}
/**
* The default NUX checklist for a brand-new account. `Objective` is an `ObjectiveType`
* ordinal (from the client's `ProgressionManager`) that the client matches its own
* progress events against — the names below are what those ordinals mean.
*/
const DEFAULT_CHECKLIST = [
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 }, // SaveOutfitSlot
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 }, // VisitACustomRoom
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 }, // AddAFriend
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 }, // GoToRecCenter
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 }, // CheerAPlayer
]
/** The `UpdateResponse` context a checklist reward is reported under. */
const CHECKLIST_REWARD_CONTEXT = 303
/**
* A concise `describeRoute` spec for a route that serves an opaque JSON array — either
* a static catalog served verbatim or an empty-list stub. `auth` adds the bearer
@@ -1201,11 +1222,12 @@ const app = new Hono<App>({ strict: false })
(c) => c.json(defaultAvatarItems)
)
// Default base avatar items — empty stub for now. No auth.
// The base items UGC clothing is built on top of — served from bundled static JSON,
// separate from the `defaultunlocked` catalog. No auth.
.get(
'/api/avatar/v1/defaultbaseavataritems',
listRoute('Default base avatar items', 'Empty stub for now'),
(c) => c.json([])
listRoute('Default base avatar items', 'The bundled base items UGC clothing builds on'),
(c) => c.json(defaultBaseAvatarItems)
)
// The player's avatar items — the items they've bought (from `buyItem`, stored in
@@ -1219,10 +1241,12 @@ const app = new Hono<App>({ strict: false })
description: [
'The items the player has bought (from buyItem, in the inventory table) prepended',
'to the default catalog. A player who has bought nothing gets just the catalog.',
'Both sources are projected into the camelCase v4 DTO — the sibling item endpoints',
'(`defaultunlocked`, `defaultbaseavataritems`) serve their records raw instead.',
].join(' '),
security: AUTHED,
responses: {
200: json(JsonArray, 'Owned items followed by the default catalog'),
200: json(AvatarItemV4Dto.array(), 'Owned items followed by the default catalog'),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -1230,7 +1254,7 @@ const app = new Hono<App>({ strict: false })
const id = await authedId(c)
if (id === null) return unauthorized(c)
const owned = await getInventory(c.env.DB, id)
return c.json([...owned, ...defaultAvatarItems])
return c.json([...owned, ...defaultAvatarItems].map(toAvatarItemV4))
}
)
@@ -1376,15 +1400,69 @@ const app = new Hono<App>({ strict: false })
}
)
// NUX checklist — the client fetches this on the econ host during load. []
// with no DB. A 404 here can abort the load orchestration before matchmake.
.get(
'/api/checklist/v1/current',
listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true),
// NUX checklist — the client fetches this on the econ host during load, on either
// version path. A 404 here can abort the load orchestration before matchmake. We
// serve the default brand-new-account list to everyone: nothing records per-player
// checklist progress yet, so it never shrinks as steps are done.
.on(
'GET',
['/api/checklist/v1/current', '/api/checklist/v2/current'],
describeRoute({
tags: ['Econ'],
summary: 'NUX checklist',
description:
'The new-user checklist, as the default brand-new-account list — nothing records ' +
'per-player progress yet, so the same rows come back however much the player has ' +
'done. `Objective` is an `ObjectiveType` ordinal the client matches its own ' +
'progress events against. v1 and v2 serve the same list.',
security: AUTHED,
responses: {
200: json(ChecklistEntry.array(), 'The checklist rows, in `Order`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
return c.json(DEFAULT_CHECKLIST)
}
)
// Mark a checklist row done. [Authorize]. Stubbed: there is no objective-progress
// table to record the completion in, and no reward ledger to make the 25-token grant
// once-only — without one, re-posting the same row would mint tokens indefinitely, so
// we grant nothing and report a change of 0. The envelope is still the balance-update
// shape the client parses, so the flow completes instead of erroring.
.on(
'POST',
['/api/checklist/v1/complete', '/api/checklist/v2/complete'],
describeRoute({
tags: ['Econ'],
summary: 'Complete a checklist row (stub)',
description:
'Marks a NUX checklist row done. Stubbed: nothing records the completion (no ' +
'objective-progress table) and nothing is granted — a reward is worth 25 XP and 25 ' +
'tokens, but making that once-only needs a ledger we do not have, and without one ' +
're-posting the same row would mint tokens indefinitely. The response is still the ' +
'balance-update envelope, with `Balance` (the change) 0. v1 and v2 behave alike.',
security: AUTHED,
requestBody: jsonBody(CompleteChecklistRequest, 'Which row was completed — `{ ItemIndex }`'),
responses: {
200: json(ChecklistCompleteResponse, 'The balance-update envelope, granting nothing'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The body names the row (`{ ItemIndex: 1 }`, or `Id` as a fallback) — read only
// once there is somewhere to record it.
return c.json({
BalanceUpdates: [{ UpdateResponse: CHECKLIST_REWARD_CONTEXT, Data: [] }],
Balance: 0,
CurrencyType: CurrencyType.RecCenterTokens,
BalanceType: -2,
})
}
)
+37
View File
@@ -40,6 +40,43 @@ export interface AvatarItem extends Record<string, unknown> {
Rarity: number
}
/**
* The camelCase DTO `GET /api/avatar/v4/items` serves. Distinct from the PascalCase
* `AvatarItem` we store and from what the sibling item endpoints (`defaultunlocked`,
* `defaultbaseavataritems`) serve — those hand back their stored/bundled records raw.
*/
export interface AvatarItemV4 {
avatarItemId: number
avatarItemDesc: string
friendlyName: string
tooltip: string
tagList: string
avatarItemType: number
rarity: number
isBaseAvatarItem: boolean
}
/**
* Project a stored or bundled avatar item into the v4 DTO. Neither source carries an
* `AvatarItemId`, a `TagList` or an `IsBaseAvatarItem` flag — the storefront gift-drops
* we grant from have none and the default catalog has none either — so those default to
* 0 / "" / false rather than being invented.
*/
export function toAvatarItemV4(item: Record<string, unknown>): AvatarItemV4 {
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
return {
avatarItemId: num(item.AvatarItemId),
avatarItemDesc: str(item.AvatarItemDesc),
friendlyName: str(item.FriendlyName),
tooltip: str(item.Tooltip),
tagList: str(item.TagList),
avatarItemType: num(item.AvatarItemType),
rarity: num(item.Rarity),
isBaseAvatarItem: item.IsBaseAvatarItem === true,
}
}
/**
* Grant an item into a player's inventory. Upserts on (account_id, avatar_item_desc):
* owning an item is boolean, so re-buying it refreshes the stored DTO rather than
+50
View File
@@ -130,6 +130,56 @@ export const SubscriptionDto = z.object({
ModifiedAt: z.string(),
})
/**
* One item as `GET /api/avatar/v4/items` serves it — camelCase, unlike the PascalCase
* records the sibling item endpoints hand back. `avatarItemId` is 0 and `tagList` empty
* for every item we have: neither the default catalog nor a storefront gift-drop carries
* them.
*/
export const AvatarItemV4Dto = z.object({
avatarItemId: z.int(),
avatarItemDesc: z.string().describe('The comma-delimited item descriptor, commas and all'),
friendlyName: z.string(),
tooltip: z.string(),
tagList: z.string(),
avatarItemType: z.int(),
rarity: z.int(),
isBaseAvatarItem: z.boolean(),
})
/**
* `POST /api/checklist/v1|v2/complete` JSON body — which checklist row was finished.
* The client posts just `{ "ItemIndex": 1 }`; `Id` is the fallback key read when
* `ItemIndex` is absent or 0.
*/
export const CompleteChecklistRequest = z.object({
ItemIndex: z.int().describe('The rows index — what the client actually sends'),
Id: z.int().optional().describe('Fallback row id, read when ItemIndex is absent or 0'),
})
/**
* `POST /api/checklist/v1|v2/complete` — the balance-update envelope, the same shape
* buyItem answers with. `Balance` is the CHANGE applied, so a stubbed (ungranted)
* completion reports 0. `UpdateResponse` 303 is the checklist-reward context.
*/
export const ChecklistCompleteResponse = z.object({
BalanceUpdates: z.array(z.object({ UpdateResponse: z.int(), Data: z.array(JsonObject) })),
Balance: z.int().describe('The change applied — 0 while completion is stubbed'),
CurrencyType: z.int(),
BalanceType: z.int().describe('-2 = account-wide'),
})
/**
* One row of the new-user checklist (`GET /api/checklist/v1|v2/current`). `Objective` is
* an `ObjectiveType` ordinal the client matches its own progress events against.
*/
export const ChecklistEntry = z.object({
Order: z.int().describe('Position in the list, from 0'),
Objective: z.int().describe('ObjectiveType ordinal, e.g. 38 = SaveOutfitSlot'),
Count: z.int().describe('How many times the objective must happen'),
CreditAmount: z.int().describe('Tokens awarded on completion'),
})
/**
* `POST /api/CampusCard/v1/UpdateAndGetSubscription` — the caller's subscription, or `{}`
* when they have none (which is everyone without the `developer` role). `{}` rather than a
-59
View File
@@ -1,59 +0,0 @@
/**
* Saved outfits on the shared `recflare` D1 database — the outfit slots a player
* saves from the avatar screen (`POST /api/avatar/v3/saved/set`) and loads back from
* `GET /api/avatar/v3/saved`.
*
* One row per (account, slot). The outfit itself is stored as the opaque JSON payload
* the client posted: we never query inside it, and its fields (OutfitSelectionsV2,
* FaceFeatures, …) are themselves JSON-in-a-string produced by the client's own
* serializer. Round-tripping it verbatim is both the simplest and the safest thing —
* re-encoding risks changing a payload the client has to parse back.
*
* The `econ` worker owns this table and its migration (apps/econ/migrations/
* 0002_outfit.sql).
*/
/** Schema DDL (mirror of migrations 0002_outfit.sql) — also used to build the table in tests. */
export const OUTFIT_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS outfit (
account_id INTEGER NOT NULL,
set_id INTEGER NOT NULL,
avatar TEXT NOT NULL,
PRIMARY KEY (account_id, set_id)
)`,
]
/**
* A saved outfit, as the client posts it. `Slot` is the outfit slot it occupies (the
* `set_id` column) — saving to a slot the player already used overwrites it, which is
* exactly what the avatar screen's "save over this outfit" does. The rest of the
* payload (PreviewImageName, OutfitSelections, FaceFeatures, SkinColor, HairColor,
* CustomAvatarItems, …) is stored and served back untouched.
*/
export interface Outfit extends Record<string, unknown> {
Slot: number
}
/** Every outfit a player has saved, ordered by slot. */
export async function getOutfits(db: D1Database, accountId: number): Promise<Outfit[]> {
const { results } = await db
.prepare('SELECT avatar FROM outfit WHERE account_id = ?1 ORDER BY set_id')
.bind(accountId)
.all<{ avatar: string }>()
return results.map((r) => JSON.parse(r.avatar) as Outfit)
}
/**
* Save an outfit into one of the player's slots, replacing whatever was there. The
* upsert is keyed on (account_id, set_id), so re-saving a slot overwrites rather than
* accumulating duplicate rows for it.
*/
export async function setOutfit(db: D1Database, accountId: number, outfit: Outfit): Promise<void> {
await db
.prepare(
`INSERT INTO outfit (account_id, set_id, avatar) VALUES (?1, ?2, ?3)
ON CONFLICT (account_id, set_id) DO UPDATE SET avatar = ?3`
)
.bind(accountId, outfit.Slot, JSON.stringify(outfit))
.run()
}
+97 -30
View File
@@ -8,6 +8,7 @@ import {
getOwnedInventionIds,
getProgression,
INVENTORY_INVENTION_SCHEMA_DDL,
OUTFIT_SCHEMA_DDL,
PROGRESSION_SCHEMA_DDL,
RECEIVED_GIFT_SCHEMA_DDL,
} from '@repo/domain'
@@ -33,7 +34,6 @@ import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../ch
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import type { Env } from '../../context'
@@ -195,10 +195,15 @@ describe('econ endpoints', () => {
expect(body[0]).toHaveProperty('AvatarItemDesc')
})
test('GET /api/avatar/v1/defaultbaseavataritems is an empty stub (no auth)', async () => {
test('GET /api/avatar/v1/defaultbaseavataritems returns the base items (no auth)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/defaultbaseavataritems`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
const body = (await res.json()) as Array<Record<string, unknown>>
expect(body.map((i) => i.AvatarItemId)).toEqual([2184, 2918])
// The client keys these off IsBaseAvatarItem, and the trailing comma in the desc
// is part of the item descriptor — both are served verbatim.
expect(body.every((i) => i.IsBaseAvatarItem === true)).toBe(true)
expect(body[0]?.AvatarItemDesc).toBe('c5d70cb4-71dd-4fe4-b719-34fe2073c611,')
})
test('GET /api/avatar/v4/items 401s without a token', async () => {
@@ -206,16 +211,34 @@ describe('econ endpoints', () => {
expect(res.status).toBe(401)
})
test('GET /api/avatar/v4/items returns the item catalog with a valid token', async () => {
test('GET /api/avatar/v4/items serves the catalog in the camelCase v4 shape', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
const body = (await res.json()) as unknown[]
expect(Array.isArray(body)).toBe(true)
const body = (await res.json()) as Array<Record<string, unknown>>
expect(body.length).toBeGreaterThan(0)
expect(body[0]).toHaveProperty('AvatarItemDesc')
expect(body[0]).toHaveProperty('FriendlyName')
// Every key of the DTO is present on every item, and nothing PascalCase leaks
// through from the stored/bundled records.
for (const item of body) {
expect(Object.keys(item).sort()).toEqual([
'avatarItemDesc',
'avatarItemId',
'avatarItemType',
'friendlyName',
'isBaseAvatarItem',
'rarity',
'tagList',
'tooltip',
])
}
expect(typeof body[0]?.avatarItemDesc).toBe('string')
expect(typeof body[0]?.friendlyName).toBe('string')
// The catalog carries no ids, tags or base flag — those default rather than
// being invented.
expect(body[0]?.avatarItemId).toBe(0)
expect(body[0]?.tagList).toBe('')
expect(body[0]?.isBaseAvatarItem).toBe(false)
})
test('GET /api/avatar/v2 401s without a token', async () => {
@@ -397,14 +420,53 @@ describe('econ endpoints', () => {
expect(body.isCompleted).toBe(false)
})
test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`, {
headers: await bearer(),
test('GET /api/checklist/v1|v2/current 401s without a token, serves the NUX list with one', async () => {
const expected = [
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 },
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 },
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 },
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 },
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 },
]
// Both version paths are live and serve the same list.
for (const path of ['/api/checklist/v1/current', '/api/checklist/v2/current']) {
const anon = await exports.default.fetch(`${ORIGIN}${path}`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}${path}`, { headers: await bearer() })
expect(res.status).toBe(200)
expect(await res.json()).toEqual(expected)
}
})
test('POST /api/checklist/v1|v2/complete 401s without a token, grants nothing with one', async () => {
for (const path of ['/api/checklist/v1/complete', '/api/checklist/v2/complete']) {
const anon = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ItemIndex: 1 }),
})
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer('33')), 'Content-Type': 'application/json' },
body: JSON.stringify({ ItemIndex: 1 }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
BalanceUpdates: [{ UpdateResponse: 303, Data: [] }],
Balance: 0,
CurrencyType: 2,
BalanceType: -2,
})
}
// Stubbed, so completing rows does not move the balance — re-posting cannot farm
// tokens, and the checklist still lists every row.
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('33'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }])
})
test('GET /api/itemWishlists/v1/wishlist/me 401s without a token, returns [] with one', async () => {
@@ -816,9 +878,9 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('20'),
})
const list = (await items.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }>
expect(list[0].FriendlyName).toBe('Bowtie (White)')
expect(list[0].AvatarItemDesc).toBe(gift.AvatarItemDesc)
const list = (await items.json()) as Array<{ avatarItemDesc: string; friendlyName: string }>
expect(list[0].friendlyName).toBe('Bowtie (White)')
expect(list[0].avatarItemDesc).toBe(gift.AvatarItemDesc)
// And a pending gift box is waiting to be opened.
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
@@ -899,8 +961,8 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('25'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.every((i) => i.FriendlyName !== 'Supreme Pizza')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Supreme Pizza')).toBe(true)
// Buying it again stacks: a second instance, count summed to 2.
expect((await buy()).status).toBe(200)
@@ -966,8 +1028,8 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('31'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Disc Skin (Coop)')).toBe(true)
expect(first[0].Favorited).toBe(false)
@@ -1066,8 +1128,8 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('23'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Bowtie (White)')).toBe(true)
})
/**
@@ -1263,8 +1325,8 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('24'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.some((i) => i.FriendlyName === 'Bowtie (White)')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.some((i) => i.friendlyName === 'Bowtie (White)')).toBe(true)
// Opening it again is a harmless no-op — still 200.
const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, {
@@ -1670,9 +1732,10 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('76'),
})
const owned = (await items.json()) as Array<{ AvatarItemDesc: string }>
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
if ((boxes[0]?.AvatarItemDesc ?? '') !== '') {
expect(owned.map((i) => i.AvatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc)
expect(owned.map((i) => i.avatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc)
}
// A second box can't roll the same prize: "an item that you don't have" excludes what
@@ -1882,8 +1945,9 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('82'),
})
const owned = (await items.json()) as Array<{ AvatarItemDesc: string }>
expect(owned.map((i) => i.AvatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
expect((await drainFrames()).map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
@@ -2028,6 +2092,7 @@ describe('econ endpoints', () => {
'GET /api/avatar/v4/items',
'GET /api/challenge/v2/getCurrent',
'GET /api/checklist/v1/current',
'GET /api/checklist/v2/current',
'GET /api/consumables/v2/getUnlocked',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gamerewards/v1/pending',
@@ -2051,6 +2116,8 @@ describe('econ endpoints', () => {
'POST /api/avatar/v3/saved/set',
'POST /api/avatar/v4/saved/set',
'POST /api/challenge/v2/updateProgress',
'POST /api/checklist/v1/complete',
'POST /api/checklist/v2/complete',
'POST /api/consumables/v1/consume',
'POST /api/gamerewards/v1/request',
'POST /api/objectives/v1/cleargroup',
@@ -0,0 +1,28 @@
[
{
"AvatarItemDesc": "c5d70cb4-71dd-4fe4-b719-34fe2073c611,",
"AvatarItemType": 0,
"PlatformMask": -1,
"FriendlyName": "(UGCTee_Shirt) ",
"Tooltip": "",
"Rarity": -1,
"TagList": "",
"AvatarItemId": 2184,
"IsBaseAvatarItem": true,
"CreatedAt": "2022-04-19T23:40:30.807Z",
"ThumbnailImage": "KXfytDhXzES2yco-rwqDSA.png"
},
{
"AvatarItemDesc": "95a519de-f2cb-429c-b014-508477f20d42,",
"AvatarItemType": 0,
"PlatformMask": -1,
"FriendlyName": "(UGCPulloverHoodie_Shirt) ",
"Tooltip": "",
"Rarity": -1,
"TagList": "",
"AvatarItemId": 2918,
"IsBaseAvatarItem": true,
"CreatedAt": "2023-04-07T17:07:07.04Z",
"ThumbnailImage": "m4UIuZjNzEWsCP1gpZBgjg.png"
}
]