mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
[api] add outfits endpoint
This commit is contained in:
@@ -553,6 +553,36 @@ export const OutfitsMeRequest = z.object({
|
|||||||
ThumbnailFileName: z.string().nullable(),
|
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
|
* `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
|
* whole body. The save answers only whether it worked; the client keeps the outfit it just
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { describeRoute } from 'hono-openapi'
|
|||||||
import {
|
import {
|
||||||
CURRENT_OUTFIT_SLOT,
|
CURRENT_OUTFIT_SLOT,
|
||||||
getOutfit,
|
getOutfit,
|
||||||
|
getOutfitsByAccounts,
|
||||||
inventionDescriptionRejection,
|
inventionDescriptionRejection,
|
||||||
inventionNameRejection,
|
inventionNameRejection,
|
||||||
inventionTagRejection,
|
inventionTagRejection,
|
||||||
|
MAX_BULK_OUTFIT_ACCOUNTS,
|
||||||
setOutfit,
|
setOutfit,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
@@ -52,6 +54,8 @@ import {
|
|||||||
jsonBody,
|
jsonBody,
|
||||||
LegacyAvatarItemSaves,
|
LegacyAvatarItemSaves,
|
||||||
OutfitSaveResponse,
|
OutfitSaveResponse,
|
||||||
|
OutfitsBulkRequest,
|
||||||
|
OutfitsBulkResponse,
|
||||||
OutfitsMeRequest,
|
OutfitsMeRequest,
|
||||||
OutfitsMeResponse,
|
OutfitsMeResponse,
|
||||||
pageParams,
|
pageParams,
|
||||||
@@ -406,6 +410,68 @@ export const avatarRoutes = new Hono<App>({ 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<string, unknown> | 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<string, unknown> = {}
|
||||||
|
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
|
// 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
|
// `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 []
|
// belong in this list (and in what shape) has not been pinned down, so it answers []
|
||||||
|
|||||||
@@ -755,6 +755,91 @@ describe('public endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
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 () => {
|
test('PUT /outfits/me 400s on an unparseable body', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -4915,6 +5000,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/sanitize/v1',
|
'POST /api/sanitize/v1',
|
||||||
'POST /api/sanitize/v1/isPure',
|
'POST /api/sanitize/v1/isPure',
|
||||||
'POST /api/v1/progression/bulk',
|
'POST /api/v1/progression/bulk',
|
||||||
|
'POST /outfits/bulk',
|
||||||
'POST /statsigUserProperties',
|
'POST /statsigUserProperties',
|
||||||
'PUT /api/playerevents/v2/{eventId}/accessibility',
|
'PUT /api/playerevents/v2/{eventId}/accessibility',
|
||||||
'PUT /api/playerevents/v2/{eventId}/description',
|
'PUT /api/playerevents/v2/{eventId}/description',
|
||||||
|
|||||||
@@ -65,6 +65,49 @@ export async function getOutfit(
|
|||||||
return row ? (JSON.parse(row.avatar) as Outfit) : null
|
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<Map<number, Outfit>> {
|
||||||
|
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
|
* 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
|
* upsert is keyed on (account_id, set_id), so re-saving a slot overwrites rather than
|
||||||
|
|||||||
Reference in New Issue
Block a user