[invention] fix inventions of inventions

This commit is contained in:
Devin Zuczek
2026-08-12 12:07:46 -04:00
parent 42e1fb4ab7
commit da27b7e797
3 changed files with 200 additions and 21 deletions
+42
View File
@@ -301,6 +301,48 @@ export async function getMyInventions(db: D1Database, playerId: number): Promise
)
}
/**
* Whether a player owns EVERY invention in a list — the `v1/fulllineageowner` check,
* which the client runs when saving an invention BUILT OUT OF other inventions: it is
* asking whether this player may use each piece. An invention is the player's if they
* created it (`CreatorPlayerId`) or acquired it (a row in `inventory_invention`); an id
* with no invention row is not owned, so a deleted or made-up id makes the whole answer
* false.
*
* Ownership is the whole test — price and `GeneralPermission` deliberately don't enter
* into it. A free invention still has to be picked up before it can be used, and econ's
* buyInvention writes the same inventory row for a 0-token acquisition as for a paid
* one, so "acquired" already covers "free". Reading permission here as a second way to
* qualify would let a player build on an invention they never took.
*
* The lineage is whatever the CLIENT asks about: it sends the invention plus every
* invention nested inside it as repeated `id`s, so this checks exactly the ids given
* and does not walk `ReferencedInventions` itself. Walking it here would answer a
* different question than the one asked — the client knows which pieces the thing it
* is holding is actually made of, and stale references on an old record don't.
*
* An empty list is owned: no invention in it is unowned. The client never asks that,
* but false would read as "you don't own something" with nothing to name.
*/
export async function ownsAllInventions(
db: D1Database,
playerId: number,
inventionIds: number[]
): Promise<boolean> {
if (inventionIds.length === 0) return true
// The client repeats an id when the same invention is nested more than once.
const unique = [...new Set(inventionIds)]
const [inventions, ownedIds] = await Promise.all([
getInventionsByIds(db, unique),
getOwnedInventionIds(db, playerId),
])
const bought = new Set(ownedIds)
const creators = new Map(inventions.map((i) => [i.InventionId, i.CreatorPlayerId]))
return unique.every((id) => creators.get(id) === playerId || (creators.has(id) && bought.has(id)))
}
/**
* Invention search — the browse/search list the client shows when picking an
* invention to spawn. Only published, non-hidden inventions are visible here (a
+72 -20
View File
@@ -19,6 +19,7 @@ import {
getInventionVersion,
getMyInventions,
getTopInventions,
ownsAllInventions,
parsePermissionLevel,
publishInvention,
searchInventions,
@@ -82,6 +83,20 @@ async function creatorsInvention(
return { invention }
}
/**
* The `?id=1&id=2` list the invention batch endpoints take. `id` repeats, and each
* value may itself be a comma-separated list; anything non-numeric is dropped.
*/
function inventionIdQuery(c: Context<App>): number[] {
return (
c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((id) => !Number.isNaN(id)) ?? []
)
}
// ---- Avatar gifts ----------------------------------------------------------
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) and
// gift-box consume live in the `econ` worker, which the client calls on the econ host
@@ -276,12 +291,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') },
}),
async (c) => {
const ids = c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((id) => !Number.isNaN(id))
if (ids === undefined || ids.length === 0) return c.json([])
const ids = inventionIdQuery(c)
if (ids.length === 0) return c.json([])
const playerId = await authedId(c)
const inventions = await getInventionsByIds(c.env.DB, ids)
@@ -293,6 +304,40 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Whether the caller owns every invention in a lineage (`?id=101&id=102&id=103`) —
// the invention plus everything nested inside it, as the client enumerates it. One
// bare `true`/`false` for the whole set, not a verdict per id. Auth-gated: the
// question is about the caller.
.get(
'/api/inventions/v1/fulllineageowner',
describeRoute({
tags: ['Inventions'],
summary: 'Does the caller own this whole lineage?',
description:
'Asked when saving an invention built out of other inventions: may this player use ' +
'every piece? The client sends the whole lineage as repeated `id`s, and this ' +
'answers a single bare `true`/`false` for the set — false as soon as one is not the ' +
'callers. An invention is theirs if they created it or acquired it; an id with no ' +
'invention behind it is not owned. Price and permission dont enter into it — a ' +
'free invention still has to be picked up, and that writes the same inventory row ' +
'a paid one does.\n\n' +
'Only the ids asked about are checked — this does not walk `ReferencedInventions` ' +
'to widen the lineage, since the client knows what the thing it is holding is ' +
'actually made of. No ids at all is `true`: nothing in an empty lineage is unowned.',
security: AUTHED,
parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')],
responses: {
200: json(BareBoolean, 'Whether the caller owns every invention asked about'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const playerId = await authedId(c)
if (playerId === null) return unauthorized(c)
return c.json(await ownsAllInventions(c.env.DB, playerId, inventionIdQuery(c)))
}
)
// A room's inventions (`?id=76`) — published inventions created in that room,
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
.get(
@@ -377,24 +422,26 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Edit an invention's metadata. A GET that writes — that's what the client sends
// (`?inventionId=1&description=my+description`), with the fields to change as
// query params. Absent params keep their stored value; `permission` sets what
// other players may do with it (a name like `useonly` or the raw number). An
// empty `description` clears it, but an empty `name`/`imageName` is ignored
// rather than blanking the invention. Publishing and pricing are separate
// endpoints. Auth-gated, creator only; answers the save envelope.
.get(
// Edit an invention's metadata. The fields to change ride as QUERY PARAMS on both
// verbs (`?inventionId=1&description=my+description`) — the client sends this as a
// GET that writes in some places and as a bodyless POST in others (the permission
// picker posts `?inventionId=84&permission=Publish`), so both are registered and
// neither reads a body. Absent params keep their stored value; `permission` sets
// what other players may do with it. An empty `description` clears it, but an empty
// `name`/`imageName` is ignored rather than blanking the invention. Publishing and
// pricing are separate endpoints. Auth-gated, creator only; answers the save envelope.
.on(
['GET', 'POST'],
'/api/inventions/v1/update',
describeRoute({
tags: ['Inventions'],
summary: 'Edit an inventions metadata',
description:
'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` ' +
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
'invention. A supplied name/description must satisfy the same rules `v6/save` ' +
'enforces. Publishing and pricing are separate endpoints.',
'GET or POST — the client sends both, and the fields to change ride as query ' +
'params either way; no body is read. Absent params keep their stored value. An ' +
'empty `description` clears it, but an empty `name`/`imageName` is ignored rather ' +
'than blanking the invention. A supplied name/description must satisfy the same ' +
'rules `v6/save` enforces. Publishing and pricing are separate endpoints.',
security: AUTHED,
parameters: [
intQuery('inventionId', 'Invention id; required'),
@@ -402,7 +449,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
stringQuery('imageName', 'New thumbnail; empty is ignored'),
stringQuery('allowTrial', '`true`/`1` to allow trials'),
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
stringQuery(
'permission',
'What other players get (`GeneralPermission`). The picker sends `UseOnly`, ' +
'`EditAndSave` or `Publish`; any ladder name (case- and underscore-insensitive) ' +
'or the raw number is accepted'
),
],
responses: {
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
+86 -1
View File
@@ -940,6 +940,52 @@ describe('public endpoints', () => {
expect(await batch('')).toEqual([])
})
test('GET /api/inventions/v1/fulllineageowner answers for the whole set of ids', async () => {
const save = async (sub: string, name: string): Promise<SavedInvention> => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
})
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention
}
const owns = async (query: string, sub: string): Promise<unknown> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/fulllineageowner?${query}`,
{ headers: await bearer(sub) }
)
expect(res.status).toBe(200)
return await res.json()
}
// 7301 makes two; 7302 makes one and buys one of 7301's.
const own = await save('7301', 'Lineage Root')
const nested = await save('7301', 'Lineage Nested')
const others = await save('7302', 'Someone Elses')
await grantInvention(env.DB, 7302, nested.InventionId)
// The creator owns their own lineage; one invention that isn't theirs sinks it.
expect(await owns(`id=${own.InventionId}&id=${nested.InventionId}`, '7301')).toBe(true)
expect(
await owns(`id=${own.InventionId}&id=${nested.InventionId}&id=${others.InventionId}`, '7301')
).toBe(false)
// Bought counts as owned, and comma-separated ids parse like the batch endpoint.
expect(await owns(`id=${nested.InventionId},${others.InventionId}`, '7302')).toBe(true)
expect(await owns(`id=${own.InventionId}`, '7302')).toBe(false)
// An id with no invention behind it is not owned, whoever asks.
expect(await owns(`id=${own.InventionId}&id=999999`, '7301')).toBe(false)
// No ids at all: nothing in an empty lineage is unowned.
expect(await owns('', '7301')).toBe(true)
})
test('GET /api/inventions/v1/fulllineageowner 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fulllineageowner?id=1`)
expect(res.status).toBe(401)
})
test('GET /api/inventions/v1/room lists a rooms published inventions', async () => {
// Two inventions created in room 76, one of them still a draft.
const create = async (name: string, room: number): Promise<SavedInvention> => {
@@ -1151,6 +1197,42 @@ describe('public endpoints', () => {
expect(anon.status).toBe(401)
})
test('POST /api/inventions/v1/update takes the permission pickers query params', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('3232')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Posted Lamp', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
const post = async (query: string, sub = '3232'): Promise<Response> =>
exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&${query}`,
{ method: 'POST', headers: await bearer(sub) }
)
// The picker posts the permission by CamelCase name, with no body at all.
const permission = async (name: string): Promise<number> => {
const res = await post(`permission=${name}`)
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention.GeneralPermission
}
expect(await permission('UseOnly')).toBe(20)
expect(await permission('EditAndSave')).toBe(40)
expect(await permission('Publish')).toBe(60)
// Setting the permission is not publishing — that stays v3/publish's job.
const still = await post('permission=Publish')
expect(((await still.json()) as InventionSaveResult).Invention.IsPublished).toBe(false)
// Same gate as the GET: creator only, and a token is required.
expect((await post('permission=UseOnly', '9999')).status).toBe(403)
const anonPost = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&permission=Publish`,
{ method: 'POST' }
)
expect(anonPost.status).toBe(401)
})
test('GET /api/inventions/v3/publish publishes + prices; search then lists it', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -3166,7 +3248,8 @@ describe('openapi', () => {
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
// `.on(['GET','POST'], …)` relationship routes contribute both methods.
// `.on(['GET','POST'], …)` routes (the relationship mutations, invention update)
// contribute both methods.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
@@ -3203,6 +3286,7 @@ describe('openapi', () => {
'GET /api/inventions/v1',
'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured',
'GET /api/inventions/v1/fulllineageowner',
'GET /api/inventions/v1/personaldetails/{inventionId}',
'GET /api/inventions/v1/room',
'GET /api/inventions/v1/tagfilters',
@@ -3256,6 +3340,7 @@ describe('openapi', () => {
'POST /api/images/v1/cheer',
'POST /api/images/v4/uploadsaved',
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v6/save',
'POST /api/messages/v1/sendMultiple',