From cfde2cebf4375f85ffc7a6bb459a8235ccd0c00a Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 21 Jul 2026 01:52:37 -0400 Subject: [PATCH] add equipment update --- .../migrations/0007_equipment_dto_rename.sql | 24 +++++++ apps/econ/src/econ.app.ts | 47 ++++++++++++- apps/econ/src/equipment-db.ts | 67 ++++++++++++++++--- apps/econ/src/openapi.ts | 15 +++++ apps/econ/src/test/integration/api.test.ts | 63 ++++++++++++----- 5 files changed, 187 insertions(+), 29 deletions(-) create mode 100644 apps/econ/migrations/0007_equipment_dto_rename.sql diff --git a/apps/econ/migrations/0007_equipment_dto_rename.sql b/apps/econ/migrations/0007_equipment_dto_rename.sql new file mode 100644 index 0000000..fb48bd5 --- /dev/null +++ b/apps/econ/migrations/0007_equipment_dto_rename.sql @@ -0,0 +1,24 @@ +-- Rewrite the stored unlocked-equipment DTOs onto the shape the client actually reads. +-- Rows written before this used `EquipmentModificationGuid`/`EquipmentPrefabName` (the +-- gift-drop's prefixed names, carried straight over at grant time) and had no +-- `Favorited`/`PlatformMask`. The live endpoint serves the unprefixed +-- `ModificationGuid`/`PrefabName` plus both of those, and the entries the client PUTs +-- back to `/api/equipment/v1/update` use the unprefixed names too — so an un-rewritten +-- row renders with a blank prefab and can never be favourited (the update matches on a +-- guid the row's `data` no longer spells the same way). +-- +-- The `data` column is the DTO verbatim, so the fix is a JSON rewrite in place; the row +-- key (`equipment_modification_guid`) is unchanged. Guarded on the old key being +-- present, which also makes it a no-op on re-run. + +UPDATE equipment +SET data = json_object( + 'ModificationGuid', json_extract(data, '$.EquipmentModificationGuid'), + 'PrefabName', json_extract(data, '$.EquipmentPrefabName'), + 'FriendlyName', json_extract(data, '$.FriendlyName'), + 'Tooltip', json_extract(data, '$.Tooltip'), + 'Rarity', json_extract(data, '$.Rarity'), + 'PlatformMask', -1, + 'Favorited', json('false') + ) +WHERE json_extract(data, '$.EquipmentModificationGuid') IS NOT NULL; diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index c723bdd..32ca43b 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -28,7 +28,7 @@ import { getConsumables, grantConsumable, } from './consumables-db' -import { getEquipment, grantEquipment } from './equipment-db' +import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db' import { getInventory, grantItem } from './inventory-db' import { AUTHED, @@ -42,6 +42,7 @@ import { ConsumeEnvelope, ConsumeGiftRequest, CustomAvatarItemsResponse, + EquipmentUpdateRequest, ErrorResponse, form, json, @@ -280,11 +281,13 @@ function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem { /** Build the owned equipment DTO granted into the buyer's inventory from a gift-drop. */ function toEquipment(giftDrop: StoreGiftDrop): Equipment { return { - EquipmentModificationGuid: giftDrop.EquipmentModificationGuid, - EquipmentPrefabName: giftDrop.EquipmentPrefabName, + ModificationGuid: giftDrop.EquipmentModificationGuid, + PrefabName: giftDrop.EquipmentPrefabName, FriendlyName: giftDrop.FriendlyName, Tooltip: giftDrop.Tooltip, Rarity: giftDrop.Rarity, + PlatformMask: -1, + Favorited: false, } } @@ -714,6 +717,44 @@ const app = new Hono({ strict: false }) } ) + // Favourite/un-favourite owned equipment. [Authorize]. The client PUTs the entries + // it wants changed (one request can carry several) and reads nothing back. Only + // `Favorited` is written — the rest of each entry is the client echoing what it was + // served, and a guid the caller doesn't own matches no row and is dropped. + .put( + '/api/equipment/v1/update', + describeRoute({ + tags: ['Equipment'], + summary: 'Update owned equipment', + description: + 'Applies the posted `Favorited` flags to the caller’s owned equipment, matched by ' + + '`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller ' + + 'doesn’t own is silently skipped. Empty body on success.', + security: AUTHED, + requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'), + responses: { + 200: { description: 'Applied (empty body)' }, + 400: { description: 'Body isn’t a JSON array (empty 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 unknown + if (!Array.isArray(body)) return c.body(null, 400) + const updates = body + .filter((e): e is Record => typeof e === 'object' && e !== null) + .filter((e) => typeof e.ModificationGuid === 'string' && e.ModificationGuid !== '') + .map((e) => ({ + ModificationGuid: e.ModificationGuid as string, + Favorited: e.Favorited === true, + })) + await setEquipmentFavorited(c.env.DB, id, updates) + return c.body(null, 200) + } + ) + // Room consumables/currencies for a given room. Stubbed as empty lists so the // client doesn't 404. .get( diff --git a/apps/econ/src/equipment-db.ts b/apps/econ/src/equipment-db.ts index 02cb25a..ffadc72 100644 --- a/apps/econ/src/equipment-db.ts +++ b/apps/econ/src/equipment-db.ts @@ -6,10 +6,10 @@ * gift-drop carries an `EquipmentModificationGuid`) and read back by * `GET /api/equipment/v2/getUnlocked`. * - * The item is keyed by its `EquipmentModificationGuid` — the gift-drop's equipment guid - * — so re-buying the same skin upserts rather than piling up duplicate rows (these - * drops are flagged `Unique`). `data` is the rendered unlocked-equipment DTO, stored - * opaquely and served back untouched. + * The item is keyed by the gift-drop's equipment guid, so re-buying the same skin + * upserts rather than piling up duplicate rows (these drops are flagged `Unique`). + * `data` is the rendered unlocked-equipment DTO, stored opaquely and served back + * untouched. * * This worker (`econ`) owns the table and its migration — see apps/econ/migrations/ * 0006_equipment.sql. The gift box the purchase also creates lives in a separate table @@ -28,21 +28,34 @@ export const EQUIPMENT_SCHEMA_DDL: string[] = [ /** * A rendered piece of unlocked equipment, as `/api/equipment/v2/getUnlocked` serves it. - * `EquipmentModificationGuid` is the item's guid string and the row's key; - * `EquipmentPrefabName` names the base equipment the modification applies to. + * `ModificationGuid` is the item's guid string and the row's key; `PrefabName` names the + * base equipment the modification applies to. + * + * The names are UNPREFIXED here, unlike the gift-drop/gift-box shapes that carry the + * same two values as `EquipmentPrefabName`/`EquipmentModificationGuid`. That's not an + * inconsistency to tidy up: a drop is a flat record holding avatar, consumable and + * equipment fields side by side, so it needs the prefix to disambiguate, while this + * record is all equipment. Confirmed against the live endpoint, and the entries the + * client PUTs back to `/api/equipment/v1/update` use the same unprefixed names. */ export interface Equipment extends Record { - EquipmentModificationGuid: string - EquipmentPrefabName: string + ModificationGuid: string + PrefabName: string FriendlyName: string Tooltip: string Rarity: number + /** Always -1 (all platforms) — we don't gate equipment per platform. */ + PlatformMask: number + /** Player-set favourite flag, toggled by `PUT /api/equipment/v1/update`. */ + Favorited: boolean } /** * Grant a piece of equipment into a player's inventory. Upserts on * (account_id, equipment_modification_guid): owning equipment is boolean, so re-buying - * it refreshes the stored DTO rather than adding a second copy. + * it refreshes the stored DTO rather than adding a second copy. The refresh carries the + * player's `Favorited` flag over, so re-buying doesn't quietly un-favourite the skin + * (a row written before the flag existed reads as not favourited). */ export async function grantEquipment( db: D1Database, @@ -52,12 +65,44 @@ export async function grantEquipment( await db .prepare( `INSERT INTO equipment (account_id, equipment_modification_guid, data) VALUES (?1, ?2, ?3) - ON CONFLICT (account_id, equipment_modification_guid) DO UPDATE SET data = ?3` + ON CONFLICT (account_id, equipment_modification_guid) DO UPDATE SET + data = json_set(?3, '$.Favorited', + json(CASE WHEN json_extract(equipment.data, '$.Favorited') THEN 'true' ELSE 'false' END))` ) - .bind(accountId, equipment.EquipmentModificationGuid, JSON.stringify(equipment)) + .bind(accountId, equipment.ModificationGuid, JSON.stringify(equipment)) .run() } +/** One entry of the `PUT /api/equipment/v1/update` body. */ +export interface EquipmentFavoriteUpdate { + ModificationGuid: string + Favorited: boolean +} + +/** + * Apply the client's favourite toggles. Only the `Favorited` flag is writable — the + * rest of the posted entry (PrefabName, Rarity, …) is the client echoing back what it + * was served, and the reference server ignores it too. + * + * A guid the caller doesn't own matches no row and is silently dropped: equipment is + * only ever granted by a purchase, so there is nothing to favourite until then (and an + * insert here would let a client mint equipment for itself). + */ +export async function setEquipmentFavorited( + db: D1Database, + accountId: number, + updates: EquipmentFavoriteUpdate[] +): Promise { + if (updates.length === 0) return + const stmt = db.prepare( + `UPDATE equipment SET data = json_set(data, '$.Favorited', json(?3)) + WHERE account_id = ?1 AND equipment_modification_guid = ?2` + ) + await db.batch( + updates.map((u) => stmt.bind(accountId, u.ModificationGuid, u.Favorited ? 'true' : 'false')) + ) +} + /** Every piece of equipment a player owns, ordered by guid for a stable listing. */ export async function getEquipment(db: D1Database, accountId: number): Promise { const { results } = await db diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 9ab3c7b..6090622 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -177,5 +177,20 @@ export const SaveOutfitRequest = z .catchall(z.unknown()) .describe('Plus opaque outfit fields (OutfitSelectionsV2, FaceFeatures, …) stored verbatim') +/** + * `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 + * ignored (as on the reference server). + */ +export const EquipmentUpdateRequest = z.array( + z + .object({ + ModificationGuid: z.string().describe('Identifies the owned equipment row'), + Favorited: z.boolean(), + }) + .catchall(z.unknown()) + .describe('Plus the echoed-back PrefabName / FriendlyName / Tooltip / Rarity, all ignored') +) + /** An opaque JSON body stored verbatim (the avatar blob for `POST /api/avatar/v2/set`). */ export const OpaqueJsonBody = JsonObject.describe('Stored verbatim and echoed back') diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 5764070..5cfbad1 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -413,16 +413,6 @@ describe('econ endpoints', () => { expect(await res.json()).toEqual([]) }) - test('POST /api/settings/v2/set 401s without a token, 200s with one', async () => { - const anon = await exports.default.fetch(`${ORIGIN}/api/settings/v2/set`, { method: 'POST' }) - expect(anon.status).toBe(401) - const res = await exports.default.fetch(`${ORIGIN}/api/settings/v2/set`, { - method: 'POST', - headers: await bearer(), - }) - expect(res.status).toBe(200) - }) - test('GET /api/consumables/v2/getUnlocked 401s without a token, returns []', async () => { const anon = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`) expect(anon.status).toBe(401) @@ -774,16 +764,20 @@ describe('econ endpoints', () => { }) expect(r.status).toBe(200) return (await r.json()) as Array<{ - EquipmentModificationGuid: string - EquipmentPrefabName: string + ModificationGuid: string + PrefabName: string FriendlyName: string + PlatformMask: number + Favorited: boolean }> } const first = await unlocked() expect(first).toHaveLength(1) - expect(first[0].EquipmentModificationGuid).toBe(guid) - expect(first[0].EquipmentPrefabName).toBe('[DiscGolfDisc]') + // The unlocked DTO is unprefixed, unlike the gift-drop the grant came from. + expect(first[0].ModificationGuid).toBe(guid) + expect(first[0].PrefabName).toBe('[DiscGolfDisc]') expect(first[0].FriendlyName).toBe('Disc Skin (Coop)') + expect(first[0].PlatformMask).toBe(-1) // Equipment is not an avatar item — it does not show up in v4/items. const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { @@ -792,9 +786,48 @@ describe('econ endpoints', () => { const list = (await items.json()) as Array<{ FriendlyName: string }> expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true) + expect(first[0].Favorited).toBe(false) + // Owning equipment is boolean: re-buying upserts, it does not add a second row. expect((await buy()).status).toBe(200) expect(await unlocked()).toHaveLength(1) + + // Favouriting sticks. + const update = async (favorited: boolean) => + exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, { + method: 'PUT', + headers: { ...(await bearer('31')), 'Content-Type': 'application/json' }, + body: JSON.stringify([ + { PrefabName: '[DiscGolfDisc]', ModificationGuid: guid, Favorited: favorited }, + // A guid the caller doesn't own is silently skipped, not inserted. + { PrefabName: '[Basketball]', ModificationGuid: 'not-owned', Favorited: true }, + ]), + }) + expect((await update(true)).status).toBe(200) + let after = await unlocked() + expect(after).toHaveLength(1) + expect(after[0].Favorited).toBe(true) + + // …and un-favouriting flips it back. + expect((await update(false)).status).toBe(200) + after = await unlocked() + expect(after[0].Favorited).toBe(false) + }) + + test('PUT /api/equipment/v1/update 401s without a token, 400s on a non-array body', async () => { + const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: '[]', + }) + expect(anon.status).toBe(401) + + const bad = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, { + method: 'PUT', + headers: { ...(await bearer('32')), 'Content-Type': 'application/json' }, + body: '{}', + }) + expect(bad.status).toBe(400) }) test('POST /api/storefronts/v2/buyItem 409s when the sent price no longer matches', async () => { @@ -1127,8 +1160,8 @@ describe('econ endpoints', () => { 'POST /api/consumables/v1/consume', 'POST /api/gamerewards/v1/request', 'POST /api/objectives/v1/cleargroup', - 'POST /api/settings/v2/set', 'POST /api/storefronts/v2/buyItem', + 'PUT /api/equipment/v1/update', ]) // Every operation carries a summary — a path present but undescribed is not