diff --git a/apps/econ/migrations/0008_inventory_invention.sql b/apps/econ/migrations/0008_inventory_invention.sql new file mode 100644 index 0000000..a1dee48 --- /dev/null +++ b/apps/econ/migrations/0008_inventory_invention.sql @@ -0,0 +1,18 @@ +-- Owned inventions, owned by the `econ` worker. One row per (account, invention): the +-- inventions a player has bought from the invention store. Written at purchase time by +-- `/api/storefronts/v2/buyInvention`, which also uses it to reject a re-buy. Ownership +-- is boolean (you own an invention or you don't), so the pair is the primary key and a +-- second purchase is a no-op rather than a duplicate row. +-- +-- The invention itself lives in the `invention` table, whose schema/migrations the `api` +-- worker owns (apps/api/migrations/0002_invention.sql) on this same `recflare` database; +-- only the id is stored here. Creators are NOT listed here — an invention's creator owns +-- it by virtue of `CreatorPlayerId`, and never buys their own. Kept in sync with +-- INVENTORY_INVENTION_SCHEMA_DDL in src/inventory-invention-db.ts. + +CREATE TABLE IF NOT EXISTS inventory_invention ( + account_id INTEGER NOT NULL, + invention_id INTEGER NOT NULL, + acquired_at TEXT NOT NULL, + PRIMARY KEY (account_id, invention_id) + ); diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 1ce5e99..92e5086 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -6,6 +6,10 @@ import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' +// Invention storage (owned by the `api` worker, on this same `recflare` database). +// Imported directly rather than copied: these are plain D1 helpers with no bindings of +// their own, and buyInvention has to read the very rows `api` writes. +import { getInventionById, toSaveResult } from '../../api/src/inventions-db' // The notification-type ids the hub carries (owned by the `notify` worker). Imported // as a value — the enum has no runtime dependencies. import { NotificationType } from '../../notify/src/notification-types' @@ -17,6 +21,7 @@ import weeklyChallenge from '../static/weekly-challenge.json' import { getAvatar, setAvatar } from './avatar-db' import { ALL_PLATFORMS, + CurrencyType, DEFAULT_STARTING_TOKENS, getBalance, isSpendable, @@ -30,10 +35,12 @@ import { } from './consumables-db' import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db' import { getInventory, grantItem } from './inventory-db' +import { grantInvention, ownsInvention } from './inventory-invention-db' import { AUTHED, AvatarV2Dto, BalanceEntry, + BuyInventionResponse, BuyItemRequest, BuyItemResponse, ChallengeProgressRequest, @@ -69,7 +76,8 @@ import type { Outfit } from './outfit-db' /** * Economy Worker. Hosts the avatar/economy endpoints the game client calls on * the `econ` service (these are separate from the main `api` worker). Balances, - * inventory, consumables, saved outfits, avatars and gift boxes are D1-backed; + * inventory (avatar items, equipment, bought inventions), consumables, saved outfits, + * avatars and gift boxes are D1-backed; * storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS * binding. Some routes are still empty-list stubs (room keys, wishlist, …). * @@ -1164,6 +1172,114 @@ const app = new Hono({ strict: false }) } ) + // Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends + // `?inventionId=…&requestedPrice=…` with no body, so that's what we answer. + // + // Only FREE inventions can be bought for now: we look the invention up by id and + // confirm its stored `Price` — both against the price the client rendered (a + // mismatch is a stale or tampered client, 409) and against 0 (a priced invention is + // 402, since nothing here debits the buyer or pays the creator yet). That keeps the + // path from ever moving currency while the payout half is unimplemented. + // + // Ownership is recorded in `inventory_invention`; the creator is not sold their own + // invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather + // than a second row. The invention's `NumDownloads` counter is deliberately NOT + // bumped: that column lives on the `invention` table the `api` worker owns, and this + // worker only reads it. + .get( + '/api/storefronts/v2/buyInvention', + describeRoute({ + tags: ['Storefront'], + summary: 'Buy an invention', + description: [ + 'Looks the invention up by id, confirms the client’s `requestedPrice` still matches', + 'its stored `Price`, records ownership in `inventory_invention`, and returns the', + 'invention alongside the (unchanged) balance. Only FREE inventions are sellable for', + 'now — a priced one is 402. A GET because that is how the client sends it.', + ].join(' '), + security: AUTHED, + parameters: [ + { + name: 'inventionId', + in: 'query', + required: true, + description: 'Invention id; missing or non-numeric is 400', + schema: { type: 'integer' }, + }, + { + name: 'requestedPrice', + in: 'query', + required: false, + description: 'The price the client rendered; a mismatch is 409. Defaults to 0', + schema: { type: 'integer' }, + }, + ], + responses: { + 200: json(BuyInventionResponse, 'The purchase result (invention + balance)'), + 400: json(ErrorResponse, 'Missing/non-numeric inventionId, or buying your own'), + 401: UNAUTHORIZED_RESPONSE, + 402: json(ErrorResponse, 'The invention is not free (unsupported for now)'), + 403: json(ErrorResponse, 'The invention is not published, so it is not for sale'), + 404: json(ErrorResponse, 'No such invention'), + 409: json(ErrorResponse, 'Already owned, or the price has changed'), + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10) + if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400) + // Absent/non-numeric requestedPrice reads as 0 — the only price we sell at anyway, + // so the confirmation below still has something to compare against. + const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0 + + const invention = await getInventionById(c.env.DB, inventionId) + if (invention === null) return c.json({ error: 'Invention not found' }, 404) + // An unpublished invention is a draft: it isn't on sale, not even for free. + if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403) + if (invention.CreatorPlayerId === id) { + return c.json({ error: 'Cannot buy your own invention' }, 400) + } + if (await ownsInvention(c.env.DB, id, inventionId)) { + return c.json({ error: 'Already owned' }, 409) + } + + // Confirm the price twice: against what the client rendered, then against the only + // price we can actually settle (free). + if (invention.Price !== requestedPrice) { + return c.json({ error: 'Price has changed' }, 409) + } + if (invention.Price !== 0) { + return c.json({ error: 'Only free inventions can be bought right now' }, 402) + } + + await grantInvention(c.env.DB, id, inventionId) + + // Nothing was debited, so this is the buyer's balance as it stands (a first read + // seeds their starting grant, as everywhere else). Unlike buyItem — whose `Balance` + // is the change applied — the reference server answers this one with the RESULTING + // total, so no StorefrontBalanceUpdate push is needed either: nothing changed. + const balance = await getBalance( + c.env.DB, + id, + CurrencyType.RecCenterTokens, + intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) + ) + return c.json({ + BalanceUpdateResponse: { + Balance: balance, + BalanceType: ALL_PLATFORMS, + CurrencyType: CurrencyType.RecCenterTokens, + BalanceUpdates: [{ UpdateResponse: 0, Data: invention }], + }, + // The same `{ Status, Invention, InventionVersion }` envelope the invention + // save/read endpoints serve — the client re-renders the invention from it. + InventionResponse: toSaveResult(invention), + }) + } + ) + // Storefront ad-carousel items. Served from the bundled static JSON — one // placeholder banner with no purchasable items until real promo data exists. .get( diff --git a/apps/econ/src/inventory-invention-db.ts b/apps/econ/src/inventory-invention-db.ts new file mode 100644 index 0000000..70b10f5 --- /dev/null +++ b/apps/econ/src/inventory-invention-db.ts @@ -0,0 +1,72 @@ +/** + * Owned inventions on the shared `recflare` D1 database — the inventions a player has + * bought. One row per (account, invention), written at purchase time by + * `GET /api/storefronts/v2/buyInvention`. + * + * Only the invention id is stored: the invention record itself lives in the `invention` + * table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on + * this same database, and copying its DTO here would leave two rows to keep in step. A + * creator is not listed here either — they own their invention through its + * `CreatorPlayerId`, and the buy path refuses to sell an invention to its own creator. + * + * This worker (`econ`) owns the table and its migration — see apps/econ/migrations/ + * 0008_inventory_invention.sql. + */ + +/** Schema DDL (mirror of migrations 0008_inventory_invention.sql) — also builds the table in tests. */ +export const INVENTORY_INVENTION_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS inventory_invention ( + account_id INTEGER NOT NULL, + invention_id INTEGER NOT NULL, + acquired_at TEXT NOT NULL, + PRIMARY KEY (account_id, invention_id) + )`, +] + +/** + * Grant an invention to a player. INSERT OR IGNORE on the (account, invention) primary + * key: owning an invention is boolean, so a second grant keeps the original + * `acquired_at` rather than back-dating the purchase to now. + */ +export async function grantInvention( + db: D1Database, + accountId: number, + inventionId: number +): Promise { + await db + .prepare( + 'INSERT OR IGNORE INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)' + ) + .bind(accountId, inventionId, new Date().toISOString()) + .run() +} + +/** + * Whether a player has bought an invention. This answers for BOUGHT inventions only — + * the creator of an invention owns it without a row here, so callers that mean "may use + * this invention" must check `CreatorPlayerId` as well. + */ +export async function ownsInvention( + db: D1Database, + accountId: number, + inventionId: number +): Promise { + const row = await db + .prepare( + 'SELECT 1 AS owned FROM inventory_invention WHERE account_id = ?1 AND invention_id = ?2' + ) + .bind(accountId, inventionId) + .first<{ owned: number }>() + return row !== null +} + +/** The ids of every invention a player has bought, oldest purchase first. */ +export async function getOwnedInventionIds(db: D1Database, accountId: number): Promise { + const { results } = await db + .prepare( + 'SELECT invention_id FROM inventory_invention WHERE account_id = ?1 ORDER BY acquired_at, invention_id' + ) + .bind(accountId) + .all<{ invention_id: number }>() + return results.map((r) => r.invention_id) +} diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index ab0fdaf..045b40f 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -130,7 +130,34 @@ export const BuyItemResponse = z.object({ BalanceType: z.int().describe('-2 = account-wide'), }) -/** buyItem error body (`{ error }`), returned on 400/404/409. */ +/** + * `GET /api/storefronts/v2/buyInvention` — the purchase result. Two envelopes side by + * side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING + * total, not the change, and `Data` is a single invention rather than a gift-drop list) + * and the invention envelope the invention endpoints already serve. + */ +export const BuyInventionResponse = z.object({ + BalanceUpdateResponse: z.object({ + Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'), + BalanceType: z.int().describe('-2 = account-wide'), + CurrencyType: z.int().describe('2 = RecCenterTokens'), + BalanceUpdates: z.array( + z.object({ + UpdateResponse: z.int(), + Data: JsonObject.describe('The bought invention (`RRInvention`)'), + }) + ), + }), + InventionResponse: z + .object({ + Status: z.int(), + Invention: JsonObject, + InventionVersion: JsonObject, + }) + .describe('The same envelope `POST /api/inventions/v6/save` returns'), +}) + +/** buyItem / buyInvention error body (`{ error }`), returned on 400/402/403/404/409. */ export const ErrorResponse = z.object({ error: z.string() }) // ---- Request schemas ------------------------------------------------------- diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index e03dd82..2ca82bf 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -6,6 +6,9 @@ import '../../econ.app' import { RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain' +// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL +// is built here too (see the same cross-worker import in econ.app.ts). +import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db' import { SCHEMA_DDL } from '../../avatar-db' import { BALANCE_SCHEMA_DDL, @@ -17,6 +20,7 @@ import { import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' +import { getOwnedInventionIds, INVENTORY_INVENTION_SCHEMA_DDL } from '../../inventory-invention-db' import { OUTFIT_SCHEMA_DDL } from '../../outfit-db' import type { Env } from '../../context' @@ -39,11 +43,76 @@ beforeAll(async () => { 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() + for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of INVENTION_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' })) .run() + for (const invention of SEEDED_INVENTIONS) { + await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)') + .bind(JSON.stringify(invention)) + .run() + } }) +/** + * Inventions the buyInvention tests buy (or fail to buy). Only the fields that path + * reads are meaningful — id, creator, published flag and price — but the record is + * shaped like a real stored `RRInvention` so the response envelope is realistic. + */ +function invention( + inventionId: number, + overrides: { CreatorPlayerId?: number; IsPublished?: boolean; Price?: number } = {} +) { + return { + InventionId: inventionId, + ReplicationId: `replication-${inventionId}`, + CreatorPlayerId: 999, + Name: `Invention ${inventionId}`, + Description: 'A test invention', + ImageName: '', + CurrentVersionNumber: 1, + CurrentVersion: { + InventionId: inventionId, + ReplicationId: `version-${inventionId}`, + VersionNumber: 1, + BlobName: `invention-${inventionId}.inv`, + BlobHash: null, + InstantiationCost: 0, + LightsCost: 0, + ChipsCost: 0, + CloudVariablesCost: 0, + AICost: 0, + }, + Accessibility: 0, + IsPublished: true, + IsFeatured: false, + ModifiedAt: '2026-01-01T00:00:00.000Z', + CreatedAt: '2026-01-01T00:00:00.000Z', + FirstPublishedAt: '2026-01-01T00:00:00.000Z', + CreationRoomId: 0, + NumPlayersHaveUsedInRoom: 0, + NumDownloads: 0, + CheerCount: 0, + CreatorPermission: 100, + GeneralPermission: 20, + IsAGInvention: false, + IsCertifiedInvention: false, + Price: 0, + AllowTrial: true, + HideFromPlayer: false, + ReferencedInventions: [], + ...overrides, + } +} + +const SEEDED_INVENTIONS = [ + invention(8), // free, published, someone else's — the sellable one + invention(9, { Price: 250 }), // priced: not sellable while only free is supported + invention(10, { IsPublished: false }), // a draft, not on sale even at 0 + invention(11, { CreatorPlayerId: 60 }), // account 60's own invention +] + /** * A real outfit as the client posts it to /api/avatar/v3/saved/set — kept verbatim * (including the JSON-in-a-string OutfitSelectionsV2/FaceFeatures fields) so the @@ -920,6 +989,76 @@ describe('econ endpoints', () => { expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true) }) + // buyInvention is a GET with query params — that is how the client sends it. + const buyInvention = async (sub: string, inventionId: number, requestedPrice = 0) => + exports.default.fetch( + `${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=${inventionId}&requestedPrice=${requestedPrice}`, + { headers: await bearer(sub) } + ) + + test('GET /api/storefronts/v2/buyInvention 401s without a token', async () => { + const res = await exports.default.fetch( + `${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=8&requestedPrice=0` + ) + expect(res.status).toBe(401) + }) + + test('GET /api/storefronts/v2/buyInvention records ownership of a free invention', async () => { + const res = await buyInvention('50', 8) + expect(res.status).toBe(200) + const body = (await res.json()) as { + BalanceUpdateResponse: { + Balance: number + BalanceType: number + CurrencyType: number + BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }> + } + InventionResponse: { + Status: number + Invention: { InventionId: number; Name: string } + InventionVersion: { InventionId: number; VersionNumber: number } + } + } + // Nothing was debited, so `Balance` is the resulting total — the untouched starting + // grant — not a change, unlike buyItem's. + expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS) + expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens) + expect(body.BalanceUpdateResponse.BalanceType).toBe(-2) + expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8) + expect(body.InventionResponse.Status).toBe(0) + expect(body.InventionResponse.Invention.Name).toBe('Invention 8') + expect(body.InventionResponse.InventionVersion.VersionNumber).toBe(1) + + expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8]) + + // Owning an invention is boolean: buying it again is a conflict, not a second row. + expect((await buyInvention('50', 8)).status).toBe(409) + expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8]) + }) + + test('GET /api/storefronts/v2/buyInvention refuses anything but a free invention', async () => { + // Invention 9 costs 250. Sending the price the client rendered is a 402 (nothing + // here can settle a paid purchase yet); sending 0 for it is a stale/tampered price. + expect((await buyInvention('51', 9, 250)).status).toBe(402) + expect((await buyInvention('51', 9, 0)).status).toBe(409) + expect(await getOwnedInventionIds(env.DB, 51)).toEqual([]) + }) + + test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => { + // Unpublished — a draft is not on sale, free or not. + expect((await buyInvention('52', 10)).status).toBe(403) + // Account 60 created invention 11; a creator already owns it. + expect((await buyInvention('60', 11)).status).toBe(400) + expect((await buyInvention('52', 9999)).status).toBe(404) + // Missing/non-numeric inventionId. + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyInvention`, { + headers: await bearer('52'), + }) + expect(res.status).toBe(400) + expect(await getOwnedInventionIds(env.DB, 52)).toEqual([]) + expect(await getOwnedInventionIds(env.DB, 60)).toEqual([]) + }) + test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => { // Buy an item for account 24, then consume the box the way the client does: on the // econ host, with a form body (`Id=..&UnlockedLevel=..`). @@ -1182,6 +1321,7 @@ describe('econ endpoints', () => { 'GET /api/roomkeys/v1/mine', 'GET /api/roomkeys/v1/room', 'GET /api/storefronts/v1/adcarouselitems', + 'GET /api/storefronts/v2/buyInvention', 'GET /api/storefronts/v3/giftdropstore/{id}', 'GET /api/storefronts/v4/balance/{currencyType}', 'GET /econ/customAvatarItems/v1/owned',