mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-10 07:31:27 -07:00
[invention] fix inventions of inventions
This commit is contained in:
@@ -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 search — the browse/search list the client shows when picking an
|
||||||
* invention to spawn. Only published, non-hidden inventions are visible here (a
|
* invention to spawn. Only published, non-hidden inventions are visible here (a
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
getInventionVersion,
|
getInventionVersion,
|
||||||
getMyInventions,
|
getMyInventions,
|
||||||
getTopInventions,
|
getTopInventions,
|
||||||
|
ownsAllInventions,
|
||||||
parsePermissionLevel,
|
parsePermissionLevel,
|
||||||
publishInvention,
|
publishInvention,
|
||||||
searchInventions,
|
searchInventions,
|
||||||
@@ -82,6 +83,20 @@ async function creatorsInvention(
|
|||||||
return { invention }
|
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 ----------------------------------------------------------
|
// ---- Avatar gifts ----------------------------------------------------------
|
||||||
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) and
|
// 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
|
// 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') },
|
responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const ids = c.req
|
const ids = inventionIdQuery(c)
|
||||||
.queries('id')
|
if (ids.length === 0) return c.json([])
|
||||||
?.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 playerId = await authedId(c)
|
const playerId = await authedId(c)
|
||||||
const inventions = await getInventionsByIds(c.env.DB, ids)
|
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 ' +
|
||||||
|
'caller’s. An invention is theirs if they created it or acquired it; an id with no ' +
|
||||||
|
'invention behind it is not owned. Price and permission don’t 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,
|
// A room's inventions (`?id=76`) — published inventions created in that room,
|
||||||
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
|
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
|
||||||
.get(
|
.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
|
// Edit an invention's metadata. The fields to change ride as QUERY PARAMS on both
|
||||||
// (`?inventionId=1&description=my+description`), with the fields to change as
|
// verbs (`?inventionId=1&description=my+description`) — the client sends this as a
|
||||||
// query params. Absent params keep their stored value; `permission` sets what
|
// GET that writes in some places and as a bodyless POST in others (the permission
|
||||||
// other players may do with it (a name like `useonly` or the raw number). An
|
// picker posts `?inventionId=84&permission=Publish`), so both are registered and
|
||||||
// empty `description` clears it, but an empty `name`/`imageName` is ignored
|
// neither reads a body. Absent params keep their stored value; `permission` sets
|
||||||
// rather than blanking the invention. Publishing and pricing are separate
|
// what other players may do with it. An empty `description` clears it, but an empty
|
||||||
// endpoints. Auth-gated, creator only; answers the save envelope.
|
// `name`/`imageName` is ignored rather than blanking the invention. Publishing and
|
||||||
.get(
|
// pricing are separate endpoints. Auth-gated, creator only; answers the save envelope.
|
||||||
|
.on(
|
||||||
|
['GET', 'POST'],
|
||||||
'/api/inventions/v1/update',
|
'/api/inventions/v1/update',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'Edit an invention’s metadata',
|
summary: 'Edit an invention’s metadata',
|
||||||
description:
|
description:
|
||||||
'A GET that writes — that is what the client sends, with the fields to change as ' +
|
'GET or POST — the client sends both, and the fields to change ride as query ' +
|
||||||
'query params. Absent params keep their stored value. An empty `description` ' +
|
'params either way; no body is read. Absent params keep their stored value. An ' +
|
||||||
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
|
'empty `description` clears it, but an empty `name`/`imageName` is ignored rather ' +
|
||||||
'invention. A supplied name/description must satisfy the same rules `v6/save` ' +
|
'than blanking the invention. A supplied name/description must satisfy the same ' +
|
||||||
'enforces. Publishing and pricing are separate endpoints.',
|
'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'),
|
||||||
@@ -402,7 +449,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
stringQuery('description', 'Max 512 chars; 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',
|
||||||
|
'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: {
|
responses: {
|
||||||
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
|
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
|
||||||
|
|||||||
@@ -940,6 +940,52 @@ describe('public endpoints', () => {
|
|||||||
expect(await batch('')).toEqual([])
|
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 room’s published inventions', async () => {
|
test('GET /api/inventions/v1/room lists a room’s published inventions', async () => {
|
||||||
// Two inventions created in room 76, one of them still a draft.
|
// Two inventions created in room 76, one of them still a draft.
|
||||||
const create = async (name: string, room: number): Promise<SavedInvention> => {
|
const create = async (name: string, room: number): Promise<SavedInvention> => {
|
||||||
@@ -1151,6 +1197,42 @@ describe('public endpoints', () => {
|
|||||||
expect(anon.status).toBe(401)
|
expect(anon.status).toBe(401)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /api/inventions/v1/update takes the permission picker’s 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 () => {
|
test('GET /api/inventions/v3/publish publishes + prices; search then lists it', async () => {
|
||||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -3166,7 +3248,8 @@ describe('openapi', () => {
|
|||||||
// Every route the worker serves is described. This is the drift guard: adding a
|
// 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
|
// route without a describeRoute() block fails here rather than silently shipping
|
||||||
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
|
// 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(
|
const documented = new Set(
|
||||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||||
@@ -3203,6 +3286,7 @@ describe('openapi', () => {
|
|||||||
'GET /api/inventions/v1',
|
'GET /api/inventions/v1',
|
||||||
'GET /api/inventions/v1/details',
|
'GET /api/inventions/v1/details',
|
||||||
'GET /api/inventions/v1/featured',
|
'GET /api/inventions/v1/featured',
|
||||||
|
'GET /api/inventions/v1/fulllineageowner',
|
||||||
'GET /api/inventions/v1/personaldetails/{inventionId}',
|
'GET /api/inventions/v1/personaldetails/{inventionId}',
|
||||||
'GET /api/inventions/v1/room',
|
'GET /api/inventions/v1/room',
|
||||||
'GET /api/inventions/v1/tagfilters',
|
'GET /api/inventions/v1/tagfilters',
|
||||||
@@ -3256,6 +3340,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/images/v1/cheer',
|
'POST /api/images/v1/cheer',
|
||||||
'POST /api/images/v4/uploadsaved',
|
'POST /api/images/v4/uploadsaved',
|
||||||
'POST /api/inventions/v1/settags',
|
'POST /api/inventions/v1/settags',
|
||||||
|
'POST /api/inventions/v1/update',
|
||||||
'POST /api/inventions/v1/updateprice',
|
'POST /api/inventions/v1/updateprice',
|
||||||
'POST /api/inventions/v6/save',
|
'POST /api/inventions/v6/save',
|
||||||
'POST /api/messages/v1/sendMultiple',
|
'POST /api/messages/v1/sendMultiple',
|
||||||
|
|||||||
Reference in New Issue
Block a user