support for outfits

This commit is contained in:
Devin Zuczek
2026-08-01 16:50:50 -04:00
parent 8cc7c7a994
commit 3030b480c8
7 changed files with 232 additions and 36 deletions
+45 -11
View File
@@ -378,24 +378,58 @@ export const LegacyAvatarItemSaves = z.object({
}) })
/** /**
* `GET /outfits/me` — the empty-outfit envelope. Stubbed, so every field that would * `GET /outfits/me` — the outfit envelope. Either the outfit stored in slot 0, served
* carry a stored outfit is null/empty; `DataVersion` 9 is what the client parses against. * back exactly as it was saved, or (for a player who has never saved) the brand-new-
* account form, where every field that would carry an outfit is null/empty and
* `DataVersion` is 9.
*/ */
export const OutfitsMeResponse = z.object({ export const OutfitsMeResponse = z.object({
LegacyData: z.object({ LegacyData: z.object({
SelectionsV1: z.null(), SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
SelectionsV2: z.null(), SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
FaceFeatures: z.null(), FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
SkinColor: z.null(), SkinColor: z.string().nullable(),
HairColor: z.null(), HairColor: z.string().nullable(),
}), }),
Selections: JsonArray, Selections: JsonArray,
DataVersion: z.int(), DataVersion: z.int().describe('9 in the new-account envelope; whatever was saved otherwise'),
CustomizationSettings: z.null(), CustomizationSettings: z
ThumbnailFileName: z.null(), .string()
Name: z.null(), .nullable()
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
ThumbnailFileName: z.string().nullable(),
Name: z.string().nullable(),
Accessibility: z.int(), Accessibility: z.int(),
Slot: z.int().describe('0 — the outfit being worn'),
})
/**
* `PUT /outfits/me` JSON body — the outfit the client is saving, in the newer envelope.
* The heavy fields are JSON-in-a-string, exactly as the client serialises them:
* `SelectionsV2` and `CustomizationSettings` are whole documents encoded as strings, and
* `FaceFeatures` likewise. Note the two formats overlap: `LegacyData` carries the old
* flat descriptors while `CustomizationSettings` carries the same outfit in the new
* structured form, and the client sends both. `Selections` arrives empty — the actual
* selections are inside those strings.
*/
export const OutfitsMeRequest = z.object({
DataVersion: z.int().describe('The clients outfit format version (2 in observed saves)'),
LegacyData: z.object({
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
SkinColor: z.string().nullable(),
HairColor: z.string().nullable(),
}),
CustomizationSettings: z
.string()
.nullable()
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
Selections: JsonArray.describe('Empty in observed saves'),
Slot: z.int(), Slot: z.int(),
Name: z.string().nullable(),
Accessibility: z.int(),
ThumbnailFileName: z.string().nullable(),
}) })
/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */ /** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
+60 -9
View File
@@ -1,6 +1,8 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi' import { describeRoute } from 'hono-openapi'
import { CURRENT_OUTFIT_SLOT, getOutfit, setOutfit } from '@repo/domain'
import { authedId, unauthorized } from '../http' import { authedId, unauthorized } from '../http'
import { import {
createInvention, createInvention,
@@ -40,6 +42,7 @@ import {
JsonArray, JsonArray,
jsonBody, jsonBody,
LegacyAvatarItemSaves, LegacyAvatarItemSaves,
OutfitsMeRequest,
OutfitsMeResponse, OutfitsMeResponse,
pageParams, pageParams,
SaveInventionRequest, SaveInventionRequest,
@@ -235,28 +238,35 @@ export const avatarRoutes = new Hono<App>({ strict: false })
(c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} }) (c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} })
) )
// The newer outfit read, on a bare (un-prefixed) path. Auth-gated. Stubbed: every // The newer outfit read, on a bare (un-prefixed) path. Auth-gated. The outfit the
// caller gets the brand-new-account envelope — all-null LegacyData, no selections — // player is wearing is slot 0 of the shared `outfit` table (the same table the `econ`
// rather than their saved outfit, which lives on the `econ` worker. // worker's saved-outfit slots live in); a player who has never saved gets the
// brand-new-account envelope instead.
.get( .get(
'/outfits/me', '/outfits/me',
describeRoute({ describeRoute({
tags: ['Avatar'], tags: ['Avatar'],
summary: 'The callers outfit (stub)', summary: 'The callers outfit',
description: description:
'The newer outfit read, on a bare un-prefixed path. Stubbed for now: every caller ' + 'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' +
'gets the brand-new-account envelope — all-null `LegacyData`, no `Selections` — ' + '`outfit` table — the newer client treats slot 0 as the outfit currently worn — and ' +
'regardless of what they have saved (saved outfits live on the `econ` worker). ' + 'handed back exactly as it was saved, since the payloads heavy fields are the ' +
'`DataVersion` 9 is the version the client expects to parse.', 'clients own JSON-in-a-string documents.\n\n' +
'A player who has never saved gets the brand-new-account envelope: all-null ' +
'`LegacyData`, no `Selections`, `DataVersion` 9.',
security: AUTHED, security: AUTHED,
responses: { responses: {
200: json(OutfitsMeResponse, 'The empty-outfit envelope'), 200: json(OutfitsMeResponse, 'The stored outfit, or the empty envelope'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
}, },
}), }),
async (c) => { async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const outfit = await getOutfit(c.env.DB, id, CURRENT_OUTFIT_SLOT)
if (outfit !== null) return c.json(outfit)
return c.json({ return c.json({
LegacyData: { LegacyData: {
SelectionsV1: null, SelectionsV1: null,
@@ -276,6 +286,47 @@ export const avatarRoutes = new Hono<App>({ strict: false })
} }
) )
// Saving an outfit through the same bare path — into the slot the body names, which
// is slot 0 for the outfit being worn. Stored verbatim: the heavy fields are the
// client's own JSON-in-a-string documents, and re-encoding risks changing a payload
// it has to parse back. Answers the saved outfit, which is what the client re-renders
// from.
.put(
'/outfits/me',
describeRoute({
tags: ['Avatar'],
summary: 'Save the callers outfit',
description:
'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' +
'outfit worn, which is what the GET reads. Re-saving a slot overwrites it.\n\n' +
'The payload is stored verbatim and answered back: its heavy fields (`SelectionsV2`, ' +
'`FaceFeatures`, `CustomizationSettings`) are whole JSON documents encoded as ' +
'strings by the clients own serializer, so nothing here parses or re-encodes them.',
security: AUTHED,
requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'),
responses: {
200: json(OutfitsMeRequest, 'The outfit as stored'),
400: json(ErrorResponse, 'Unparseable body'),
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)
// The client sends `Slot`; a body without one saves the worn outfit.
const outfit = {
...body,
Slot: typeof body.Slot === 'number' ? body.Slot : CURRENT_OUTFIT_SLOT,
}
await setOutfit(c.env.DB, id, outfit)
return c.json(outfit)
}
)
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention, // A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
// or 404 when there's no such invention. // or 404 when there's no such invention.
.get( .get(
+87 -3
View File
@@ -2,7 +2,12 @@ import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers' import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest' import { beforeAll, describe, expect, test } from 'vitest'
import { GAME_VERSION, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL } from '@repo/domain' import {
GAME_VERSION,
OUTFIT_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import '../../api.app' import '../../api.app'
@@ -79,6 +84,9 @@ beforeAll(async () => {
// Relationships table (owned by the api worker) — friendship endpoints use it. // Relationships table (owned by the api worker) — friendship endpoints use it.
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// 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()
// Inventions table (owned by the api worker) — invention save/mine use it. // 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() for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
}) })
@@ -363,10 +371,11 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual({ customAvatarItemSavesByAvatarItemDesc: {} }) expect(await res.json()).toEqual({ customAvatarItemSavesByAvatarItemDesc: {} })
}) })
test('GET /outfits/me 401s without a token, serves the empty envelope with one', async () => { test('GET /outfits/me 401s without a token, serves the empty envelope for a new player', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`) const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`)
expect(anon.status).toBe(401) expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() }) // Account 77 never saves an outfit, so it keeps getting the new-account envelope.
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer('77') })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
LegacyData: { LegacyData: {
@@ -386,6 +395,80 @@ describe('public endpoints', () => {
}) })
}) })
test('PUT /outfits/me saves into slot 0; GET reads it back verbatim', async () => {
// The client's own payload, trimmed to one selection: the point is that the heavy
// JSON-in-a-string fields survive the round trip as strings, unparsed.
const outfit = {
DataVersion: 2,
LegacyData: {
SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0',
SelectionsV2:
'{"selections":[{"PrefabGuid":"193a3bf9-abc0-4d78-8d63-92046908b1c5","CombinationGuid":"","BodyPart":0}]}',
FaceFeatures: '{"ver":7,"eyeId":"Aeu0yxJXG0qCOLZW5Tcu7A","hideEars":false}',
SkinColor: 'Dc6StLFk60u5iUTrb3_C3w',
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
},
CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}',
Selections: [],
Slot: 0,
Name: null,
Accessibility: 1,
ThumbnailFileName: null,
}
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(outfit),
})
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify(outfit),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual(outfit)
// The read serves it back byte-for-byte — the JSON-in-a-string fields are still
// strings, not re-encoded objects.
const read = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
expect(await read.json()).toEqual(outfit)
// Re-saving overwrites slot 0 rather than adding a second row.
const changed = { ...outfit, LegacyData: { ...outfit.LegacyData, SkinColor: 'changed' } }
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify(changed),
})
const reread = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
expect(await reread.json()).toEqual(changed)
const rows = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM outfit WHERE account_id = 42'
).first<{ n: number }>()
expect(rows?.n).toBe(1)
// A save naming another slot does not touch what the caller is wearing.
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({ ...changed, Slot: 3, Name: 'slot three' }),
})
const worn = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
})
test('PUT /outfits/me 400s on an unparseable body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: 'not json',
})
expect(res.status).toBe(400)
})
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => { test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`) const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -2040,6 +2123,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',
'PUT /outfits/me',
]) ])
// Every operation carries a summary — an undescribed one renders as a bare path. // Every operation carries a summary — an undescribed one renders as a bare path.
+9 -4
View File
@@ -2,7 +2,14 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain' import {
consumeGift,
createGift,
getGift,
getOutfits,
getPendingGifts,
setOutfit,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
@@ -61,16 +68,14 @@ import {
SubscriptionResponse, SubscriptionResponse,
UNAUTHORIZED_RESPONSE, UNAUTHORIZED_RESPONSE,
} from './openapi' } from './openapi'
import { getOutfits, setOutfit } from './outfit-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { GiftContent, StoredGift } from '@repo/domain' import type { GiftContent, Outfit, StoredGift } from '@repo/domain'
import type { Avatar } from './avatar-db' import type { Avatar } from './avatar-db'
import type { ConsumeResult } from './consumables-db' import type { ConsumeResult } from './consumables-db'
import type { App } from './context' import type { App } from './context'
import type { Equipment } from './equipment-db' import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db' import type { AvatarItem } from './inventory-db'
import type { Outfit } from './outfit-db'
/** /**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on * Economy Worker. Hosts the avatar/economy endpoints the game client calls on
+1 -2
View File
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../econ.app' import '../../econ.app'
import { RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain' import { OUTFIT_SCHEMA_DDL, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain'
import { SCHEMA_DDL } from '../../avatar-db' import { SCHEMA_DDL } from '../../avatar-db'
import { import {
@@ -17,7 +17,6 @@ import {
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db' import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
import type { Env } from '../../context' import type { Env } from '../../context'
+1
View File
@@ -7,4 +7,5 @@ export * from './rooms-db'
export * from './room-instance-db' export * from './room-instance-db'
export * from './presence-db' export * from './presence-db'
export * from './gifts-db' export * from './gifts-db'
export * from './outfits-db'
export * from './relationships-db' export * from './relationships-db'
@@ -1,7 +1,6 @@
/** /**
* Saved outfits on the shared `recflare` D1 database the outfit slots a player * Saved outfits on the shared `recflare` D1 database the outfit slots a player
* saves from the avatar screen (`POST /api/avatar/v3/saved/set`) and loads back from * saves from the avatar screen.
* `GET /api/avatar/v3/saved`.
* *
* One row per (account, slot). The outfit itself is stored as the opaque JSON payload * One row per (account, slot). The outfit itself is stored as the opaque JSON payload
* the client posted: we never query inside it, and its fields (OutfitSelectionsV2, * the client posted: we never query inside it, and its fields (OutfitSelectionsV2,
@@ -9,11 +8,19 @@
* serializer. Round-tripping it verbatim is both the simplest and the safest thing * serializer. Round-tripping it verbatim is both the simplest and the safest thing
* re-encoding risks changing a payload the client has to parse back. * re-encoding risks changing a payload the client has to parse back.
* *
* The `econ` worker owns this table and its migration (apps/econ/migrations/ * The `econ` worker owns the schema/migration (apps/econ/migrations/0002_outfit.sql) and
* 0002_outfit.sql). * serves the slot list (`GET /api/avatar/v3/saved`, `POST /api/avatar/v3/saved/set`). The
* `api` worker reads and writes SLOT 0 through `/outfits/me` the newer client treats
* slot 0 as the outfit currently worn. Both import these helpers so the table name and
* row shape live in one place.
*
* Note the two write paths store DIFFERENT payload shapes into the same column: econ's
* saved-outfit slots hold the old flat PascalCase outfit, while `/outfits/me` holds the
* newer `{ DataVersion, LegacyData, CustomizationSettings, … }` envelope. Each endpoint
* serves back what it stored, so don't add a projection that assumes either one.
*/ */
/** Schema DDL (mirror of migrations 0002_outfit.sql) — also used to build the table in tests. */ /** Schema DDL (mirror of apps/econ/migrations/0002_outfit.sql) — also builds the table in tests. */
export const OUTFIT_SCHEMA_DDL: string[] = [ export const OUTFIT_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS outfit ( `CREATE TABLE IF NOT EXISTS outfit (
account_id INTEGER NOT NULL, account_id INTEGER NOT NULL,
@@ -27,13 +34,15 @@ export const OUTFIT_SCHEMA_DDL: string[] = [
* A saved outfit, as the client posts it. `Slot` is the outfit slot it occupies (the * A saved outfit, as the client posts it. `Slot` is the outfit slot it occupies (the
* `set_id` column) saving to a slot the player already used overwrites it, which is * `set_id` column) saving to a slot the player already used overwrites it, which is
* exactly what the avatar screen's "save over this outfit" does. The rest of the * exactly what the avatar screen's "save over this outfit" does. The rest of the
* payload (PreviewImageName, OutfitSelections, FaceFeatures, SkinColor, HairColor, * payload is stored and served back untouched.
* CustomAvatarItems, ) is stored and served back untouched.
*/ */
export interface Outfit extends Record<string, unknown> { export interface Outfit extends Record<string, unknown> {
Slot: number Slot: number
} }
/** The slot the newer client wears — what `/outfits/me` reads and writes. */
export const CURRENT_OUTFIT_SLOT = 0
/** Every outfit a player has saved, ordered by slot. */ /** Every outfit a player has saved, ordered by slot. */
export async function getOutfits(db: D1Database, accountId: number): Promise<Outfit[]> { export async function getOutfits(db: D1Database, accountId: number): Promise<Outfit[]> {
const { results } = await db const { results } = await db
@@ -43,6 +52,19 @@ export async function getOutfits(db: D1Database, accountId: number): Promise<Out
return results.map((r) => JSON.parse(r.avatar) as Outfit) return results.map((r) => JSON.parse(r.avatar) as Outfit)
} }
/** One slot's outfit, or null when the player has never saved into it. */
export async function getOutfit(
db: D1Database,
accountId: number,
slot: number
): Promise<Outfit | null> {
const row = await db
.prepare('SELECT avatar FROM outfit WHERE account_id = ?1 AND set_id = ?2')
.bind(accountId, slot)
.first<{ avatar: string }>()
return row ? (JSON.parse(row.avatar) as Outfit) : null
}
/** /**
* 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