mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
inventions...almost working
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
-- Break `IsFeatured` out of the invention JSON blob into a queryable generated
|
||||
-- column, so the featured feed (`/api/inventions/v1/featured`) filters in SQL on
|
||||
-- an index instead of parsing every published invention in memory. Generated from
|
||||
-- src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- SQLite allows ALTER TABLE ADD COLUMN only for VIRTUAL generated columns (a
|
||||
-- STORED one would need rewriting existing rows), which is what we want anyway:
|
||||
-- the value stays derived from `data`, so nothing can drift out of sync with it.
|
||||
-- json_extract of a JSON `true` is 1, so the column reads 1/0.
|
||||
|
||||
ALTER TABLE invention
|
||||
ADD COLUMN is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL;
|
||||
CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured);
|
||||
+445
-12
@@ -14,15 +14,21 @@
|
||||
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0002_invention.sql, sans any seed rows). */
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql,
|
||||
* sans any seed rows). `is_featured` backs the featured feed's query; json_extract
|
||||
* of a JSON `true` is 1, so the column is 1/0.
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS invention (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
|
||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL
|
||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
|
||||
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`,
|
||||
]
|
||||
|
||||
/** A single saved version of an invention (Rec Room's `RRInventionVersion`). */
|
||||
@@ -39,6 +45,25 @@ export interface InventionVersion {
|
||||
AICost: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a tag came from. 1 is a third kind the real API emits (the size bucket,
|
||||
* e.g. `medium`) that nothing here produces, so it's named but unused.
|
||||
*/
|
||||
export const INVENTION_TAG_TYPE = {
|
||||
custom: 0, // user submitted
|
||||
unknown: 1,
|
||||
auto: 2, // derived from the invention itself, e.g. `useonly` / `lowink`
|
||||
} as const
|
||||
|
||||
/**
|
||||
* A tag on an invention (Rec Room's `RRInventionTag`). Stored on the record and
|
||||
* echoed back through `v1/details`; `v1/settags` answers with the bare tag names.
|
||||
*/
|
||||
export interface InventionTag {
|
||||
Tag: string
|
||||
Type: number
|
||||
}
|
||||
|
||||
/** A stored invention record (Rec Room's `RRInvention`; returned by save / mine). */
|
||||
export interface SavedInvention {
|
||||
InventionId: number
|
||||
@@ -67,16 +92,53 @@ export interface SavedInvention {
|
||||
AllowTrial: boolean
|
||||
HideFromPlayer: boolean
|
||||
ReferencedInventions: number[]
|
||||
/**
|
||||
* Tags served by `v1/details` and written by `v1/settags`. Optional and unset on
|
||||
* save: the real `RRInvention` carries no Tags field and the client sends no tags
|
||||
* when saving, so an untagged invention's DTO stays identical to the real one.
|
||||
*/
|
||||
Tags?: InventionTag[]
|
||||
}
|
||||
|
||||
interface InventionRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
/** Fields the client supplies on save (camelCase); everything else is defaulted here. */
|
||||
/**
|
||||
* What the client expects back from `v6/save`: the invention and its version side
|
||||
* by side under a status envelope, rather than the single nested `RRInvention` the
|
||||
* read endpoints return. `Status` is 0 on success.
|
||||
*/
|
||||
export interface InventionSaveResult {
|
||||
Status: number
|
||||
Invention: SavedInvention
|
||||
InventionVersion: InventionVersion
|
||||
}
|
||||
|
||||
/** Wrap a stored invention in the save envelope, lifting out its current version. */
|
||||
export function toSaveResult(invention: SavedInvention): InventionSaveResult {
|
||||
return { Status: 0, Invention: invention, InventionVersion: invention.CurrentVersion }
|
||||
}
|
||||
|
||||
/**
|
||||
* Invention data blobs are named `<name>.inv`, and the client expects the extension
|
||||
* on the `BlobName` it reads back. Uploads through the `storage` worker already land
|
||||
* under an `.inv` key, so this is a no-op for them; it's here so a `BlobName` we hand
|
||||
* the client can never be missing the extension.
|
||||
*/
|
||||
function inventionBlobName(filename: string): string {
|
||||
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields the client supplies on save (camelCase); everything else is defaulted here.
|
||||
* `inventionDataFilename` is the one the caller must supply — an invention with no
|
||||
* data blob is unusable. An empty `name`/`description` is defaulted, not rejected.
|
||||
*/
|
||||
export interface NewInvention {
|
||||
creatorPlayerId: number
|
||||
name: string
|
||||
inventionDataFilename: string
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
imageName?: string | null
|
||||
instantiationCost?: number
|
||||
@@ -85,15 +147,19 @@ export interface NewInvention {
|
||||
cloudVariablesCost?: number
|
||||
aiCost?: number
|
||||
creationRoomId?: number | null
|
||||
inventionDataFilename?: string | null
|
||||
referencedInventions?: number[]
|
||||
creatorAccountRole?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new invention record, returning the stored row. A freshly saved
|
||||
* invention is private/unpublished — it shows up only in the creator's own list
|
||||
* until they publish it, so Accessibility/IsPublished/FirstPublishedAt reflect that.
|
||||
*
|
||||
* It is, however, fully permissioned from the start: the creator gets Unlimited over
|
||||
* their own invention, and so does everyone else once it's published — publishing is
|
||||
* what narrows `GeneralPermission` down (to UseOnly by default). Trials are allowed.
|
||||
* The client's `creatorAccountRole` is ignored: it's the player's role in the room
|
||||
* they built it in, not a permission over the invention.
|
||||
*/
|
||||
export async function createInvention(
|
||||
db: D1Database,
|
||||
@@ -109,15 +175,15 @@ export async function createInvention(
|
||||
InventionId: inventionId,
|
||||
ReplicationId: crypto.randomUUID(),
|
||||
CreatorPlayerId: input.creatorPlayerId,
|
||||
Name: input.name,
|
||||
Description: input.description ?? '',
|
||||
Name: input.name?.trim() || 'Untitled',
|
||||
Description: input.description?.trim() || 'No description yet',
|
||||
ImageName: input.imageName ?? '',
|
||||
CurrentVersionNumber: 1,
|
||||
CurrentVersion: {
|
||||
InventionId: inventionId,
|
||||
ReplicationId: crypto.randomUUID(),
|
||||
VersionNumber: 1,
|
||||
BlobName: input.inventionDataFilename ?? '',
|
||||
BlobName: inventionBlobName(input.inventionDataFilename),
|
||||
BlobHash: null,
|
||||
InstantiationCost: input.instantiationCost ?? 0,
|
||||
LightsCost: input.lightsCost ?? 0,
|
||||
@@ -135,12 +201,12 @@ export async function createInvention(
|
||||
NumPlayersHaveUsedInRoom: 0,
|
||||
NumDownloads: 0,
|
||||
CheerCount: 0,
|
||||
CreatorPermission: input.creatorAccountRole ?? 0,
|
||||
GeneralPermission: 0,
|
||||
CreatorPermission: INVENTION_PERMISSION.unlimited,
|
||||
GeneralPermission: INVENTION_PERMISSION.unlimited,
|
||||
IsAGInvention: false,
|
||||
IsCertifiedInvention: false,
|
||||
Price: 0,
|
||||
AllowTrial: false,
|
||||
AllowTrial: true,
|
||||
HideFromPlayer: false,
|
||||
ReferencedInventions: input.referencedInventions ?? [],
|
||||
}
|
||||
@@ -166,6 +232,373 @@ export async function getInventionsByCreator(
|
||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invention search — the browse/search list the client shows when picking an
|
||||
* invention to spawn. Only published, non-hidden inventions are visible here (a
|
||||
* player's own unpublished ones come from `getInventionsByCreator`). `value` is
|
||||
* matched case-insensitively against the name and description, term by term; an
|
||||
* empty `value` browses everything published. Paginated via skip/take, newest
|
||||
* first. Returns a bare array — the shape the client expects from v2/search.
|
||||
*/
|
||||
export async function searchInventions(
|
||||
db: D1Database,
|
||||
value: string,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedInvention[]> {
|
||||
let inventions = await publicInventions(db)
|
||||
|
||||
const terms = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/[\s+]+/)
|
||||
.filter(Boolean)
|
||||
for (const term of terms) {
|
||||
inventions = inventions.filter(
|
||||
(i) => i.Name.toLowerCase().includes(term) || i.Description.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
|
||||
return inventions
|
||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every invention any player may see: published and not hidden. The feeds and
|
||||
* search all draw from this set; a player's own unpublished inventions reach them
|
||||
* only through `getInventionsByCreator`. `featuredOnly` narrows to the curated
|
||||
* ones via the indexed `is_featured` column.
|
||||
*/
|
||||
async function publicInventions(db: D1Database, featuredOnly = false): Promise<SavedInvention[]> {
|
||||
// json_extract of a JSON `true` is 1, so these filters stay in SQL.
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM invention
|
||||
WHERE json_extract(data, '$.IsPublished') = 1
|
||||
AND json_extract(data, '$.HideFromPlayer') = 0
|
||||
${featuredOnly ? 'AND is_featured = 1' : ''}`
|
||||
)
|
||||
.all<InventionRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as SavedInvention)
|
||||
}
|
||||
|
||||
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
|
||||
function topScore(invention: SavedInvention): number {
|
||||
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
||||
return (
|
||||
n(invention.NumDownloads) * 3 +
|
||||
n(invention.CheerCount) * 2 +
|
||||
n(invention.NumPlayersHaveUsedInRoom)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The "top today" feed — published inventions ranked by engagement. The real feed
|
||||
* ranks by *today's* activity; we don't track per-day counters, so this ranks by
|
||||
* lifetime engagement instead. Ties fall back to invention id so paging is stable.
|
||||
* Paginated via skip/take; returns a bare array, like the other invention feeds.
|
||||
*/
|
||||
export async function getTopInventions(
|
||||
db: D1Database,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedInvention[]> {
|
||||
const inventions = await publicInventions(db)
|
||||
return inventions
|
||||
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/**
|
||||
* The featured feed — published inventions flagged `IsFeatured`, newest first.
|
||||
* Selected on the indexed `is_featured` column rather than by parsing every public
|
||||
* invention. Nothing sets that flag yet, so this falls back to the top feed rather
|
||||
* than handing the client an empty shelf; once inventions are curated it serves them.
|
||||
*/
|
||||
export async function getFeaturedInventions(
|
||||
db: D1Database,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedInvention[]> {
|
||||
const featured = await publicInventions(db, true)
|
||||
if (featured.length === 0) return getTopInventions(db, skip, take)
|
||||
return featured
|
||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an invention's tags (the `v1/settags` write). Auto tags are the ones the
|
||||
* client derives from the invention itself (Type 2); custom tags are the creator's
|
||||
* own (Type 0). Both lists are replaced wholesale — auto first, then custom, the
|
||||
* order the tags come back in — and are lowercased/trimmed and de-duplicated so
|
||||
* `details` doesn't echo back near-duplicates. Returns the stored tag list, or null
|
||||
* when there's no such invention.
|
||||
*/
|
||||
export async function setInventionTags(
|
||||
db: D1Database,
|
||||
inventionId: number,
|
||||
autoTags: string[],
|
||||
customTags: string[]
|
||||
): Promise<InventionTag[] | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
|
||||
const tags: InventionTag[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const [list, type] of [
|
||||
[autoTags, INVENTION_TAG_TYPE.auto],
|
||||
[customTags, INVENTION_TAG_TYPE.custom],
|
||||
] as const) {
|
||||
for (const raw of list) {
|
||||
const tag = raw.trim().toLowerCase()
|
||||
if (tag === '' || seen.has(tag)) continue
|
||||
seen.add(tag)
|
||||
tags.push({ Tag: tag, Type: type })
|
||||
}
|
||||
}
|
||||
|
||||
await writeInvention(db, { ...invention, Tags: tags })
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* What other players may do with a published invention — the `GeneralPermission`
|
||||
* ladder, each level implying the ones below it. `v1/update` takes these by name or
|
||||
* number (`permission=useonly` / `permission=20`), and `v3/publish` defaults to
|
||||
* UseOnly.
|
||||
*/
|
||||
export const INVENTION_PERMISSION = {
|
||||
unassigned: 0,
|
||||
limitedoneuseonly: 10,
|
||||
useonly: 20,
|
||||
editandsave: 40,
|
||||
publish: 60,
|
||||
charge: 80,
|
||||
unlimited: 100,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parse a permission level the way the client sends it: a name (`useonly`,
|
||||
* `edit_and_save`) or the raw number. Undefined when it's neither.
|
||||
*/
|
||||
export function parsePermissionLevel(value: string): number | undefined {
|
||||
const key = value.trim().toLowerCase().replace(/_/g, '')
|
||||
if (key in INVENTION_PERMISSION) {
|
||||
return INVENTION_PERMISSION[key as keyof typeof INVENTION_PERMISSION]
|
||||
}
|
||||
const numeric = Number.parseInt(value.trim(), 10)
|
||||
return Number.isNaN(numeric) ? undefined : numeric
|
||||
}
|
||||
|
||||
/** Fields `v1/update` can change. Anything left undefined keeps its stored value. */
|
||||
export interface InventionPatch {
|
||||
name?: string
|
||||
description?: string
|
||||
imageName?: string
|
||||
allowTrial?: boolean
|
||||
generalPermission?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an edit to an invention's metadata (the `v1/update` write). Only the keys
|
||||
* present on the patch change; everything else — versions, counters, published
|
||||
* state — is left alone. Publishing and pricing are deliberately *not* here: they
|
||||
* go through `publishInvention` / `setInventionPrice`, as they do in the real API.
|
||||
* Returns the updated invention, or null when there's no such row.
|
||||
*/
|
||||
export async function updateInvention(
|
||||
db: D1Database,
|
||||
inventionId: number,
|
||||
patch: InventionPatch
|
||||
): Promise<SavedInvention | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
|
||||
const updated: SavedInvention = {
|
||||
...invention,
|
||||
Name: patch.name ?? invention.Name,
|
||||
Description: patch.description ?? invention.Description,
|
||||
ImageName: patch.imageName ?? invention.ImageName,
|
||||
AllowTrial: patch.allowTrial ?? invention.AllowTrial,
|
||||
GeneralPermission: patch.generalPermission ?? invention.GeneralPermission,
|
||||
}
|
||||
await writeInvention(db, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an invention (`v3/publish`) — what puts it into search and the feeds.
|
||||
* Publishing sets the permission other players get (UseOnly unless the creator asks
|
||||
* for another level) and its price, and the first publish stamps `FirstPublishedAt`.
|
||||
* Returns the published invention, or null when there's no such row.
|
||||
*/
|
||||
export async function publishInvention(
|
||||
db: D1Database,
|
||||
inventionId: number,
|
||||
permissionLevel: number | undefined,
|
||||
price: number | undefined
|
||||
): Promise<SavedInvention | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
|
||||
const updated: SavedInvention = {
|
||||
...invention,
|
||||
IsPublished: true,
|
||||
GeneralPermission: permissionLevel ?? INVENTION_PERMISSION.useonly,
|
||||
Price: price ?? 0,
|
||||
FirstPublishedAt: invention.FirstPublishedAt ?? new Date().toISOString(),
|
||||
}
|
||||
await writeInvention(db, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an invention's price (`v1/updateprice`). Returns the updated invention, or
|
||||
* null when there's no such row; the caller rejects negative prices.
|
||||
*/
|
||||
export async function setInventionPrice(
|
||||
db: D1Database,
|
||||
inventionId: number,
|
||||
price: number
|
||||
): Promise<SavedInvention | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
const updated: SavedInvention = { ...invention, Price: price }
|
||||
await writeInvention(db, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/** The tag filter chips the client offers when browsing inventions. */
|
||||
export interface InventionTagFilters {
|
||||
PinnedFilters: string[]
|
||||
PopularFilters: string[]
|
||||
TrendingFilters: string[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The tag filters shown on the invention browse screen (`v1/tagfilters`), derived
|
||||
* from the tags actually in use: the most common tags across published inventions,
|
||||
* most popular first, with the top few pinned. `TrendingFilters` is null — that
|
||||
* needs recent-activity tracking we don't keep, and the client treats it as absent.
|
||||
*
|
||||
* With no published, tagged inventions this is empty, which just means no chips.
|
||||
*/
|
||||
export async function getInventionTagFilters(db: D1Database): Promise<InventionTagFilters> {
|
||||
const counts = new Map<string, number>()
|
||||
for (const invention of await publicInventions(db)) {
|
||||
for (const tag of invention.Tags ?? []) {
|
||||
counts.set(tag.Tag, (counts.get(tag.Tag) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const popular = [...counts.entries()]
|
||||
.sort(([tagA, countA], [tagB, countB]) => countB - countA || tagA.localeCompare(tagB))
|
||||
.slice(0, 20)
|
||||
.map(([tag]) => tag)
|
||||
|
||||
return {
|
||||
PinnedFilters: popular.slice(0, 5),
|
||||
PopularFilters: popular,
|
||||
TrendingFilters: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a batch of inventions by id (`v2/batch?id=1&id=2`). Returns whatever
|
||||
* exists, in the order the ids were asked for; unknown ids are simply absent. The
|
||||
* caller decides who may see what — an unpublished invention is visible only to its
|
||||
* creator — so this returns the rows unfiltered.
|
||||
*/
|
||||
export async function getInventionsByIds(
|
||||
db: D1Database,
|
||||
inventionIds: number[]
|
||||
): Promise<SavedInvention[]> {
|
||||
if (inventionIds.length === 0) return []
|
||||
const placeholders = inventionIds.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM invention WHERE id IN (${placeholders})`)
|
||||
.bind(...inventionIds)
|
||||
.all<InventionRow>()
|
||||
|
||||
const byId = new Map<number, SavedInvention>()
|
||||
for (const row of results) {
|
||||
const invention = JSON.parse(row.data) as SavedInvention
|
||||
byId.set(invention.InventionId, invention)
|
||||
}
|
||||
return inventionIds.map((id) => byId.get(id)).filter((i): i is SavedInvention => i !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* The inventions belonging to a room (`v1/room?id=…`) — the ones created there,
|
||||
* matched on `CreationRoomId`. Published, non-hidden only, so this can't expose a
|
||||
* creator's drafts to everyone else in the room. Newest first, paginated via
|
||||
* skip/take; bare array, like the other invention lists.
|
||||
*/
|
||||
export async function getInventionsByRoom(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedInvention[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM invention
|
||||
WHERE json_extract(data, '$.CreationRoomId') = ?1
|
||||
AND json_extract(data, '$.IsPublished') = 1
|
||||
AND json_extract(data, '$.HideFromPlayer') = 0`
|
||||
)
|
||||
.bind(roomId)
|
||||
.all<InventionRow>()
|
||||
return results
|
||||
.map((r) => JSON.parse(r.data) as SavedInvention)
|
||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/**
|
||||
* A single version of an invention (`v1/version?inventionId=…&version=…`), which
|
||||
* is how the client resolves the blob to download for a given version number.
|
||||
*
|
||||
* We keep only the current version on the record — nothing writes version history
|
||||
* (there's no `v4/addversion` yet), and a fresh save is always version 1. So this
|
||||
* answers for the current version number and reports null for any other, rather
|
||||
* than inventing a version whose blob doesn't exist.
|
||||
*/
|
||||
export async function getInventionVersion(
|
||||
db: D1Database,
|
||||
inventionId: number,
|
||||
versionNumber: number
|
||||
): Promise<InventionVersion | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
return invention.CurrentVersionNumber === versionNumber ? invention.CurrentVersion : null
|
||||
}
|
||||
|
||||
/** Persist an edited invention record, bumping ModifiedAt. */
|
||||
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
|
||||
const updated: SavedInvention = { ...invention, ModifiedAt: new Date().toISOString() }
|
||||
await db
|
||||
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
|
||||
.bind(JSON.stringify(updated), invention.InventionId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags shown on an invention's detail card (`v1/details`). Returns null when
|
||||
* there's no such invention, so the route can 404 rather than pretend the id is a
|
||||
* real, untagged invention. Untagged inventions come back as an empty list — which
|
||||
* is every invention today, since nothing writes tags yet.
|
||||
*/
|
||||
export async function getInventionTags(
|
||||
db: D1Database,
|
||||
inventionId: number
|
||||
): Promise<InventionTag[] | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
return invention === null ? null : (invention.Tags ?? [])
|
||||
}
|
||||
|
||||
/** Look up a single invention by its numeric id, or null when there's no such row. */
|
||||
export async function getInventionById(
|
||||
db: D1Database,
|
||||
|
||||
@@ -1,9 +1,51 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import { createInvention, getInventionById, getInventionsByCreator } from '../inventions-db'
|
||||
import {
|
||||
createInvention,
|
||||
getFeaturedInventions,
|
||||
getInventionById,
|
||||
getInventionsByCreator,
|
||||
getInventionsByIds,
|
||||
getInventionsByRoom,
|
||||
getInventionTagFilters,
|
||||
getInventionTags,
|
||||
getInventionVersion,
|
||||
getTopInventions,
|
||||
parsePermissionLevel,
|
||||
publishInvention,
|
||||
searchInventions,
|
||||
setInventionPrice,
|
||||
setInventionTags,
|
||||
toSaveResult,
|
||||
updateInvention,
|
||||
} from '../inventions-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
import type { SavedInvention } from '../inventions-db'
|
||||
|
||||
/**
|
||||
* The gate every invention write runs through: the caller must be signed in, the
|
||||
* invention must exist, and it must be theirs. Yields the loaded invention, or the
|
||||
* error response to return as-is (401 / 404 / 403).
|
||||
*/
|
||||
async function creatorsInvention(
|
||||
c: Context<App>,
|
||||
inventionId: number
|
||||
): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> {
|
||||
const playerId = await authedId(c)
|
||||
if (playerId === null) return { response: unauthorized(c) }
|
||||
if (Number.isNaN(inventionId)) {
|
||||
return { response: c.json({ error: 'inventionId is required' }, 400) }
|
||||
}
|
||||
const invention = await getInventionById(c.env.DB, inventionId)
|
||||
if (invention === null) return { response: c.notFound() }
|
||||
if (invention.CreatorPlayerId !== playerId) {
|
||||
return { response: c.json({ error: 'Not your invention' }, 403) }
|
||||
}
|
||||
return { invention }
|
||||
}
|
||||
|
||||
// ---- Avatar gifts ----------------------------------------------------------
|
||||
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`)
|
||||
@@ -78,6 +120,194 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
return invention ? c.json(invention) : c.notFound()
|
||||
})
|
||||
|
||||
// The tag filter chips on the invention browse screen. Derived from the tags in
|
||||
// use on published inventions — most popular first, top few pinned. Public.
|
||||
.get('/api/inventions/v1/tagfilters', async (c) => c.json(await getInventionTagFilters(c.env.DB)))
|
||||
|
||||
// A batch of inventions by id (`?id=1&id=2`, and each `id` may itself be a
|
||||
// comma-separated list). Unknown ids are dropped rather than 404ing, and an empty
|
||||
// request is an empty list. Auth is optional and only widens what you see: an
|
||||
// unpublished invention comes back only to its creator. Bare array.
|
||||
.get('/api/inventions/v2/batch', 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 playerId = await authedId(c)
|
||||
const inventions = await getInventionsByIds(c.env.DB, ids)
|
||||
return c.json(
|
||||
inventions.filter(
|
||||
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// 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('/api/inventions/v1/room', async (c) => {
|
||||
const roomId = Number.parseInt(c.req.query('id') ?? '', 10)
|
||||
if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getInventionsByRoom(c.env.DB, roomId, skip, take))
|
||||
})
|
||||
|
||||
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
|
||||
// — just whether they're cheering it. We store no cheers (nothing can cheer an
|
||||
// invention yet), so this is always false; it stays a 200 for signed-out callers
|
||||
// too, since the client only reads the flag.
|
||||
.get('/api/inventions/v1/personaldetails/:inventionId{[0-9]+}', (c) =>
|
||||
c.json({ IsCheering: false })
|
||||
)
|
||||
|
||||
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
||||
// RRInventionVersion, which carries the blob name the client downloads. Public.
|
||||
// Only the current version exists (nothing writes version history yet), so any
|
||||
// other version number 404s rather than naming a blob that isn't there.
|
||||
.get('/api/inventions/v1/version', async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
|
||||
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
|
||||
|
||||
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
|
||||
return version === null ? c.notFound() : c.json(version)
|
||||
})
|
||||
|
||||
// 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('/api/inventions/v1/update', async (c) => {
|
||||
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
|
||||
if ('response' in gate) return gate.response
|
||||
|
||||
// Query params arrive as strings; only the ones actually present are applied.
|
||||
const nonEmpty = (name: string): string | undefined => {
|
||||
const v = c.req.query(name)?.trim()
|
||||
return v === undefined || v === '' ? undefined : v
|
||||
}
|
||||
const allowTrial = c.req.query('allowTrial')
|
||||
const permission = c.req.query('permission')
|
||||
|
||||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||
name: nonEmpty('name'),
|
||||
// Present-but-empty clears the description, so this checks presence.
|
||||
description: c.req.query('description'),
|
||||
imageName: nonEmpty('imageName'),
|
||||
allowTrial:
|
||||
allowTrial === undefined
|
||||
? undefined
|
||||
: allowTrial.toLowerCase() === 'true' || allowTrial === '1',
|
||||
generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission),
|
||||
})
|
||||
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
|
||||
})
|
||||
|
||||
// Publish an invention — this is what puts it into search and the feeds. Sets the
|
||||
// permission other players get (`permissionLevel`, defaulting to UseOnly) and its
|
||||
// `price`. Auth-gated, creator only; answers the save envelope.
|
||||
.get('/api/inventions/v3/publish', async (c) => {
|
||||
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
|
||||
if ('response' in gate) return gate.response
|
||||
|
||||
const permissionLevel = c.req.query('permissionLevel')
|
||||
const price = Number.parseInt(c.req.query('price') ?? '', 10)
|
||||
|
||||
const published = await publishInvention(
|
||||
c.env.DB,
|
||||
gate.invention.InventionId,
|
||||
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
|
||||
Number.isNaN(price) || price < 0 ? undefined : price
|
||||
)
|
||||
return published === null ? c.notFound() : c.json(toSaveResult(published))
|
||||
})
|
||||
|
||||
// Set an invention's price. Unlike update/publish this one POSTs a JSON body.
|
||||
// Auth-gated, creator only; answers the save envelope.
|
||||
.post('/api/inventions/v1/updateprice', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||
|
||||
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
const gate = await creatorsInvention(c, inventionId)
|
||||
if ('response' in gate) return gate.response
|
||||
|
||||
const price = typeof body.Price === 'number' ? body.Price : Number.NaN
|
||||
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
|
||||
|
||||
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
|
||||
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
|
||||
})
|
||||
|
||||
// Replace an invention's tags. `CustomTags` are the creator's own (Type 0),
|
||||
// `AutoTags` the ones the client derives from the invention (Type 2); both lists
|
||||
// are replaced wholesale. Auth-gated, and only the creator may retag their own
|
||||
// invention. Answers `{ Result, Tags }` — `Result` 0 is success, and `Tags` is the
|
||||
// flat list of tag *names* (auto first, then custom); the typed `{ Tag, Type }`
|
||||
// objects are what `v1/details` serves.
|
||||
.post('/api/inventions/v1/settags', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||
|
||||
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
const gate = await creatorsInvention(c, inventionId)
|
||||
if ('response' in gate) return gate.response
|
||||
|
||||
const strings = (v: unknown): string[] =>
|
||||
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
||||
|
||||
const tags = await setInventionTags(
|
||||
c.env.DB,
|
||||
gate.invention.InventionId,
|
||||
strings(body.AutoTags),
|
||||
strings(body.CustomTags)
|
||||
)
|
||||
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
|
||||
})
|
||||
|
||||
// An invention's detail card (`?inventionId=…`) — just its tags, as `{ Tags }`.
|
||||
// Untagged inventions report an empty list. 404s on unknown ids.
|
||||
.get('/api/inventions/v1/details', async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const tags = await getInventionTags(c.env.DB, inventionId)
|
||||
return tags === null ? c.notFound() : c.json({ Tags: tags })
|
||||
})
|
||||
|
||||
// The "top today" invention feed — published inventions ranked by engagement
|
||||
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
|
||||
// (take defaults to 50, as the client asks for). Bare array.
|
||||
.get('/api/inventions/v1/toptoday', async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||||
return c.json(await getTopInventions(c.env.DB, skip, take))
|
||||
})
|
||||
|
||||
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
|
||||
// to the top feed while nothing is curated. Bare array, like toptoday.
|
||||
.get('/api/inventions/v1/featured', async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||||
return c.json(await getFeaturedInventions(c.env.DB, skip, take))
|
||||
})
|
||||
|
||||
// Invention search/browse: published inventions matching `value` (matched against
|
||||
// name + description; absent → browse everything published), newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
.get('/api/inventions/v2/search', async (c) => {
|
||||
const value = c.req.query('value') ?? ''
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await searchInventions(c.env.DB, value, skip, take))
|
||||
})
|
||||
|
||||
// The signed-in player's saved inventions ("my inventions"), newest first.
|
||||
// Auth-gated; returns a bare array (empty when the player has saved none).
|
||||
.get('/api/inventions/v2/mine', async (c) => {
|
||||
@@ -87,8 +317,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
})
|
||||
|
||||
// Save an invention's metadata. The data file itself is uploaded separately
|
||||
// through the `storage` worker and referenced here by `inventionDataFilename`.
|
||||
// Auth-gated; returns the stored invention (with its assigned inventionId).
|
||||
// through the `storage` worker and referenced here by `inventionDataFilename` —
|
||||
// the one required field, since an invention with no data blob is unusable. An
|
||||
// omitted name/description is defaulted rather than rejected. Auth-gated; returns
|
||||
// the `{ Status, Invention, InventionVersion }` envelope the client expects (the
|
||||
// invention carries its assigned inventionId).
|
||||
.post('/api/inventions/v6/save', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
@@ -99,12 +332,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
|
||||
const name = str(body.name)
|
||||
if (name === undefined) return c.json({ error: 'name is required' }, 400)
|
||||
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
|
||||
if (!inventionDataFilename) {
|
||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
||||
}
|
||||
|
||||
const invention = await createInvention(c.env.DB, {
|
||||
creatorPlayerId: id,
|
||||
name,
|
||||
inventionDataFilename,
|
||||
name: str(body.name),
|
||||
description: str(body.description),
|
||||
imageName: str(body.imageName),
|
||||
instantiationCost: num(body.instantiationCost),
|
||||
@@ -113,11 +349,9 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
cloudVariablesCost: num(body.cloudVariablesCost),
|
||||
aiCost: num(body.aiCost),
|
||||
creationRoomId: num(body.creationRoomId),
|
||||
inventionDataFilename: str(body.inventionDataFilename),
|
||||
referencedInventions: Array.isArray(body.referencedInventions)
|
||||
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
|
||||
: undefined,
|
||||
creatorAccountRole: num(body.creatorAccountRole),
|
||||
})
|
||||
return c.json(invention)
|
||||
return c.json(toSaveResult(invention))
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
import type { SavedImage } from '../../images-db'
|
||||
import type { SavedInvention } from '../../inventions-db'
|
||||
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
@@ -286,17 +286,43 @@ describe('public endpoints', () => {
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const saved = (await res.json()) as SavedInvention
|
||||
// Save answers with the `{ Status, Invention, InventionVersion }` envelope —
|
||||
// the version sits alongside the invention, not only nested inside it.
|
||||
const result = (await res.json()) as InventionSaveResult
|
||||
expect(result.Status).toBe(0)
|
||||
const saved = result.Invention
|
||||
expect(saved.InventionId).toBeGreaterThan(0)
|
||||
expect(saved.CreatorPlayerId).toBe(5150)
|
||||
expect(saved.Name).toBe(body.name)
|
||||
expect(saved.Description).toBe(body.description)
|
||||
expect(saved.ImageName).toBe(body.imageName)
|
||||
// Costs + the data blob live on the nested CurrentVersion.
|
||||
expect(saved.CurrentVersion.InstantiationCost).toBe(103)
|
||||
expect(saved.CurrentVersion.BlobName).toBe(body.inventionDataFilename)
|
||||
// Costs + the data blob live on the version. The blob name always carries the
|
||||
// `.inv` extension the client expects, whether or not the client sent it.
|
||||
expect(result.InventionVersion).toMatchObject({
|
||||
InventionId: saved.InventionId,
|
||||
VersionNumber: 1,
|
||||
InstantiationCost: 103,
|
||||
LightsCost: 0,
|
||||
BlobName: `${body.inventionDataFilename}.inv`,
|
||||
})
|
||||
expect(saved.CurrentVersion.BlobName).toBe(`${body.inventionDataFilename}.inv`)
|
||||
|
||||
// An extension the client already supplied isn't doubled up.
|
||||
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }),
|
||||
})
|
||||
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
|
||||
'2026-07-12/x.inv'
|
||||
)
|
||||
expect(saved.CreationRoomId).toBe(73)
|
||||
expect(saved.CreatorPermission).toBe(255)
|
||||
// Fully permissioned from the start (the client's creatorAccountRole is a room
|
||||
// role, not an invention permission, so it's ignored); publishing is what
|
||||
// narrows GeneralPermission down.
|
||||
expect(saved.CreatorPermission).toBe(100)
|
||||
expect(saved.GeneralPermission).toBe(100)
|
||||
expect(saved.AllowTrial).toBe(true)
|
||||
// Freshly saved → private/unpublished until the player publishes it.
|
||||
expect(saved.IsPublished).toBe(false)
|
||||
expect(saved.FirstPublishedAt).toBeNull()
|
||||
@@ -321,25 +347,563 @@ describe('public endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'x' }),
|
||||
body: JSON.stringify({ name: 'x', inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save 400s without a name', async () => {
|
||||
test('POST /api/inventions/v6/save 400s without the invention data blob', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description: 'no name' }),
|
||||
body: JSON.stringify({ name: 'no blob' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save defaults a missing name and description', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('6161')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: ' ' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as InventionSaveResult).Invention).toMatchObject({
|
||||
Name: 'Untitled',
|
||||
Description: 'No description yet',
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/details returns the tag list; 404s on an unknown id', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('6060')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Tagless Sofabed', inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
const { Invention } = (await save.json()) as InventionSaveResult
|
||||
|
||||
// A freshly saved invention is untagged until settags writes to it.
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ Tags: [] })
|
||||
|
||||
// Tags stored on the record are echoed back under `Tags`.
|
||||
const tagged = { ...Invention, InventionId: 5150, Tags: [{ Tag: 'medium', Type: 1 }] }
|
||||
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
|
||||
.bind(JSON.stringify(tagged))
|
||||
.run()
|
||||
const withTags = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=5150`
|
||||
)
|
||||
expect(await withTags.json()).toEqual({ Tags: [{ Tag: 'medium', Type: 1 }] })
|
||||
|
||||
const unknown = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=999999`
|
||||
)
|
||||
expect(unknown.status).toBe(404)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v1/settags tags the invention; details serves them back', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('4242')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Sofabed', inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
const { Invention } = (await save.json()) as InventionSaveResult
|
||||
const settags = async (body: unknown, sub = '4242'): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}/api/inventions/v1/settags`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
// Custom tags are Type 0, auto tags Type 2.
|
||||
const res = await settags({
|
||||
InventionId: Invention.InventionId,
|
||||
AutoTags: ['lowink'],
|
||||
CustomTags: ['blah'],
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// settags answers the flat list of tag *names*, auto first, then custom.
|
||||
expect(await res.json()).toEqual({ Result: 0, Tags: ['lowink', 'blah'] })
|
||||
|
||||
// details serves the typed objects: custom is Type 0, auto is Type 2.
|
||||
const details = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
|
||||
)
|
||||
expect(await details.json()).toEqual({
|
||||
Tags: [
|
||||
{ Tag: 'lowink', Type: 2 },
|
||||
{ Tag: 'blah', Type: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
// Both lists are replaced wholesale, and tags are normalized + de-duplicated.
|
||||
const replaced = await settags({
|
||||
InventionId: Invention.InventionId,
|
||||
AutoTags: [],
|
||||
CustomTags: ['Modern', ' modern ', 'Bed'],
|
||||
})
|
||||
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
|
||||
|
||||
// Only the creator may retag; unknown inventions 404; no token → 401.
|
||||
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
|
||||
expect(notMine.status).toBe(403)
|
||||
|
||||
const unknown = await settags({ InventionId: 999999, CustomTags: ['x'] })
|
||||
expect(unknown.status).toBe(404)
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/settags`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: Invention.InventionId, CustomTags: ['x'] }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v2/search returns published inventions, filtered by value', async () => {
|
||||
// Only published inventions are searchable, and nothing published exists via
|
||||
// the save path (a fresh save is private), so seed the rows directly.
|
||||
const published = (id: number, name: string, description: string): SavedInvention =>
|
||||
({
|
||||
InventionId: id,
|
||||
ReplicationId: crypto.randomUUID(),
|
||||
CreatorPlayerId: 8080,
|
||||
Name: name,
|
||||
Description: description,
|
||||
ImageName: '',
|
||||
CurrentVersionNumber: 1,
|
||||
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
|
||||
IsPublished: true,
|
||||
HideFromPlayer: false,
|
||||
CreatedAt: `2026-07-0${id}T00:00:00Z`,
|
||||
}) as unknown as SavedInvention
|
||||
|
||||
for (const inv of [
|
||||
published(101, 'Modern Sofabed', 'Stylistic modern bed'),
|
||||
published(102, 'Racing Game', 'A retro inspired TV gaming set'),
|
||||
// Unpublished + hidden rows must stay out of the results.
|
||||
{ ...published(103, 'Secret Sofabed', ''), IsPublished: false },
|
||||
{ ...published(104, 'Hidden Sofabed', ''), HideFromPlayer: true },
|
||||
]) {
|
||||
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
|
||||
.bind(JSON.stringify(inv))
|
||||
.run()
|
||||
}
|
||||
|
||||
// No `value` → browse everything published, newest first.
|
||||
const all = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?skip=0&take=100`)
|
||||
expect(all.status).toBe(200)
|
||||
expect(((await all.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([102, 101])
|
||||
|
||||
// `value` matches name or description, case-insensitively.
|
||||
const hit = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent('modern sofabed')}`
|
||||
)
|
||||
expect(((await hit.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([101])
|
||||
|
||||
// skip/take paginate the published set.
|
||||
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?skip=1&take=1`)
|
||||
expect(((await page.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([101])
|
||||
|
||||
const miss = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?value=nomatch`)
|
||||
expect(await miss.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/tagfilters ranks the tags in use', async () => {
|
||||
// Two published inventions tagged `furniture`, one `bed` — plus a tagged draft,
|
||||
// whose tags must not leak into the public filter chips.
|
||||
const make = async (name: string, tags: string[], publish: boolean): Promise<void> => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('9090')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
const { Invention } = (await save.json()) as InventionSaveResult
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/settags`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('9090')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: Invention.InventionId, CustomTags: tags }),
|
||||
})
|
||||
if (publish) {
|
||||
await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`,
|
||||
{ headers: await bearer('9090') }
|
||||
)
|
||||
}
|
||||
}
|
||||
await make('Filter Sofa', ['furniture', 'bed'], true)
|
||||
await make('Filter Chair', ['furniture'], true)
|
||||
await make('Filter Draft', ['secrettag'], false)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/tagfilters`)
|
||||
expect(res.status).toBe(200)
|
||||
const filters = (await res.json()) as {
|
||||
PinnedFilters: string[]
|
||||
PopularFilters: string[]
|
||||
TrendingFilters: null
|
||||
}
|
||||
// Most-used tag first, and the draft's tag is nowhere to be seen.
|
||||
expect(filters.PopularFilters.slice(0, 2)).toEqual(['furniture', 'bed'])
|
||||
expect(filters.PopularFilters).not.toContain('secrettag')
|
||||
expect(filters.PinnedFilters).toEqual(filters.PopularFilters.slice(0, 5))
|
||||
expect(filters.TrendingFilters).toBeNull()
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v2/batch returns the requested inventions', async () => {
|
||||
const save = async (name: string): Promise<SavedInvention> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5566')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
return ((await res.json()) as InventionSaveResult).Invention
|
||||
}
|
||||
const batch = async (query: string, sub?: string): Promise<SavedInvention[]> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/batch?${query}`, {
|
||||
headers: sub === undefined ? {} : await bearer(sub),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as SavedInvention[]
|
||||
}
|
||||
|
||||
const first = await save('Batch One')
|
||||
const draft = await save('Batch Draft')
|
||||
await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v3/publish?inventionId=${first.InventionId}`,
|
||||
{
|
||||
headers: await bearer('5566'),
|
||||
}
|
||||
)
|
||||
|
||||
// Repeated ids and comma-separated ids both work, and order is preserved.
|
||||
const ids = await batch(`id=${first.InventionId}&id=${first.InventionId}`)
|
||||
expect(ids.map((i) => i.InventionId)).toEqual([first.InventionId, first.InventionId])
|
||||
const commaSeparated = await batch(`id=${first.InventionId},999999`)
|
||||
expect(commaSeparated.map((i) => i.InventionId)).toEqual([first.InventionId])
|
||||
|
||||
// The draft is hidden from everyone but its creator.
|
||||
expect((await batch(`id=${draft.InventionId}`)).map((i) => i.InventionId)).toEqual([])
|
||||
expect((await batch(`id=${draft.InventionId}`, '9999')).map((i) => i.InventionId)).toEqual([])
|
||||
expect((await batch(`id=${draft.InventionId}`, '5566')).map((i) => i.InventionId)).toEqual([
|
||||
draft.InventionId,
|
||||
])
|
||||
|
||||
// No ids at all → an empty list, not an error.
|
||||
expect(await batch('')).toEqual([])
|
||||
})
|
||||
|
||||
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> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('8484')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, creationRoomId: room, inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
return ((await res.json()) as InventionSaveResult).Invention
|
||||
}
|
||||
const publish = async (id: number): Promise<void> => {
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v3/publish?inventionId=${id}`, {
|
||||
headers: await bearer('8484'),
|
||||
})
|
||||
}
|
||||
|
||||
const inRoom = await create('Room Lamp', 76)
|
||||
const draft = await create('Draft Lamp In Room', 76)
|
||||
const otherRoom = await create('Other Room Lamp', 77)
|
||||
await publish(inRoom.InventionId)
|
||||
await publish(otherRoom.InventionId)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=76`)
|
||||
expect(res.status).toBe(200)
|
||||
const ids = ((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
||||
expect(ids).toEqual([inRoom.InventionId])
|
||||
// The unpublished one and the other room's are both excluded.
|
||||
expect(ids).not.toContain(draft.InventionId)
|
||||
expect(ids).not.toContain(otherRoom.InventionId)
|
||||
|
||||
// A room with no inventions is an empty list, not a 404.
|
||||
const empty = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=999`)
|
||||
expect(await empty.json()).toEqual([])
|
||||
|
||||
const noId = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room`)
|
||||
expect(noId.status).toBe(400)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/personaldetails/:id reports the cheer flag', async () => {
|
||||
// No cheer storage yet, so nobody is ever cheering — signed in or not.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`, {
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ IsCheering: false })
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`)
|
||||
expect(anon.status).toBe(200)
|
||||
expect(await anon.json()).toEqual({ IsCheering: false })
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Versioned Lamp',
|
||||
instantiationCost: 42,
|
||||
inventionDataFilename: '2026-07-12/lamp.inv',
|
||||
}),
|
||||
})
|
||||
const { Invention } = (await save.json()) as InventionSaveResult
|
||||
|
||||
// The bare RRInventionVersion — the blob name is what the client downloads.
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({
|
||||
InventionId: Invention.InventionId,
|
||||
VersionNumber: 1,
|
||||
BlobName: '2026-07-12/lamp.inv',
|
||||
InstantiationCost: 42,
|
||||
})
|
||||
|
||||
// Only the current version exists; anything else 404s, as does an unknown id.
|
||||
const v2 = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=2`
|
||||
)
|
||||
expect(v2.status).toBe(404)
|
||||
const unknown = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/version?inventionId=999999&version=1`
|
||||
)
|
||||
expect(unknown.status).toBe(404)
|
||||
|
||||
// Both params are required.
|
||||
const noVersion = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}`
|
||||
)
|
||||
expect(noVersion.status).toBe(400)
|
||||
const noId = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/version?version=1`)
|
||||
expect(noId.status).toBe(400)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('3131')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Draft Lamp',
|
||||
description: 'No description yet',
|
||||
inventionDataFilename: 'a.inv',
|
||||
}),
|
||||
})
|
||||
const { Invention } = (await save.json()) as InventionSaveResult
|
||||
const update = async (query: string, sub = '3131'): Promise<Response> =>
|
||||
exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&${query}`,
|
||||
{ headers: await bearer(sub) }
|
||||
)
|
||||
|
||||
// Update answers the save envelope. Only the params present change — the name
|
||||
// is left alone here.
|
||||
const res = await update(`description=${encodeURIComponent('my description')}`)
|
||||
expect(res.status).toBe(200)
|
||||
const edited = (await res.json()) as InventionSaveResult
|
||||
expect(edited.Status).toBe(0)
|
||||
expect(edited.InventionVersion.InventionId).toBe(Invention.InventionId)
|
||||
expect(edited.Invention).toMatchObject({
|
||||
InventionId: Invention.InventionId,
|
||||
Description: 'my description',
|
||||
Name: 'Draft Lamp',
|
||||
IsPublished: false,
|
||||
})
|
||||
|
||||
// `permission` takes a name or the raw number, and lands on GeneralPermission.
|
||||
const byName = (await (await update('permission=edit_and_save')).json()) as InventionSaveResult
|
||||
expect(byName.Invention.GeneralPermission).toBe(40)
|
||||
const byNumber = (await (await update('permission=80')).json()) as InventionSaveResult
|
||||
expect(byNumber.Invention.GeneralPermission).toBe(80)
|
||||
|
||||
// An empty description clears it; an empty name does *not* blank the invention.
|
||||
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
|
||||
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
|
||||
|
||||
// allowTrial takes true/1.
|
||||
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
|
||||
expect(trial.Invention.AllowTrial).toBe(true)
|
||||
|
||||
// Update does not publish or price — those are v3/publish and v1/updateprice.
|
||||
expect(trial.Invention.IsPublished).toBe(false)
|
||||
|
||||
// Only the creator may edit; unknown inventions 404; no token → 401.
|
||||
expect((await update('description=nope', '9999')).status).toBe(403)
|
||||
const unknown = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/update?inventionId=999999&description=x`,
|
||||
{ headers: await bearer('3131') }
|
||||
)
|
||||
expect(unknown.status).toBe(404)
|
||||
const anon = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&description=x`
|
||||
)
|
||||
expect(anon.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',
|
||||
headers: { ...(await bearer('2121')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Publishable Lamp', inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
const { Invention } = (await save.json()) as InventionSaveResult
|
||||
const search = async (): Promise<number[]> => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent('Publishable Lamp')}`
|
||||
)
|
||||
return ((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
||||
}
|
||||
|
||||
// A saved draft is invisible until it's published.
|
||||
expect(await search()).toEqual([])
|
||||
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&permissionLevel=charge&price=250`,
|
||||
{ headers: await bearer('2121') }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const published = (await res.json()) as InventionSaveResult
|
||||
expect(published.Status).toBe(0)
|
||||
expect(published.Invention).toMatchObject({
|
||||
IsPublished: true,
|
||||
GeneralPermission: 80, // charge
|
||||
Price: 250,
|
||||
})
|
||||
expect(typeof published.Invention.FirstPublishedAt).toBe('string')
|
||||
|
||||
expect(await search()).toEqual([Invention.InventionId])
|
||||
|
||||
// Publishing with no permissionLevel defaults to UseOnly, and price to 0.
|
||||
const other = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('2121')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Plain Lamp', inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
const plain = ((await other.json()) as InventionSaveResult).Invention
|
||||
const defaulted = (await (
|
||||
await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v3/publish?inventionId=${plain.InventionId}`,
|
||||
{ headers: await bearer('2121') }
|
||||
)
|
||||
).json()) as InventionSaveResult
|
||||
expect(defaulted.Invention).toMatchObject({
|
||||
IsPublished: true,
|
||||
GeneralPermission: 20, // useonly
|
||||
Price: 0,
|
||||
})
|
||||
|
||||
// Creator-gated like the other writes.
|
||||
const notMine = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`,
|
||||
{ headers: await bearer('9999') }
|
||||
)
|
||||
expect(notMine.status).toBe(403)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v1/updateprice sets the price, creator only', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('1212')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Priced Lamp', inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
const { Invention } = (await save.json()) as InventionSaveResult
|
||||
const updateprice = async (body: unknown, sub = '1212'): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}/api/inventions/v1/updateprice`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const res = await updateprice({ InventionId: Invention.InventionId, Price: 500 })
|
||||
expect(res.status).toBe(200)
|
||||
const priced = (await res.json()) as InventionSaveResult
|
||||
expect(priced.Status).toBe(0)
|
||||
expect(priced.Invention.Price).toBe(500)
|
||||
|
||||
// A negative price is rejected; other players can't reprice someone's invention.
|
||||
expect((await updateprice({ InventionId: Invention.InventionId, Price: -1 })).status).toBe(400)
|
||||
expect(
|
||||
(await updateprice({ InventionId: Invention.InventionId, Price: 10 }, '9999')).status
|
||||
).toBe(403)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/toptoday + v1/featured serve the invention feeds', async () => {
|
||||
const ids = async (res: Response): Promise<number[]> =>
|
||||
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
||||
|
||||
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
|
||||
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
||||
const beforeFeatured = await ids(
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
|
||||
)
|
||||
expect(beforeFeatured).toEqual(beforeTop)
|
||||
|
||||
const feedInvention = (
|
||||
id: number,
|
||||
downloads: number,
|
||||
extra: Partial<SavedInvention> = {}
|
||||
): SavedInvention =>
|
||||
({
|
||||
InventionId: id,
|
||||
CreatorPlayerId: 8080,
|
||||
Name: `Feed ${id}`,
|
||||
Description: '',
|
||||
ImageName: '',
|
||||
CurrentVersionNumber: 1,
|
||||
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
|
||||
IsPublished: true,
|
||||
IsFeatured: false,
|
||||
HideFromPlayer: false,
|
||||
NumDownloads: downloads,
|
||||
CheerCount: 0,
|
||||
NumPlayersHaveUsedInRoom: 0,
|
||||
CreatedAt: '2026-07-01T00:00:00Z',
|
||||
...extra,
|
||||
}) as unknown as SavedInvention
|
||||
|
||||
for (const inv of [
|
||||
feedInvention(201, 500),
|
||||
feedInvention(202, 9000, { IsFeatured: true, CreatedAt: '2026-07-02T00:00:00Z' }),
|
||||
feedInvention(203, 3000, { IsFeatured: true, CreatedAt: '2026-07-03T00:00:00Z' }),
|
||||
// Unpublished/hidden inventions stay out of both feeds, featured or not.
|
||||
feedInvention(204, 99999, { IsPublished: false, IsFeatured: true }),
|
||||
feedInvention(205, 99999, { HideFromPlayer: true, IsFeatured: true }),
|
||||
]) {
|
||||
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
|
||||
.bind(JSON.stringify(inv))
|
||||
.run()
|
||||
}
|
||||
|
||||
// Top: engagement-ranked, so the biggest download counts lead.
|
||||
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
||||
expect(top.slice(0, 3)).toEqual([202, 203, 201])
|
||||
expect(top).not.toContain(204)
|
||||
expect(top).not.toContain(205)
|
||||
|
||||
// Featured: only the flagged, visible inventions — newest first.
|
||||
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
|
||||
expect(featured).toEqual([203, 202])
|
||||
|
||||
// skip/take paginate the top feed.
|
||||
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
|
||||
expect(await ids(page)).toEqual([203])
|
||||
})
|
||||
|
||||
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
|
||||
const san = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -107,4 +107,10 @@ const app = new Hono<App>()
|
||||
// the path.
|
||||
.get('/room/:dataBlob{.+}', (c) => serveAsset(c, `room/${c.req.param('dataBlob')}`))
|
||||
|
||||
// Invention data by name. The client fetches this for an invention's
|
||||
// `CurrentVersion.BlobName` to spawn it. Streamed from R2 under `invention/`.
|
||||
// Like room blobs the name is date-foldered, and it carries the `.inv` extension
|
||||
// the upload stored it under, so the rest of the path is matched as-is.
|
||||
.get('/invention/:dataBlob{.+}', (c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`))
|
||||
|
||||
export default app
|
||||
|
||||
@@ -64,4 +64,19 @@ describe('cdn endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/room/missing.room`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /invention/:dataBlob streams the invention blob from R2', async () => {
|
||||
// Date-foldered, `.inv`-suffixed — the name the storage worker generates and the
|
||||
// api worker hands back as the invention's BlobName.
|
||||
const name = '2026-07-12/6f1c0c3e-1b6a-4a52-9f52-0f4a1a6d2f77.inv'
|
||||
await env.CDN_ASSETS.put(`invention/${name}`, new Uint8Array([1, 2, 3]))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/invention/${name}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]))
|
||||
})
|
||||
|
||||
test('GET /invention/:dataBlob 404s when the blob is absent', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/invention/missing.inv`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,20 @@ function subfolderForFileType(fileType: string): string | undefined {
|
||||
return UPLOAD_SUBFOLDER[Number.parseInt(fileType, 10)]
|
||||
}
|
||||
|
||||
/**
|
||||
* The file extension an upload of a given type keeps. Invention data blobs are
|
||||
* named `<name>.inv` — the client expects the extension on the `BlobName` it later
|
||||
* gets back from the api worker, so it has to be part of the stored key too, or the
|
||||
* blob wouldn't be there to download. Other types are stored under a bare name.
|
||||
*/
|
||||
const UPLOAD_EXTENSION: Record<number, string> = {
|
||||
5: '.inv',
|
||||
}
|
||||
|
||||
function extensionForFileType(fileType: string): string {
|
||||
return UPLOAD_EXTENSION[Number.parseInt(fileType, 10)] ?? ''
|
||||
}
|
||||
|
||||
/** Read a text form field by any of its accepted names, matched case-insensitively. */
|
||||
function textField(body: Record<string, unknown>, ...names: string[]): string | undefined {
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
@@ -80,16 +94,18 @@ const app = new Hono<App>()
|
||||
const file = Object.values(body).find((v): v is File => v instanceof File)
|
||||
|
||||
if (file) {
|
||||
const subfolder = subfolderForFileType(textField(body, 'filetype') ?? '0')
|
||||
const fileType = textField(body, 'filetype') ?? '0'
|
||||
const subfolder = subfolderForFileType(fileType)
|
||||
if (subfolder === undefined) {
|
||||
// makeUploadName == "" → no destination for an unknown/missing type.
|
||||
return c.json({ error: 'missing or unknown FileType' }, 400)
|
||||
}
|
||||
// Folder each upload under its date (e.g. `room/2026-02-03/<uuid>`) so the
|
||||
// bucket stays browsable. The date is part of the returned name, so the key
|
||||
// the `cdn` worker reads back (`<subfolder>/<name>`) still round-trips.
|
||||
// the `cdn` worker reads back (`<subfolder>/<name>`) still round-trips — as
|
||||
// does the extension, which is why it goes on the key, not just the name.
|
||||
const datePrefix = new Date().toISOString().slice(0, 10)
|
||||
const filename = `${datePrefix}/${crypto.randomUUID()}`
|
||||
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}`
|
||||
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, await file.arrayBuffer(), {
|
||||
httpMetadata: { contentType: file.type || 'application/octet-stream' },
|
||||
})
|
||||
|
||||
@@ -103,6 +103,25 @@ it('POST /upload folders each FileType under its own subfolder', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('POST /upload names an Invention (FileType 5) upload with the .inv extension', async () => {
|
||||
// The client expects the `.inv` extension on the BlobName it later reads back from
|
||||
// the api worker, so the extension has to be on the stored key too — otherwise the
|
||||
// cdn worker would have nothing to serve at that name.
|
||||
const bytes = new Uint8Array([0x49, 0x4e, 0x56])
|
||||
const res = await SELF.fetch(`${ORIGIN}/upload`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
body: uploadForm('5', bytes),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { filename } = (await res.json()) as { filename: string }
|
||||
expect(filename).toMatch(/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}\.inv$/)
|
||||
|
||||
const stored = await env.CDN_ASSETS.get(`invention/${filename}`)
|
||||
expect(stored).not.toBeNull()
|
||||
expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('POST /upload 400s for a binary with an unknown/missing FileType', async () => {
|
||||
// Unknown type (999) and the Unknown enum value (0) have no destination → 400.
|
||||
for (const fileType of ['999', '0']) {
|
||||
|
||||
Reference in New Issue
Block a user