diff --git a/apps/econ/migrations/0006_equipment.sql b/apps/econ/migrations/0006_equipment.sql new file mode 100644 index 0000000..89a31b7 --- /dev/null +++ b/apps/econ/migrations/0006_equipment.sql @@ -0,0 +1,15 @@ +-- Owned equipment, owned by the `econ` worker. Like avatar items (own-once, one row +-- per (account, item)) rather than consumables (which stack): equipment is a boolean +-- unlock keyed by its `EquipmentModificationGuid` (the gift-drop's equipment guid), so +-- re-buying the same skin is a no-op rather than a duplicate row. Granted at purchase +-- time by `/api/storefronts/v2/buyItem` (when the gift-drop carries an +-- `EquipmentModificationGuid`) and read back by `/api/equipment/v2/getUnlocked`; `data` +-- is the rendered unlocked-equipment DTO. Kept in sync with EQUIPMENT_SCHEMA_DDL in +-- src/equipment-db.ts. + +CREATE TABLE IF NOT EXISTS equipment ( + account_id INTEGER NOT NULL, + equipment_modification_guid TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (account_id, equipment_modification_guid) + ); diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 8efefb7..c723bdd 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -28,6 +28,7 @@ import { getConsumables, grantConsumable, } from './consumables-db' +import { getEquipment, grantEquipment } from './equipment-db' import { getInventory, grantItem } from './inventory-db' import { AUTHED, @@ -59,6 +60,7 @@ import type { GiftContent, 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' @@ -275,6 +277,17 @@ 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, + FriendlyName: giftDrop.FriendlyName, + Tooltip: giftDrop.Tooltip, + Rarity: giftDrop.Rarity, + } +} + /** Quantity of a consumable granted per purchase — our storefront catalogs don't specify one. */ const CONSUMABLE_GRANT_COUNT = 1 @@ -688,9 +701,17 @@ const app = new Hono({ strict: false }) } ) - // Unlocked equipment. Returns "[]" with no auth. - .get('/api/equipment/v2/getUnlocked', listRoute('Unlocked equipment', 'Empty for now'), (c) => - c.json([]) + // Unlocked equipment. [Authorize]. The equipment skins the player has bought (from + // `buyItem`, stored in the `equipment` table). A player who has bought none gets an + // empty list. + .get( + '/api/equipment/v2/getUnlocked', + listRoute('Unlocked equipment', 'The equipment skins the player has bought', true), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json(await getEquipment(c.env.DB, id)) + } ) // Room consumables/currencies for a given room. Stubbed as empty lists so the @@ -951,10 +972,17 @@ const app = new Hono({ strict: false }) if (!paid) return c.json({ error: 'Insufficient balance' }, 400) // Grant the item to the recipient. A gift-drop carries an avatar item, a consumable, - // or neither (currency/xp drops aren't granted yet); grant whichever it actually has. + // an equipment skin, or none of these (currency/xp drops aren't granted yet); grant + // whichever it actually has. if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') { await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop)) } + if ( + typeof item.GiftDrop.EquipmentModificationGuid === 'string' && + item.GiftDrop.EquipmentModificationGuid !== '' + ) { + await grantEquipment(c.env.DB, receiverId, toEquipment(item.GiftDrop)) + } const isConsumable = typeof item.GiftDrop.ConsumableItemDesc === 'string' && item.GiftDrop.ConsumableItemDesc !== '' diff --git a/apps/econ/src/equipment-db.ts b/apps/econ/src/equipment-db.ts new file mode 100644 index 0000000..02cb25a --- /dev/null +++ b/apps/econ/src/equipment-db.ts @@ -0,0 +1,70 @@ +/** + * Owned equipment on the shared `recflare` D1 database — the equipment skins a player + * has bought from a storefront (e.g. a "Bow Skin (Dryad Summer)"). One row per + * (account, item): like avatar items, owning a piece of equipment is boolean, so the + * skin is granted at purchase time (`POST /api/storefronts/v2/buyItem`, when the + * 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. + * + * 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 + * (@repo/domain's received_gift); ownership does not depend on the box being opened. + */ + +/** Schema DDL (mirror of migrations 0006_equipment.sql) — also builds the table in tests. */ +export const EQUIPMENT_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS equipment ( + account_id INTEGER NOT NULL, + equipment_modification_guid TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (account_id, equipment_modification_guid) + )`, +] + +/** + * 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. + */ +export interface Equipment extends Record { + EquipmentModificationGuid: string + EquipmentPrefabName: string + FriendlyName: string + Tooltip: string + Rarity: number +} + +/** + * 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. + */ +export async function grantEquipment( + db: D1Database, + accountId: number, + equipment: Equipment +): Promise { + 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` + ) + .bind(accountId, equipment.EquipmentModificationGuid, JSON.stringify(equipment)) + .run() +} + +/** 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 + .prepare( + 'SELECT data FROM equipment WHERE account_id = ?1 ORDER BY equipment_modification_guid' + ) + .bind(accountId) + .all<{ data: string }>() + return results.map((r) => JSON.parse(r.data) as Equipment) +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index d3bbc7e..5764070 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -15,6 +15,7 @@ import { spendCurrency, } from '../../balance-db' 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' @@ -36,6 +37,7 @@ beforeAll(async () => { for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run() await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') .bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' })) @@ -364,8 +366,13 @@ describe('econ endpoints', () => { expect(await res.json()).toEqual([]) }) - test('GET /api/equipment/v2/getUnlocked returns [] (no auth)', async () => { - const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`) + test('GET /api/equipment/v2/getUnlocked 401s without a token, returns [] when none owned', async () => { + const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`) + expect(anon.status).toBe(401) + // Account 30 has bought no equipment → empty list. + const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, { + headers: await bearer('30'), + }) expect(res.status).toBe(200) expect(await res.json()).toEqual([]) }) @@ -732,6 +739,64 @@ describe('econ endpoints', () => { expect(second[0].CreatedAts).toHaveLength(2) }) + test('POST /api/storefronts/v2/buyItem grants equipment, read back by getUnlocked, no re-buy dupe', async () => { + // Item 1950 (Disc Skin (Coop)) in storefront 3 is a pure equipment drop — its + // gift-drop carries an EquipmentModificationGuid but no avatar/consumable desc. + const guid = '19ef59c7-f74b-4c63-935a-1d4b1abd8518' + const buy = async () => + exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('31')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 1950, + CurrencyType: 2, + RequestedPrice: 3500, + }), + }) + + const res = await buy() + expect(res.status).toBe(200) + const body = (await res.json()) as { + Balance: number + BalanceUpdates: Array<{ + Data: Array<{ Id: number; EquipmentModificationGuid: string; EquipmentPrefabName: string }> + }> + } + expect(body.Balance).toBe(-3500) + const gift = body.BalanceUpdates[0].Data[0] + expect(gift.EquipmentModificationGuid).toBe(guid) + expect(gift.EquipmentPrefabName).toBe('[DiscGolfDisc]') + + const unlocked = async () => { + const r = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, { + headers: await bearer('31'), + }) + expect(r.status).toBe(200) + return (await r.json()) as Array<{ + EquipmentModificationGuid: string + EquipmentPrefabName: string + FriendlyName: string + }> + } + const first = await unlocked() + expect(first).toHaveLength(1) + expect(first[0].EquipmentModificationGuid).toBe(guid) + expect(first[0].EquipmentPrefabName).toBe('[DiscGolfDisc]') + expect(first[0].FriendlyName).toBe('Disc Skin (Coop)') + + // 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`, { + headers: await bearer('31'), + }) + const list = (await items.json()) as Array<{ FriendlyName: string }> + expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true) + + // 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) + }) + test('POST /api/storefronts/v2/buyItem 409s when the sent price no longer matches', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { method: 'POST',