mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
+354
-20
@@ -23,8 +23,8 @@ import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domai
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
|
||||
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
|
||||
* feed's query and `is_published`/`hide_from_player` the "may anyone see this" filter
|
||||
* every feed shares; json_extract of a JSON `true` is 1, so those columns are 1/0 — and
|
||||
* feed's query and `is_published`/`hide_from_player` most of the "may anyone see this"
|
||||
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones); json_extract of a JSON `true` is 1, so those columns are 1/0 — and
|
||||
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
@@ -53,6 +53,16 @@ export interface InventionVersion {
|
||||
ChipsCost: number
|
||||
CloudVariablesCost: number
|
||||
AICost: number
|
||||
/**
|
||||
* Whether the blob uses content still in beta, sent from `v9/save` on. It sits on the
|
||||
* VERSION, where the client's own `RRInventionVersion` carries it and for the same
|
||||
* reason the costs do: it describes the one revision saved, not the invention across
|
||||
* all of them. `UgcVersion`, which reads like its twin, is an INVENTION field — see
|
||||
* {@link SavedInvention}. Absent on a record saved through `v6/save`, which sends
|
||||
* neither; the client's decoder reads a missing member as its default, so an old
|
||||
* record is not retroactively wrong.
|
||||
*/
|
||||
HasBetaContent?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,9 +113,32 @@ export interface SavedInvention {
|
||||
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.
|
||||
* The rest of what `v9/save` sends, kept beside `ReferencedInventions` — the field
|
||||
* they most resemble, and the one this record has always carried on the invention.
|
||||
* Note the v9 client's own `RRInvention` has no `Referenced*` at all (its VERSION
|
||||
* carries them) and no `LongDescription`/`ConvertedFromInventionId` (it sends both and
|
||||
* never reads them back); they are stored anyway, because what the client sent is
|
||||
* worth keeping, and `toSaveResultV9` puts each where that client expects it.
|
||||
*
|
||||
* `UgcVersion` is the exception that has to be got right rather than tolerated: it is
|
||||
* an invention field there, not a version one, next to `CurrentVersionNumber`.
|
||||
*
|
||||
* `DisplayMetadataJson` is stored as the opaque string the client sent: it is the
|
||||
* client's own display state (`{"0":0,"99":0}`), and re-encoding it would be
|
||||
* inventing a schema for something only the client reads.
|
||||
*
|
||||
* Each is absent on a `v6/save` record, which sends none of them.
|
||||
*/
|
||||
ReferencedUnityAssetIds?: string[]
|
||||
UgcVersion?: number
|
||||
LongDescription?: string
|
||||
DisplayMetadataJson?: string
|
||||
ConvertedFromInventionId?: number
|
||||
/**
|
||||
* Tags served by `v1/details` and written by `v1/settags`. Optional: the real
|
||||
* `RRInvention` carries no Tags field, so an untagged invention's DTO stays
|
||||
* identical to the real one. `v6/save` never sets it (that client tags in a second
|
||||
* call); `v9/save` sets it when its `tagsRequest` names at least one tag.
|
||||
*/
|
||||
Tags?: InventionTag[]
|
||||
}
|
||||
@@ -130,6 +163,208 @@ export function toSaveResult(invention: SavedInvention): InventionSaveResult {
|
||||
return { Status: 0, Invention: invention, InventionVersion: invention.CurrentVersion }
|
||||
}
|
||||
|
||||
/**
|
||||
* `TagsResponse.Result` on a v9 save — the client's own tag-result enum, whose members
|
||||
* run Success 0 … ReservedWordViolation 13. Only Success is named: the members between
|
||||
* were not recovered from the client, and it never reads this field anyway, so a refused
|
||||
* tag needs only to be something other than Success.
|
||||
*/
|
||||
export const INVENTION_TAG_RESULT = {
|
||||
success: 0,
|
||||
rejected: 1,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The `Invention` inside a v9 save response — the client's 28-key `RRInvention`, which is
|
||||
* NOT the record this server stores (that one mirrors the older shape the read endpoints
|
||||
* still serve). The differences that matter: no nested `CurrentVersion` (the version rides
|
||||
* beside it), no `Referenced*` (they moved onto the version), no `IsPublished` (the client
|
||||
* infers it from `FirstPublishedAt`), and `UgcVersion`/`LatestVersionNumber`/
|
||||
* `ForceCannotPublish`/`IsRecRoomApproved` that the stored record has no equivalent for.
|
||||
*
|
||||
* The client reads exactly one of these keys — `InventionId` — and its decoder null-checks
|
||||
* every member and drops the ones it doesn't know, so this projection is about being right
|
||||
* rather than about being parseable.
|
||||
*/
|
||||
export interface InventionV9Dto {
|
||||
InventionId: number
|
||||
ReplicationId: string
|
||||
CreatorPlayerId: number
|
||||
Name: string
|
||||
Description: string
|
||||
ImageName: string
|
||||
UgcVersion: number
|
||||
CurrentVersionNumber: number
|
||||
LatestVersionNumber: number
|
||||
Accessibility: number
|
||||
ForceCannotPublish: boolean
|
||||
ModifiedAt: string
|
||||
CreatedAt: string
|
||||
FirstPublishedAt: string | null
|
||||
CreationRoomId: number | null
|
||||
NumPlayersHaveUsedInRoom: number
|
||||
NumDownloads: number
|
||||
CheerCount: number
|
||||
CreatorPermission: number
|
||||
GeneralPermission: number
|
||||
IsAGInvention: boolean
|
||||
IsCertifiedInvention: boolean
|
||||
IsRecRoomApproved: boolean
|
||||
AllowTrial: boolean
|
||||
Price: number | null
|
||||
HideFromPlayer: boolean
|
||||
DisplayMetadataJson: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The `InventionVersion` inside a v9 save response — the client's 13-key
|
||||
* `RRInventionVersion`. It carries `HasBetaContent`, a `CreatedAt` of its own and a
|
||||
* nullable `UgcAccessibility` the stored version has no field for, and notably NO
|
||||
* `AICost`, which the request body still sends and this server still stores.
|
||||
*
|
||||
* Both `Referenced*` lists are emitted here even though the client's DTO has room for one:
|
||||
* which of the two it is wasn't recovered, and an unknown member is dropped silently while
|
||||
* a missing one would be the list the client asked for going astray.
|
||||
*/
|
||||
export interface InventionVersionV9Dto {
|
||||
InventionId: number
|
||||
ReplicationId: string
|
||||
VersionNumber: number
|
||||
HasBetaContent: boolean
|
||||
InstantiationCost: number
|
||||
LightsCost: number
|
||||
ChipsCost: number
|
||||
CloudVariablesCost: number
|
||||
BlobName: string
|
||||
BlobHash: string | null
|
||||
CreatedAt: string
|
||||
UgcAccessibility: number | null
|
||||
ReferencedInventions: number[]
|
||||
ReferencedUnityAssetIds: string[]
|
||||
}
|
||||
|
||||
/** The tag half of a v9 save — `v1/settags`' answer, folded into the save response. */
|
||||
export interface InventionTagsV9Dto {
|
||||
Result: number
|
||||
Tags: string[]
|
||||
}
|
||||
|
||||
/** The four keys inside a v9 save envelope's `Value`. `Status` is 0 on success. */
|
||||
export interface InventionSaveV9Value {
|
||||
Status: number
|
||||
Invention: InventionV9Dto
|
||||
InventionVersion: InventionVersionV9Dto
|
||||
TagsResponse: InventionTagsV9Dto
|
||||
}
|
||||
|
||||
/**
|
||||
* What `v9/save` answers, and the whole reason it isn't just v6 with a bigger body: the
|
||||
* result is ENVELOPED, where v6 serves the bare `{ Status, Invention, InventionVersion }`.
|
||||
*
|
||||
* The client's contract is two fields deep. It checks `Success`, then reads
|
||||
* `Value.Invention.InventionId` and tags the invention with it; `Error` is the only text
|
||||
* that ever reaches a human (it is logged as "Invention datablob upload failed"). `Status`
|
||||
* is deserialized and never read on this route — the failure channel is the envelope, not
|
||||
* the 55-member status enum — and so are `InventionVersion` and `TagsResponse`.
|
||||
*
|
||||
* The one shape that CRASHES the client is `Success: true` with `Value` null or absent: it
|
||||
* dereferences `Value.Invention` unguarded. `Success: false` with a null `Value` is safe —
|
||||
* that branch reads only `Error` — which is why every refusal goes through
|
||||
* {@link inventionSaveV9Failure} rather than answering a bare `{ error }` like v6 does. A
|
||||
* body that doesn't deserialize into this envelope at all is the same crash, so even the
|
||||
* 401 answers it.
|
||||
*/
|
||||
export interface InventionSaveV9Result {
|
||||
Value: InventionSaveV9Value | null
|
||||
Success: boolean
|
||||
Error: string | null
|
||||
error_id: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored invention into the v9 save envelope. `tags` are the ones stored with
|
||||
* it, answered as the bare names `v1/settags` answers with; `tagResult` says whether they
|
||||
* were taken (see {@link INVENTION_TAG_RESULT}) — a tag the rules refuse costs the tags,
|
||||
* never the save, because the save is the thing the player would have to redo.
|
||||
*
|
||||
* Fields the stored record has no equivalent for are served as what they are here rather
|
||||
* than guessed: nothing forces an invention not to publish, and nothing in this server
|
||||
* approves one.
|
||||
*/
|
||||
export function toSaveResultV9(
|
||||
invention: SavedInvention,
|
||||
tags: InventionTag[],
|
||||
tagResult: number = INVENTION_TAG_RESULT.success
|
||||
): InventionSaveV9Result {
|
||||
const version = invention.CurrentVersion
|
||||
return {
|
||||
Value: {
|
||||
Status: 0,
|
||||
Invention: {
|
||||
InventionId: invention.InventionId,
|
||||
ReplicationId: invention.ReplicationId,
|
||||
CreatorPlayerId: invention.CreatorPlayerId,
|
||||
Name: invention.Name,
|
||||
Description: invention.Description,
|
||||
ImageName: invention.ImageName,
|
||||
UgcVersion: invention.UgcVersion ?? 0,
|
||||
CurrentVersionNumber: invention.CurrentVersionNumber,
|
||||
// One save, one version: the newest is the current one.
|
||||
LatestVersionNumber: invention.CurrentVersionNumber,
|
||||
Accessibility: invention.Accessibility,
|
||||
ForceCannotPublish: false,
|
||||
ModifiedAt: invention.ModifiedAt,
|
||||
CreatedAt: invention.CreatedAt,
|
||||
FirstPublishedAt: invention.FirstPublishedAt,
|
||||
CreationRoomId: invention.CreationRoomId,
|
||||
NumPlayersHaveUsedInRoom: invention.NumPlayersHaveUsedInRoom,
|
||||
NumDownloads: invention.NumDownloads,
|
||||
CheerCount: invention.CheerCount,
|
||||
CreatorPermission: invention.CreatorPermission,
|
||||
GeneralPermission: invention.GeneralPermission,
|
||||
IsAGInvention: invention.IsAGInvention,
|
||||
IsCertifiedInvention: invention.IsCertifiedInvention,
|
||||
IsRecRoomApproved: false,
|
||||
AllowTrial: invention.AllowTrial,
|
||||
Price: invention.Price,
|
||||
HideFromPlayer: invention.HideFromPlayer,
|
||||
DisplayMetadataJson: invention.DisplayMetadataJson ?? null,
|
||||
},
|
||||
InventionVersion: {
|
||||
InventionId: version.InventionId,
|
||||
ReplicationId: version.ReplicationId,
|
||||
VersionNumber: version.VersionNumber,
|
||||
HasBetaContent: version.HasBetaContent ?? false,
|
||||
InstantiationCost: version.InstantiationCost,
|
||||
LightsCost: version.LightsCost,
|
||||
ChipsCost: version.ChipsCost,
|
||||
CloudVariablesCost: version.CloudVariablesCost,
|
||||
BlobName: version.BlobName,
|
||||
BlobHash: version.BlobHash,
|
||||
// The version is minted with the invention, so they share a timestamp.
|
||||
CreatedAt: invention.CreatedAt,
|
||||
UgcAccessibility: null,
|
||||
ReferencedInventions: invention.ReferencedInventions,
|
||||
ReferencedUnityAssetIds: invention.ReferencedUnityAssetIds ?? [],
|
||||
},
|
||||
TagsResponse: { Result: tagResult, Tags: tags.map((t) => t.Tag) },
|
||||
},
|
||||
Success: true,
|
||||
Error: null,
|
||||
error_id: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused v9 save. `Value` is null, which is safe precisely because `Success` is false:
|
||||
* the client reads `Error` on that branch and nothing else. See
|
||||
* {@link InventionSaveV9Result} for why the alternative — a bare `{ error }` body — would
|
||||
* take the client down instead.
|
||||
*/
|
||||
export function inventionSaveV9Failure(message: string): InventionSaveV9Result {
|
||||
return { Value: null, Success: false, Error: message, error_id: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -189,6 +424,19 @@ export interface NewInvention {
|
||||
aiCost?: number
|
||||
creationRoomId?: number | null
|
||||
referencedInventions?: number[]
|
||||
/**
|
||||
* The rest of what `v9/save` sends. Every one is optional and is written onto the
|
||||
* record only when the caller actually supplied it, so a `v6/save` — which sends
|
||||
* none of them — stores and answers exactly the record it always did.
|
||||
*/
|
||||
ugcVersion?: number
|
||||
hasBetaContent?: boolean
|
||||
referencedUnityAssetIds?: string[]
|
||||
longDescription?: string | null
|
||||
displayMetadataJson?: string | null
|
||||
convertedFromInventionId?: number | null
|
||||
/** Tags to store with the record, already normalized by {@link normalizeInventionTags}. */
|
||||
tags?: InventionTag[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,6 +449,11 @@ export interface NewInvention {
|
||||
* 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.
|
||||
*
|
||||
* The fields `v9/save` added over `v6/save` are written only when the caller supplies
|
||||
* them, so the record a v6 client stores is byte-for-byte the one it always stored —
|
||||
* the new keys appear on new records rather than being back-filled with defaults onto
|
||||
* every old one.
|
||||
*/
|
||||
export async function createInvention(
|
||||
db: D1Database,
|
||||
@@ -233,6 +486,7 @@ export async function createInvention(
|
||||
ChipsCost: input.chipsCost ?? 0,
|
||||
CloudVariablesCost: input.cloudVariablesCost ?? 0,
|
||||
AICost: input.aiCost ?? 0,
|
||||
...(input.hasBetaContent === undefined ? {} : { HasBetaContent: input.hasBetaContent }),
|
||||
},
|
||||
Accessibility: 0,
|
||||
IsPublished: false,
|
||||
@@ -252,6 +506,16 @@ export async function createInvention(
|
||||
AllowTrial: true,
|
||||
HideFromPlayer: false,
|
||||
ReferencedInventions: input.referencedInventions ?? [],
|
||||
...(input.referencedUnityAssetIds === undefined
|
||||
? {}
|
||||
: { ReferencedUnityAssetIds: input.referencedUnityAssetIds }),
|
||||
...(input.ugcVersion === undefined ? {} : { UgcVersion: input.ugcVersion }),
|
||||
...(input.longDescription ? { LongDescription: input.longDescription } : {}),
|
||||
...(input.displayMetadataJson ? { DisplayMetadataJson: input.displayMetadataJson } : {}),
|
||||
...(typeof input.convertedFromInventionId === 'number'
|
||||
? { ConvertedFromInventionId: input.convertedFromInventionId }
|
||||
: {}),
|
||||
...(input.tags?.length ? { Tags: input.tags } : {}),
|
||||
}
|
||||
await db.prepare('INSERT INTO invention (data) VALUES (?1)').bind(JSON.stringify(invention)).run()
|
||||
return invention
|
||||
@@ -379,7 +643,7 @@ export async function searchInventions(
|
||||
const offset = Math.max(skip, 0)
|
||||
if (limit === 0) return []
|
||||
|
||||
const where = ['is_published = 1', 'hide_from_player = 0']
|
||||
const where = [...VISIBLE_IN_FEEDS]
|
||||
const binds: Array<string | number> = []
|
||||
/** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */
|
||||
const bind = (v: string | number): string => `?${binds.push(v)}`
|
||||
@@ -421,8 +685,7 @@ async function publicInventions(db: D1Database, featuredOnly = false): Promise<S
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM invention
|
||||
WHERE is_published = 1
|
||||
AND hide_from_player = 0
|
||||
WHERE ${VISIBLE_IN_FEEDS.join(' AND ')}
|
||||
${featuredOnly ? 'AND is_featured = 1' : ''}`
|
||||
)
|
||||
.all<InventionRow>()
|
||||
@@ -499,10 +762,9 @@ export async function getFeaturedInventions(
|
||||
/**
|
||||
* 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.
|
||||
* own (Type 0). Both lists are replaced wholesale, normalized as
|
||||
* {@link normalizeInventionTags} describes. Returns the stored tag list, or null when
|
||||
* there's no such invention.
|
||||
*/
|
||||
export async function setInventionTags(
|
||||
db: D1Database,
|
||||
@@ -513,6 +775,21 @@ export async function setInventionTags(
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
|
||||
const tags = normalizeInventionTags(autoTags, customTags)
|
||||
await writeInvention(db, { ...invention, Tags: tags })
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* The two tag lists as they are stored: auto first (Type 2), then custom (Type 0) —
|
||||
* the order they come back in — each trimmed, lowercased and de-duplicated across both
|
||||
* lists so `details` doesn't echo back near-duplicates. Blanks are dropped: the client
|
||||
* pads its lists with empties.
|
||||
*
|
||||
* Shared by `v1/settags` and by `v9/save`, which carries the same two lists in its
|
||||
* `tagsRequest` — a tag has to mean the same thing however it arrived.
|
||||
*/
|
||||
export function normalizeInventionTags(autoTags: string[], customTags: string[]): InventionTag[] {
|
||||
const tags: InventionTag[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const [list, type] of [
|
||||
@@ -526,8 +803,6 @@ export async function setInventionTags(
|
||||
tags.push({ Tag: tag, Type: type })
|
||||
}
|
||||
}
|
||||
|
||||
await writeInvention(db, { ...invention, Tags: tags })
|
||||
return tags
|
||||
}
|
||||
|
||||
@@ -540,6 +815,9 @@ export async function setInventionTags(
|
||||
export const INVENTION_PERMISSION = {
|
||||
unassigned: 0,
|
||||
limitedoneuseonly: 10,
|
||||
// Recovered from the client's own ladder; nothing here sends it, and no name for it
|
||||
// appears in `v1/update`'s picker.
|
||||
disallowkeylock: 15,
|
||||
useonly: 20,
|
||||
editandsave: 40,
|
||||
publish: 60,
|
||||
@@ -547,6 +825,36 @@ export const INVENTION_PERMISSION = {
|
||||
unlimited: 100,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Where a published invention may be FOUND, which `v4/publish` sets and nothing before it
|
||||
* did — every record written before that endpoint carries 0, the value a save mints.
|
||||
*
|
||||
* Only `unlisted` is recovered from the client for certain; the other two mirror the room
|
||||
* accessibility enum, which they match member-for-member, and the publish sheet sends 1 for
|
||||
* an ordinary publish.
|
||||
*
|
||||
* Note what that leaves ambiguous: a stored 0 is either "private" or "written before this
|
||||
* enum meant anything", and the two are indistinguishable without a backfill. So the browse
|
||||
* filter excludes `unlisted` by name rather than requiring `public` — the latter reads
|
||||
* every invention published through `v3/publish` as private and empties the feeds.
|
||||
*/
|
||||
export const INVENTION_ACCESSIBILITY = {
|
||||
private: 0,
|
||||
public: 1,
|
||||
unlisted: 2,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The "anyone may come across this" test the browse feeds and search share: published, not
|
||||
* hidden, and not unlisted. An unlisted invention is still reachable BY ID — that is what
|
||||
* unlisted means — so the by-id reads deliberately don't apply it.
|
||||
*/
|
||||
const VISIBLE_IN_FEEDS = [
|
||||
'is_published = 1',
|
||||
'hide_from_player = 0',
|
||||
`COALESCE(json_extract(data, '$.Accessibility'), 0) <> ${INVENTION_ACCESSIBILITY.unlisted}`,
|
||||
]
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -567,6 +875,13 @@ export interface InventionPatch {
|
||||
imageName?: string
|
||||
allowTrial?: boolean
|
||||
generalPermission?: number
|
||||
/**
|
||||
* The rest of what `v2/metadata` can edit. Undefined leaves the stored value alone,
|
||||
* which is how both editors say "not this field" — `v1/update` by omitting the query
|
||||
* param, `v2/metadata` by sending the key as null.
|
||||
*/
|
||||
longDescription?: string
|
||||
tags?: InventionTag[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -591,11 +906,27 @@ export async function updateInvention(
|
||||
ImageName: patch.imageName ?? invention.ImageName,
|
||||
AllowTrial: patch.allowTrial ?? invention.AllowTrial,
|
||||
GeneralPermission: patch.generalPermission ?? invention.GeneralPermission,
|
||||
// Both of these are optional ON the record, so an untouched one resolves to
|
||||
// undefined and JSON.stringify drops the key — an invention that never had a long
|
||||
// description doesn't acquire an empty one by being edited.
|
||||
LongDescription: patch.longDescription ?? invention.LongDescription,
|
||||
Tags: patch.tags ?? invention.Tags,
|
||||
}
|
||||
await writeInvention(db, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* What a publish decides. Each is optional and an omitted one keeps what the invention
|
||||
* has — except the permission, which falls back to UseOnly, the level the older
|
||||
* `v3/publish` has always defaulted to when its query string named none.
|
||||
*/
|
||||
export interface InventionPublish {
|
||||
permissionLevel?: number
|
||||
accessibility?: number
|
||||
price?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -605,8 +936,7 @@ export async function updateInvention(
|
||||
export async function publishInvention(
|
||||
db: D1Database,
|
||||
inventionId: number,
|
||||
permissionLevel: number | undefined,
|
||||
price: number | undefined
|
||||
publish: InventionPublish = {}
|
||||
): Promise<SavedInvention | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
@@ -614,8 +944,13 @@ export async function publishInvention(
|
||||
const updated: SavedInvention = {
|
||||
...invention,
|
||||
IsPublished: true,
|
||||
GeneralPermission: permissionLevel ?? INVENTION_PERMISSION.useonly,
|
||||
Price: price ?? 0,
|
||||
GeneralPermission: publish.permissionLevel ?? INVENTION_PERMISSION.useonly,
|
||||
Accessibility: publish.accessibility ?? invention.Accessibility,
|
||||
// An unmentioned price is the price it already has, not zero: a republish that says
|
||||
// nothing about money must not quietly give away something that was for sale. A
|
||||
// first publish is unaffected — a fresh invention's price is 0 either way.
|
||||
Price: publish.price ?? invention.Price,
|
||||
// The FIRST publish is the one that gets dated; re-publishing doesn't reset it.
|
||||
FirstPublishedAt: invention.FirstPublishedAt ?? new Date().toISOString(),
|
||||
}
|
||||
await writeInvention(db, updated)
|
||||
@@ -714,8 +1049,7 @@ export async function getInventionsByRoom(
|
||||
.prepare(
|
||||
`SELECT data FROM invention
|
||||
WHERE json_extract(data, '$.CreationRoomId') = ?1
|
||||
AND is_published = 1
|
||||
AND hide_from_player = 0`
|
||||
AND ${VISIBLE_IN_FEEDS.join(' AND ')}`
|
||||
)
|
||||
.bind(roomId)
|
||||
.all<InventionRow>()
|
||||
|
||||
+187
-1
@@ -420,6 +420,10 @@ export const InventionVersionDto = z.object({
|
||||
ChipsCost: z.int(),
|
||||
CloudVariablesCost: z.int(),
|
||||
AICost: z.int(),
|
||||
HasBetaContent: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Set from `v9/save` on — absent on a version saved through `v6/save`'),
|
||||
})
|
||||
|
||||
/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
|
||||
@@ -456,10 +460,30 @@ export const InventionDto = z.object({
|
||||
AllowTrial: z.boolean(),
|
||||
HideFromPlayer: z.boolean(),
|
||||
ReferencedInventions: z.array(z.int()),
|
||||
ReferencedUnityAssetIds: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Set from `v9/save` on — absent on an invention saved through `v6/save`'),
|
||||
UgcVersion: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('An invention field, not a version one — set from `v9/save` on'),
|
||||
LongDescription: z.string().optional().describe('Set from `v9/save` on, when non-empty'),
|
||||
DisplayMetadataJson: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The client’s own display state, stored as the opaque string it sent'),
|
||||
ConvertedFromInventionId: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('The invention this one was converted from, when `v9/save` named one'),
|
||||
Tags: z
|
||||
.array(InventionTagDto)
|
||||
.optional()
|
||||
.describe('Unset on save — the real RRInvention carries no Tags field'),
|
||||
.describe(
|
||||
'The real RRInvention carries no Tags field. Unset by `v6/save`; set by `v9/save` ' +
|
||||
'when its `tagsRequest` names at least one tag'
|
||||
),
|
||||
})
|
||||
|
||||
/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
|
||||
@@ -469,6 +493,88 @@ export const InventionSaveResult = z.object({
|
||||
InventionVersion: InventionVersionDto,
|
||||
})
|
||||
|
||||
/**
|
||||
* The `Invention` a v9 save answers with — the newer client's own `RRInvention`, which is
|
||||
* not the record this server stores or the read endpoints serve: no nested
|
||||
* `CurrentVersion` (the version rides beside it), no `Referenced*` (those moved onto the
|
||||
* version), no `IsPublished`.
|
||||
*/
|
||||
export const InventionV9Dto = z.object({
|
||||
InventionId: z.int(),
|
||||
ReplicationId: z.string(),
|
||||
CreatorPlayerId: z.int(),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
ImageName: z.string(),
|
||||
UgcVersion: z.int().describe('The UGC format the blob was written in; 0 when unsent'),
|
||||
CurrentVersionNumber: z.int(),
|
||||
LatestVersionNumber: z.int().describe('The same as CurrentVersionNumber on a fresh save'),
|
||||
Accessibility: z.int(),
|
||||
ForceCannotPublish: z.boolean().describe('Always false — nothing here forbids publishing'),
|
||||
ModifiedAt: z.string(),
|
||||
CreatedAt: z.string(),
|
||||
FirstPublishedAt: z.string().nullable(),
|
||||
CreationRoomId: z.int().nullable(),
|
||||
NumPlayersHaveUsedInRoom: z.int(),
|
||||
NumDownloads: z.int(),
|
||||
CheerCount: z.int(),
|
||||
CreatorPermission: z.int(),
|
||||
GeneralPermission: z.int(),
|
||||
IsAGInvention: z.boolean(),
|
||||
IsCertifiedInvention: z.boolean(),
|
||||
IsRecRoomApproved: z.boolean().describe('Always false — nothing here approves an invention'),
|
||||
AllowTrial: z.boolean(),
|
||||
Price: z.int().nullable(),
|
||||
HideFromPlayer: z.boolean(),
|
||||
DisplayMetadataJson: z.string().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The `InventionVersion` a v9 save answers with. It carries `HasBetaContent`, a `CreatedAt`
|
||||
* of its own and a nullable `UgcAccessibility`, and notably no `AICost` — which the request
|
||||
* still sends and this server still stores.
|
||||
*/
|
||||
export const InventionVersionV9Dto = z.object({
|
||||
InventionId: z.int(),
|
||||
ReplicationId: z.string(),
|
||||
VersionNumber: z.int(),
|
||||
HasBetaContent: z.boolean(),
|
||||
InstantiationCost: z.int(),
|
||||
LightsCost: z.int(),
|
||||
ChipsCost: z.int(),
|
||||
CloudVariablesCost: z.int(),
|
||||
BlobName: z.string(),
|
||||
BlobHash: z.string().nullable(),
|
||||
CreatedAt: z.string(),
|
||||
UgcAccessibility: z.int().nullable().describe('Always null — versions carry no accessibility'),
|
||||
ReferencedInventions: z.array(z.int()),
|
||||
ReferencedUnityAssetIds: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* What `v9/save` answers — the enveloped result. The client checks `Success` and then reads
|
||||
* `Value.Invention.InventionId`; `Error` is the only text it shows a human, and `Status`,
|
||||
* `InventionVersion` and `TagsResponse` are deserialized and never read. `Success: true`
|
||||
* with a null `Value` crashes it, so a refusal is `Success: false` with `Value: null`.
|
||||
*/
|
||||
export const InventionSaveV9Result = z.object({
|
||||
Value: z
|
||||
.object({
|
||||
Status: z.int().describe('0 = success; the client never reads it on this route'),
|
||||
Invention: InventionV9Dto,
|
||||
InventionVersion: InventionVersionV9Dto,
|
||||
TagsResponse: z.object({
|
||||
Result: z.int().describe('0 = success; non-zero when a tag broke the tag rule'),
|
||||
Tags: z.array(z.string()).describe('The stored tag NAMES, auto first, then custom'),
|
||||
}),
|
||||
})
|
||||
.nullable()
|
||||
.describe('Null when Success is false — and only then'),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('The refusal message; the only text the client shows'),
|
||||
error_id: z.string().nullable().describe('Always null'),
|
||||
})
|
||||
|
||||
/** The tag filter chips on a browse screen, derived from the tags actually in use. */
|
||||
export const TagFilters = z.object({
|
||||
PinnedFilters: z.array(z.string()),
|
||||
@@ -506,6 +612,58 @@ export const SetTagsResponse = z.object({
|
||||
Tags: z.array(z.string()).describe('Auto tags first, then custom'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /api/inventions/v2/metadata` JSON body — PascalCase, and every field but the id is
|
||||
* NULLABLE: the newer client sends the whole shape on every edit and marks the fields it
|
||||
* isn't touching as null. An empty string is not a null — it clears the field.
|
||||
*/
|
||||
export const UpdateInventionMetadataRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
Name: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('3–24 chars, letters/digits/spaces/dashes/colons; null leaves it alone'),
|
||||
Description: z.string().nullable().optional().describe('Max 512 chars; empty clears it'),
|
||||
LongDescription: z.string().nullable().optional().describe('Empty clears it'),
|
||||
ImageName: z.string().nullable().optional().describe('New thumbnail; empty clears it'),
|
||||
TagsRequest: z
|
||||
.object({
|
||||
AutoTags: z.array(z.string()).nullable().optional(),
|
||||
CustomTags: z.array(z.string()).nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Replaces both lists wholesale, as `v1/settags` does; null leaves them alone'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/inventions/v4/publish` JSON body — PascalCase, and nullable the way
|
||||
* `v2/metadata`'s is: a null field keeps what the invention already has.
|
||||
*/
|
||||
export const PublishInventionRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
Permission: z
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'The `GeneralPermission` other players get, as a raw ladder number: Unassigned 0, ' +
|
||||
'LimitedOneUseOnly 10, DisallowKeyLock 15, UseOnly 20, EditAndSave 40, Publish 60, ' +
|
||||
'Charge 80, Unlimited 100. Null publishes as UseOnly'
|
||||
),
|
||||
Accessibility: z
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Private 0, Public 1, Unlisted 2. Unlisted stays out of browse and search'),
|
||||
Price: z
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Price in tokens; null leaves it as it is, and a negative one is ignored'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/updateprice` JSON body. */
|
||||
export const UpdatePriceRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
@@ -533,6 +691,34 @@ export const SaveInventionRequest = z.object({
|
||||
aiCost: z.int().optional(),
|
||||
creationRoomId: z.int().optional(),
|
||||
referencedInventions: z.array(z.int()).optional(),
|
||||
creatorAccountRole: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('Accepted and ignored — a room role, not a permission over the invention'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/inventions/v9/save` JSON body — `v6`’s fields plus what the invention
|
||||
* points at, what it says about itself, and the tags that used to need a second
|
||||
* `v1/settags` call.
|
||||
*/
|
||||
export const SaveInventionV9Request = SaveInventionRequest.extend({
|
||||
ugcVersion: z.int().optional().describe('The UGC format the blob was written in'),
|
||||
hasBetaContent: z.boolean().optional(),
|
||||
referencedUnityAssetIds: z.array(z.string()).optional(),
|
||||
longDescription: z.string().optional().describe('Stored when non-empty'),
|
||||
displayMetadataJson: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Opaque client display state, e.g. `{"0":0,"99":0}`; stored verbatim'),
|
||||
convertedFromInventionId: z.int().nullable().optional(),
|
||||
tagsRequest: z
|
||||
.object({
|
||||
AutoTags: z.array(z.string()).nullable().optional(),
|
||||
CustomTags: z.array(z.string()).nullable().optional(),
|
||||
})
|
||||
.optional()
|
||||
.describe('The same two lists `v1/settags` takes, folded into the save'),
|
||||
})
|
||||
|
||||
// ---- Avatar / custom avatar items ------------------------------------------
|
||||
|
||||
+371
-51
@@ -6,6 +6,7 @@ import {
|
||||
getOutfit,
|
||||
getOutfitsByAccounts,
|
||||
inventionDescriptionRejection,
|
||||
inventionLongDescriptionRejection,
|
||||
inventionNameRejection,
|
||||
inventionTagRejection,
|
||||
MAX_BULK_OUTFIT_ACCOUNTS,
|
||||
@@ -35,6 +36,9 @@ import {
|
||||
getInventionVersion,
|
||||
getMyInventions,
|
||||
getTopInventions,
|
||||
INVENTION_TAG_RESULT,
|
||||
inventionSaveV9Failure,
|
||||
normalizeInventionTags,
|
||||
ownsAllInventions,
|
||||
parsePermissionLevel,
|
||||
publishInvention,
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
setInventionPrice,
|
||||
setInventionTags,
|
||||
toSaveResult,
|
||||
toSaveResultV9,
|
||||
updateInvention,
|
||||
} from '../inventions-db'
|
||||
import {
|
||||
@@ -65,6 +70,7 @@ import {
|
||||
InventionPersonalDetails,
|
||||
InventionReportRequest,
|
||||
InventionSaveResult,
|
||||
InventionSaveV9Result,
|
||||
InventionVersionDto,
|
||||
json,
|
||||
JsonArray,
|
||||
@@ -77,7 +83,9 @@ import {
|
||||
OutfitsMeRequest,
|
||||
OutfitsMeResponse,
|
||||
pageParams,
|
||||
PublishInventionRequest,
|
||||
SaveInventionRequest,
|
||||
SaveInventionV9Request,
|
||||
SetTagsRequest,
|
||||
SetTagsResponse,
|
||||
stringParam,
|
||||
@@ -87,13 +95,14 @@ import {
|
||||
TagFilters,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UpdateCustomAvatarItemRequest,
|
||||
UpdateInventionMetadataRequest,
|
||||
UpdatePriceRequest,
|
||||
} from '../openapi'
|
||||
import { createReport } from '../reports-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
import type { SavedInvention } from '../inventions-db'
|
||||
import type { InventionTag, SavedInvention } from '../inventions-db'
|
||||
|
||||
/**
|
||||
* The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers
|
||||
@@ -133,24 +142,150 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* invention must exist, and it must be theirs. Yields the loaded invention, or why not —
|
||||
* as a reason and the status it maps to, so that a caller answering an envelope can put
|
||||
* the reason where its client will read it instead of in a body that client can't parse.
|
||||
* {@link creatorsInvention} is the rendering the older routes want.
|
||||
*/
|
||||
async function creatorsInventionResult(
|
||||
c: Context<App>,
|
||||
inventionId: number
|
||||
): Promise<
|
||||
{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }
|
||||
> {
|
||||
const playerId = await authedId(c)
|
||||
if (playerId === null) return { rejection: 'Unauthorized', status: 401 }
|
||||
if (Number.isNaN(inventionId)) return { rejection: 'inventionId is required', status: 400 }
|
||||
|
||||
const invention = await getInventionById(c.env.DB, inventionId)
|
||||
if (invention === null) return { rejection: 'No such invention', status: 404 }
|
||||
if (invention.CreatorPlayerId !== playerId) {
|
||||
return { rejection: 'Not your invention', status: 403 }
|
||||
}
|
||||
return { invention }
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link creatorsInventionResult} as the older invention writes answer it: the loaded
|
||||
* invention, or the response to return as-is (400 / 401 / 403 / 404).
|
||||
*/
|
||||
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 gate = await creatorsInventionResult(c, inventionId)
|
||||
if ('invention' in gate) return gate
|
||||
if (gate.status === 401) return { response: unauthorized(c) }
|
||||
if (gate.status === 404) return { response: c.notFound() }
|
||||
return { response: c.json({ error: gate.rejection }, gate.status) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags a `{ AutoTags, CustomTags }` request asks for, and whether they were taken —
|
||||
* the block the v9 save sends as `tagsRequest` and `v2/metadata` sends as `TagsRequest`.
|
||||
* Null when the client named no block at all, which each caller reads its own way: a save
|
||||
* stores no tags, an edit leaves the stored ones alone.
|
||||
*
|
||||
* Tags are held to the same rule `v1/settags` applies, but a tag that breaks it costs the
|
||||
* TAGS and not the write: both replies carry a tag result of their own precisely because
|
||||
* the two outcomes are separate, and refusing a save would make the player redo a build
|
||||
* over a hyphen. All the tags go rather than the offending one alone, so nothing is
|
||||
* silently half-applied — the creator re-submits the list and sees what took. Blanks are
|
||||
* skipped rather than counted against it; the client pads its lists with empties.
|
||||
*/
|
||||
function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: number } | null {
|
||||
if (typeof request !== 'object' || request === null) return null
|
||||
|
||||
const lists = request as Record<string, unknown>
|
||||
const strings = (v: unknown): string[] =>
|
||||
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
||||
const autoTags = strings(lists.AutoTags)
|
||||
const customTags = strings(lists.CustomTags)
|
||||
|
||||
const rejected = [...autoTags, ...customTags].some((raw) => {
|
||||
const tag = raw.trim().toLowerCase()
|
||||
return tag !== '' && inventionTagRejection(tag) !== null
|
||||
})
|
||||
return rejected
|
||||
? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected }
|
||||
: { tags: normalizeInventionTags(autoTags, customTags), tagResult: INVENTION_TAG_RESULT.success }
|
||||
}
|
||||
|
||||
/**
|
||||
* What an invention save produced: the stored record and how its tags fared, or the one
|
||||
* message that refuses it. Both save routes go through {@link createInventionFromBody} to
|
||||
* get one of these and then render it their own way — v6 bare, v9 enveloped — because the
|
||||
* two versions disagree about the shape of a reply, not about what a save is.
|
||||
*/
|
||||
type InventionSaveOutcome =
|
||||
| { rejection: string }
|
||||
| { invention: SavedInvention; tags: InventionTag[]; tagResult: number }
|
||||
|
||||
/**
|
||||
* The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does
|
||||
* plus what the invention points at (`referencedUnityAssetIds`), what it says about itself
|
||||
* (`longDescription`, `displayMetadataJson`, `convertedFromInventionId`), `ugcVersion` and
|
||||
* `hasBetaContent`, and the tags that until now needed a second `v1/settags` call. One
|
||||
* reader takes them all: a v6 client sends none of them, and each is optional, so parsing
|
||||
* them here changes nothing about the record a v6 save stores.
|
||||
*/
|
||||
async function createInventionFromBody(
|
||||
c: Context<App>,
|
||||
creatorPlayerId: number,
|
||||
body: Record<string, unknown>
|
||||
): Promise<InventionSaveOutcome> {
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
const bool = (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : undefined)
|
||||
const list = <T>(v: unknown, is: (x: unknown) => x is T): T[] | undefined =>
|
||||
Array.isArray(v) ? v.filter(is) : undefined
|
||||
const isString = (v: unknown): v is string => typeof v === 'string'
|
||||
const isNumber = (v: unknown): v is number => typeof v === 'number'
|
||||
|
||||
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
|
||||
if (!inventionDataFilename) return { rejection: 'inventionDataFilename is required' }
|
||||
|
||||
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
|
||||
// "No description yet"), so only a supplied one is held to the rules — otherwise
|
||||
// saving an unnamed invention would fail the 3-character minimum on a name the
|
||||
// player never typed.
|
||||
const name = str(body.name)?.trim()
|
||||
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
|
||||
if (nameRejection !== null) return { rejection: nameRejection }
|
||||
|
||||
const description = str(body.description)
|
||||
const descriptionRejection =
|
||||
description === undefined ? null : inventionDescriptionRejection(description)
|
||||
if (descriptionRejection !== null) return { rejection: descriptionRejection }
|
||||
|
||||
// v9 folds `v1/settags` into the save; a client that names no tags gets none.
|
||||
const requested = requestedTags(body.tagsRequest) ?? {
|
||||
tags: [],
|
||||
tagResult: INVENTION_TAG_RESULT.success,
|
||||
}
|
||||
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 }
|
||||
|
||||
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
||||
creatorPlayerId,
|
||||
inventionDataFilename,
|
||||
name,
|
||||
description,
|
||||
imageName: str(body.imageName),
|
||||
instantiationCost: num(body.instantiationCost),
|
||||
lightsCost: num(body.lightsCost),
|
||||
chipsCost: num(body.chipsCost),
|
||||
cloudVariablesCost: num(body.cloudVariablesCost),
|
||||
aiCost: num(body.aiCost),
|
||||
creationRoomId: num(body.creationRoomId),
|
||||
referencedInventions: list(body.referencedInventions, isNumber),
|
||||
ugcVersion: num(body.ugcVersion),
|
||||
hasBetaContent: bool(body.hasBetaContent),
|
||||
referencedUnityAssetIds: list(body.referencedUnityAssetIds, isString),
|
||||
longDescription: str(body.longDescription),
|
||||
displayMetadataJson: str(body.displayMetadataJson),
|
||||
convertedFromInventionId: num(body.convertedFromInventionId),
|
||||
tags: requested.tags,
|
||||
})
|
||||
return { invention, ...requested }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1216,12 +1351,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
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
|
||||
)
|
||||
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
|
||||
permissionLevel:
|
||||
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
|
||||
price: Number.isNaN(price) || price < 0 ? undefined : price,
|
||||
})
|
||||
return published === null ? c.notFound() : c.json(toSaveResult(published))
|
||||
}
|
||||
)
|
||||
@@ -1670,43 +1804,229 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
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 str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
const outcome = await createInventionFromBody(c, id, body)
|
||||
if ('rejection' in outcome) return c.json({ error: outcome.rejection }, 400)
|
||||
return c.json(toSaveResult(outcome.invention))
|
||||
}
|
||||
)
|
||||
|
||||
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
|
||||
if (!inventionDataFilename) {
|
||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
||||
// The same save as the newer client sends it: v6's body plus the invention's
|
||||
// references, its long description and display metadata, what the saved blob is, and
|
||||
// the tags — which v6 clients set afterwards through `v1/settags`. It stores the same
|
||||
// record; what differs is the REPLY, which is enveloped. See `InventionSaveV9Result`:
|
||||
// the client reads `Success` and then `Value.Invention.InventionId`, and a body that
|
||||
// isn't this envelope — a bare `{ error }`, or the empty 401 the other routes answer —
|
||||
// takes it down rather than failing it, which is why every branch below answers one.
|
||||
.post(
|
||||
'/api/inventions/v9/save',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Save a new invention (v9)',
|
||||
description:
|
||||
'`v6/save` plus the fields the newer client sends: `referencedUnityAssetIds`, ' +
|
||||
'`longDescription`, `displayMetadataJson`, `convertedFromInventionId`, ' +
|
||||
'`ugcVersion`, `hasBetaContent`, and a `tagsRequest` carrying the same ' +
|
||||
'`AutoTags`/`CustomTags` lists `v1/settags` takes. Every one is optional and is ' +
|
||||
'stored only when sent, so a body v6 would accept produces the same record here.' +
|
||||
'\n\n' +
|
||||
'The reply is where the two versions part: v9 is ENVELOPED as ' +
|
||||
'`{ Value, Success, Error, error_id }`, with v6’s ' +
|
||||
'`{ Status, Invention, InventionVersion }` inside `Value` alongside a ' +
|
||||
'`TagsResponse`. The client reads `Success` and then ' +
|
||||
'`Value.Invention.InventionId`; `Error` is the only text it ever shows a human.' +
|
||||
'\n\n' +
|
||||
'So a refusal is *also* a 200 carrying `{ Success: false, Error, Value: null }` — ' +
|
||||
'the client dereferences `Value` unguarded when `Success` is true, and treats ' +
|
||||
'anything that isn’t this envelope as a null one. Tags are held to the ' +
|
||||
'`v1/settags` rule (at most 15 letters each), but one that breaks it costs the ' +
|
||||
'tags and not the save: `TagsResponse.Result` comes back non-zero and the creator ' +
|
||||
're-submits them through `v1/settags`.\n\n' +
|
||||
'A freshly saved invention is private: it shows up only in the creator’s own list ' +
|
||||
'until they call `v3/publish`.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SaveInventionV9Request, 'The invention metadata (camelCase)'),
|
||||
responses: {
|
||||
200: json(
|
||||
InventionSaveV9Result,
|
||||
'The envelope — the stored invention under `Value`, or `Success: false` with ' +
|
||||
'`Error` when the save was refused'
|
||||
),
|
||||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json(inventionSaveV9Failure('Unauthorized'), 401)
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||||
|
||||
const outcome = await createInventionFromBody(c, id, body)
|
||||
return c.json(
|
||||
'rejection' in outcome
|
||||
? inventionSaveV9Failure(outcome.rejection)
|
||||
: toSaveResultV9(outcome.invention, outcome.tags, outcome.tagResult)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Edit an invention's metadata, as the newer client sends it: one PUT with a PascalCase
|
||||
// body where every field but the id is nullable, and NULL means "leave this alone" —
|
||||
// the client sends the whole shape every time and marks the fields it isn't touching.
|
||||
// The tags ride along the way they do on `v9/save`, and the reply is that same
|
||||
// envelope: `v1/update` is the older client's version of this endpoint, query params
|
||||
// and a bare body and all.
|
||||
.put(
|
||||
'/api/inventions/v2/metadata',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Edit an invention’s metadata (v2)',
|
||||
description:
|
||||
'Creator only. Every field but `InventionId` is nullable and a null one is left ' +
|
||||
'as it is — the client sends the whole shape on every edit — so this is a patch, ' +
|
||||
'not a replace. An empty string is not a null: it is how a creator CLEARS a ' +
|
||||
'description, long description or image. `Name` is the exception, since a nameless ' +
|
||||
'invention isn’t a thing the client can draw: it is held to the same 3–24 ' +
|
||||
'character rule `v6/save` enforces, which an empty name fails.\n\n' +
|
||||
'`TagsRequest` replaces both tag lists wholesale, exactly as `v1/settags` does; a ' +
|
||||
'null one leaves the stored tags alone. A tag that breaks the tag rule costs the ' +
|
||||
'tags and not the edit — `TagsResponse.Result` comes back non-zero.\n\n' +
|
||||
'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' +
|
||||
'the client re-renders the detail page from `Value.Invention`. Refusals — an ' +
|
||||
'unknown invention and someone else’s alike — are `Success: false` with a null ' +
|
||||
'`Value` rather than a bare error body, which that client cannot parse.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(UpdateInventionMetadataRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(
|
||||
InventionSaveV9Result,
|
||||
'The envelope — the updated invention under `Value`, or `Success: false` with ' +
|
||||
'`Error` when the edit was refused'
|
||||
),
|
||||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||||
|
||||
// The id rides in the body here, not the query string.
|
||||
const gate = await creatorsInventionResult(
|
||||
c,
|
||||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
)
|
||||
// Only a missing token is answered as a transport failure. An unknown invention
|
||||
// or someone else's is a domain answer the client is meant to read — its own
|
||||
// status enum has DoesNotExist and NotCreator members — so it goes in the
|
||||
// envelope, where the message reaches a human.
|
||||
if ('rejection' in gate) {
|
||||
return gate.status === 401
|
||||
? c.json(inventionSaveV9Failure(gate.rejection), 401)
|
||||
: c.json(inventionSaveV9Failure(gate.rejection))
|
||||
}
|
||||
|
||||
// Null is "leave it"; a string, empty or not, is an edit.
|
||||
const edited = (key: string): string | undefined =>
|
||||
typeof body[key] === 'string' ? body[key] : undefined
|
||||
const name = edited('Name')?.trim()
|
||||
const description = edited('Description')
|
||||
const longDescription = edited('LongDescription')
|
||||
|
||||
for (const rejection of [
|
||||
name === undefined ? null : inventionNameRejection(name),
|
||||
description === undefined ? null : inventionDescriptionRejection(description),
|
||||
longDescription === undefined
|
||||
? null
|
||||
: inventionLongDescriptionRejection(longDescription),
|
||||
]) {
|
||||
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
|
||||
}
|
||||
|
||||
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
|
||||
// "No description yet"), so only a supplied one is held to the rules — otherwise
|
||||
// saving an unnamed invention would fail the 3-character minimum on a name the
|
||||
// player never typed.
|
||||
const name = str(body.name)?.trim()
|
||||
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
|
||||
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
|
||||
|
||||
const description = str(body.description)
|
||||
const descriptionRejection =
|
||||
description === undefined ? null : inventionDescriptionRejection(description)
|
||||
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
|
||||
|
||||
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
||||
creatorPlayerId: id,
|
||||
inventionDataFilename,
|
||||
// A null TagsRequest leaves the stored tags alone, and the reply still reports
|
||||
// them: the client reads the list back as the tags the invention now has, not as
|
||||
// the ones this call changed.
|
||||
const requested = requestedTags(body.TagsRequest)
|
||||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||
name,
|
||||
description,
|
||||
imageName: str(body.imageName),
|
||||
instantiationCost: num(body.instantiationCost),
|
||||
lightsCost: num(body.lightsCost),
|
||||
chipsCost: num(body.chipsCost),
|
||||
cloudVariablesCost: num(body.cloudVariablesCost),
|
||||
aiCost: num(body.aiCost),
|
||||
creationRoomId: num(body.creationRoomId),
|
||||
referencedInventions: Array.isArray(body.referencedInventions)
|
||||
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
|
||||
: undefined,
|
||||
longDescription,
|
||||
imageName: edited('ImageName'),
|
||||
tags: requested?.tags,
|
||||
})
|
||||
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
|
||||
return c.json(
|
||||
toSaveResultV9(
|
||||
updated,
|
||||
updated.Tags ?? [],
|
||||
requested?.tagResult ?? INVENTION_TAG_RESULT.success
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Publish an invention, as the newer client sends it: a PascalCase body instead of a
|
||||
// query string, and an Accessibility of its own — where `v3/publish` only ever flipped
|
||||
// the published flag, this decides whether the result can be FOUND. Same enveloped
|
||||
// reply as `v9/save`, carrying the published invention.
|
||||
.post(
|
||||
'/api/inventions/v4/publish',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Publish an invention (v4)',
|
||||
description:
|
||||
'What puts an invention into search and the feeds. Creator only.\n\n' +
|
||||
'`Permission` is the `GeneralPermission` other players get, as a raw ladder ' +
|
||||
'number (the publish sheet sends 20, UseOnly). `Accessibility` says where it can ' +
|
||||
'be found — 1 (Public) lists it, 2 (Unlisted) publishes it reachable by id but ' +
|
||||
'keeps it out of browse and search. A null `Price` leaves the price alone rather ' +
|
||||
'than zeroing it, so re-publishing something that was for sale doesn’t give it ' +
|
||||
'away; every field but `InventionId` is nullable and an omitted one keeps what ' +
|
||||
'the invention has.\n\n' +
|
||||
'Publishing is not undone here, and re-publishing doesn’t re-date the first ' +
|
||||
'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
|
||||
'`v9/save` does.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PublishInventionRequest, 'What the publish decides'),
|
||||
responses: {
|
||||
200: json(
|
||||
InventionSaveV9Result,
|
||||
'The envelope — the published invention under `Value`, or `Success: false` ' +
|
||||
'with `Error` when the publish was refused'
|
||||
),
|
||||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||||
|
||||
const gate = await creatorsInventionResult(
|
||||
c,
|
||||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
)
|
||||
// As on `v2/metadata`: only a missing token is a transport failure. The rest are
|
||||
// answers the client is meant to read out of the envelope.
|
||||
if ('rejection' in gate) {
|
||||
return gate.status === 401
|
||||
? c.json(inventionSaveV9Failure(gate.rejection), 401)
|
||||
: c.json(inventionSaveV9Failure(gate.rejection))
|
||||
}
|
||||
|
||||
// Null is "leave it". The permission and accessibility are taken as sent rather
|
||||
// than checked against the ladder, the way `parsePermissionLevel` already accepts
|
||||
// a raw number: the ladders are the client's, and a level this server hasn't heard
|
||||
// of is better stored than swapped for one the creator didn't pick.
|
||||
const int = (key: string): number | undefined =>
|
||||
typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined
|
||||
const price = int('Price')
|
||||
|
||||
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
|
||||
permissionLevel: int('Permission'),
|
||||
accessibility: int('Accessibility'),
|
||||
// A negative price is dropped rather than stored, as it is on `v3/publish`.
|
||||
price: price !== undefined && price < 0 ? undefined : price,
|
||||
})
|
||||
return c.json(toSaveResult(invention))
|
||||
if (published === null) return c.json(inventionSaveV9Failure('No such invention'))
|
||||
return c.json(toSaveResultV9(published, published.Tags ?? []))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -63,7 +63,11 @@ import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../war
|
||||
import type { SavedImage } from '@repo/domain'
|
||||
import type { Env } from '../../context'
|
||||
import type { EventTag, PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db'
|
||||
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
||||
import type {
|
||||
InventionSaveResult,
|
||||
InventionSaveV9Result,
|
||||
SavedInvention,
|
||||
} from '../../inventions-db'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
@@ -1808,6 +1812,557 @@ describe('public endpoints', () => {
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v9/save answers the enveloped result the client reads', async () => {
|
||||
const body = {
|
||||
name: '082926 13:42:46',
|
||||
description: 'No description yet',
|
||||
imageName: 'invention/2026-08-29/52c1e282-76f5-4974-975f-d85060884085.jpg',
|
||||
hasBetaContent: false,
|
||||
instantiationCost: 101,
|
||||
lightsCost: 0,
|
||||
chipsCost: 0,
|
||||
cloudVariablesCost: 0,
|
||||
aiCost: 0,
|
||||
ugcVersion: 1,
|
||||
creationRoomId: 398,
|
||||
inventionDataFilename: '2026-08-29/cb608051-f38b-4ef2-aa8a-a26eb0195b2b.inv',
|
||||
referencedInventions: [],
|
||||
referencedUnityAssetIds: [],
|
||||
creatorAccountRole: 255,
|
||||
convertedFromInventionId: null,
|
||||
displayMetadataJson: '{"0":0,"99":0}',
|
||||
longDescription: '',
|
||||
tagsRequest: { AutoTags: ['small'], CustomTags: null },
|
||||
}
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5151')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// v9 is ENVELOPED where v6 is bare. The client checks Success and then reads
|
||||
// Value.Invention.InventionId — unguarded, so a true Success with a null Value is
|
||||
// the one shape that takes it down.
|
||||
const result = (await res.json()) as InventionSaveV9Result
|
||||
expect(result.Success).toBe(true)
|
||||
expect(result.Error).toBeNull()
|
||||
expect(result.error_id).toBeNull()
|
||||
const value = result.Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful save')
|
||||
expect(value.Status).toBe(0)
|
||||
expect(Object.keys(value).sort()).toEqual([
|
||||
'Invention',
|
||||
'InventionVersion',
|
||||
'Status',
|
||||
'TagsResponse',
|
||||
])
|
||||
|
||||
const saved = value.Invention
|
||||
expect(saved.InventionId).toBeGreaterThan(0)
|
||||
expect(saved.CreatorPlayerId).toBe(5151)
|
||||
expect(saved.Name).toBe(body.name)
|
||||
expect(saved.CreationRoomId).toBe(398)
|
||||
expect(saved.DisplayMetadataJson).toBe('{"0":0,"99":0}')
|
||||
// UgcVersion is an INVENTION field here, next to the version numbers — not a
|
||||
// version one, where its twin HasBetaContent lives.
|
||||
expect(saved.UgcVersion).toBe(1)
|
||||
expect(saved.CurrentVersionNumber).toBe(1)
|
||||
expect(saved.LatestVersionNumber).toBe(1)
|
||||
// The v9 RRInvention has no nested version, no Referenced* and no IsPublished —
|
||||
// the client reads publication from FirstPublishedAt.
|
||||
expect(saved).not.toHaveProperty('CurrentVersion')
|
||||
expect(saved).not.toHaveProperty('ReferencedInventions')
|
||||
expect(saved).not.toHaveProperty('IsPublished')
|
||||
expect(saved.FirstPublishedAt).toBeNull()
|
||||
|
||||
// Costs, the blob and the beta flag ride on the version beside it. No AICost: the
|
||||
// request sends one and this DTO has nowhere to put it.
|
||||
expect(value.InventionVersion).toMatchObject({
|
||||
InventionId: saved.InventionId,
|
||||
VersionNumber: 1,
|
||||
InstantiationCost: 101,
|
||||
HasBetaContent: false,
|
||||
BlobName: body.inventionDataFilename,
|
||||
UgcAccessibility: null,
|
||||
ReferencedInventions: [],
|
||||
ReferencedUnityAssetIds: [],
|
||||
})
|
||||
expect(value.InventionVersion).not.toHaveProperty('AICost')
|
||||
|
||||
// The tagsRequest is applied as `v1/settags` would have applied it, and answered
|
||||
// the way settags answers: a result code and the bare tag NAMES.
|
||||
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] })
|
||||
const details = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=${saved.InventionId}`
|
||||
)
|
||||
expect(await details.json()).toEqual({ Tags: [{ Tag: 'small', Type: 2 }] })
|
||||
|
||||
// Stored once, read by every version: the older lookup still serves the record it
|
||||
// always did, nested CurrentVersion and all.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${saved.InventionId}`
|
||||
)
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({
|
||||
InventionId: saved.InventionId,
|
||||
IsPublished: false,
|
||||
CurrentVersion: { BlobName: body.inventionDataFilename },
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v9/save leaves the v9-only keys off the stored record', async () => {
|
||||
// The same body a v6 client sends, posted at v9: nothing is back-filled, so the
|
||||
// record is the one v6 has always stored. The response still carries the full v9
|
||||
// projection — those fields have defaults there, not absences.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5152')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Bare Save', inventionDataFilename: 'bare.inv' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful save')
|
||||
expect(value.Invention.UgcVersion).toBe(0)
|
||||
expect(value.Invention.DisplayMetadataJson).toBeNull()
|
||||
expect(value.InventionVersion.HasBetaContent).toBe(false)
|
||||
expect(value.TagsResponse).toEqual({ Result: 0, Tags: [] })
|
||||
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}`
|
||||
)
|
||||
const stored = (await one.json()) as SavedInvention
|
||||
expect(stored).not.toHaveProperty('Tags')
|
||||
expect(stored).not.toHaveProperty('UgcVersion')
|
||||
expect(stored).not.toHaveProperty('ReferencedUnityAssetIds')
|
||||
expect(stored.CurrentVersion).not.toHaveProperty('HasBetaContent')
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v9/save refuses through the envelope, never a bare error', async () => {
|
||||
// A refusal the client can show is Success:false with a null Value — the branch
|
||||
// that reads Error and nothing else. A bare `{ error }` body would deserialize to
|
||||
// a null envelope and take the client down instead of failing the save.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5153')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'No Blob' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'inventionDataFilename is required',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
// Even the 401 answers the envelope: an empty body is a null envelope to the
|
||||
// client, which is the crash, not a refusal.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Anon', inventionDataFilename: 'anon.inv' }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Success: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v9/save keeps the save when a tag breaks the tag rule', async () => {
|
||||
// The reply carries a tag result of its own, so the two outcomes are separate: a
|
||||
// hyphen in a tag must not cost the player the build they just saved.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5154')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Tagged Badly',
|
||||
inventionDataFilename: 'tagged-badly.inv',
|
||||
tagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] },
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const result = (await res.json()) as InventionSaveV9Result
|
||||
expect(result.Success).toBe(true)
|
||||
const value = result.Value
|
||||
if (value === null) throw new Error('a refused tag must not refuse the save')
|
||||
expect(value.Invention.InventionId).toBeGreaterThan(0)
|
||||
|
||||
// Non-zero result, and the whole list dropped rather than the offending tag alone —
|
||||
// the creator re-submits it through `v1/settags` and sees what took.
|
||||
expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] })
|
||||
const details = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=${value.Invention.InventionId}`
|
||||
)
|
||||
expect(await details.json()).toEqual({ Tags: [] })
|
||||
|
||||
// The invention is on the creator's shelf regardless.
|
||||
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer('5154'),
|
||||
})
|
||||
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([
|
||||
value.Invention.InventionId,
|
||||
])
|
||||
})
|
||||
|
||||
test('PUT /api/inventions/v2/metadata edits only the fields that aren’t null', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Before Edit',
|
||||
description: 'the original description',
|
||||
imageName: 'invention/before.jpg',
|
||||
inventionDataFilename: 'before-edit.inv',
|
||||
longDescription: 'the original blurb',
|
||||
tagsRequest: { AutoTags: ['small'], CustomTags: null },
|
||||
}),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
expect(inventionId).toBeGreaterThan(0)
|
||||
|
||||
// The client sends the whole shape every time and marks what it isn't touching as
|
||||
// null — so a null Name must not blank the name.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
InventionId: inventionId,
|
||||
Name: null,
|
||||
Description: 'devin test No description yet',
|
||||
LongDescription: null,
|
||||
ImageName: null,
|
||||
TagsRequest: null,
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const result = (await res.json()) as InventionSaveV9Result
|
||||
expect(result.Success).toBe(true)
|
||||
const value = result.Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful edit')
|
||||
|
||||
// The edit answers the UPDATED invention — the client re-renders the detail page
|
||||
// from it — in the same envelope the save answers.
|
||||
expect(value.Invention.Description).toBe('devin test No description yet')
|
||||
expect(value.Invention.Name).toBe('Before Edit')
|
||||
expect(value.Invention.ImageName).toBe('invention/before.jpg')
|
||||
expect(value.Invention.InventionId).toBe(inventionId)
|
||||
// A null TagsRequest leaves the stored tags alone, and they are still reported: the
|
||||
// list is what the invention HAS, not what this call changed.
|
||||
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] })
|
||||
|
||||
// And it stuck — including the long description, which the v9 Invention DTO has no
|
||||
// key for but the record keeps.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({
|
||||
Name: 'Before Edit',
|
||||
Description: 'devin test No description yet',
|
||||
LongDescription: 'the original blurb',
|
||||
Tags: [{ Tag: 'small', Type: 2 }],
|
||||
})
|
||||
})
|
||||
|
||||
test('PUT /api/inventions/v2/metadata treats an empty string as a clear, not a null', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Clear Me',
|
||||
description: 'to be cleared',
|
||||
imageName: 'invention/clear-me.jpg',
|
||||
inventionDataFilename: 'clear-me.inv',
|
||||
longDescription: 'blurb to be cleared',
|
||||
}),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
InventionId: inventionId,
|
||||
Name: null,
|
||||
Description: '',
|
||||
LongDescription: '',
|
||||
ImageName: '',
|
||||
TagsRequest: { AutoTags: ['large'], CustomTags: ['puzzle'] },
|
||||
}),
|
||||
})
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful edit')
|
||||
expect(value.Invention.Description).toBe('')
|
||||
expect(value.Invention.ImageName).toBe('')
|
||||
// TagsRequest replaces both lists wholesale, auto first, then custom.
|
||||
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['large', 'puzzle'] })
|
||||
|
||||
// An empty name is not how a name is cleared — nothing can draw a nameless
|
||||
// invention, so it fails the same rule a save holds it to and nothing is written.
|
||||
const named = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Name: '' }),
|
||||
})
|
||||
expect(named.status).toBe(200)
|
||||
expect((await named.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Success: false,
|
||||
})
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect(((await one.json()) as SavedInvention).Name).toBe('Clear Me')
|
||||
})
|
||||
|
||||
test('PUT /api/inventions/v2/metadata refuses another creator’s invention in-band', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Not Yours', inventionDataFilename: 'not-yours.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
// Someone else's invention and an unknown one are domain answers, not transport
|
||||
// ones — the client's own status enum has NotCreator and DoesNotExist members — so
|
||||
// they come back 200 in the envelope, where the message reaches a human.
|
||||
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5163')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Description: 'hijacked' }),
|
||||
})
|
||||
expect(theirs.status).toBe(200)
|
||||
expect(await theirs.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'Not your invention',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: 987654, Description: 'nobody' }),
|
||||
})
|
||||
expect(missing.status).toBe(200)
|
||||
expect((await missing.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Error: 'No such invention',
|
||||
})
|
||||
|
||||
// A missing token is the one refusal that stays a transport failure — but it still
|
||||
// answers the envelope, because an unparseable body crashes the client.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Description: 'anon' }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Success: false,
|
||||
})
|
||||
|
||||
// Untouched throughout.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect(((await one.json()) as SavedInvention).Description).toBe('No description yet')
|
||||
})
|
||||
|
||||
test('PUT /api/inventions/v2/metadata keeps the edit when a tag breaks the tag rule', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Tag Trouble',
|
||||
inventionDataFilename: 'tag-trouble.inv',
|
||||
tagsRequest: { AutoTags: ['small'], CustomTags: null },
|
||||
}),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
InventionId: inventionId,
|
||||
Description: 'edited anyway',
|
||||
TagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] },
|
||||
}),
|
||||
})
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
if (value === null) throw new Error('a refused tag must not refuse the edit')
|
||||
// The metadata edit lands; the tags are what didn't.
|
||||
expect(value.Invention.Description).toBe('edited anyway')
|
||||
expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] })
|
||||
const details = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=${inventionId}`
|
||||
)
|
||||
expect(await details.json()).toEqual({ Tags: [] })
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v4/publish publishes with the permission and accessibility sent', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Publish Me', inventionDataFilename: 'publish-me.inv' }),
|
||||
})
|
||||
const saved = ((await save.json()) as InventionSaveV9Result).Value?.Invention
|
||||
expect(saved?.FirstPublishedAt).toBeNull()
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
InventionId: saved?.InventionId,
|
||||
Permission: 20,
|
||||
Accessibility: 1,
|
||||
Price: null,
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const result = (await res.json()) as InventionSaveV9Result
|
||||
expect(result.Success).toBe(true)
|
||||
const value = result.Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful publish')
|
||||
|
||||
// Publishing narrows what everyone else gets down to what the sheet sent, and dates
|
||||
// the invention — the client reads publication from FirstPublishedAt, not a flag.
|
||||
expect(value.Invention.GeneralPermission).toBe(20)
|
||||
expect(value.Invention.Accessibility).toBe(1)
|
||||
expect(typeof value.Invention.FirstPublishedAt).toBe('string')
|
||||
expect(value.Invention.Price).toBe(0)
|
||||
|
||||
// And it's findable now: the record the older reads serve says published, and it
|
||||
// turns up in search.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}`
|
||||
)
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({ IsPublished: true })
|
||||
const found = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?value=Publish Me`)
|
||||
expect(((await found.json()) as SavedInvention[]).map((i) => i.InventionId)).toContain(
|
||||
value.Invention.InventionId
|
||||
)
|
||||
|
||||
// Taken back out: the browse tests below assert the exact published catalogue, and
|
||||
// a test that publishes something publicly is a test that changes it.
|
||||
await env.DB.prepare('DELETE FROM invention WHERE id = ?1')
|
||||
.bind(value.Invention.InventionId)
|
||||
.run()
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the feeds', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Quietly Published',
|
||||
inventionDataFilename: 'quietly-published.inv',
|
||||
creationRoomId: 4171,
|
||||
}),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 2 }),
|
||||
})
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
expect(value?.Invention.Accessibility).toBe(2)
|
||||
|
||||
// Unlisted is published — it is reachable by id, which is the whole point of it —
|
||||
// but it is not something anyone comes across.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({
|
||||
InventionId: inventionId,
|
||||
IsPublished: true,
|
||||
})
|
||||
const found = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v2/search?value=Quietly Published`
|
||||
)
|
||||
expect(await found.json()).toEqual([])
|
||||
const room = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=4171`)
|
||||
expect(await room.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v4/publish leaves a price and a first-publish date alone', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'For Sale', inventionDataFilename: 'for-sale.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
const publish = async (body: Record<string, unknown>) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, ...body }),
|
||||
})
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful publish')
|
||||
return value.Invention
|
||||
}
|
||||
|
||||
const first = await publish({ Permission: 80, Accessibility: 1, Price: 250 })
|
||||
expect(first.Price).toBe(250)
|
||||
|
||||
// A republish that says nothing about money must not give away something that was
|
||||
// for sale, and must not re-date the first publish.
|
||||
const again = await publish({ Permission: 20, Accessibility: 1, Price: null })
|
||||
expect(again.Price).toBe(250)
|
||||
expect(again.GeneralPermission).toBe(20)
|
||||
expect(again.FirstPublishedAt).toBe(first.FirstPublishedAt)
|
||||
|
||||
// A negative price is dropped rather than stored.
|
||||
expect((await publish({ Price: -5 })).Price).toBe(250)
|
||||
|
||||
// Out of the published catalogue again — see the note in the publish test above.
|
||||
await env.DB.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run()
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v4/publish refuses another creator’s invention in-band', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Theirs Alone', inventionDataFilename: 'theirs-alone.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }),
|
||||
})
|
||||
expect(theirs.status).toBe(200)
|
||||
expect(await theirs.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'Not your invention',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({ Value: null })
|
||||
|
||||
// Still unpublished throughout.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({
|
||||
IsPublished: false,
|
||||
FirstPublishedAt: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v2/mine lists bought inventions alongside the caller’s own', async () => {
|
||||
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
|
||||
// exactly this row) and also creates one of their own.
|
||||
@@ -6317,7 +6872,9 @@ describe('openapi', () => {
|
||||
'POST /api/inventions/v1/settags',
|
||||
'POST /api/inventions/v1/update',
|
||||
'POST /api/inventions/v1/updateprice',
|
||||
'POST /api/inventions/v4/publish',
|
||||
'POST /api/inventions/v6/save',
|
||||
'POST /api/inventions/v9/save',
|
||||
'POST /api/messages/v1/friendOnlineStatus',
|
||||
'POST /api/messages/v1/sendMultiple',
|
||||
'POST /api/messages/v2/send',
|
||||
@@ -6350,6 +6907,7 @@ describe('openapi', () => {
|
||||
'POST /outfits/bulk',
|
||||
'POST /statsigUserProperties',
|
||||
'PUT /api/customAvatarItems/v1/{id}',
|
||||
'PUT /api/inventions/v2/metadata',
|
||||
'PUT /api/playerevents/v2/{eventId}/accessibility',
|
||||
'PUT /api/playerevents/v2/{eventId}/description',
|
||||
'PUT /api/playerevents/v2/{eventId}/name',
|
||||
|
||||
Reference in New Issue
Block a user