mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
support for outfits
This commit is contained in:
+45
-11
@@ -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 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(),
|
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. */
|
||||||
|
|||||||
@@ -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 caller’s outfit (stub)',
|
summary: 'The caller’s 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 payload’s heavy fields are the ' +
|
||||||
'`DataVersion` 9 is the version the client expects to parse.',
|
'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,
|
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 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<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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user