diff --git a/CLAUDE.md b/CLAUDE.md index 3116350..1d45564 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,6 +287,19 @@ inconsistency here without checking the client first. other implementations set it at all, it names the start of a voice-chat timeout. Other implementations with timed bans keep their set-time private and serve the remainder on every read; `banned_at` here is the staff panel's audit trail, not the client's. +- An outfit is served to OTHER players (`api`: `POST /outfits/bulk`) with the selections the + wearer does not own stripped (`restrictOutfitToOwned` in `@repo/domain`); the owner's + `GET /outfits/me` stays verbatim. Nothing on the save path checks ownership. A legacy + selection entry (`SelectionsV1`/`OutfitSelections`, `;`-delimited) is + `,,` with the descriptor's empty trailing fields dropped, + which is why `5cd08cfb-…,,1` and `40528de7-…,51ef8d39-…,0b2395e1-…,,0` are both one entry; + a v2 selection (`SelectionsV2`/`OutfitSelectionsV2`, JSON-in-a-string) names the item as + `PrefabGuid` and a custom shirt as `UgcOutfitData.CustomAvatarItemId`. Ownership is decided + on the BASE ASSET (first descriptor field, which is `PrefabGuid`) — not the colour — because + the v2 forms carry no colour and the forms must agree; the free default catalog and the + custom-clothing base items count as owned. `CustomizationSettings` has only been observed + as its header (`AvatarVersion`, `AvatarBodyType`); any v2-shaped selections inside it are + stripped wherever they sit. A document is re-encoded only when something was removed. - Accessibility is sent as the `RoomAccessibility` enum NAME on `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index b9469e9..33948f5 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1017,14 +1017,19 @@ export const OutfitsBulkRequest = z.object({ /** * `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. + * The key is the id as a STRING (a JSON object key always is) and the value is the stored + * outfit `GET /outfits/me` serves, less any selection naming an item the wearer does not own + * (see `restrictOutfitToOwned` in @repo/domain) — this is what other players dress them + * from. 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'), + .describe( + 'Keyed by account id as a string. Accounts with no saved outfit are omitted. ' + + 'Selections naming items the wearer does not own are stripped' + ), }) /** diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index d4d6b46..5a24ddb 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -6,14 +6,22 @@ import { getOutfit, getOutfits, getOutfitsByAccounts, + getOwnedOutfitItemsByAccounts, inventionDescriptionRejection, inventionLongDescriptionRejection, inventionNameRejection, inventionTagRejection, MAX_BULK_OUTFIT_ACCOUNTS, + outfitItemBase, + restrictOutfitToOwned, setOutfit, } from '@repo/domain' +// The free catalog, as the econ worker bundles it: what `GET /api/avatar/v4/items` hands +// every player before their purchases, and the base items custom clothing is built on. +// Read here so the bulk outfit read knows which worn items need no purchase. +import defaultAvatarItems from '../../../econ/static/default-avatar-items.json' +import defaultBaseAvatarItems from '../../../econ/static/default-base-avatar-items.json' import { createCustomAvatarItem, deleteCustomAvatarItem, @@ -134,6 +142,19 @@ import type { InventionTag, SavedInvention } from '../inventions-db' */ const BULK_CUSTOM_AVATAR_ITEM_CAP = 100 +/** + * The base assets every player may wear without buying anything: the default catalog econ + * hands everyone (`GET /api/avatar/v4/items` prepends purchases to it) and the base items + * custom clothing is built on. Folded into each wearer's owned set when `POST /outfits/bulk` + * strips unowned selections — see `restrictOutfitToOwned` for why ownership is decided on + * the base asset. + */ +const FREE_ITEM_BASES: ReadonlySet = new Set( + [...defaultAvatarItems, ...defaultBaseAvatarItems].map((item) => + outfitItemBase(item.AvatarItemDesc) + ) +) + /** * The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated * `customAvatarItemIds` form fields, and the same spelling is read off the query string @@ -1092,6 +1113,11 @@ export const avatarRoutes = new Hono({ strict: false }) // 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`. // + // This is how OTHER players see an outfit, so it is served with the selections the wearer + // does not own stripped: nothing on the save path checks ownership, and an edited save + // would otherwise dress a player in items they never bought for the whole room to see. + // The owner's own `GET /outfits/me` stays verbatim. + // // `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( @@ -1103,8 +1129,12 @@ export const avatarRoutes = new Hono({ strict: false }) '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 ' + + 'as a string, and the value is the stored outfit `GET /outfits/me` serves, LESS any ' + + 'selection naming an item the wearer does not own: a base asset that is neither in ' + + 'the free default catalog nor on any item they bought, or a custom item they ' + + 'neither bought nor made. The save path checks nothing, and this read is what ' + + 'everyone else dresses the player from. An outfit that loses nothing is served ' + + 'exactly as 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' + @@ -1141,8 +1171,21 @@ export const avatarRoutes = new Hono({ strict: false }) } const outfits = await getOutfitsByAccounts(c.env.DB, accountIds, CURRENT_OUTFIT_SLOT) + // What each wearer may wear, read for the accounts that HAVE an outfit: a selection + // naming an item the wearer never bought is dropped before anyone else dresses them + // in it. The free catalog counts as owned; see `restrictOutfitToOwned`. + const owned = await getOwnedOutfitItemsByAccounts( + c.env.DB, + [...outfits.keys()], + FREE_ITEM_BASES + ) const OutfitsByAccountId: Record = {} - for (const [accountId, outfit] of outfits) OutfitsByAccountId[String(accountId)] = outfit + for (const [accountId, outfit] of outfits) { + const wearable = owned.get(accountId) + OutfitsByAccountId[String(accountId)] = wearable + ? restrictOutfitToOwned(outfit, wearable) + : outfit + } return c.json({ OutfitsByAccountId }) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index c01183f..eb90e54 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -24,6 +24,7 @@ import { ROOM_INSTANCE_SCHEMA_DDL, ROOM_SCHEMA_DDL, seedRoomWithSubRooms, + setOutfit, SUBROOM_SCHEMA_DDL, SUPPORTED_GAME_VERSIONS, } from '@repo/domain' @@ -38,6 +39,11 @@ import { SYSTEM_SENDER_ID, THREAD_SCHEMA_DDL, } from '../../../../chat/src/thread-db' +import { + grantCustomAvatarItem, + INVENTORY_CUSTOM_SCHEMA_DDL, +} from '../../../../econ/src/inventory-custom-db' +import { grantItem, INVENTORY_SCHEMA_DDL } from '../../../../econ/src/inventory-db' import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw' import inventionPriceCapSql from '../../../migrations/0033_invention_price_cap.sql?raw' import { banEvasionMatch, resolveBan } from '../../bans-db' @@ -173,6 +179,10 @@ beforeAll(async () => { // Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0. for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Bought items and bought custom items (owned by the econ worker) — /outfits/bulk strips + // what a wearer doesn't own. + for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of INVENTORY_CUSTOM_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Inventions table (owned by the api worker) — invention save/mine use it. for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run() @@ -2057,6 +2067,172 @@ describe('public endpoints', () => { expect(bad.status).toBe(400) }) + test('POST /outfits/bulk strips the selections a wearer does not own', async () => { + const bulk = async (ids: number[]) => { + const res = await exports.default.fetch(`${ORIGIN}/outfits/bulk`, { + method: 'POST', + headers: { ...(await bearer('42')), 'content-type': 'application/json' }, + body: JSON.stringify({ AccountIds: ids, UnityAssetTarget: null, UnityAssetVersion: null }), + }) + expect(res.status).toBe(200) + return ((await res.json()) as { OutfitsByAccountId: Record }) + .OutfitsByAccountId + } + const save = async (sub: string, outfit: unknown) => { + const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { + method: 'PUT', + headers: { ...(await bearer(sub)), 'content-type': 'application/json' }, + body: JSON.stringify(outfit), + }) + expect(res.status).toBe(200) + } + + // Base assets: one from the free default catalog, the base item custom shirts are built + // on, one account 301 BOUGHT (in a colour — the inventory key is the whole descriptor), + // and one nobody owns. + const free = '5cd08cfb-c729-4c30-96d9-6a99bb934d91' + const base = 'c5d70cb4-71dd-4fe4-b719-34fe2073c611' + const bought = 'aaaaaaaa-1111-4222-8333-444444444444' + const unowned = 'bbbbbbbb-1111-4222-8333-444444444444' + await grantItem(env.DB, 301, { + AvatarItemType: 0, + AvatarItemDesc: `${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,`, + PlatformMask: -1, + FriendlyName: 'Bought Hat', + Tooltip: '', + Rarity: 1, + }) + // Custom items: one 301 made, one they bought from 999, one of 999's they never bought. + const custom = async (customAvatarItemId: string, creatorAccountId: number) => + createCustomAvatarItem(env.DB, { + customAvatarItemId, + creatorAccountId, + name: customAvatarItemId, + description: '', + price: 0, + baseAvatarItemId: 2184, + baseAvatarItemColor: '#F55C1A', + accessibility: 1, + designFilename: 'd.png', + thumbnailImageFilename: 't.png', + }) + const made = (await custom('cccccccc-1111-4222-8333-444444444444', 301)).CustomAvatarItemId + const boughtCustom = (await custom('dddddddd-1111-4222-8333-444444444444', 999)) + .CustomAvatarItemId + const unownedCustom = (await custom('eeeeeeee-1111-4222-8333-444444444444', 999)) + .CustomAvatarItemId + await grantCustomAvatarItem(env.DB, 301, boughtCustom) + + const selection = (PrefabGuid: string, BodyPart: number, customId?: string) => ({ + PrefabGuid, + CombinationGuid: '', + BodyPart, + ...(customId === undefined + ? {} + : { + UgcOutfitData: { + BaseAvatarItemColor: { r: 0, g: 0, b: 0, a: 0 }, + CustomAvatarItemId: customId, + }, + }), + }) + // Guid case is not identity — the client is not consistent about it — so the bought + // base rides upper-cased in the v2 list. + const v2 = { + selections: [ + selection(free, 1), + selection(bought.toUpperCase(), 0), + selection(unowned, 2), + selection(base, 3, made), + selection(base, 4, boughtCustom.toUpperCase()), + selection(base, 5, unownedCustom), + ], + } + // Only the header of `CustomizationSettings` has been observed; whatever selections it + // carries are found wherever they sit, by the same rule. + const settings = { + AvatarVersion: 2, + AvatarBodyType: 0, + Body: { Items: [selection(free, 1), selection(unowned, 2)] }, + } + const envelope = ( + SelectionsV1: string, + SelectionsV2: string, + CustomizationSettings: string + ) => ({ + DataVersion: 2, + LegacyData: { + SelectionsV1, + SelectionsV2, + FaceFeatures: '{"ver":7}', + SkinColor: 'skin', + HairColor: 'hair', + }, + CustomizationSettings, + Selections: [], + Slot: 0, + Name: '', + Accessibility: 1, + ThumbnailFileName: null, + }) + const saved = envelope( + `${free},,1;${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,,0;${unowned},,2;${base},,3`, + JSON.stringify(v2), + JSON.stringify(settings) + ) + await save('301', saved) + + // Account 303 wears only free things: served byte-for-byte as saved. + const allFree = envelope( + `${free},,1;${base},,0`, + JSON.stringify({ selections: [selection(free, 1)] }), + JSON.stringify({ AvatarVersion: 2, AvatarBodyType: 0 }) + ) + await save('303', allFree) + + // Account 302 saved through econ's flat shape (`/api/avatar/v3/saved/set`), which lands + // in the same table: the flat fields are stripped the same way. + const flat = { + Slot: 0, + PreviewImageName: null, + OutfitSelections: `${free},,1;${unowned},,0`, + OutfitSelectionsV2: JSON.stringify({ + selections: [selection(free, 1), selection(unowned, 0)], + }), + FaceFeatures: '{"ver":6}', + SkinColor: 'skin', + HairColor: 'hair', + CustomAvatarItems: [{ CustomAvatarItemId: unownedCustom }], + } + await setOutfit(env.DB, 302, flat) + + const outfits = await bulk([301, 302, 303]) + + // 301: the unowned base is gone from every form, the unbought custom item too; the + // free, bought (either case), made and bought-custom selections stay. The documents + // are re-encoded only because something was removed from them. + expect(outfits['301']).toEqual( + envelope( + `${free},,1;${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,,0;${base},,3`, + JSON.stringify({ selections: v2.selections.filter((_, i) => i !== 2 && i !== 5) }), + JSON.stringify({ ...settings, Body: { Items: [selection(free, 1)] } }) + ) + ) + expect(outfits['302']).toEqual({ + ...flat, + OutfitSelections: `${free},,1`, + OutfitSelectionsV2: JSON.stringify({ selections: [selection(free, 1)] }), + CustomAvatarItems: [], + }) + expect(outfits['303']).toEqual(allFree) + + // The owner's own read is untouched: what they saved is what they get back. + const mine = await exports.default.fetch(`${ORIGIN}/outfits/me`, { + headers: await bearer('301'), + }) + expect(await mine.json()).toEqual(saved) + }) + test('PUT /outfits/me 400s on an unparseable body', async () => { const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { method: 'PUT', diff --git a/packages/domain/src/outfits-db.ts b/packages/domain/src/outfits-db.ts index 54eca76..de8ad8a 100644 --- a/packages/domain/src/outfits-db.ts +++ b/packages/domain/src/outfits-db.ts @@ -122,3 +122,252 @@ export async function setOutfit(db: D1Database, accountId: number, outfit: Outfi .bind(accountId, outfit.Slot, JSON.stringify(outfit)) .run() } + +// ---- Serving an outfit to OTHER players ------------------------------------------------- +// +// An outfit is stored exactly as its owner posted it, and `/outfits/me` hands it straight +// back to them. But the bulk read (`POST /outfits/bulk`) is how everyone ELSE's client +// dresses a player, and nothing on the way in checks that the player may wear what they +// saved — an edited save, or a client that skips the store, puts an unbought item on an +// avatar everyone in the room then sees. So the bulk read serves each outfit with the +// selections the wearer does not own taken OUT, and the owner keeps seeing their own +// save untouched. +// +// What a selection names, in each of the forms the two write paths store: +// +// - A legacy descriptor list (`LegacyData.SelectionsV1` in the newer envelope, the flat +// `OutfitSelections` in econ's) is `;`-delimited, one entry per worn item, each +// `,,` — e.g. `40528de7-…,51ef8d39-…,0b2395e1-…,,0` +// and `5cd08cfb-…,,1`. The `AvatarItemDesc` is `,,,` with its +// empty trailing fields dropped, which is why the entries differ in length. +// - A v2 selection list (`LegacyData.SelectionsV2`, `OutfitSelectionsV2`) is JSON in a +// string, `{ selections: [ { PrefabGuid, CombinationGuid, BodyPart, UgcOutfitData? } ] }`, +// where `PrefabGuid` is the base asset and a custom shirt rides in +// `UgcOutfitData.CustomAvatarItemId`. +// - `CustomizationSettings` is the newer client's own structured document, also JSON in a +// string. Only its header (`AvatarVersion`, `AvatarBodyType`) has been observed; whatever +// selections it carries are assumed to be the v2 shape above, found wherever they sit. +// - `Selections` and `CustomAvatarItems` are plain arrays (observed empty), walked the same +// way. +// +// Ownership is decided on the BASE ASSET — the first field of the descriptor, which is the +// one identifier every form carries (`PrefabGuid` is nothing else) and the field the client +// decides renderability on (see `baseAsset` in econ's catalog). A player owns a base asset +// when any `inventory` row of theirs starts with it, or when it is in the free default +// catalog (what econ's `GET /api/avatar/v4/items` hands every player) or is one of the base +// items custom clothing is built on. That is coarser than a purchase — a store sells the +// same base in several colours as separate items, and owning one colour lets the others +// through — because the v2 forms record no colour to check, and a rule the three forms +// disagree on would dress a player differently depending on which one the client reads. +// A custom item (`CustomAvatarItemId`) is owned when bought (`inventory_custom`) or made +// (`custom_avatar_item.creator_account_id`), the same pair econ's owned list serves. +// +// An outfit that loses nothing is returned as the very object it came in as, string fields +// byte-for-byte: the JSON-in-a-string documents are re-encoded only when a selection inside +// them was removed, keeping the verbatim round trip the note at the top of this file asks +// for in the common case. + +/** What one player may wear: base assets and custom item ids, each lower-cased. */ +export interface OwnedOutfitItems { + bases: Set + customItemIds: Set +} + +/** + * The base asset of an `AvatarItemDesc` or of a legacy selection entry: the first + * comma-separated field, lower-cased. The same first field whichever form of the item it is + * read from — a catalog descriptor (`,,,`), an inventory key, or a + * worn selection (`,…,`). + */ +export function outfitItemBase(desc: string): string { + return (desc.split(',')[0] ?? '').trim().toLowerCase() +} + +/** A JSON object, as the walkers below see one. */ +type JsonObject = Record + +function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Whether one v2-style selection object names only owned things. An object with neither a + * `PrefabGuid` nor a `CustomAvatarItemId` is not a selection and is always kept. + */ +function selectionOwned(selection: JsonObject, owned: OwnedOutfitItems): boolean { + const prefab = selection.PrefabGuid + if (typeof prefab === 'string' && prefab !== '' && !owned.bases.has(outfitItemBase(prefab))) { + return false + } + const ugc = selection.UgcOutfitData + const customIds = [ + selection.CustomAvatarItemId, + isObject(ugc) ? ugc.CustomAvatarItemId : undefined, + ] + for (const id of customIds) { + if (typeof id === 'string' && id !== '' && !owned.customItemIds.has(id.toLowerCase())) { + return false + } + } + return true +} + +/** + * Walk a parsed JSON value and drop, from every array in it, the selection objects the + * player does not own. Returns the (possibly new) value and whether anything was dropped; + * an untouched subtree is returned as the same reference. + */ +function pruneSelections(value: unknown, owned: OwnedOutfitItems): [unknown, boolean] { + if (Array.isArray(value)) { + let changed = false + const kept: unknown[] = [] + for (const entry of value) { + if (isObject(entry) && !selectionOwned(entry, owned)) { + changed = true + continue + } + const [pruned, entryChanged] = pruneSelections(entry, owned) + changed ||= entryChanged + kept.push(pruned) + } + return changed ? [kept, true] : [value, false] + } + if (isObject(value)) { + let changed = false + const out: JsonObject = {} + for (const [key, entry] of Object.entries(value)) { + const [pruned, entryChanged] = pruneSelections(entry, owned) + changed ||= entryChanged + out[key] = pruned + } + return changed ? [out, true] : [value, false] + } + return [value, false] +} + +/** + * The legacy `;`-delimited descriptor list with the unowned entries removed. Returned as + * the same string when nothing was removed; a string that is not a list (empty, or + * carrying no `;`-separated descriptors) is returned as it is. + */ +function pruneLegacySelections(list: string, owned: OwnedOutfitItems): string { + const entries = list.split(';') + const kept = entries.filter((entry) => entry === '' || owned.bases.has(outfitItemBase(entry))) + return kept.length === entries.length ? list : kept.join(';') +} + +/** + * A JSON-in-a-string document with the unowned selections removed. The original string is + * returned when nothing was removed — or when it does not parse, which is the client's own + * document to make sense of, not this server's. + */ +function pruneJsonDocument(document: string, owned: OwnedOutfitItems): string { + let parsed: unknown + try { + parsed = JSON.parse(document) + } catch { + return document + } + const [pruned, changed] = pruneSelections(parsed, owned) + return changed ? JSON.stringify(pruned) : document +} + +/** + * An outfit as other players should see it: every selection naming something the wearer + * does not own is removed from each form the outfit carries (see the section note above). + * The outfit is returned as the same object when it loses nothing. + */ +export function restrictOutfitToOwned(outfit: Outfit, owned: OwnedOutfitItems): Outfit { + let changed = false + const out: JsonObject = { ...outfit } + + const pruneString = ( + target: JsonObject, + key: string, + prune: (value: string, owned: OwnedOutfitItems) => string + ) => { + const value = target[key] + if (typeof value !== 'string') return + const pruned = prune(value, owned) + if (pruned !== value) { + target[key] = pruned + changed = true + } + } + const pruneArray = (target: JsonObject, key: string) => { + const [pruned, arrayChanged] = pruneSelections(target[key], owned) + if (arrayChanged) { + target[key] = pruned + changed = true + } + } + + // The newer envelope's legacy block, copied so the stored object is not written to. + if (isObject(outfit.LegacyData)) { + const legacy: JsonObject = { ...outfit.LegacyData } + pruneString(legacy, 'SelectionsV1', pruneLegacySelections) + pruneString(legacy, 'SelectionsV2', pruneJsonDocument) + out.LegacyData = legacy + } + pruneString(out, 'CustomizationSettings', pruneJsonDocument) + pruneArray(out, 'Selections') + + // Econ's flat shape. + pruneString(out, 'OutfitSelections', pruneLegacySelections) + pruneString(out, 'OutfitSelectionsV2', pruneJsonDocument) + pruneArray(out, 'CustomAvatarItems') + + return changed ? (out as Outfit) : outfit +} + +/** + * What each of several players may wear — the ownership behind + * {@link restrictOutfitToOwned}, read for the bulk outfit read's whole roster in one + * round trip. `freeBases` are the base assets every player has (the default catalog and + * the custom-clothing base items), folded into each player's set. + * + * Three reads batched: bought items (`inventory`, keyed by `AvatarItemDesc`, reduced to the + * base asset), bought custom items (`inventory_custom`) and made custom items + * (`custom_avatar_item` by creator) — the two sources econ's owned-items list joins. Ids are + * de-duplicated and the caller must already have held the list to + * {@link MAX_BULK_OUTFIT_ACCOUNTS}, D1's parameter cap. Every account asked for gets an + * entry, if only the free set. + */ +export async function getOwnedOutfitItemsByAccounts( + db: D1Database, + accountIds: number[], + freeBases: ReadonlySet +): Promise> { + const ids = [...new Set(accountIds)] + const owned = new Map() + for (const id of ids) owned.set(id, { bases: new Set(freeBases), customItemIds: new Set() }) + if (ids.length === 0) return owned + + const placeholders = ids.map((_, n) => `?${n + 1}`).join(', ') + const [items, bought, made] = await db.batch<{ account_id: number; value: string }>([ + db + .prepare( + `SELECT account_id, avatar_item_desc AS value FROM inventory WHERE account_id IN (${placeholders})` + ) + .bind(...ids), + db + .prepare( + `SELECT account_id, custom_avatar_item_id AS value FROM inventory_custom WHERE account_id IN (${placeholders})` + ) + .bind(...ids), + db + .prepare( + `SELECT creator_account_id AS account_id, custom_avatar_item_id AS value + FROM custom_avatar_item WHERE creator_account_id IN (${placeholders})` + ) + .bind(...ids), + ]) + + for (const row of items.results) owned.get(row.account_id)?.bases.add(outfitItemBase(row.value)) + for (const row of [...bought.results, ...made.results]) { + if (typeof row.value === 'string') { + owned.get(row.account_id)?.customItemIds.add(row.value.toLowerCase()) + } + } + return owned +}