mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[api] migrate custom avatar items for incoming RRO items
This commit is contained in:
@@ -196,6 +196,17 @@ inconsistency here without checking the client first.
|
|||||||
ten rows as `RankStart` 0, `RankEnd` 9, both inclusive, so reading them as 1-based also
|
ten rows as `RankStart` 0, `RankEnd` 9, both inclusive, so reading them as 1-based also
|
||||||
serves nine rows starting at the runner-up. The unranked sentinel stays a big number
|
serves nine rows starting at the runner-up. The unranked sentinel stays a big number
|
||||||
(99999) precisely because 0 is now a real rank, first place.
|
(99999) precisely because 0 is now a real rank, first place.
|
||||||
|
- A custom avatar item (`api`: `custom_avatar_item`, every `/api/customAvatarItems/*` read) is ONE
|
||||||
|
record for two kinds of thing. A player-made shirt has `BaseAvatarItemId`/`BaseAvatarItemColor`
|
||||||
|
and a design PNG (`DesignFilename`, `ThumbnailImageFilename`) and an empty `CurrentSaves`; a
|
||||||
|
FIRST-PARTY item (imported from the official export, Coach-authored, its own `OutfitType`) has
|
||||||
|
those four NULL and is rendered from `CurrentSaves` — one built Unity assetbundle per
|
||||||
|
`BodyType`, which the client picks by the wearer's body. The row is the record as JSON, so an
|
||||||
|
import is the export verbatim (`apps/api/scripts/import-custom-avatar-items.ts` writes the
|
||||||
|
migration; re-importing an id replaces it). Serve both through one shape: the shirt carries
|
||||||
|
empty `CurrentSaves`/`Tags` and null `CustomBadgeMetadata`, never a missing key. The saves
|
||||||
|
name their assetbundles and thumbnails by bare filename; storing the record does nothing
|
||||||
|
about serving those files.
|
||||||
- Accessibility is sent as the `RoomAccessibility` enum NAME on
|
- Accessibility is sent as the `RoomAccessibility` enum NAME on
|
||||||
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
||||||
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
-- Custom avatar items become a JSON blob per row, the layout every other entity table on this
|
||||||
|
-- database uses (`invention`, `event`, `room`, `account`, `club`): the client's `CustomAvatarItem`
|
||||||
|
-- record verbatim in `data`, with the fields the queries filter and sort on exposed as generated
|
||||||
|
-- (virtual) columns. Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
|
||||||
|
--
|
||||||
|
-- Why: the flat table (0015) had one NOT NULL column per field of the player-made shirt, and the
|
||||||
|
-- FIRST-PARTY items being imported are the same record with those four fields NULL
|
||||||
|
-- (`BaseAvatarItemId`, `BaseAvatarItemColor`, `DesignFilename`, `ThumbnailImageFilename` — a
|
||||||
|
-- studio-built item has no base item and no design PNG) plus fields the table had no home for:
|
||||||
|
-- `CurrentSaves` (one built Unity assetbundle per body type, which is how the client renders
|
||||||
|
-- them), `Tags`, `CustomBadgeMetadata`, `RankedEntityId`. Storing the record whole means the
|
||||||
|
-- import is the record as exported, and a field the source adds later is served without a
|
||||||
|
-- migration. `CurrentSaves` stays embedded rather than in a child table: the client reads a
|
||||||
|
-- save off its item and picks one by `BodyType`; nothing looks a save up by its bare id.
|
||||||
|
--
|
||||||
|
-- The generated columns keep the names the flat table had, so the indexes and every query
|
||||||
|
-- read the same. `name_lower`/`description_lower` back the store's search, which matches the
|
||||||
|
-- needle as a case-folded substring of either. Booleans come out of `json_extract` as 1/0
|
||||||
|
-- (see 0008), so `is_featured = 1` keeps working.
|
||||||
|
--
|
||||||
|
-- The existing player-made rows are carried across by rebuilding the DTO the old `toDto`
|
||||||
|
-- built from their columns — the same record the routes were already serving — with the
|
||||||
|
-- fields the flat table never had filled in as it served them (empty saves and tags, null
|
||||||
|
-- badge/ranking, `RankedEntityId` = the item id, as the official record carries it).
|
||||||
|
|
||||||
|
CREATE TABLE custom_avatar_item_new (
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
custom_avatar_item_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.CustomAvatarItemId')) VIRTUAL,
|
||||||
|
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
|
||||||
|
accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
|
||||||
|
outfit_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.OutfitType')) VIRTUAL,
|
||||||
|
price INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Price')) VIRTUAL,
|
||||||
|
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
|
||||||
|
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.CreatedAt')) VIRTUAL,
|
||||||
|
name_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Name'), ''))) VIRTUAL,
|
||||||
|
description_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Description'), ''))) VIRTUAL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO custom_avatar_item_new (data)
|
||||||
|
SELECT json_object(
|
||||||
|
'CustomAvatarItemId', custom_avatar_item_id,
|
||||||
|
'CreatorAccountId', creator_account_id,
|
||||||
|
'Name', name,
|
||||||
|
'Description', description,
|
||||||
|
'Price', price,
|
||||||
|
'Accessibility', accessibility,
|
||||||
|
'ForceCannotPublish', json(iif(force_cannot_publish, 'true', 'false')),
|
||||||
|
'IsFeatured', json(iif(is_featured, 'true', 'false')),
|
||||||
|
'IsRecRoomApproved', json(iif(is_rec_room_approved, 'true', 'false')),
|
||||||
|
'BaseAvatarItemId', base_avatar_item_id,
|
||||||
|
'BaseAvatarItemColor', base_avatar_item_color,
|
||||||
|
'DesignFilename', design_filename,
|
||||||
|
'ThumbnailImageFilename', thumbnail_image_filename,
|
||||||
|
'CreatedAt', created_at,
|
||||||
|
'ModifiedAt', modified_at,
|
||||||
|
'PreviewOrientation', preview_orientation,
|
||||||
|
'OutfitType', outfit_type,
|
||||||
|
'CurrentSaves', json('[]'),
|
||||||
|
'Tags', json('[]'),
|
||||||
|
'CustomBadgeMetadata', NULL,
|
||||||
|
'RankedEntityId', custom_avatar_item_id,
|
||||||
|
'RankingContext', NULL
|
||||||
|
) FROM custom_avatar_item;
|
||||||
|
|
||||||
|
DROP TABLE custom_avatar_item;
|
||||||
|
ALTER TABLE custom_avatar_item_new RENAME TO custom_avatar_item;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Turns an export of first-party custom avatar items into a D1 migration that seeds the
|
||||||
|
* `custom_avatar_item` table with them, one `INSERT` per record, the way
|
||||||
|
* `apps/rooms/migrations/0002_import_rooms.sql` seeds the rooms.
|
||||||
|
*
|
||||||
|
* bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>
|
||||||
|
*
|
||||||
|
* `export.json` is a JSON array of `CustomAvatarItem` records as the official service serves
|
||||||
|
* them (a `{ Results: [...] }` page or a single record are accepted too). `NNNN` is the
|
||||||
|
* migration number; the file is written to `apps/api/migrations/NNNN_import_custom_avatar_items.sql`.
|
||||||
|
*
|
||||||
|
* Each record is stored VERBATIM as the row's JSON (the table is a JSON blob per row, see
|
||||||
|
* src/custom-avatar-items-db.ts) minus `PurchaseInfo`, which is a store-side projection and
|
||||||
|
* never stored. A record whose id is already present is REPLACED, so re-running an import
|
||||||
|
* with corrected data lands; a `DELETE` is not issued, so player-made items and previously
|
||||||
|
* imported items the new export lacks are left alone.
|
||||||
|
*
|
||||||
|
* The saves name assetbundles and thumbnails by bare filename; the migration does nothing
|
||||||
|
* about those files, which have to be served from wherever the client fetches them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
interface Record_ {
|
||||||
|
CustomAvatarItemId: string
|
||||||
|
PurchaseInfo?: unknown
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const [input, number] = process.argv.slice(2)
|
||||||
|
if (!input || !number || !/^\d{4}$/.test(number)) {
|
||||||
|
console.error('usage: bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(readFileSync(input, 'utf8'))
|
||||||
|
const records: Record_[] = Array.isArray(parsed)
|
||||||
|
? parsed
|
||||||
|
: parsed && typeof parsed === 'object' && Array.isArray((parsed as { Results?: unknown }).Results)
|
||||||
|
? (parsed as { Results: Record_[] }).Results
|
||||||
|
: [parsed as Record_]
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const lines: string[] = [
|
||||||
|
`-- Seed first-party custom avatar items (${records.length} records) from ${input.split('/').pop()}.`,
|
||||||
|
'-- Generated by apps/api/scripts/import-custom-avatar-items.ts — do not edit by hand.',
|
||||||
|
'-- Each record is the official `CustomAvatarItem` verbatim; an existing id is replaced.',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
for (const record of records) {
|
||||||
|
if (typeof record.CustomAvatarItemId !== 'string' || record.CustomAvatarItemId === '') {
|
||||||
|
throw new Error(`record without a CustomAvatarItemId: ${JSON.stringify(record).slice(0, 80)}`)
|
||||||
|
}
|
||||||
|
if (seen.has(record.CustomAvatarItemId)) {
|
||||||
|
throw new Error(`duplicate CustomAvatarItemId in export: ${record.CustomAvatarItemId}`)
|
||||||
|
}
|
||||||
|
seen.add(record.CustomAvatarItemId)
|
||||||
|
const { PurchaseInfo: _purchaseInfo, ...stored } = record
|
||||||
|
const json = JSON.stringify(stored).replaceAll("'", "''")
|
||||||
|
lines.push(
|
||||||
|
`INSERT INTO custom_avatar_item (data) VALUES ('${json}') ON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data;`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = decodeURIComponent(
|
||||||
|
new URL(`../migrations/${number}_import_custom_avatar_items.sql`, import.meta.url).pathname
|
||||||
|
)
|
||||||
|
writeFileSync(out, lines.join('\n') + '\n')
|
||||||
|
console.log(`wrote ${records.length} records to ${out}`)
|
||||||
@@ -1,42 +1,88 @@
|
|||||||
/**
|
/**
|
||||||
* Custom avatar items — player-designed items built on a base catalog item — on the
|
* Custom avatar items on the shared `recflare` D1 database: the player-designed shirts built
|
||||||
* shared `recflare` D1 database. One column per field of the client's `CustomAvatarItem`
|
* on a base catalog item, and the first-party items imported from the official service, which
|
||||||
* DTO, so a row maps straight onto the response.
|
* are the same record with a set of built Unity assetbundles (`CurrentSaves`) in place of a
|
||||||
|
* base item and a design PNG.
|
||||||
*
|
*
|
||||||
* The two uploads that accompany a creation (the design blob and the thumbnail PNG) live
|
* A row is the client's `CustomAvatarItem` record verbatim, as JSON in `data` — the layout the
|
||||||
* in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
|
* `invention`, `event`, `room`, `account` and `club` tables use — with the fields the queries
|
||||||
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; the two filename columns hold
|
* filter and sort on exposed as generated (virtual) columns. So a row IS the response, an
|
||||||
* those bucket keys, which the `img` worker serves back by key.
|
* import is the exported record as-is, and a field the source adds later is served without a
|
||||||
|
* migration. The flat one-column-per-field table this replaced (0015) could not hold a
|
||||||
|
* first-party item at all: its base-item and filename columns were NOT NULL, and it had no home
|
||||||
|
* for the saves.
|
||||||
*
|
*
|
||||||
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql,
|
* `CurrentSaves` stays EMBEDDED rather than in a child table: the client reads a save off its
|
||||||
* applied under its own `migrations_table`).
|
* item and picks one by `BodyType`, and nothing on this server looks a save up by its bare id
|
||||||
|
* (the legacy-item lookup keys them by `AvatarItemDesc`). Room saves got their own table
|
||||||
|
* because the client points at one by bare id; that does not apply here.
|
||||||
|
*
|
||||||
|
* The two uploads that accompany a player's creation (the design blob and the thumbnail PNG)
|
||||||
|
* live in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
|
||||||
|
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; `ThumbnailImageFilename` and
|
||||||
|
* `DesignFilename` hold those bucket keys, which the `img` worker serves back by key. A
|
||||||
|
* first-party item has both null and is rendered from its saves' assetbundles instead.
|
||||||
|
*
|
||||||
|
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql, rebuilt
|
||||||
|
* as JSON by 0022_custom_avatar_item_json.sql, applied under its own `migrations_table`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0015_custom_avatar_item.sql). */
|
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */
|
||||||
export const SCHEMA_DDL: string[] = [
|
export const SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
||||||
custom_avatar_item_id TEXT PRIMARY KEY,
|
data TEXT NOT NULL,
|
||||||
creator_account_id INTEGER NOT NULL,
|
custom_avatar_item_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.CustomAvatarItemId')) VIRTUAL,
|
||||||
name TEXT NOT NULL,
|
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
|
||||||
price INTEGER NOT NULL DEFAULT 0,
|
outfit_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.OutfitType')) VIRTUAL,
|
||||||
accessibility INTEGER NOT NULL DEFAULT 0,
|
price INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Price')) VIRTUAL,
|
||||||
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
|
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
|
||||||
is_featured INTEGER NOT NULL DEFAULT 0,
|
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.CreatedAt')) VIRTUAL,
|
||||||
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
|
name_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Name'), ''))) VIRTUAL,
|
||||||
base_avatar_item_id INTEGER NOT NULL,
|
description_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Description'), ''))) VIRTUAL
|
||||||
base_avatar_item_color TEXT NOT NULL,
|
|
||||||
design_filename TEXT NOT NULL,
|
|
||||||
thumbnail_image_filename TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
modified_at TEXT NOT NULL,
|
|
||||||
preview_orientation INTEGER NOT NULL DEFAULT 0,
|
|
||||||
outfit_type INTEGER NOT NULL DEFAULT 0
|
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/** The client's `CustomAvatarItem` record (PascalCase, as served). */
|
/**
|
||||||
|
* One built version of a first-party item, as the client's PascalCase `CustomAvatarItemSave`:
|
||||||
|
* a Unity assetbundle (`UnityAsset`, with `UnityAsset2` a second variant) the client downloads
|
||||||
|
* to render the item, plus the thumbnail it shows in the store. An item carries one per
|
||||||
|
* `BodyType` it was built for; the client picks the save matching the wearer's body.
|
||||||
|
* `AdditionalConfiguration` is the client's own JSON-in-a-string document and is served as
|
||||||
|
* stored. Not the camelCase `CustomAvatarItemSave` the legacy-item lookup documents — same
|
||||||
|
* save, different casing and field set; keep the two apart.
|
||||||
|
*/
|
||||||
|
export interface CustomAvatarItemSave {
|
||||||
|
CustomAvatarItemSaveId: number
|
||||||
|
CustomAvatarItemId: string
|
||||||
|
UnityAssetId: string
|
||||||
|
BodyType: number
|
||||||
|
OutfitType: number
|
||||||
|
QAState: number
|
||||||
|
CreatedAt: string
|
||||||
|
ModifiedAt: string
|
||||||
|
Description: string | null
|
||||||
|
ThumbnailFileName: string
|
||||||
|
AdditionalConfiguration: string
|
||||||
|
UnityAsset: string
|
||||||
|
UnityAssetHash: string
|
||||||
|
UnityAsset2: string | null
|
||||||
|
UnityAsset2Hash: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
|
||||||
|
export interface CustomAvatarItemTag {
|
||||||
|
TagType: number
|
||||||
|
Value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client's `CustomAvatarItem` record (PascalCase, as served). The four nullable fields are
|
||||||
|
* null on a first-party item, which has no base item or design PNG: it is rendered from
|
||||||
|
* `CurrentSaves` instead, which is empty on a player-made shirt.
|
||||||
|
*/
|
||||||
export interface CustomAvatarItem {
|
export interface CustomAvatarItem {
|
||||||
CustomAvatarItemId: string
|
CustomAvatarItemId: string
|
||||||
CreatorAccountId: number
|
CreatorAccountId: number
|
||||||
@@ -47,16 +93,19 @@ export interface CustomAvatarItem {
|
|||||||
ForceCannotPublish: boolean
|
ForceCannotPublish: boolean
|
||||||
IsFeatured: boolean
|
IsFeatured: boolean
|
||||||
IsRecRoomApproved: boolean
|
IsRecRoomApproved: boolean
|
||||||
BaseAvatarItemId: number
|
BaseAvatarItemId: number | null
|
||||||
BaseAvatarItemColor: string
|
BaseAvatarItemColor: string | null
|
||||||
DesignFilename: string
|
DesignFilename: string | null
|
||||||
ThumbnailImageFilename: string
|
ThumbnailImageFilename: string | null
|
||||||
CreatedAt: string
|
CreatedAt: string
|
||||||
ModifiedAt: string
|
ModifiedAt: string
|
||||||
PreviewOrientation: number
|
PreviewOrientation: number
|
||||||
RankingContext: null
|
RankingContext: null
|
||||||
OutfitType: number
|
OutfitType: number
|
||||||
CurrentSaves: never[]
|
CurrentSaves: CustomAvatarItemSave[]
|
||||||
|
Tags: CustomAvatarItemTag[]
|
||||||
|
CustomBadgeMetadata: unknown
|
||||||
|
RankedEntityId: string
|
||||||
PurchaseInfo: null
|
PurchaseInfo: null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +120,8 @@ export interface CustomAvatarItem {
|
|||||||
* This is what the store's user-generated-content tab searches for, ALONE
|
* This is what the store's user-generated-content tab searches for, ALONE
|
||||||
* (`GET /api/customAvatarItems/v1/search?outfitTypes=105&includeCoachItems=False`), so it is
|
* (`GET /api/customAvatarItems/v1/search?outfitTypes=105&includeCoachItems=False`), so it is
|
||||||
* what every created item must be filed under: the table's default of 0 (Hat) matched nothing,
|
* what every created item must be filed under: the table's default of 0 (Hat) matched nothing,
|
||||||
* and the tab sat empty over a full catalog.
|
* and the tab sat empty over a full catalog. The imported first-party items carry their own
|
||||||
|
* slot (a wing is 100, Shoulder) and are found by the storefront tab's dozen-slot query.
|
||||||
*
|
*
|
||||||
* Not to be confused with the search's `itemTypes`, a different 3-member enum (All -1, None 0,
|
* Not to be confused with the search's `itemTypes`, a different 3-member enum (All -1, None 0,
|
||||||
* Shirt 1) whose Shirt is 1, not 101 or 105. The client sends `itemTypes=-1` (All) alongside
|
* Shirt 1) whose Shirt is 1, not 101 or 105. The client sends `itemTypes=-1` (All) alongside
|
||||||
@@ -97,85 +147,88 @@ export interface CreateCustomAvatarItemInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
custom_avatar_item_id: string
|
data: string
|
||||||
creator_account_id: number
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
price: number
|
|
||||||
accessibility: number
|
|
||||||
force_cannot_publish: number
|
|
||||||
is_featured: number
|
|
||||||
is_rec_room_approved: number
|
|
||||||
base_avatar_item_id: number
|
|
||||||
base_avatar_item_color: string
|
|
||||||
design_filename: string
|
|
||||||
thumbnail_image_filename: string
|
|
||||||
created_at: string
|
|
||||||
modified_at: string
|
|
||||||
preview_orientation: number
|
|
||||||
outfit_type: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a stored record may lack and the response must carry: an imported record predates
|
||||||
|
* some of these, and `PurchaseInfo` is a store-side projection (`econ` prices items), never
|
||||||
|
* stored. Everything the record does carry wins over these.
|
||||||
|
*/
|
||||||
|
const DTO_DEFAULTS = {
|
||||||
|
CurrentSaves: [] as CustomAvatarItemSave[],
|
||||||
|
Tags: [] as CustomAvatarItemTag[],
|
||||||
|
CustomBadgeMetadata: null,
|
||||||
|
RankingContext: null,
|
||||||
|
PurchaseInfo: null,
|
||||||
|
} as const
|
||||||
|
|
||||||
function toDto(row: Row): CustomAvatarItem {
|
function toDto(row: Row): CustomAvatarItem {
|
||||||
return {
|
const stored = JSON.parse(row.data) as CustomAvatarItem
|
||||||
CustomAvatarItemId: row.custom_avatar_item_id,
|
return { ...DTO_DEFAULTS, ...stored, RankingContext: null, PurchaseInfo: null }
|
||||||
CreatorAccountId: row.creator_account_id,
|
|
||||||
Name: row.name,
|
|
||||||
Description: row.description,
|
|
||||||
Price: row.price,
|
|
||||||
Accessibility: row.accessibility,
|
|
||||||
ForceCannotPublish: row.force_cannot_publish === 1,
|
|
||||||
IsFeatured: row.is_featured === 1,
|
|
||||||
IsRecRoomApproved: row.is_rec_room_approved === 1,
|
|
||||||
BaseAvatarItemId: row.base_avatar_item_id,
|
|
||||||
BaseAvatarItemColor: row.base_avatar_item_color,
|
|
||||||
DesignFilename: row.design_filename,
|
|
||||||
ThumbnailImageFilename: row.thumbnail_image_filename,
|
|
||||||
CreatedAt: row.created_at,
|
|
||||||
ModifiedAt: row.modified_at,
|
|
||||||
PreviewOrientation: row.preview_orientation,
|
|
||||||
RankingContext: null,
|
|
||||||
OutfitType: row.outfit_type,
|
|
||||||
CurrentSaves: [],
|
|
||||||
PurchaseInfo: null,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inserts a new custom avatar item and returns it as the client's DTO. */
|
/** Inserts a new player-made custom avatar item and returns it as the client's DTO. */
|
||||||
export async function createCustomAvatarItem(
|
export async function createCustomAvatarItem(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
input: CreateCustomAvatarItemInput,
|
input: CreateCustomAvatarItemInput,
|
||||||
now: Date = new Date()
|
now: Date = new Date()
|
||||||
): Promise<CustomAvatarItem> {
|
): Promise<CustomAvatarItem> {
|
||||||
const ts = now.toISOString()
|
const ts = now.toISOString()
|
||||||
|
const record: Omit<CustomAvatarItem, 'PurchaseInfo'> = {
|
||||||
|
CustomAvatarItemId: input.customAvatarItemId,
|
||||||
|
CreatorAccountId: input.creatorAccountId,
|
||||||
|
Name: input.name,
|
||||||
|
Description: input.description,
|
||||||
|
Price: input.price,
|
||||||
|
Accessibility: input.accessibility,
|
||||||
|
ForceCannotPublish: false,
|
||||||
|
IsFeatured: false,
|
||||||
|
IsRecRoomApproved: false,
|
||||||
|
BaseAvatarItemId: input.baseAvatarItemId,
|
||||||
|
BaseAvatarItemColor: input.baseAvatarItemColor,
|
||||||
|
DesignFilename: input.designFilename,
|
||||||
|
ThumbnailImageFilename: input.thumbnailImageFilename,
|
||||||
|
CreatedAt: ts,
|
||||||
|
ModifiedAt: ts,
|
||||||
|
PreviewOrientation: 0,
|
||||||
|
OutfitType: input.outfitType ?? OUTFIT_TYPE_CUSTOM_SHIRT,
|
||||||
|
CurrentSaves: [],
|
||||||
|
Tags: [],
|
||||||
|
CustomBadgeMetadata: null,
|
||||||
|
RankedEntityId: input.customAvatarItemId,
|
||||||
|
RankingContext: null,
|
||||||
|
}
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare('INSERT INTO custom_avatar_item (data) VALUES (?1) RETURNING data')
|
||||||
`INSERT INTO custom_avatar_item (
|
.bind(JSON.stringify(record))
|
||||||
custom_avatar_item_id, creator_account_id, name, description, price, accessibility,
|
|
||||||
base_avatar_item_id, base_avatar_item_color, design_filename, thumbnail_image_filename,
|
|
||||||
created_at, modified_at, outfit_type
|
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12)
|
|
||||||
RETURNING *`
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
input.customAvatarItemId,
|
|
||||||
input.creatorAccountId,
|
|
||||||
input.name,
|
|
||||||
input.description,
|
|
||||||
input.price,
|
|
||||||
input.accessibility,
|
|
||||||
input.baseAvatarItemId,
|
|
||||||
input.baseAvatarItemColor,
|
|
||||||
input.designFilename,
|
|
||||||
input.thumbnailImageFilename,
|
|
||||||
ts,
|
|
||||||
input.outfitType ?? OUTFIT_TYPE_CUSTOM_SHIRT
|
|
||||||
)
|
|
||||||
.first<Row>()
|
.first<Row>()
|
||||||
if (!row) throw new Error('custom_avatar_item insert returned no row')
|
if (!row) throw new Error('custom_avatar_item insert returned no row')
|
||||||
return toDto(row)
|
return toDto(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a record as exported from the official service (the first-party items), verbatim,
|
||||||
|
* replacing any row with the same `CustomAvatarItemId` so a re-import with corrections lands.
|
||||||
|
* This is what the generated import migration does in SQL; it is here for tests and tooling.
|
||||||
|
*/
|
||||||
|
export async function importCustomAvatarItem(
|
||||||
|
db: D1Database,
|
||||||
|
record: Omit<CustomAvatarItem, 'PurchaseInfo'> & { PurchaseInfo?: unknown }
|
||||||
|
): Promise<CustomAvatarItem> {
|
||||||
|
const { PurchaseInfo: _purchaseInfo, ...stored } = record
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO custom_avatar_item (data) VALUES (?1)
|
||||||
|
ON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data
|
||||||
|
RETURNING data`
|
||||||
|
)
|
||||||
|
.bind(JSON.stringify(stored))
|
||||||
|
.first<Row>()
|
||||||
|
if (!row) throw new Error('custom_avatar_item import returned no row')
|
||||||
|
return toDto(row)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `ItemType` that names a custom avatar item in a UGC-purchasable reference
|
* The `ItemType` that names a custom avatar item in a UGC-purchasable reference
|
||||||
* (`POST /api/ugcPurchasables/v1/items/bulk`'s `Ids[].itemType`).
|
* (`POST /api/ugcPurchasables/v1/items/bulk`'s `Ids[].itemType`).
|
||||||
@@ -200,6 +253,8 @@ export interface UgcPurchasableItem {
|
|||||||
* The store-facing projection of a custom avatar item. `RoomId` is echoed from the
|
* The store-facing projection of a custom avatar item. `RoomId` is echoed from the
|
||||||
* request — the item table has no room; what the client wants it for is still unknown.
|
* request — the item table has no room; what the client wants it for is still unknown.
|
||||||
* `PurchaseCurrencyId` is null (the client's field is nullable) until a currency exists.
|
* `PurchaseCurrencyId` is null (the client's field is nullable) until a currency exists.
|
||||||
|
* `ImageName` is the item's own thumbnail for a player-made shirt; a first-party item has
|
||||||
|
* none at the item level, so the first save's thumbnail stands in.
|
||||||
*/
|
*/
|
||||||
export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPurchasableItem {
|
export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPurchasableItem {
|
||||||
return {
|
return {
|
||||||
@@ -207,7 +262,7 @@ export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPur
|
|||||||
ItemId: item.CustomAvatarItemId,
|
ItemId: item.CustomAvatarItemId,
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
Description: item.Description,
|
Description: item.Description,
|
||||||
ImageName: item.ThumbnailImageFilename,
|
ImageName: item.ThumbnailImageFilename ?? item.CurrentSaves[0]?.ThumbnailFileName ?? '',
|
||||||
RoomId: roomId,
|
RoomId: roomId,
|
||||||
Price: item.Price,
|
Price: item.Price,
|
||||||
PurchaseCurrencyId: null,
|
PurchaseCurrencyId: null,
|
||||||
@@ -224,10 +279,10 @@ export async function getCustomAvatarItems(
|
|||||||
if (ids.length === 0) return []
|
if (ids.length === 0) return []
|
||||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
|
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(`SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
|
.prepare(`SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
|
||||||
.bind(...ids)
|
.bind(...ids)
|
||||||
.all<Row>()
|
.all<Row>()
|
||||||
const byId = new Map(results.map((r) => [r.custom_avatar_item_id, toDto(r)]))
|
const byId = new Map(results.map(toDto).map((item) => [item.CustomAvatarItemId, item]))
|
||||||
return ids.flatMap((id) => byId.get(id) ?? [])
|
return ids.flatMap((id) => byId.get(id) ?? [])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +298,7 @@ export async function listFeaturedCustomAvatarItems(
|
|||||||
): Promise<CustomAvatarItem[]> {
|
): Promise<CustomAvatarItem[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
|
`SELECT data FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
|
||||||
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
|
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
|
||||||
)
|
)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
@@ -263,7 +318,7 @@ export async function listHotCustomAvatarItems(
|
|||||||
): Promise<CustomAvatarItem[]> {
|
): Promise<CustomAvatarItem[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM custom_avatar_item WHERE accessibility != 0
|
`SELECT data FROM custom_avatar_item WHERE accessibility != 0
|
||||||
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
|
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
|
||||||
)
|
)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
@@ -314,10 +369,10 @@ export const SEARCH_MAX_TAKE = 200
|
|||||||
* surface everyone shares, so an unpublished item must not appear here even to its creator (who
|
* surface everyone shares, so an unpublished item must not appear here even to its creator (who
|
||||||
* has `fromCreator` for that).
|
* has `fromCreator` for that).
|
||||||
*
|
*
|
||||||
* `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring.
|
* `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring,
|
||||||
* Both sides are lowered rather than relying on `LIKE`, which folds case for ASCII only and
|
* or its `CustomAvatarItemId` whole. Both sides are lowered rather than relying on `LIKE`, which
|
||||||
* would miss half of what players type. `%` and `_` in the needle are escaped, so searching for
|
* folds case for ASCII only and would miss half of what players type. `%` and `_` in the needle
|
||||||
* a literal one finds it instead of matching everything.
|
* are escaped, so searching for a literal one finds it instead of matching everything.
|
||||||
*
|
*
|
||||||
* `outfitTypes` is a WHITELIST when non-empty and no filter when empty, which is the opposite of
|
* `outfitTypes` is a WHITELIST when non-empty and no filter when empty, which is the opposite of
|
||||||
* how an empty IN () clause reads in SQL: the client sends every type it can render, so treating
|
* how an empty IN () clause reads in SQL: the client sends every type it can render, so treating
|
||||||
@@ -351,8 +406,13 @@ export async function searchCustomAvatarItems(
|
|||||||
// Escaped so a needle of LIKE metacharacters matches them literally rather than everything.
|
// Escaped so a needle of LIKE metacharacters matches them literally rather than everything.
|
||||||
const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`)
|
const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`)
|
||||||
const pattern = bind(`%${escaped}%`)
|
const pattern = bind(`%${escaped}%`)
|
||||||
|
// `name_lower`/`description_lower` are generated columns, lowered once at the row rather
|
||||||
|
// than per query. An id typed in whole finds its item too, since the client searches by
|
||||||
|
// `CustomAvatarItemId` as well as by text.
|
||||||
|
const exact = bind(needle.toLowerCase())
|
||||||
where.push(
|
where.push(
|
||||||
`(lower(name) LIKE ${pattern} ESCAPE '\\' OR lower(description) LIKE ${pattern} ESCAPE '\\')`
|
`(name_lower LIKE ${pattern} ESCAPE '\\' OR description_lower LIKE ${pattern} ESCAPE '\\'` +
|
||||||
|
` OR lower(custom_avatar_item_id) = ${exact})`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +432,7 @@ export async function searchCustomAvatarItems(
|
|||||||
const offset = bind(skip)
|
const offset = bind(skip)
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM custom_avatar_item WHERE ${where.join(' AND ')}
|
`SELECT data FROM custom_avatar_item WHERE ${where.join(' AND ')}
|
||||||
ORDER BY created_at DESC, custom_avatar_item_id
|
ORDER BY created_at DESC, custom_avatar_item_id
|
||||||
LIMIT ${limit} OFFSET ${offset}`
|
LIMIT ${limit} OFFSET ${offset}`
|
||||||
)
|
)
|
||||||
@@ -395,7 +455,7 @@ export async function listCustomAvatarItemsByCreator(
|
|||||||
): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> {
|
): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM custom_avatar_item
|
`SELECT data FROM custom_avatar_item
|
||||||
WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2)
|
WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2)
|
||||||
ORDER BY created_at DESC, custom_avatar_item_id`
|
ORDER BY created_at DESC, custom_avatar_item_id`
|
||||||
)
|
)
|
||||||
@@ -414,8 +474,9 @@ export interface UpdateCustomAvatarItemInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a partial edit to one item, bumping `modified_at`. Fields the caller leaves
|
* Applies a partial edit to one item in place with `json_set`, bumping `ModifiedAt`. Fields
|
||||||
* null keep their value (the client sends every field, nulling the untouched ones).
|
* the caller leaves null keep their value (the client sends every field, nulling the
|
||||||
|
* untouched ones).
|
||||||
* Returns the updated item, or null when no row has that id.
|
* Returns the updated item, or null when no row has that id.
|
||||||
*/
|
*/
|
||||||
export async function updateCustomAvatarItem(
|
export async function updateCustomAvatarItem(
|
||||||
@@ -426,14 +487,14 @@ export async function updateCustomAvatarItem(
|
|||||||
): Promise<CustomAvatarItem | null> {
|
): Promise<CustomAvatarItem | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE custom_avatar_item SET
|
`UPDATE custom_avatar_item SET data = json_set(data,
|
||||||
name = COALESCE(?2, name),
|
'$.Name', coalesce(?2, json_extract(data, '$.Name')),
|
||||||
description = COALESCE(?3, description),
|
'$.Description', coalesce(?3, json_extract(data, '$.Description')),
|
||||||
price = COALESCE(?4, price),
|
'$.Price', coalesce(?4, json_extract(data, '$.Price')),
|
||||||
accessibility = COALESCE(?5, accessibility),
|
'$.Accessibility', coalesce(?5, json_extract(data, '$.Accessibility')),
|
||||||
modified_at = ?6
|
'$.ModifiedAt', ?6)
|
||||||
WHERE custom_avatar_item_id = ?1
|
WHERE custom_avatar_item_id = ?1
|
||||||
RETURNING *`
|
RETURNING data`
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
id,
|
id,
|
||||||
@@ -453,7 +514,7 @@ export async function deleteCustomAvatarItem(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<CustomAvatarItem | null> {
|
): Promise<CustomAvatarItem | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING *')
|
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING data')
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.first<Row>()
|
.first<Row>()
|
||||||
return row ? toDto(row) : null
|
return row ? toDto(row) : null
|
||||||
@@ -465,7 +526,7 @@ export async function getCustomAvatarItem(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<CustomAvatarItem | null> {
|
): Promise<CustomAvatarItem | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare('SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
|
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.first<Row>()
|
.first<Row>()
|
||||||
return row ? toDto(row) : null
|
return row ? toDto(row) : null
|
||||||
|
|||||||
+38
-6
@@ -374,7 +374,36 @@ export const CreateCustomAvatarItemRequest = z.object({
|
|||||||
design: z.string().describe('The design blob (binary file part)'),
|
design: z.string().describe('The design blob (binary file part)'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** The client's `CustomAvatarItem` record. */
|
/**
|
||||||
|
* One built version of a first-party item, PascalCase as it rides inside `CurrentSaves`: the
|
||||||
|
* Unity assetbundle the client downloads to render the item on one `BodyType`, its hash, and
|
||||||
|
* the thumbnail the store shows. `AdditionalConfiguration` is the client's own JSON-in-a-string.
|
||||||
|
* Not the camelCase `CustomAvatarItemSave` of the legacy-item lookup below — same save,
|
||||||
|
* different casing and field set.
|
||||||
|
*/
|
||||||
|
export const CustomAvatarItemCurrentSave = z.object({
|
||||||
|
CustomAvatarItemSaveId: z.int(),
|
||||||
|
CustomAvatarItemId: z.string(),
|
||||||
|
UnityAssetId: z.string(),
|
||||||
|
BodyType: z.int(),
|
||||||
|
OutfitType: z.int(),
|
||||||
|
QAState: z.int(),
|
||||||
|
CreatedAt: z.string(),
|
||||||
|
ModifiedAt: z.string(),
|
||||||
|
Description: z.string().nullable(),
|
||||||
|
ThumbnailFileName: z.string(),
|
||||||
|
AdditionalConfiguration: z.string().describe('JSON-in-a-string'),
|
||||||
|
UnityAsset: z.string(),
|
||||||
|
UnityAssetHash: z.string(),
|
||||||
|
UnityAsset2: z.string().nullable(),
|
||||||
|
UnityAsset2Hash: z.string().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client's `CustomAvatarItem` record, served straight out of the row's JSON. A player-made
|
||||||
|
* shirt has a base item and a design PNG and no saves; a first-party item has the four
|
||||||
|
* base/filename fields null and is rendered from `CurrentSaves` instead.
|
||||||
|
*/
|
||||||
export const CustomAvatarItemDto = z.object({
|
export const CustomAvatarItemDto = z.object({
|
||||||
CustomAvatarItemId: z.string(),
|
CustomAvatarItemId: z.string(),
|
||||||
CreatorAccountId: z.number().int(),
|
CreatorAccountId: z.number().int(),
|
||||||
@@ -385,16 +414,19 @@ export const CustomAvatarItemDto = z.object({
|
|||||||
ForceCannotPublish: z.boolean(),
|
ForceCannotPublish: z.boolean(),
|
||||||
IsFeatured: z.boolean(),
|
IsFeatured: z.boolean(),
|
||||||
IsRecRoomApproved: z.boolean(),
|
IsRecRoomApproved: z.boolean(),
|
||||||
BaseAvatarItemId: z.number().int(),
|
BaseAvatarItemId: z.number().int().nullable(),
|
||||||
BaseAvatarItemColor: z.string(),
|
BaseAvatarItemColor: z.string().nullable(),
|
||||||
DesignFilename: z.string(),
|
DesignFilename: z.string().nullable(),
|
||||||
ThumbnailImageFilename: z.string(),
|
ThumbnailImageFilename: z.string().nullable(),
|
||||||
CreatedAt: z.string(),
|
CreatedAt: z.string(),
|
||||||
ModifiedAt: z.string(),
|
ModifiedAt: z.string(),
|
||||||
PreviewOrientation: z.number().int(),
|
PreviewOrientation: z.number().int(),
|
||||||
RankingContext: z.null(),
|
RankingContext: z.null(),
|
||||||
OutfitType: z.number().int(),
|
OutfitType: z.number().int(),
|
||||||
CurrentSaves: z.array(z.unknown()),
|
CurrentSaves: z.array(CustomAvatarItemCurrentSave),
|
||||||
|
Tags: z.array(z.object({ TagType: z.int(), Value: z.string() })),
|
||||||
|
CustomBadgeMetadata: z.unknown().nullable(),
|
||||||
|
RankedEntityId: z.string(),
|
||||||
PurchaseInfo: z.null(),
|
PurchaseInfo: z.null(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -639,8 +639,13 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
|
|
||||||
const item = await deleteCustomAvatarItem(c.env.DB, itemId)
|
const item = await deleteCustomAvatarItem(c.env.DB, itemId)
|
||||||
if (!item) return fail(404, 'No such item')
|
if (!item) return fail(404, 'No such item')
|
||||||
// The row is gone; the objects follow. A missing key is a no-op for R2.
|
// The row is gone; the objects follow. A missing key is a no-op for R2. A first-party
|
||||||
await c.env.IMAGES.delete([item.ThumbnailImageFilename, item.DesignFilename])
|
// item has neither (it is rendered from its saves' assetbundles), so there is nothing
|
||||||
|
// to delete for one.
|
||||||
|
const keys = [item.ThumbnailImageFilename, item.DesignFilename].filter(
|
||||||
|
(k): k is string => k !== null
|
||||||
|
)
|
||||||
|
if (keys.length > 0) await c.env.IMAGES.delete(keys)
|
||||||
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { banEvasionMatch, resolveBan } from '../../bans-db'
|
|||||||
import {
|
import {
|
||||||
createCustomAvatarItem,
|
createCustomAvatarItem,
|
||||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||||
|
importCustomAvatarItem,
|
||||||
} from '../../custom-avatar-items-db'
|
} from '../../custom-avatar-items-db'
|
||||||
import {
|
import {
|
||||||
countGoing,
|
countGoing,
|
||||||
@@ -999,7 +1000,8 @@ describe('public endpoints', () => {
|
|||||||
const unflagged = await createCustomAvatarItem(env.DB, item('Unflagged', 1))
|
const unflagged = await createCustomAvatarItem(env.DB, item('Unflagged', 1))
|
||||||
// Nothing flags items yet, so flag straight in the table — the unflagged one stays.
|
// Nothing flags items yet, so flag straight in the table — the unflagged one stays.
|
||||||
await env.DB.prepare(
|
await env.DB.prepare(
|
||||||
'UPDATE custom_avatar_item SET is_featured = 1 WHERE custom_avatar_item_id != ?1'
|
`UPDATE custom_avatar_item SET data = json_set(data, '$.IsFeatured', json('true'))
|
||||||
|
WHERE custom_avatar_item_id != ?1`
|
||||||
)
|
)
|
||||||
.bind(unflagged.CustomAvatarItemId)
|
.bind(unflagged.CustomAvatarItemId)
|
||||||
.run()
|
.run()
|
||||||
@@ -1153,7 +1155,8 @@ describe('public endpoints', () => {
|
|||||||
// slots to filter across, so set them straight in the table.
|
// slots to filter across, so set them straight in the table.
|
||||||
expect(created.OutfitType).toBe(105)
|
expect(created.OutfitType).toBe(105)
|
||||||
await env.DB.prepare(
|
await env.DB.prepare(
|
||||||
'UPDATE custom_avatar_item SET outfit_type = ?2 WHERE custom_avatar_item_id = ?1'
|
`UPDATE custom_avatar_item SET data = json_set(data, '$.OutfitType', ?2)
|
||||||
|
WHERE custom_avatar_item_id = ?1`
|
||||||
)
|
)
|
||||||
.bind(created.CustomAvatarItemId, outfitType)
|
.bind(created.CustomAvatarItemId, outfitType)
|
||||||
.run()
|
.run()
|
||||||
@@ -1320,6 +1323,152 @@ describe('public endpoints', () => {
|
|||||||
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/customAvatarItems/v1/search serves an imported first-party item as exported', async () => {
|
||||||
|
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||||
|
|
||||||
|
// A first-party item, as the official service exports it: the same record as a player's
|
||||||
|
// shirt with the base-item and filename fields NULL and a built Unity assetbundle per
|
||||||
|
// body type in `CurrentSaves`. The flat table could not hold this at all; the JSON row
|
||||||
|
// stores it verbatim and the search serves it back as-is, saves included.
|
||||||
|
const wings = {
|
||||||
|
Accessibility: 1,
|
||||||
|
BaseAvatarItemColor: null,
|
||||||
|
BaseAvatarItemId: null,
|
||||||
|
CreatedAt: '2024-09-26T21:32:48.523Z',
|
||||||
|
CreatorAccountId: 1,
|
||||||
|
CurrentSaves: [
|
||||||
|
{
|
||||||
|
AdditionalConfiguration:
|
||||||
|
'{"v":1,"d":{"n":"(BB_SkellyWings_Shoulder)","o":100,"b":0,"ir":[6,184,0],"p":"3a74ca85-fa71-44c7-9c28-aabd605908db","a":"09999705-15d4-4a84-9c0a-65cfe5bde8d7"},"ls":1}',
|
||||||
|
BodyType: 0,
|
||||||
|
CreatedAt: '2024-10-02T22:50:38.809Z',
|
||||||
|
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||||
|
CustomAvatarItemSaveId: 1083,
|
||||||
|
Description: null,
|
||||||
|
ModifiedAt: '2024-11-27T02:34:07.523Z',
|
||||||
|
OutfitType: 100,
|
||||||
|
QAState: 2,
|
||||||
|
ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||||
|
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||||
|
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
|
||||||
|
UnityAsset2Hash: '/QYNs1vk1E+GF6qhVf3eR4YUCb0z4vFVkzPIA6mqjqY=',
|
||||||
|
UnityAssetHash: 'hQjd30phdbC0U7lBMOX/YtpwxEjPwVvVJaQ/4UzKA0Y=',
|
||||||
|
UnityAssetId: '7ba365a7-cf85-4029-80a3-7d0a08ab6ce6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AdditionalConfiguration:
|
||||||
|
'{"v":1,"d":{"n":"(FB_SkellyWings_Shoulder)","o":100,"b":1,"ir":[6,186,0],"p":"09999705-15d4-4a84-9c0a-65cfe5bde8d7","a":"3a74ca85-fa71-44c7-9c28-aabd605908db"},"ls":1}',
|
||||||
|
BodyType: 1,
|
||||||
|
CreatedAt: '2024-10-02T22:52:04.891Z',
|
||||||
|
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||||
|
CustomAvatarItemSaveId: 1085,
|
||||||
|
Description: null,
|
||||||
|
ModifiedAt: '2024-11-27T02:34:07.523Z',
|
||||||
|
OutfitType: 100,
|
||||||
|
QAState: 2,
|
||||||
|
ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png',
|
||||||
|
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
|
||||||
|
UnityAsset2: '5ppm948m3znl2chvbvy88j092.assetbundle',
|
||||||
|
UnityAsset2Hash: 'ZtPbhPSKXkBD9bsmajTmO7A99Ew8tCQBYjE1THE+VUk=',
|
||||||
|
UnityAssetHash: 'TCPaQ1+T74pPBWiVChCYDyC/MNHjHbWEYk7leIqVTOU=',
|
||||||
|
UnityAssetId: '03413da3-e63c-4971-8ca9-c726bc90c0e2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||||
|
CustomBadgeMetadata: null,
|
||||||
|
Description:
|
||||||
|
'This item will have a fix on its connection point to the back on some body types in a future patch.',
|
||||||
|
DesignFilename: null,
|
||||||
|
ForceCannotPublish: false,
|
||||||
|
IsFeatured: false,
|
||||||
|
IsRecRoomApproved: true,
|
||||||
|
ModifiedAt: '2025-02-19T05:19:28.328Z',
|
||||||
|
Name: 'Skeletal Wings',
|
||||||
|
OutfitType: 100,
|
||||||
|
PreviewOrientation: 0,
|
||||||
|
Price: 6000,
|
||||||
|
RankedEntityId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||||
|
RankingContext: null,
|
||||||
|
Tags: [{ TagType: 0, Value: 'export' }],
|
||||||
|
ThumbnailImageFilename: null,
|
||||||
|
}
|
||||||
|
await importCustomAvatarItem(env.DB, wings)
|
||||||
|
// A player-made shirt beside it, to prove the two kinds share one table and one shape.
|
||||||
|
const shirt = await createCustomAvatarItem(env.DB, {
|
||||||
|
customAvatarItemId: crypto.randomUUID(),
|
||||||
|
creatorAccountId: 205,
|
||||||
|
name: 'Skeleton Tee',
|
||||||
|
description: '',
|
||||||
|
price: 0,
|
||||||
|
baseAvatarItemId: 2184,
|
||||||
|
baseAvatarItemColor: '#F55C1A',
|
||||||
|
accessibility: 1,
|
||||||
|
designFilename: 'design_x.bin',
|
||||||
|
thumbnailImageFilename: 'thumb_x.png',
|
||||||
|
})
|
||||||
|
|
||||||
|
const search = async (query: string) => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
|
||||||
|
expect(res.status, query).toBe(200)
|
||||||
|
return (await res.json()) as Array<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// The storefront tab's real query finds it (Coach-authored, OutfitType 100 among the
|
||||||
|
// dozen slots asked for) and serves the record exactly as exported, plus the store-side
|
||||||
|
// `PurchaseInfo` the client asked for with `includePurchaseInfos` (null until priced).
|
||||||
|
const store = await search(
|
||||||
|
'?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' +
|
||||||
|
'&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' +
|
||||||
|
'&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
|
||||||
|
'&take=100&unityAssetTarget=0&unityAssetVersion=3'
|
||||||
|
)
|
||||||
|
expect(store).toEqual([{ ...wings, PurchaseInfo: null }])
|
||||||
|
|
||||||
|
// Text search matches it by name and by description, and by its id typed whole.
|
||||||
|
expect((await search('?searchQuery=skeletal')).map((i) => i.Name)).toEqual(['Skeletal Wings'])
|
||||||
|
expect((await search('?searchQuery=connection%20point')).map((i) => i.Name)).toEqual([
|
||||||
|
'Skeletal Wings',
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
(await search('?searchQuery=83FE651F-15B3-46A7-8AFC-ADEC32C35568')).map((i) => i.Name)
|
||||||
|
).toEqual(['Skeletal Wings'])
|
||||||
|
// "skeleton" is not a substring of "skeletal": only the shirt.
|
||||||
|
expect((await search('?searchQuery=skeleton')).map((i) => i.Name)).toEqual(['Skeleton Tee'])
|
||||||
|
|
||||||
|
// The user-generated-content tab's query does not see it: wrong side of the catalog and
|
||||||
|
// wrong slot both.
|
||||||
|
expect(
|
||||||
|
(await search('?outfitTypes=105&includeCoachItems=False')).map((i) => i.CustomAvatarItemId)
|
||||||
|
).toEqual([shirt.CustomAvatarItemId])
|
||||||
|
|
||||||
|
// A player-made shirt still carries the full shape — empty saves, empty tags — so the
|
||||||
|
// client decodes both kinds through one parser.
|
||||||
|
const [tee] = await search('?searchQuery=skeleton')
|
||||||
|
expect(tee).toMatchObject({
|
||||||
|
BaseAvatarItemId: 2184,
|
||||||
|
CurrentSaves: [],
|
||||||
|
Tags: [],
|
||||||
|
CustomBadgeMetadata: null,
|
||||||
|
RankedEntityId: shirt.CustomAvatarItemId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-importing the same id REPLACES the record (a corrected export lands) rather than
|
||||||
|
// duplicating or being ignored.
|
||||||
|
await importCustomAvatarItem(env.DB, { ...wings, Price: 5000 })
|
||||||
|
expect((await search('?searchQuery=skeletal')).map((i) => i.Price)).toEqual([5000])
|
||||||
|
|
||||||
|
// The bulk lookup resolves it too, saves and all.
|
||||||
|
const bulk = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer('42')) },
|
||||||
|
body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId]]),
|
||||||
|
})
|
||||||
|
expect(bulk.status).toBe(200)
|
||||||
|
const [got] = (await bulk.json()) as Array<{ CurrentSaves: unknown[]; Price: number }>
|
||||||
|
expect(got.CurrentSaves).toHaveLength(2)
|
||||||
|
expect(got.Price).toBe(5000)
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
|
test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
|
||||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||||
const base = {
|
const base = {
|
||||||
@@ -4104,11 +4253,16 @@ describe('custom avatar items', () => {
|
|||||||
BaseAvatarItemColor: '#F55C1A',
|
BaseAvatarItemColor: '#F55C1A',
|
||||||
PreviewOrientation: 0,
|
PreviewOrientation: 0,
|
||||||
RankingContext: null,
|
RankingContext: null,
|
||||||
OutfitType: 0,
|
// Filed as a custom shirt, the slot the store's user-generated-content tab searches.
|
||||||
|
OutfitType: 105,
|
||||||
|
// The full shape a first-party item carries, empty: no built saves, no tags.
|
||||||
CurrentSaves: [],
|
CurrentSaves: [],
|
||||||
|
Tags: [],
|
||||||
|
CustomBadgeMetadata: null,
|
||||||
PurchaseInfo: null,
|
PurchaseInfo: null,
|
||||||
})
|
})
|
||||||
const itemId = body.Value.CustomAvatarItemId as string
|
const itemId = body.Value.CustomAvatarItemId as string
|
||||||
|
expect(body.Value.RankedEntityId).toBe(itemId)
|
||||||
expect(itemId).toMatch(/^[0-9a-f-]{36}$/)
|
expect(itemId).toMatch(/^[0-9a-f-]{36}$/)
|
||||||
const date = (body.Value.CreatedAt as string).slice(0, 10)
|
const date = (body.Value.CreatedAt as string).slice(0, 10)
|
||||||
expect(body.Value.ThumbnailImageFilename).toBe(`avatar-item/${date}/${itemId}-thumb.png`)
|
expect(body.Value.ThumbnailImageFilename).toBe(`avatar-item/${date}/${itemId}-thumb.png`)
|
||||||
@@ -4126,8 +4280,10 @@ describe('custom avatar items', () => {
|
|||||||
new Uint8Array([4, 5, 6])
|
new Uint8Array([4, 5, 6])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The row is the record as JSON, with the queried fields generated off it.
|
||||||
const row = await env.DB.prepare(
|
const row = await env.DB.prepare(
|
||||||
'SELECT name, creator_account_id FROM custom_avatar_item WHERE custom_avatar_item_id = ?1'
|
`SELECT json_extract(data, '$.Name') AS name, creator_account_id
|
||||||
|
FROM custom_avatar_item WHERE custom_avatar_item_id = ?1`
|
||||||
)
|
)
|
||||||
.bind(body.Value.CustomAvatarItemId)
|
.bind(body.Value.CustomAvatarItemId)
|
||||||
.first()
|
.first()
|
||||||
@@ -4302,7 +4458,7 @@ describe('custom avatar items', () => {
|
|||||||
|
|
||||||
// Neither rejected request may create metadata or leave one of its two objects behind.
|
// Neither rejected request may create metadata or leave one of its two objects behind.
|
||||||
const row = await env.DB.prepare(
|
const row = await env.DB.prepare(
|
||||||
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE name = 'bounded'"
|
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE json_extract(data, '$.Name') = 'bounded'"
|
||||||
).first<{ n: number }>()
|
).first<{ n: number }>()
|
||||||
expect(row?.n).toBe(0)
|
expect(row?.n).toBe(0)
|
||||||
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(
|
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(
|
||||||
|
|||||||
Reference in New Issue
Block a user