invention purchase, at least, how I think they should work

This commit is contained in:
Devin Zuczek
2026-08-05 14:58:55 -04:00
parent b82a5e1dc0
commit a986d012f5
7 changed files with 379 additions and 51 deletions
+16 -4
View File
@@ -320,8 +320,14 @@ export const InventionPersonalDetails = z.object({
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */ /** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
export const SetTagsRequest = z.object({ export const SetTagsRequest = z.object({
InventionId: z.int(), InventionId: z.int(),
AutoTags: z.array(z.string()).optional().describe('Client-derived tags (Type 2)'), AutoTags: z
CustomTags: z.array(z.string()).optional().describe('Creator-submitted tags (Type 0)'), .array(z.string())
.optional()
.describe('Client-derived tags (Type 2); each at most 15 letters once lowercased'),
CustomTags: z
.array(z.string())
.optional()
.describe('Creator-submitted tags (Type 0); each at most 15 letters once lowercased'),
}) })
/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */ /** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
@@ -341,8 +347,14 @@ export const SaveInventionRequest = z.object({
inventionDataFilename: z inventionDataFilename: z
.string() .string()
.describe('The blob uploaded through the storage worker; the one required field'), .describe('The blob uploaded through the storage worker; the one required field'),
name: z.string().optional().describe('Defaults to “Untitled”'), name: z
description: z.string().optional(), .string()
.optional()
.describe('324 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'),
description: z
.string()
.optional()
.describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'),
imageName: z.string().optional(), imageName: z.string().optional(),
instantiationCost: z.int().optional(), instantiationCost: z.int().optional(),
lightsCost: z.int().optional(), lightsCost: z.int().optional(),
+71 -12
View File
@@ -1,6 +1,12 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi' import { describeRoute } from 'hono-openapi'
import {
inventionDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
} from '@repo/domain'
import { authedId, unauthorized } from '../http' import { authedId, unauthorized } from '../http'
import { import {
createInvention, createInvention,
@@ -387,18 +393,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'A GET that writes — that is what the client sends, with the fields to change as ' + 'A GET that writes — that is what the client sends, with the fields to change as ' +
'query params. Absent params keep their stored value. An empty `description` ' + 'query params. Absent params keep their stored value. An empty `description` ' +
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' + 'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
'invention. Publishing and pricing are separate endpoints.', 'invention. A supplied name/description must satisfy the same rules `v6/save` ' +
'enforces. Publishing and pricing are separate endpoints.',
security: AUTHED, security: AUTHED,
parameters: [ parameters: [
intQuery('inventionId', 'Invention id; required'), intQuery('inventionId', 'Invention id; required'),
stringQuery('name', 'New name; empty is ignored'), stringQuery('name', '324 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
stringQuery('description', 'New description; present-but-empty clears it'), stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
stringQuery('imageName', 'New thumbnail; empty is ignored'), stringQuery('imageName', 'New thumbnail; empty is ignored'),
stringQuery('allowTrial', '`true`/`1` to allow trials'), stringQuery('allowTrial', '`true`/`1` to allow trials'),
stringQuery('permission', 'A name like `useonly`, or the raw permission number'), stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
], ],
responses: { responses: {
200: json(InventionSaveResult, 'The updated invention, in the save envelope'), 200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'), 403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' }, 404: { description: 'No such invention' },
@@ -416,10 +424,23 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const allowTrial = c.req.query('allowTrial') const allowTrial = c.req.query('allowTrial')
const permission = c.req.query('permission') const permission = c.req.query('permission')
// Only a name that's actually being changed is checked — an absent or empty one
// keeps the stored name, which was already validated when it was set.
const name = nonEmpty('name')
const nameRejection = name === undefined ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
// The description is checked on presence, not emptiness: empty is how a creator
// clears it, and the length rule accepts that.
const description = c.req.query('description')
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, { const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
name: nonEmpty('name'), name,
// Present-but-empty clears the description, so this checks presence. // Present-but-empty clears the description, so this checks presence.
description: c.req.query('description'), description,
imageName: nonEmpty('imageName'), imageName: nonEmpty('imageName'),
allowTrial: allowTrial:
allowTrial === undefined allowTrial === undefined
@@ -523,13 +544,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'`CustomTags` are the creators own (Type 0), `AutoTags` the ones the client ' + '`CustomTags` are the creators own (Type 0), `AutoTags` the ones the client ' +
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' + 'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
'only.\n\n' + 'only.\n\n' +
'Every tag in either list must be at most 15 letters (az once lowercased); one ' +
'that isnt fails the whole call, so no tag is ever silently dropped.\n\n' +
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' + 'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.', 'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
security: AUTHED, security: AUTHED,
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'), requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
responses: { responses: {
200: json(SetTagsResponse, 'The resulting tag names'), 200: json(SetTagsResponse, 'The resulting tag names'),
400: json(ErrorResponse, 'Unparseable body'), 400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'), 403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' }, 404: { description: 'No such invention' },
@@ -546,11 +569,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const strings = (v: unknown): string[] => const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : [] Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
const autoTags = strings(body.AutoTags)
const customTags = strings(body.CustomTags)
// Both lists are held to the tag rule, and one bad tag fails the whole call rather
// than being dropped — a silently missing tag looks to the creator like a tag that
// saved. Checked against the normalized form `setInventionTags` will store, so the
// rejection quotes the tag as it would have been stored, not as it was typed.
// Blanks are skipped, not rejected: the store already drops them, and the client
// pads its list with empties.
for (const raw of [...autoTags, ...customTags]) {
const tag = raw.trim().toLowerCase()
if (tag === '') continue
const rejection = inventionTagRejection(tag)
if (rejection !== null) {
return c.json({ error: `${rejection} (“${tag}”)` }, 400)
}
}
const tags = await setInventionTags( const tags = await setInventionTags(
c.env.DB, c.env.DB,
gate.invention.InventionId, gate.invention.InventionId,
strings(body.AutoTags), autoTags,
strings(body.CustomTags) customTags
) )
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) }) return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
} }
@@ -690,14 +731,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'Records an inventions metadata. The data file itself is uploaded separately ' + 'Records an inventions metadata. The data file itself is uploaded separately ' +
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' + 'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
'one required field, since an invention with no data blob is unusable. An omitted ' + 'one required field, since an invention with no data blob is unusable. An omitted ' +
'name/description is defaulted rather than rejected.\n\n' + 'name/description is defaulted rather than rejected; a supplied one must be 324 ' +
'characters of letters, digits, spaces, dashes and colons (name) or at most 512 ' +
'characters (description).\n\n' +
'A freshly saved invention is private: it shows up only in the creators own list ' + 'A freshly saved invention is private: it shows up only in the creators own list ' +
'until they call `v3/publish`.', 'until they call `v3/publish`.',
security: AUTHED, security: AUTHED,
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'), requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
responses: { responses: {
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'), 200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
400: json(ErrorResponse, 'Unparseable body, or no inventionDataFilename'), 400: json(
ErrorResponse,
'Unparseable body, no inventionDataFilename, or an invalid name/description'
),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
}, },
}), }),
@@ -716,11 +762,24 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return c.json({ error: 'inventionDataFilename is required' }, 400) return c.json({ error: 'inventionDataFilename is required' }, 400)
} }
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
// "No description yet"), so only a supplied one is held to the rules — otherwise
// saving an unnamed invention would fail the 3-character minimum on a name the
// player never typed.
const name = str(body.name)?.trim()
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
const description = str(body.description)
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, { const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId: id, creatorPlayerId: id,
inventionDataFilename, inventionDataFilename,
name: str(body.name), name,
description: str(body.description), description,
imageName: str(body.imageName), imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost), instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost), lightsCost: num(body.lightsCost),
+89 -1
View File
@@ -426,7 +426,7 @@ describe('public endpoints', () => {
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST', method: 'POST',
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' }, headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }), body: JSON.stringify({ name: 'Already Suffixed', inventionDataFilename: '2026-07-12/x.inv' }),
}) })
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe( expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
'2026-07-12/x.inv' '2026-07-12/x.inv'
@@ -532,6 +532,51 @@ describe('public endpoints', () => {
}) })
}) })
test('POST /api/inventions/v6/save enforces the name and description rules', async () => {
const save = async (fields: Record<string, unknown>): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6262')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }),
})
// A name is 324 characters of letters, digits, spaces, dashes and colons.
expect((await save({ name: 'ab' })).status).toBe(400)
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
expect((await save({ name: 'Rocket!' })).status).toBe(400)
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
expect(ok.status).toBe(200)
expect(((await ok.json()) as InventionSaveResult).Invention.Name).toBe('Rocket Sofa-Bed 2')
// The rejection carries the player-facing sentence, not a code.
const short = await save({ name: 'ab' })
expect((await short.json()) as { error: string }).toEqual({
error: 'Invention names must be at least 3 characters.',
})
// A description is prose: any characters, at most 512 of them.
expect((await save({ name: 'Long Winded', description: 'x'.repeat(513) })).status).toBe(400)
expect((await save({ name: 'Long Winded', description: 'x'.repeat(512) })).status).toBe(200)
expect((await save({ name: 'Punctuated', description: 'Yes! Its 100% good.' })).status).toBe(
200
)
})
test('POST /api/inventions/v6/save accepts the clients auto-generated timestamp name', async () => {
// The real client names an unnamed invention after the moment it was saved
// (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name
// charset on purpose. Dropping it from the pattern would 400 every unnamed save the
// game makes — this test is what would catch that.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }),
})
expect(res.status).toBe(200)
expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50')
})
test('GET /api/inventions/v1 404s for an unknown invention', async () => { test('GET /api/inventions/v1 404s for an unknown invention', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`) const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
expect(res.status).toBe(404) expect(res.status).toBe(404)
@@ -611,6 +656,39 @@ describe('public endpoints', () => {
}) })
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] }) expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
// A tag is at most 15 letters once lowercased. One bad tag in either list fails the
// whole call — nothing is dropped silently — and leaves the stored tags alone.
const punctuated = await settags({
InventionId: Invention.InventionId,
CustomTags: ['racing', 'Cool Stuff!'],
})
expect(punctuated.status).toBe(400)
expect((await punctuated.json()) as { error: string }).toEqual({
error: 'Invention tags can only contain letters. (“cool stuff!”)',
})
expect(
(await settags({ InventionId: Invention.InventionId, AutoTags: ['a'.repeat(16)] })).status
).toBe(400)
expect(
(await settags({ InventionId: Invention.InventionId, CustomTags: ['tag2'] })).status
).toBe(400)
const stillThere = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
)
expect(await stillThere.json()).toEqual({
Tags: [
{ Tag: 'modern', Type: 0 },
{ Tag: 'bed', Type: 0 },
],
})
// Blank entries are skipped rather than rejected: the store already drops them.
const padded = await settags({
InventionId: Invention.InventionId,
CustomTags: ['modern', '', ' '],
})
expect(await padded.json()).toEqual({ Result: 0, Tags: ['modern'] })
// Only the creator may retag; unknown inventions 404; no token → 401. // Only the creator may retag; unknown inventions 404; no token → 401.
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999') const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
expect(notMine.status).toBe(403) expect(notMine.status).toBe(403)
@@ -939,6 +1017,16 @@ describe('public endpoints', () => {
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' }) expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
// A supplied name/description is held to the same rules as the save path, and a
// rejected edit changes nothing.
expect((await update('name=xy')).status).toBe(400)
expect((await update(`name=${encodeURIComponent('Lamp?')}`)).status).toBe(400)
expect((await update(`description=${'x'.repeat(513)}`)).status).toBe(400)
const unchanged = (await (await update('permission=20')).json()) as InventionSaveResult
expect(unchanged.Invention).toMatchObject({ Name: 'Draft Lamp', Description: '' })
const renamed = (await (await update('name=Draft-Lamp%20Two')).json()) as InventionSaveResult
expect(renamed.Invention.Name).toBe('Draft-Lamp Two')
// allowTrial takes true/1. // allowTrial takes true/1.
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
expect(trial.Invention.AllowTrial).toBe(true) expect(trial.Invention.AllowTrial).toBe(true)
+73 -26
View File
@@ -28,8 +28,10 @@ import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db' import { getAvatar, setAvatar } from './avatar-db'
import { import {
ALL_PLATFORMS, ALL_PLATFORMS,
creditCurrency,
CurrencyType, CurrencyType,
DEFAULT_STARTING_TOKENS, DEFAULT_STARTING_TOKENS,
ensureStartingBalances,
getBalance, getBalance,
isSpendable, isSpendable,
spendCurrency, spendCurrency,
@@ -1181,11 +1183,13 @@ const app = new Hono<App>({ strict: false })
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends // Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer. // `?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 // A priced invention is settled player-to-player: the buyer is debited its `Price` in
// confirm its stored `Price` — both against the price the client rendered (a // RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
// mismatch is a stale or tampered client, 409) and against 0 (a priced invention is // tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
// 402, since nothing here debits the buyer or pays the creator yet). That keeps the // money entirely: nothing is debited and nobody is paid. The stored price is confirmed
// path from ever moving currency while the payout half is unimplemented. // against the price the client rendered first, so a stale or tampered client can't buy
// at a price the creator no longer offers (409), and an unaffordable one is a 400 —
// the same "Insufficient balance" buyItem answers with.
// //
// Ownership is recorded in `inventory_invention`; the creator is not sold their own // 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 // invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
@@ -1199,9 +1203,11 @@ const app = new Hono<App>({ strict: false })
summary: 'Buy an invention', summary: 'Buy an invention',
description: [ description: [
'Looks the invention up by id, confirms the clients `requestedPrice` still matches', 'Looks the invention up by id, confirms the clients `requestedPrice` still matches',
'its stored `Price`, records ownership in `inventory_invention`, and returns the', 'its stored `Price`, debits the buyer and pays the creator that price in',
'invention alongside the (unchanged) balance. Only FREE inventions are sellable for', 'RecCenterTokens (a free invention moves nothing), records ownership in',
'now — a priced one is 402. A GET because that is how the client sends it.', '`inventory_invention`, and returns the invention alongside the buyers resulting',
'balance. Both players get a StorefrontBalanceUpdate push when tokens moved.',
'A GET because that is how the client sends it.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
parameters: [ parameters: [
@@ -1222,9 +1228,11 @@ const app = new Hono<App>({ strict: false })
], ],
responses: { responses: {
200: json(BuyInventionResponse, 'The purchase result (invention + balance)'), 200: json(BuyInventionResponse, 'The purchase result (invention + balance)'),
400: json(ErrorResponse, 'Missing/non-numeric inventionId, or buying your own'), 400: json(
ErrorResponse,
'Missing/non-numeric inventionId, buying your own, or insufficient balance'
),
401: UNAUTHORIZED_RESPONSE, 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'), 403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
404: json(ErrorResponse, 'No such invention'), 404: json(ErrorResponse, 'No such invention'),
409: json(ErrorResponse, 'Already owned, or the price has changed'), 409: json(ErrorResponse, 'Already owned, or the price has changed'),
@@ -1236,8 +1244,8 @@ const app = new Hono<App>({ strict: false })
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10) const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400) 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, // Absent/non-numeric requestedPrice reads as 0, which only matches a free invention —
// so the confirmation below still has something to compare against. // a priced one then fails the confirmation below rather than selling for nothing.
const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0 const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
const invention = await getInventionById(c.env.DB, inventionId) const invention = await getInventionById(c.env.DB, inventionId)
@@ -1251,27 +1259,66 @@ const app = new Hono<App>({ strict: false })
return c.json({ error: 'Already owned' }, 409) return c.json({ error: 'Already owned' }, 409)
} }
// Confirm the price twice: against what the client rendered, then against the only // The price the client rendered must still be the stored one: a mismatch is a stale
// price we can actually settle (free). // catalog or a tampered request, never a sale.
if (invention.Price !== requestedPrice) { if (invention.Price !== requestedPrice) {
return c.json({ error: 'Price has changed' }, 409) 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) const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Inventions are priced in RecCenterTokens only — the store shows no other currency
// for them, and `Price` carries no currency of its own to pick a different one from.
const price = invention.Price
if (price > 0) {
// Debit the buyer atomically; false means they couldn't afford it and nothing
// changed, so no ownership is recorded and the creator is not paid.
const paid = await spendCurrency(
c.env.DB,
id,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
} }
// Grant before paying out: these are three separate D1 writes with no transaction
// around them, so order them by what a failure costs. A buyer who paid and got the
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
// is not.
await grantInvention(c.env.DB, id, inventionId) await grantInvention(c.env.DB, id, inventionId)
// Nothing was debited, so this is the buyer's balance as it stands (a first read if (price > 0) {
// seeds their starting grant, as everywhere else). Unlike buyItem — whose `Balance` // Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
// is the change applied — the reference server answers this one with the RESULTING // the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
// total, so no StorefrontBalanceUpdate push is needed either: nothing changed. // creator who had never touched their balance would otherwise have the row created
const balance = await getBalance( // here and lose their starting tokens forever.
c.env.DB, await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
id, const creatorBalance = await creditCurrency(
CurrencyType.RecCenterTokens, c.env.DB,
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) invention.CreatorPlayerId,
) CurrencyType.RecCenterTokens,
price,
startingTokens
)
// The creator is a different, probably-online player: push their new total so a
// sale lands on their shown balance without a re-fetch. Best-effort, as everywhere.
await pushBalanceUpdate(
c,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
creatorBalance
)
}
// Unlike buyItem — whose `Balance` is the change applied — the reference server
// answers this one with the RESULTING total (a first read seeds the buyer's starting
// grant, as everywhere else).
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no balance to push for it.
if (price > 0) {
await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, balance)
}
return c.json({ return c.json({
BalanceUpdateResponse: { BalanceUpdateResponse: {
Balance: balance, Balance: balance,
+1 -1
View File
@@ -157,7 +157,7 @@ export const BuyInventionResponse = z.object({
.describe('The same envelope `POST /api/inventions/v6/save` returns'), .describe('The same envelope `POST /api/inventions/v6/save` returns'),
}) })
/** buyItem / buyInvention error body (`{ error }`), returned on 400/402/403/404/409. */ /** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
export const ErrorResponse = z.object({ error: z.string() }) export const ErrorResponse = z.object({ error: z.string() })
// ---- Request schemas ------------------------------------------------------- // ---- Request schemas -------------------------------------------------------
+47 -7
View File
@@ -111,7 +111,7 @@ function invention(
const SEEDED_INVENTIONS = [ const SEEDED_INVENTIONS = [
invention(8), // free, published, someone else's — the sellable one invention(8), // free, published, someone else's — the sellable one
invention(9, { Price: 250 }), // priced: not sellable while only free is supported invention(9, { Price: 250 }), // priced: buying it pays creator 999 250 tokens
invention(10, { IsPublished: false }), // a draft, not on sale even at 0 invention(10, { IsPublished: false }), // a draft, not on sale even at 0
invention(11, { CreatorPlayerId: 60 }), // account 60's own invention invention(11, { CreatorPlayerId: 60 }), // account 60's own invention
] ]
@@ -1039,12 +1039,52 @@ describe('econ endpoints', () => {
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8]) expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
}) })
test('GET /api/storefronts/v2/buyInvention refuses anything but a free invention', async () => { test('GET /api/storefronts/v2/buyInvention pays the creator the buyers tokens', async () => {
// Invention 9 costs 250. Sending the price the client rendered is a 402 (nothing // Invention 9 costs 250 and was made by account 999. Buying it moves 250 tokens from
// here can settle a paid purchase yet); sending 0 for it is a stale/tampered price. // the buyer to that creator — no house cut, so the two sides are equal and opposite.
expect((await buyInvention('51', 9, 250)).status).toBe(402) const res = await buyInvention('51', 9, 250)
expect((await buyInvention('51', 9, 0)).status).toBe(409) expect(res.status).toBe(200)
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([]) const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
// `Balance` is the buyer's RESULTING total, so it already has the debit in it.
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
expect(
await getBalance(env.DB, 51, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(DEFAULT_STARTING_TOKENS - 250)
// The creator had never touched their balance: they keep their starting grant AND get
// paid, rather than the payout standing in for the grant.
expect(
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(DEFAULT_STARTING_TOKENS + 250)
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9])
})
test('GET /api/storefronts/v2/buyInvention rejects a stale price and an unaffordable one', async () => {
// Sending 0 for the 250-token invention 9 is a stale (or tampered) price.
expect((await buyInvention('53', 9, 0)).status).toBe(409)
// Account 54 can't afford it: nothing is debited, nobody is paid, nothing is owned.
await spendCurrency(
env.DB,
54,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS,
DEFAULT_STARTING_TOKENS
)
const creatorBefore = await getBalance(
env.DB,
999,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
expect((await buyInvention('54', 9, 250)).status).toBe(400)
expect(
await getBalance(env.DB, 54, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(0)
expect(
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(creatorBefore)
expect(await getOwnedInventionIds(env.DB, 53)).toEqual([])
expect(await getOwnedInventionIds(env.DB, 54)).toEqual([])
}) })
test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => { test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => {
+82
View File
@@ -37,6 +37,25 @@ export const MAX_CLUB_DESCRIPTION_LENGTH = 512
export const MAX_EVENT_NAME_LENGTH = 64 export const MAX_EVENT_NAME_LENGTH = 64
export const MAX_EVENT_DESCRIPTION_LENGTH = 512 export const MAX_EVENT_DESCRIPTION_LENGTH = 512
/**
* Invention limits. A name is a title a player types into the invention-save box and
* reads back in a browse tile, so it allows the punctuation a title needs — but
* nothing else, since it is also what invention search matches on. The minimum is real:
* one- and two-character names are unsearchable and unreadable in a tile, and the client
* offers `Untitled` rather than an empty box.
*/
export const MIN_INVENTION_NAME_LENGTH = 3
export const MAX_INVENTION_NAME_LENGTH = 24
export const MAX_INVENTION_DESCRIPTION_LENGTH = 512
/**
* One invention tag. Short and letters-only because tags are a controlled vocabulary the
* browse chips are derived from (see `getInventionTagFilters`) — a tag with digits,
* punctuation or spaces makes a chip nobody else will ever type again. Tags are stored
* lowercased, so the rule is checked against the normalized form, not what was typed.
*/
export const MAX_INVENTION_TAG_LENGTH = 15
/** /**
* Length in code points rather than UTF-16 units, so an emoji or other astral character * Length in code points rather than UTF-16 units, so an emoji or other astral character
* counts once instead of twice — the way a player counts what they typed. * counts once instead of twice — the way a player counts what they typed.
@@ -74,6 +93,69 @@ export function nameRejection(value: string, label: string, max: number): string
return null return null
} }
/**
* Letters, digits, spaces, dashes and colons — the title charset. Wider than
* `NAME_PATTERN` because an invention is a thing with a name ("Grappling Hook v2",
* "Speed-Boost Pad"), not an identifier someone types into a sign-in box. Still no
* arbitrary Unicode, for the same homoglyph reasons.
*
* The colon is not decorative: an invention the player never named is called after the
* moment it was saved (`071126 13:10:50`), generated by the CLIENT, so a rule without it
* would refuse every unnamed save the game makes. The dash stays last in the class so it
* reads as a literal rather than a range.
*/
const INVENTION_NAME_PATTERN = /^[A-Za-z0-9 :-]+$/
/** Lowercase letters only — the normalized form a tag is stored in. */
const INVENTION_TAG_PATTERN = /^[a-z]+$/
/**
* Why a player-supplied invention name is unacceptable, or `null` when it's fine.
*
* Callers pass the TRIMMED name: leading and trailing spaces are the player's typing,
* not part of what they named the thing, and counting them toward the minimum would let
* `" a "` through.
*/
export function inventionNameRejection(value: string): string | null {
if (glyphLength(value) < MIN_INVENTION_NAME_LENGTH) {
return `Invention names must be at least ${MIN_INVENTION_NAME_LENGTH} characters.`
}
if (glyphLength(value) > MAX_INVENTION_NAME_LENGTH) {
return `Invention names can be at most ${MAX_INVENTION_NAME_LENGTH} characters.`
}
if (!INVENTION_NAME_PATTERN.test(value)) {
return 'Invention names can only contain letters, numbers, spaces, dashes and colons.'
}
return null
}
/**
* Why an invention description is unacceptable, or `null` when it's fine. Length only —
* a description is prose, so nothing is refused for the characters it's made of, and an
* empty one is fine (it's how a creator clears the field).
*/
export function inventionDescriptionRejection(value: string): string | null {
if (glyphLength(value) > MAX_INVENTION_DESCRIPTION_LENGTH) {
return `Invention descriptions can be at most ${MAX_INVENTION_DESCRIPTION_LENGTH} characters.`
}
return null
}
/**
* Why an invention tag is unacceptable, or `null` when it's fine. Pass the NORMALIZED
* tag (trimmed and lowercased, as `setInventionTags` stores it) — checking what was typed
* instead would refuse `Racing` for a capital that never reaches the database.
*/
export function inventionTagRejection(value: string): string | null {
if (value.length > MAX_INVENTION_TAG_LENGTH) {
return `Invention tags can be at most ${MAX_INVENTION_TAG_LENGTH} characters.`
}
if (!INVENTION_TAG_PATTERN.test(value)) {
return 'Invention tags can only contain letters.'
}
return null
}
/** /**
* Whether a supplied email is one worth storing — RFC 5321/5322 syntax, via `isemail`. * Whether a supplied email is one worth storing — RFC 5321/5322 syntax, via `isemail`.
* *