diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index cc6a1ba..983fdb5 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -553,6 +553,36 @@ export const OutfitsMeRequest = z.object({ ThumbnailFileName: z.string().nullable(), }) +/** + * `POST /outfits/bulk` JSON body — whose outfits to fetch. The client sends the accounts it + * needs to dress (a room's roster, typically), and the two `UnityAsset*` fields name the + * baked-asset build it would like them for. + */ +export const OutfitsBulkRequest = z.object({ + AccountIds: z.array(z.int()).describe('The accounts whose worn outfit is wanted'), + UnityAssetTarget: z + .string() + .nullable() + .describe('Baked-asset platform. Accepted and ignored — nothing bakes assets here'), + UnityAssetVersion: z + .string() + .nullable() + .describe('Baked-asset version. Accepted and ignored, like its sibling'), +}) + +/** + * `POST /outfits/bulk` — the worn outfit of each account asked for, keyed by account id. + * + * The key is the id as a STRING (a JSON object key always is) and the value is the same + * stored outfit `GET /outfits/me` serves. An account with nothing saved is ABSENT from the + * map rather than present with a null — a map expresses "no outfit" by not carrying the key. + */ +export const OutfitsBulkResponse = z.object({ + OutfitsByAccountId: z + .record(z.string(), StoredOutfit) + .describe('Keyed by account id as a string. Accounts with no saved outfit are omitted'), +}) + /** * `PUT /outfits/me` — the base envelope, with NO `Value` key: three keys and that is the * whole body. The save answers only whether it worked; the client keeps the outfit it just diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 41a04f9..b406120 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -4,9 +4,11 @@ import { describeRoute } from 'hono-openapi' import { CURRENT_OUTFIT_SLOT, getOutfit, + getOutfitsByAccounts, inventionDescriptionRejection, inventionNameRejection, inventionTagRejection, + MAX_BULK_OUTFIT_ACCOUNTS, setOutfit, } from '@repo/domain' @@ -52,6 +54,8 @@ import { jsonBody, LegacyAvatarItemSaves, OutfitSaveResponse, + OutfitsBulkRequest, + OutfitsBulkResponse, OutfitsMeRequest, OutfitsMeResponse, pageParams, @@ -406,6 +410,68 @@ export const avatarRoutes = new Hono({ strict: false }) } ) + // Several players' worn outfits at once — what the client calls to dress everyone in a + // room rather than asking per player. POST because the account list rides in the body. + // + // The answer is a MAP keyed by account id, not a list: the client looks each player up by + // id, and a list would make it match up the order itself. An account with nothing saved is + // left out of the map — see `getOutfitsByAccounts`. + // + // `UnityAssetTarget` / `UnityAssetVersion` name the baked-asset build the client would + // like the outfits for. Nothing here bakes assets, so both are accepted and ignored. + .post( + '/outfits/bulk', + describeRoute({ + tags: ['Avatar'], + summary: 'Several players’ outfits', + description: + 'The worn outfit (slot 0) of each account in `AccountIds`, keyed by account id — the ' + + 'call the client makes to dress a room full of players in one request.\n\n' + + 'A MAP rather than a list: the client looks each player up by id. The key is the id ' + + 'as a string, and the value is the same stored outfit `GET /outfits/me` serves, ' + + 'handed back exactly as it was saved. An account with nothing saved in slot 0 is ' + + 'ABSENT from the map rather than carrying a null — a map says “no outfit” by not ' + + 'having the key, and inventing one for a player who has never saved would dress them ' + + 'in something they never chose.\n\n' + + 'Repeated ids collapse, and at most 99 distinct accounts may be named — one query, ' + + 'one round trip, and a room holds nothing like that many players. A longer list is ' + + 'a 400 rather than a partial answer, which would read as “those players have no ' + + 'outfit”. `UnityAssetTarget` / `UnityAssetVersion` name a baked-asset build and are ' + + 'accepted and ignored: nothing here bakes assets.', + security: AUTHED, + requestBody: jsonBody(OutfitsBulkRequest, 'The accounts whose outfits are wanted'), + responses: { + 200: json(OutfitsBulkResponse, 'The outfits that exist, keyed by account id'), + 400: json(ErrorResponse, 'Unparseable body, or more than 99 accounts'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null) return c.json({ error: 'Invalid request body' }, 400) + + // Only the integers survive: the field is the client's, and a malformed entry is + // dropped rather than turned into a NaN lookup that can never match a row. + const accountIds = Array.isArray(body.AccountIds) + ? body.AccountIds.filter((v): v is number => Number.isInteger(v)) + : [] + // One query, one round trip — so the list has to fit D1's parameter cap. A room + // holds nothing like this many players; a longer list is refused rather than + // quietly answered in part, which would look like those accounts have no outfit. + if (new Set(accountIds).size > MAX_BULK_OUTFIT_ACCOUNTS) { + return c.json({ error: `At most ${MAX_BULK_OUTFIT_ACCOUNTS} accounts per request` }, 400) + } + + const outfits = await getOutfitsByAccounts(c.env.DB, accountIds, CURRENT_OUTFIT_SLOT) + const OutfitsByAccountId: Record = {} + for (const [accountId, outfit] of outfits) OutfitsByAccountId[String(accountId)] = outfit + return c.json({ OutfitsByAccountId }) + } + ) + // The caller's outfit wardrobe. An empty list for now — the outfits saved through // `PUT /outfits/me` are in the shared `outfit` table already, but which of them // belong in this list (and in what shape) has not been pinned down, so it answers [] diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 98197a1..05ce8de 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -755,6 +755,91 @@ describe('public endpoints', () => { expect(await res.json()).toEqual([]) }) + test('POST /outfits/bulk serves each account’s worn outfit, keyed by id', async () => { + const bulk = async (body: unknown, sub?: string) => + exports.default.fetch(`${ORIGIN}/outfits/bulk`, { + method: 'POST', + headers: { + ...(sub === undefined ? {} : await bearer(sub)), + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }) + + // Two accounts with a saved outfit, and one with none. + const outfitFor = (skin: string) => ({ + DataVersion: 2, + LegacyData: { + SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0', + SelectionsV2: '{"selections":[]}', + FaceFeatures: '{"ver":7}', + SkinColor: skin, + HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg', + }, + CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}', + Selections: [], + Slot: 0, + Name: '', + Accessibility: 1, + ThumbnailFileName: null, + }) + const saved = new Map([ + [187, outfitFor('skin-187')], + [220, outfitFor('skin-220')], + ]) + for (const [accountId, outfit] of saved) { + const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { + method: 'PUT', + headers: { ...(await bearer(String(accountId))), 'content-type': 'application/json' }, + body: JSON.stringify(outfit), + }) + expect(res.status).toBe(200) + } + + expect((await bulk({ AccountIds: [187] })).status).toBe(401) + + const res = await bulk( + { AccountIds: [187, 220], UnityAssetTarget: null, UnityAssetVersion: null }, + '42' + ) + expect(res.status).toBe(200) + // A map keyed by the account id as a STRING, each value the outfit exactly as saved — + // the JSON-in-a-string fields are still strings. + expect(await res.json()).toEqual({ + OutfitsByAccountId: { + '187': saved.get(187), + '220': saved.get(220), + }, + }) + + // An account with nothing saved is ABSENT rather than carrying a null, and a repeated + // id collapses instead of appearing twice. + const sparse = await bulk({ AccountIds: [187, 999888, 187] }, '42') + expect(await sparse.json()).toEqual({ OutfitsByAccountId: { '187': saved.get(187) } }) + + // No ids is an empty map, not every outfit on the server. + expect(await (await bulk({ AccountIds: [] }, '42')).json()).toEqual({ OutfitsByAccountId: {} }) + + // 99 distinct accounts is the most one request may name — one query, one round trip. + const atCap = [...Array.from({ length: 98 }, (_, i) => 500000 + i), 220] + expect(await (await bulk({ AccountIds: atCap }, '42')).json()).toEqual({ + OutfitsByAccountId: { '220': saved.get(220) }, + }) + // One more is refused rather than answered in part, which would read as "those + // accounts have no outfit". Duplicates don't count against the cap. + expect((await bulk({ AccountIds: [...atCap, 500999] }, '42')).status).toBe(400) + expect((await bulk({ AccountIds: [...atCap, ...atCap] }, '42')).status).toBe(200) + + // An unparseable body is a 400, like the save's. (A body that parses but isn't an + // object — a bare string, say — names no accounts and so answers an empty map.) + const bad = await exports.default.fetch(`${ORIGIN}/outfits/bulk`, { + method: 'POST', + headers: { ...(await bearer('42')), 'content-type': 'application/json' }, + body: 'not json', + }) + expect(bad.status).toBe(400) + }) + test('PUT /outfits/me 400s on an unparseable body', async () => { const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { method: 'PUT', @@ -4915,6 +5000,7 @@ describe('openapi', () => { 'POST /api/sanitize/v1', 'POST /api/sanitize/v1/isPure', 'POST /api/v1/progression/bulk', + 'POST /outfits/bulk', 'POST /statsigUserProperties', 'PUT /api/playerevents/v2/{eventId}/accessibility', 'PUT /api/playerevents/v2/{eventId}/description', diff --git a/packages/domain/src/outfits-db.ts b/packages/domain/src/outfits-db.ts index 3c22aec..54eca76 100644 --- a/packages/domain/src/outfits-db.ts +++ b/packages/domain/src/outfits-db.ts @@ -65,6 +65,49 @@ export async function getOutfit( return row ? (JSON.parse(row.avatar) as Outfit) : null } +/** + * The most accounts one bulk read may name. D1 caps a prepared statement at 100 bound + * parameters and `?1` is the slot, leaving 99 for the ids — so this is the whole query in + * one round trip, with no chunking to get wrong. A room holds far fewer players than that, + * which is what the caller asks about; the route rejects a longer list rather than silently + * answering part of it. + */ +export const MAX_BULK_OUTFIT_ACCOUNTS = 99 + +/** + * One slot's outfit for each of several accounts — the bulk read behind + * `POST /outfits/bulk`, which the client uses to dress everyone in a room at once. + * + * Keyed by account id, and an account with nothing saved in that slot is simply ABSENT from + * the map rather than present with a null: a map says "no outfit" by not having the key, and + * the alternative would be inventing an outfit shape for a player who has never saved one. + * + * Ids are de-duplicated — the client sends a room's roster, which can repeat — and the + * caller must already have held the list to {@link MAX_BULK_OUTFIT_ACCOUNTS}; more than that + * overflows D1's parameter cap and fails the query outright. Each outfit is served exactly + * as it was stored: see the note at the top of this file — slot 0 written through + * `/outfits/me` is the newer envelope, while econ's saved-set writes are the old flat shape, + * and neither is projected. + */ +export async function getOutfitsByAccounts( + db: D1Database, + accountIds: number[], + slot: number +): Promise> { + const ids = [...new Set(accountIds)] + if (ids.length === 0) return new Map() + + const placeholders = ids.map((_, n) => `?${n + 2}`).join(', ') + const { results } = await db + .prepare( + `SELECT account_id, avatar FROM outfit WHERE set_id = ?1 AND account_id IN (${placeholders})` + ) + .bind(slot, ...ids) + .all<{ account_id: number; avatar: string }>() + + return new Map(results.map((row) => [row.account_id, JSON.parse(row.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