diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 178998d..1910d2f 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -378,24 +378,58 @@ export const LegacyAvatarItemSaves = z.object({ }) /** - * `GET /outfits/me` — the empty-outfit envelope. Stubbed, so every field that would - * carry a stored outfit is null/empty; `DataVersion` 9 is what the client parses against. + * `GET /outfits/me` — the outfit envelope. Either the outfit stored in slot 0, served + * 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({ LegacyData: z.object({ - SelectionsV1: z.null(), - SelectionsV2: z.null(), - FaceFeatures: z.null(), - SkinColor: z.null(), - HairColor: z.null(), + 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(), }), Selections: JsonArray, - DataVersion: z.int(), - CustomizationSettings: z.null(), - ThumbnailFileName: z.null(), - Name: z.null(), + DataVersion: z.int().describe('9 in the new-account envelope; whatever was saved otherwise'), + CustomizationSettings: z + .string() + .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(), + 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 client’s 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(), + Name: z.string().nullable(), + Accessibility: z.int(), + ThumbnailFileName: z.string().nullable(), }) /** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */ diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index cf111d0..fa3ed6a 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -1,6 +1,8 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' +import { CURRENT_OUTFIT_SLOT, getOutfit, setOutfit } from '@repo/domain' + import { authedId, unauthorized } from '../http' import { createInvention, @@ -40,6 +42,7 @@ import { JsonArray, jsonBody, LegacyAvatarItemSaves, + OutfitsMeRequest, OutfitsMeResponse, pageParams, SaveInventionRequest, @@ -235,28 +238,35 @@ export const avatarRoutes = new Hono({ strict: false }) (c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} }) ) - // The newer outfit read, on a bare (un-prefixed) path. Auth-gated. Stubbed: every - // caller gets the brand-new-account envelope — all-null LegacyData, no selections — - // rather than their saved outfit, which lives on the `econ` worker. + // The newer outfit read, on a bare (un-prefixed) path. Auth-gated. The outfit the + // player is wearing is slot 0 of the shared `outfit` table (the same table the `econ` + // worker's saved-outfit slots live in); a player who has never saved gets the + // brand-new-account envelope instead. .get( '/outfits/me', describeRoute({ tags: ['Avatar'], - summary: 'The caller’s outfit (stub)', + summary: 'The caller’s outfit', description: - 'The newer outfit read, on a bare un-prefixed path. Stubbed for now: every caller ' + - 'gets the brand-new-account envelope — all-null `LegacyData`, no `Selections` — ' + - 'regardless of what they have saved (saved outfits live on the `econ` worker). ' + - '`DataVersion` 9 is the version the client expects to parse.', + 'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' + + '`outfit` table — the newer client treats slot 0 as the outfit currently worn — and ' + + 'handed back exactly as it was saved, since the payload’s heavy fields are the ' + + 'client’s 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, responses: { - 200: json(OutfitsMeResponse, 'The empty-outfit envelope'), + 200: json(OutfitsMeResponse, 'The stored outfit, or the empty envelope'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(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({ LegacyData: { SelectionsV1: null, @@ -276,6 +286,47 @@ export const avatarRoutes = new Hono({ 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 caller’s 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 client’s 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 | 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, // or 404 when there's no such invention. .get( diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 1a08d27..81d60f5 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -2,7 +2,12 @@ import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' 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' @@ -79,6 +84,9 @@ beforeAll(async () => { // Relationships table (owned by the api worker) — friendship endpoints use it. 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. 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: {} }) }) - 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`) 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(await res.json()).toEqual({ 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 () => { const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`) expect(res.status).toBe(200) @@ -2040,6 +2123,7 @@ describe('openapi', () => { 'POST /api/sanitize/v1', 'POST /api/sanitize/v1/isPure', 'POST /api/v1/progression/bulk', + 'PUT /outfits/me', ]) // Every operation carries a summary — an undescribed one renders as a bare path. diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 00c6f1d..6114ce9 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -2,7 +2,14 @@ import { Hono } from 'hono' import { describeRoute, openAPIRouteHandler } from 'hono-openapi' 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 { validateAndGetAccountId } from '@repo/jwt' @@ -61,16 +68,14 @@ import { SubscriptionResponse, UNAUTHORIZED_RESPONSE, } from './openapi' -import { getOutfits, setOutfit } from './outfit-db' 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 { ConsumeResult } from './consumables-db' import type { App } from './context' import type { Equipment } from './equipment-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 diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 7b5df64..7a52f0d 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest' 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 { @@ -17,7 +17,6 @@ import { import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' -import { OUTFIT_SCHEMA_DDL } from '../../outfit-db' import type { Env } from '../../context' diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 0c4df20..30ee9d8 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -7,4 +7,5 @@ export * from './rooms-db' export * from './room-instance-db' export * from './presence-db' export * from './gifts-db' +export * from './outfits-db' export * from './relationships-db' diff --git a/apps/econ/src/outfit-db.ts b/packages/domain/src/outfits-db.ts similarity index 57% rename from apps/econ/src/outfit-db.ts rename to packages/domain/src/outfits-db.ts index 66f95c9..3c22aec 100644 --- a/apps/econ/src/outfit-db.ts +++ b/packages/domain/src/outfits-db.ts @@ -1,7 +1,6 @@ /** * 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 - * `GET /api/avatar/v3/saved`. + * saves from the avatar screen. * * 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, @@ -9,11 +8,19 @@ * 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. * - * The `econ` worker owns this table and its migration (apps/econ/migrations/ - * 0002_outfit.sql). + * The `econ` worker owns the schema/migration (apps/econ/migrations/0002_outfit.sql) and + * 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[] = [ `CREATE TABLE IF NOT EXISTS outfit ( 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 * `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 - * payload (PreviewImageName, OutfitSelections, FaceFeatures, SkinColor, HairColor, - * CustomAvatarItems, …) is stored and served back untouched. + * payload is stored and served back untouched. */ export interface Outfit extends Record { 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. */ export async function getOutfits(db: D1Database, accountId: number): Promise { const { results } = await db @@ -43,6 +52,19 @@ export async function getOutfits(db: D1Database, accountId: number): Promise 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 { + 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 * upsert is keyed on (account_id, set_id), so re-saving a slot overwrites rather than