From 79aab56c9ecd97af411acbd0f74dd52829f4cd91 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sat, 1 Aug 2026 12:38:31 -0400 Subject: [PATCH] stub the checklist endpoint --- apps/accounts/src/accounts.app.ts | 4 + apps/accounts/src/openapi.ts | 2 + .../accounts/src/test/integration/api.test.ts | 4 + apps/api/src/openapi.ts | 46 ++++++++++ apps/api/src/routes/avatar.ts | 63 ++++++++++++++ apps/api/src/routes/config.ts | 6 +- apps/api/src/test/integration/api.test.ts | 38 +++++++++ apps/econ/src/econ.app.ts | 59 ++++++++++--- apps/econ/src/inventory-db.ts | 37 ++++++++ apps/econ/src/openapi.ts | 28 +++++++ apps/econ/src/test/integration/api.test.ts | 84 +++++++++++++------ .../static/default-base-avatar-items.json | 28 +++++++ packages/domain/src/accounts-db.ts | 6 ++ 13 files changed, 364 insertions(+), 41 deletions(-) create mode 100644 apps/econ/static/default-base-avatar-items.json diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index 3cb9a41..a043f88 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -94,6 +94,10 @@ function toAccountDto(account: Account) { username: account.username, displayName: account.displayName, profileImage: account.profileImage, + // Nothing writes these yet, and rows stored before they existed have neither + // key — always emit them as "" rather than letting them go missing. + bannerImage: account.bannerImage ?? '', + displayEmoji: account.displayEmoji ?? '', isJunior: account.isJunior, platforms: account.platforms, personalPronouns: account.personalPronouns, diff --git a/apps/accounts/src/openapi.ts b/apps/accounts/src/openapi.ts index c93a50c..8a5b103 100644 --- a/apps/accounts/src/openapi.ts +++ b/apps/accounts/src/openapi.ts @@ -52,6 +52,8 @@ export const AccountDto = z.object({ username: z.string(), displayName: z.string(), profileImage: z.string().describe('Avatar object key'), + bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'), + displayEmoji: z.string().describe('Emoji beside the display name — always "" (nothing sets it yet)'), isJunior: z.boolean(), platforms: z.int().describe('PlatformType bitmask of linked platforms'), personalPronouns: z.int().describe('Pronoun flags bitmask'), diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index 7cb6b70..5cdf0a0 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -161,6 +161,10 @@ describe('auth-gated endpoints', () => { personalPronouns: 0, identityFlags: 0, availableUsernameChanges: 1, + // Nothing sets these yet, but the key has to be present — the client reads + // both off the account DTO. + bannerImage: '', + displayEmoji: '', }) // juniorState + parentAccountId must be omitted when null, not emitted as // null, or the client's enum parser throws on `juniorState`. `phone` isn't diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index b6f4556..178998d 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -352,6 +352,52 @@ export const CustomAvatarItemsPage = z.object({ TotalResults: z.int(), }) +/** + * One custom-item save — the rebuilt version of a legacy avatar item. This is the + * official shape, recorded for documentation: nothing stores custom items yet, so we + * never actually emit one of these. + */ +export const CustomAvatarItemSave = z.object({ + customAvatarItemSaveId: z.int().describe('The save’s id'), + customAvatarItemId: z.string().describe('Guid of the custom item this save belongs to'), + unityAssetId: z.string().describe('Guid of the built Unity asset'), + createdAt: z.string().describe('ISO 8601 timestamp'), + thumbnailFileName: z.string(), + additionalConfiguration: z.string(), + unityAsset: z.string(), + unityAssetHash: z.string(), +}) + +/** + * The custom-item saves that replace a set of legacy avatar items, keyed by the legacy + * item's `AvatarItemDesc`. Nothing stores custom items yet, so the map is always empty — + * the value shape is documented rather than served. + */ +export const LegacyAvatarItemSaves = z.object({ + customAvatarItemSavesByAvatarItemDesc: z.record(z.string(), CustomAvatarItemSave), +}) + +/** + * `GET /outfits/me` — the empty-outfit envelope. Stubbed, so every field that would + * carry a stored outfit is null/empty; `DataVersion` 9 is what the client parses against. + */ +export const OutfitsMeResponse = z.object({ + LegacyData: z.object({ + SelectionsV1: z.null(), + SelectionsV2: z.null(), + FaceFeatures: z.null(), + SkinColor: z.null(), + HairColor: z.null(), + }), + Selections: JsonArray, + DataVersion: z.int(), + CustomizationSettings: z.null(), + ThumbnailFileName: z.null(), + Name: z.null(), + Accessibility: z.int(), + Slot: z.int(), +}) + /** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */ export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() }) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index a3f610d..cf111d0 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -39,6 +39,8 @@ import { json, JsonArray, jsonBody, + LegacyAvatarItemSaves, + OutfitsMeResponse, pageParams, SaveInventionRequest, SetTagsRequest, @@ -213,6 +215,67 @@ export const avatarRoutes = new Hono({ strict: false }) (c) => c.json({ Results: [], TotalResults: 0 }) ) + // The client asks which legacy avatar items have been rebuilt as custom items, so it + // can render the custom version instead. Nothing stores custom items yet, so nothing + // has a save — an empty list means "use the legacy items as-is". + .post( + '/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems', + describeRoute({ + tags: ['Avatar'], + summary: 'Custom-item saves for legacy avatar items', + description: + 'Given a set of legacy avatar items, the custom-item saves that replace them, keyed ' + + 'by the legacy item’s `AvatarItemDesc`. Nothing stores custom items yet, so the map ' + + 'is always empty — which the client reads as “render the legacy items as-is”. The ' + + 'request body is ignored.\n\n' + + 'The value shape is the official one, recorded here for documentation; we never ' + + 'emit one until custom items are stored.', + responses: { 200: json(LegacyAvatarItemSaves, 'An empty map') }, + }), + (c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} }) + ) + + // The newer outfit read, on a bare (un-prefixed) path. Auth-gated. Stubbed: every + // caller gets the brand-new-account envelope — all-null LegacyData, no selections — + // rather than their saved outfit, which lives on the `econ` worker. + .get( + '/outfits/me', + describeRoute({ + tags: ['Avatar'], + summary: 'The caller’s outfit (stub)', + description: + 'The newer outfit read, on a bare un-prefixed path. Stubbed for now: every caller ' + + 'gets the brand-new-account envelope — all-null `LegacyData`, no `Selections` — ' + + 'regardless of what they have saved (saved outfits live on the `econ` worker). ' + + '`DataVersion` 9 is the version the client expects to parse.', + security: AUTHED, + responses: { + 200: json(OutfitsMeResponse, 'The empty-outfit envelope'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json({ + LegacyData: { + SelectionsV1: null, + SelectionsV2: null, + FaceFeatures: null, + SkinColor: null, + HairColor: null, + }, + Selections: [], + DataVersion: 9, + CustomizationSettings: null, + ThumbnailFileName: null, + Name: null, + Accessibility: 0, + Slot: 0, + }) + } + ) + // A single invention by id (`?inventionId=…`). Returns the stored RRInvention, // or 404 when there's no such invention. .get( diff --git a/apps/api/src/routes/config.ts b/apps/api/src/routes/config.ts index 12b2140..66af051 100644 --- a/apps/api/src/routes/config.ts +++ b/apps/api/src/routes/config.ts @@ -19,10 +19,10 @@ import type { App } from '../context' /** * Client builds the version check answers as current. `GAME_VERSION` is the build the - * rest of the stack targets; `20230616` is a later client that talks the same protocol, - * so we let it through rather than telling it to update. + * rest of the stack targets; `20230616` and `20231207` are later clients that talk the + * same protocol, so we let them through rather than telling them to update. */ -const ACCEPTED_GAME_VERSIONS = new Set([GAME_VERSION, '20230616']) +const ACCEPTED_GAME_VERSIONS = new Set([GAME_VERSION, '20230616', '20231207']) // ---- Config / version ------------------------------------------------------ export const configRoutes = new Hono({ strict: false }) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index e635adb..1a08d27 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -350,6 +350,42 @@ describe('public endpoints', () => { expect(await res.json()).toEqual({ Results: [], TotalResults: 0 }) }) + test('POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems returns an empty map', async () => { + const res = await exports.default.fetch( + `${ORIGIN}/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ AvatarItemIds: [1, 2, 3] }), + } + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ customAvatarItemSavesByAvatarItemDesc: {} }) + }) + + test('GET /outfits/me 401s without a token, serves the empty envelope with one', async () => { + const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`) + expect(anon.status).toBe(401) + const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + LegacyData: { + SelectionsV1: null, + SelectionsV2: null, + FaceFeatures: null, + SkinColor: null, + HairColor: null, + }, + Selections: [], + DataVersion: 9, + CustomizationSettings: null, + ThumbnailFileName: null, + Name: null, + Accessibility: 0, + Slot: 0, + }) + }) + test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`) expect(res.status).toBe(200) @@ -1972,12 +2008,14 @@ describe('openapi', () => { 'GET /api/roomkeys/v1/room', 'GET /api/rooms/v1/filters', 'GET /api/versioncheck/v4', + 'GET /outfits/me', 'GET /voice/config', 'POST /api/CampusCard/v1/UpdateAndGetSubscription', 'POST /api/PlayerReporting/v1/deviceId', 'POST /api/PlayerReporting/v1/hile', 'POST /api/PlayerReporting/v1/moderationBlockDetails', 'POST /api/avatar/v2/gifts/generate', + 'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems', 'POST /api/gamesight/event', 'POST /api/images/v1/cheer', 'POST /api/images/v4/uploadsaved', diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 1ce5e99..8b4397f 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -12,6 +12,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' @@ -29,15 +30,17 @@ 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, BuyItemRequest, BuyItemResponse, ChallengeProgressRequest, ChallengeProgressResponse, + ChecklistEntry, ConsumeConsumableRequest, ConsumeEnvelope, ConsumeGiftRequest, @@ -347,6 +350,19 @@ function toGiftContent( } } +/** + * 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 +] + /** * 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 @@ -388,11 +404,12 @@ const app = new Hono({ 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 @@ -406,10 +423,12 @@ const app = new Hono({ 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, }, }), @@ -417,7 +436,7 @@ const app = new Hono({ 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)) } ) @@ -532,15 +551,31 @@ const app = new Hono({ 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) } ) diff --git a/apps/econ/src/inventory-db.ts b/apps/econ/src/inventory-db.ts index 3ae594b..09a84db 100644 --- a/apps/econ/src/inventory-db.ts +++ b/apps/econ/src/inventory-db.ts @@ -40,6 +40,43 @@ export interface AvatarItem extends Record { 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): 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 diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index ab0fdaf..d60d284 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -98,6 +98,34 @@ export const CustomAvatarItemsResponse = z.object({ TotalResults: z.int(), }) +/** + * 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(), +}) + +/** + * 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` — both fields null (no subs yet). */ export const SubscriptionResponse = z.object({ subscription: z.null(), diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index e03dd82..4744190 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -99,10 +99,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> + 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 () => { @@ -110,16 +115,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> 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 () => { @@ -270,14 +293,22 @@ describe('econ endpoints', () => { } }) - 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(), - }) - expect(res.status).toBe(200) - expect(await res.json()).toEqual([]) + 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('GET /api/itemWishlists/v1/wishlist/me 401s without a token, returns [] with one', async () => { @@ -666,9 +697,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`, { @@ -749,8 +780,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) @@ -816,8 +847,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) @@ -916,8 +947,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) }) test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => { @@ -957,8 +988,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/`, { @@ -1169,6 +1200,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', diff --git a/apps/econ/static/default-base-avatar-items.json b/apps/econ/static/default-base-avatar-items.json new file mode 100644 index 0000000..8538617 --- /dev/null +++ b/apps/econ/static/default-base-avatar-items.json @@ -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" + } +] diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 019b073..87dd0c0 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -31,6 +31,10 @@ export interface Account { username: string displayName: string profileImage: string + /** Profile banner image key. No route sets it yet, so it's `""` on every account. */ + bannerImage: string + /** The emoji shown beside the display name. No route sets it yet — always `""`. */ + displayEmoji: string isJunior: boolean platforms: number personalPronouns: number @@ -160,6 +164,8 @@ export function defaultAccount(id: number, overrides: Partial = {}): Ac username: `Player${id}`, displayName: `Player${id}`, profileImage: 'DefaultProfileImage.jpg', + bannerImage: '', + displayEmoji: '', isJunior: false, platforms: 0, personalPronouns: 0,