mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
invention purchase, at least, how I think they should work
This commit is contained in:
+16
-4
@@ -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('3–24 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(),
|
||||||
|
|||||||
@@ -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', '3–24 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 caller’s invention'),
|
403: json(ErrorResponse, 'Not the caller’s 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 creator’s own (Type 0), `AutoTags` the ones the client ' +
|
'`CustomTags` are the creator’s 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 (a–z once lowercased); one ' +
|
||||||
|
'that isn’t 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 caller’s invention'),
|
403: json(ErrorResponse, 'Not the caller’s 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 invention’s metadata. The data file itself is uploaded separately ' +
|
'Records an invention’s 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 3–24 ' +
|
||||||
|
'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 creator’s own list ' +
|
'A freshly saved invention is private: it shows up only in the creator’s 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),
|
||||||
|
|||||||
@@ -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 3–24 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! It’s 100% good.' })).status).toBe(
|
||||||
|
200
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/inventions/v6/save accepts the client’s 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
@@ -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 client’s `requestedPrice` still matches',
|
'Looks the invention up by id, confirms the client’s `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 buyer’s 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,
|
||||||
|
|||||||
@@ -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 -------------------------------------------------------
|
||||||
|
|||||||
@@ -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 buyer’s 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 () => {
|
||||||
|
|||||||
@@ -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`.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user