mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
v4 avatars
This commit is contained in:
+58
-10
@@ -51,6 +51,7 @@ import {
|
||||
JsonObject,
|
||||
OpaqueJsonBody,
|
||||
SaveOutfitRequest,
|
||||
SaveOutfitV4Response,
|
||||
SubscriptionResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from './openapi'
|
||||
@@ -88,6 +89,26 @@ function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared parse/validate/store for the save-outfit routes (v3 and v4). Persists the
|
||||
* posted outfit into its `Slot` verbatim and returns the stored `Outfit`; on the
|
||||
* unauth or bad-body path it returns the Response to send directly (401, or 400 for a
|
||||
* non-object body or missing/non-integer `Slot`). Callers format the success body — v3
|
||||
* echoes the whole outfit, v4 answers a lean `{ Success, Slot }` ack.
|
||||
*/
|
||||
async function persistPostedOutfit(c: Context<App>): Promise<Outfit | Response> {
|
||||
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 || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return c.body(null, 400)
|
||||
}
|
||||
if (!Number.isInteger(body.Slot)) return c.body(null, 400)
|
||||
const outfit = body as Outfit
|
||||
await setOutfit(c.env.DB, id, outfit)
|
||||
return outfit
|
||||
}
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
@@ -563,6 +584,11 @@ const app = new Hono<App>({ strict: false })
|
||||
//
|
||||
// A missing/non-integer `Slot` is a 400 rather than a default slot — guessing would
|
||||
// silently overwrite an outfit the player didn't mean to touch.
|
||||
//
|
||||
// v3 and v4 share this handler: newer clients POST to /v4/saved/set with the same
|
||||
// payload shape (Slot, PreviewImageName, OutfitSelections(V2), FaceFeatures, Skin/HairColor,
|
||||
// CustomAvatarItems) and expect the same slot-overwrite semantics, so they store into the
|
||||
// same outfit table and read back through /api/avatar/v3/saved.
|
||||
.post(
|
||||
'/api/avatar/v3/saved/set',
|
||||
describeRoute({
|
||||
@@ -583,16 +609,38 @@ const app = new Hono<App>({ strict: false })
|
||||
},
|
||||
}),
|
||||
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 || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return c.body(null, 400)
|
||||
}
|
||||
if (!Number.isInteger(body.Slot)) return c.body(null, 400)
|
||||
const outfit = body as Outfit
|
||||
await setOutfit(c.env.DB, id, outfit)
|
||||
return c.json(outfit)
|
||||
const result = await persistPostedOutfit(c)
|
||||
if (result instanceof Response) return result
|
||||
return c.json(result)
|
||||
}
|
||||
)
|
||||
|
||||
// v4 of the save-outfit route. Same payload, table and slot-overwrite semantics as v3
|
||||
// (see above) — newer clients moved to /v4/saved/set. The one difference is the response:
|
||||
// v4 answers a lean `{ Success, Slot }` acknowledgement rather than echoing the whole
|
||||
// outfit back. The outfit is read back through /api/avatar/v3/saved either way.
|
||||
.post(
|
||||
'/api/avatar/v4/saved/set',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Save an outfit into a slot (v4)',
|
||||
description: [
|
||||
'Writes the posted outfit into the given `Slot` (overwriting it), same as',
|
||||
'`POST /api/avatar/v3/saved/set`, but answers a lean `{ Success, Slot }` ack instead',
|
||||
'of echoing the outfit. A missing/non-integer `Slot` is a 400.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
|
||||
responses: {
|
||||
200: json(SaveOutfitV4Response, 'Save acknowledgement'),
|
||||
400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await persistPostedOutfit(c)
|
||||
if (result instanceof Response) return result
|
||||
return c.json({ Success: true, Slot: result.Slot })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -177,6 +177,15 @@ export const SaveOutfitRequest = z
|
||||
.catchall(z.unknown())
|
||||
.describe('Plus opaque outfit fields (OutfitSelectionsV2, FaceFeatures, …) stored verbatim')
|
||||
|
||||
/**
|
||||
* `POST /api/avatar/v4/saved/set` response — a lean acknowledgement. Unlike v3 (which
|
||||
* echoes the whole outfit), v4 answers just the success flag and the slot it wrote.
|
||||
*/
|
||||
export const SaveOutfitV4Response = z.object({
|
||||
Success: z.boolean(),
|
||||
Slot: z.int().describe('The slot that was written'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /api/equipment/v1/update` JSON body — the client's favourite toggles. It echoes
|
||||
* back the whole entry it was served, but only `Favorited` is written; the rest is
|
||||
|
||||
@@ -356,6 +356,39 @@ describe('econ endpoints', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /api/avatar/v4/saved/set stores like v3 but acks with { Success, Slot }', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(SAVED_OUTFIT),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('25')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(SAVED_OUTFIT),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// v4 answers a lean ack, not the echoed outfit.
|
||||
expect(await res.json()).toEqual({ Success: true, Slot: SAVED_OUTFIT.Slot })
|
||||
|
||||
// Shares the v3 outfit table, so the v3 read serves the outfit back verbatim.
|
||||
const saved = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`, {
|
||||
headers: await bearer('25'),
|
||||
})
|
||||
expect(await saved.json()).toEqual([SAVED_OUTFIT])
|
||||
})
|
||||
|
||||
test('POST /api/avatar/v4/saved/set 400s without an integer Slot', async () => {
|
||||
const { Slot: _Slot, ...noSlot } = SAVED_OUTFIT
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('26')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(noSlot),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('GET /api/avatar/v2/gifts 401s without a token, returns [] with one', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`)
|
||||
expect(anon.status).toBe(401)
|
||||
@@ -1156,6 +1189,7 @@ describe('econ endpoints', () => {
|
||||
'POST /api/avatar/v2/gifts/consume',
|
||||
'POST /api/avatar/v2/set',
|
||||
'POST /api/avatar/v3/saved/set',
|
||||
'POST /api/avatar/v4/saved/set',
|
||||
'POST /api/challenge/v2/updateProgress',
|
||||
'POST /api/consumables/v1/consume',
|
||||
'POST /api/gamerewards/v1/request',
|
||||
|
||||
Reference in New Issue
Block a user