free invention purchase

This commit is contained in:
Devin Zuczek
2026-08-04 02:01:21 -04:00
parent 23bc159c28
commit f94877347c
5 changed files with 375 additions and 2 deletions
@@ -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)
);
+117 -1
View File
@@ -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<App>({ 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 clients `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(
+72
View File
@@ -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<void> {
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<boolean> {
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<number[]> {
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)
}
+28 -1
View File
@@ -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 -------------------------------------------------------
+140
View File
@@ -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',