mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -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 to spawn. Only published, non-hidden inventions are visible here (a
|
||||
|
||||
@@ -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 ' +
|
||||
'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,
|
||||
// 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 invention’s 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'),
|
||||
|
||||
@@ -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 room’s 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 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 () => {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user