mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[api] quest asset split on unity target (#108)
* [api] quest asset split * [api] unity swap + hash removal
This commit is contained in:
@@ -220,6 +220,17 @@ inconsistency here without checking the client first.
|
|||||||
empty `CurrentSaves`/`Tags` and null `CustomBadgeMetadata`, never a missing key. The saves
|
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
|
name their assetbundles and thumbnails by bare filename; storing the record does nothing
|
||||||
about serving those files.
|
about serving those files.
|
||||||
|
- An assetbundle is built PER UNITY TARGET, and the client names the one it wants as
|
||||||
|
`unityAssetTarget` on every custom-avatar-item read (0 PC/Windows, 2 Android/Oculus — the
|
||||||
|
rest unobserved; a query param on the GETs, a FORM field on `POST …/v1/bulk`). The row stores
|
||||||
|
the bare PC names; a caller asking for 2 is served `UnityAsset`/`UnityAsset2` under `quest/`
|
||||||
|
(`toQuestCustomAvatarItem`), fetched from the cdn as `/avatar/quest/<name>.assetbundle`.
|
||||||
|
Every read a Quest renders from has to make the switch — `api` search and bulk, AND `econ`
|
||||||
|
`GET /econ/customAvatarItems/v1/owned`, which the client reads FIRST and renders worn items
|
||||||
|
from: with only `api` switched the Quest kept downloading PC bundles. The load BLANKS each
|
||||||
|
save's `UnityAssetHash`/`UnityAsset2Hash` (`""`, null stays null): the export's are the PC
|
||||||
|
builds' hashes, one stored save serves both targets, and the client refuses a download that
|
||||||
|
fails the hash it was given but skips the check on an empty one.
|
||||||
- Buying a custom avatar item goes through the ordinary bag (`econ`: `POST /api/items/bulkpurchase`)
|
- Buying a custom avatar item goes through the ordinary bag (`econ`: `POST /api/items/bulkpurchase`)
|
||||||
as a line whose `ItemPurchaseMethodId` is `{ Type: 1, Guid: <CustomAvatarItemId> }`, beside
|
as a line whose `ItemPurchaseMethodId` is `{ Type: 1, Guid: <CustomAvatarItemId> }`, beside
|
||||||
the `Type: 0, NumberId` catalog lines. It is a SALE between players: the price leaves the buyer
|
the `Type: 0, NumberId` catalog lines. It is a SALE between players: the price leaves the buyer
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
* `just admin cai-load` instead, which runs the same statements straight against a database.
|
* `just admin cai-load` instead, which runs the same statements straight against a database.
|
||||||
* The statements come from src/custom-avatar-items-load.ts either way: each record stored as
|
* The statements come from src/custom-avatar-items-load.ts either way: each record stored as
|
||||||
* the row's JSON minus `PurchaseInfo`, with `CreatorAccountId` forced to the Coach account (1)
|
* the row's JSON minus `PurchaseInfo`, with `CreatorAccountId` forced to the Coach account (1)
|
||||||
* and each save's `ThumbnailFileName` put under `avatar/`, an existing id REPLACED, nothing
|
* and each save's `ThumbnailFileName` put under `avatar/` and its assetbundle hashes blanked,
|
||||||
* deleted.
|
* an existing id REPLACED, nothing deleted.
|
||||||
*
|
*
|
||||||
* The export's `Price` is mostly 0; run `price-custom-avatar-items.ts` against it first to fill
|
* The export's `Price` is mostly 0; run `price-custom-avatar-items.ts` against it first to fill
|
||||||
* in prices from the storefront dump.
|
* in prices from the storefront dump.
|
||||||
|
|||||||
@@ -296,6 +296,59 @@ export async function getCustomAvatarItems(
|
|||||||
return ids.flatMap((id) => byId.get(id) ?? [])
|
return ids.flatMap((id) => byId.get(id) ?? [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the QUEST builds of the assetbundles live, relative to the PC ones. An assetbundle is
|
||||||
|
* built per Unity target, and a save names its bundle by bare filename for the client to fetch
|
||||||
|
* from the cdn's `/avatar/`; the Android build of the same bundle keeps the same filename one
|
||||||
|
* folder down, so a caller asking for the Quest target is served `quest/<name>.assetbundle` and asks the cdn for
|
||||||
|
* `/avatar/quest/<name>.assetbundle`.
|
||||||
|
*/
|
||||||
|
export const QUEST_ASSET_PREFIX = 'quest/'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `unityAssetTarget` a Quest asks with — the Unity build target the caller wants its
|
||||||
|
* assetbundles built for, which the client names on every custom-avatar-item read. 0 is PC
|
||||||
|
* (Windows), and is what the saves' bare names are; 2 has been SEEN from the Android/Oculus
|
||||||
|
* client and is read as that. The rest of the enum has not been observed.
|
||||||
|
*/
|
||||||
|
export const UNITY_ASSET_TARGET_QUEST = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a request's `unityAssetTarget` asks for the Quest builds. Anything else — 0, a
|
||||||
|
* target not seen yet, or none at all — is served the PC names, which is what was served
|
||||||
|
* before targets were read.
|
||||||
|
*/
|
||||||
|
export function isQuestAssetTarget(target: string | null | undefined): boolean {
|
||||||
|
return target?.trim() === String(UNITY_ASSET_TARGET_QUEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An assetbundle name under {@link QUEST_ASSET_PREFIX}. Only `.assetbundle` names are touched,
|
||||||
|
* and one that already carries the prefix is left alone; null (an absent `UnityAsset2`) is
|
||||||
|
* returned as it came.
|
||||||
|
*/
|
||||||
|
function questAsset<T extends string | null>(name: T): T | string {
|
||||||
|
if (name === null || !name.endsWith('.assetbundle')) return name
|
||||||
|
return name.startsWith(QUEST_ASSET_PREFIX) ? name : QUEST_ASSET_PREFIX + name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An item as a caller asking for the Quest target is served it: every save's `UnityAsset`/`UnityAsset2` pointed at
|
||||||
|
* the Quest build (see {@link QUEST_ASSET_PREFIX}). Per-response, never stored — the row keeps
|
||||||
|
* the bare names, which are what every other target is served. The hashes are left as
|
||||||
|
* stored. A player-made shirt has no saves and comes back unchanged.
|
||||||
|
*/
|
||||||
|
export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
CurrentSaves: item.CurrentSaves.map((save) => ({
|
||||||
|
...save,
|
||||||
|
UnityAsset: questAsset(save.UnityAsset),
|
||||||
|
UnityAsset2: questAsset(save.UnityAsset2),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
|
* The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
|
||||||
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
|
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
|
||||||
|
|||||||
@@ -78,8 +78,21 @@ export function prefixSaveThumbnail(name: unknown): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One record as the SQL string literal its row holds: the JSON, minus `PurchaseInfo`, with two
|
* A save's assetbundle hash as it is stored: BLANK. The export's `UnityAssetHash` /
|
||||||
* fields rewritten.
|
* `UnityAsset2Hash` are the hashes of the PC builds, and one stored save is served to every
|
||||||
|
* build target — a Quest asking with `unityAssetTarget=2` is pointed at the `quest/` build of
|
||||||
|
* the same bundle, whose bytes (and so whose hash) differ. The client checks a download against
|
||||||
|
* a hash it is given and refuses the mismatch, so the Quest rendered nothing until these were
|
||||||
|
* emptied by hand; given `""` it skips the check. A hash that is already null — the
|
||||||
|
* `UnityAsset2Hash` of a save with no second bundle — stays null, so the pair keeps agreeing.
|
||||||
|
*/
|
||||||
|
export function blankAssetHash(hash: unknown): unknown {
|
||||||
|
return typeof hash === 'string' ? '' : hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One record as the SQL string literal its row holds: the JSON, minus `PurchaseInfo`, with
|
||||||
|
* three things rewritten.
|
||||||
*
|
*
|
||||||
* `CreatorAccountId` is overridden to {@link COACH_ACCOUNT_ID} whatever the export said. An
|
* `CreatorAccountId` is overridden to {@link COACH_ACCOUNT_ID} whatever the export said. An
|
||||||
* imported item is first-party content by definition, and the creator id is what makes it so
|
* imported item is first-party content by definition, and the creator id is what makes it so
|
||||||
@@ -90,22 +103,28 @@ export function prefixSaveThumbnail(name: unknown): unknown {
|
|||||||
* account happened to share the number.
|
* account happened to share the number.
|
||||||
*
|
*
|
||||||
* Each save's `ThumbnailFileName` is prefixed with {@link SAVE_THUMBNAIL_PREFIX}, which is where
|
* Each save's `ThumbnailFileName` is prefixed with {@link SAVE_THUMBNAIL_PREFIX}, which is where
|
||||||
* this server serves those images from. The rest of the save — the assetbundle names above
|
* this server serves those images from, and its `UnityAssetHash`/`UnityAsset2Hash` are blanked
|
||||||
* all — is untouched.
|
* (see {@link blankAssetHash}). The rest of the save — the assetbundle names above all — is
|
||||||
|
* untouched.
|
||||||
*/
|
*/
|
||||||
export function customAvatarItemRowLiteral(record: CustomAvatarItemExportRecord): string {
|
export function customAvatarItemRowLiteral(record: CustomAvatarItemExportRecord): string {
|
||||||
const { PurchaseInfo: _purchaseInfo, ...stored } = record
|
const { PurchaseInfo: _purchaseInfo, ...stored } = record
|
||||||
const saves = Array.isArray(stored.CurrentSaves)
|
const saves = Array.isArray(stored.CurrentSaves)
|
||||||
? stored.CurrentSaves.map((save: unknown) =>
|
? stored.CurrentSaves.map((save: unknown) => {
|
||||||
save && typeof save === 'object' && !Array.isArray(save)
|
if (!save || typeof save !== 'object' || Array.isArray(save)) return save
|
||||||
? {
|
const fields = save as Record<string, unknown>
|
||||||
...(save as Record<string, unknown>),
|
return {
|
||||||
ThumbnailFileName: prefixSaveThumbnail(
|
...fields,
|
||||||
(save as { ThumbnailFileName?: unknown }).ThumbnailFileName
|
ThumbnailFileName: prefixSaveThumbnail(fields.ThumbnailFileName),
|
||||||
),
|
// Only where the export carries the key, so a save is never GIVEN a field.
|
||||||
|
...('UnityAssetHash' in fields && {
|
||||||
|
UnityAssetHash: blankAssetHash(fields.UnityAssetHash),
|
||||||
|
}),
|
||||||
|
...('UnityAsset2Hash' in fields && {
|
||||||
|
UnityAsset2Hash: blankAssetHash(fields.UnityAsset2Hash),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
: save
|
})
|
||||||
)
|
|
||||||
: stored.CurrentSaves
|
: stored.CurrentSaves
|
||||||
const row = { ...stored, CreatorAccountId: COACH_ACCOUNT_ID, CurrentSaves: saves }
|
const row = { ...stored, CreatorAccountId: COACH_ACCOUNT_ID, CurrentSaves: saves }
|
||||||
return `'${JSON.stringify(row).replaceAll("'", "''")}'`
|
return `'${JSON.stringify(row).replaceAll("'", "''")}'`
|
||||||
|
|||||||
@@ -879,6 +879,13 @@ export const BulkCustomAvatarItemsRequest = z.object({
|
|||||||
customAvatarItemIds: z
|
customAvatarItemIds: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.describe('The ids to resolve; repeat the field once per id'),
|
.describe('The ids to resolve; repeat the field once per id'),
|
||||||
|
unityAssetTarget: z
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
|
||||||
|
'Android/Oculus — which is served the `quest/` builds. Read off the query string too'
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** A paginated custom-avatar-item page, out of the `custom_avatar_item` table. */
|
/** A paginated custom-avatar-item page, out of the `custom_avatar_item` table. */
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ import {
|
|||||||
deleteCustomAvatarItem,
|
deleteCustomAvatarItem,
|
||||||
getCustomAvatarItem,
|
getCustomAvatarItem,
|
||||||
getCustomAvatarItems,
|
getCustomAvatarItems,
|
||||||
|
isQuestAssetTarget,
|
||||||
listCustomAvatarItemsByCreator,
|
listCustomAvatarItemsByCreator,
|
||||||
listFeaturedCustomAvatarItems,
|
listFeaturedCustomAvatarItems,
|
||||||
listHotCustomAvatarItems,
|
listHotCustomAvatarItems,
|
||||||
searchCustomAvatarItems,
|
searchCustomAvatarItems,
|
||||||
|
toQuestCustomAvatarItem,
|
||||||
updateCustomAvatarItem,
|
updateCustomAvatarItem,
|
||||||
} from '../custom-avatar-items-db'
|
} from '../custom-avatar-items-db'
|
||||||
import { authedId, unauthorized } from '../http'
|
import { authedId, unauthorized } from '../http'
|
||||||
@@ -150,6 +152,22 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
|
|||||||
.filter((v) => v !== '')
|
.filter((v) => v !== '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `unityAssetTarget` `POST /api/customAvatarItems/v1/bulk` was asked for, read the way
|
||||||
|
* {@link bulkCustomAvatarItemIds} reads the ids and for the same reason: off the query string
|
||||||
|
* (where the GET reads carry it) or out of the form, whatever the casing. `parseBody` caches,
|
||||||
|
* so reading the form a second time costs nothing.
|
||||||
|
*/
|
||||||
|
async function bulkUnityAssetTarget(c: Context<App>): Promise<string | undefined> {
|
||||||
|
const queried = c.req.query('unityAssetTarget')
|
||||||
|
if (queried !== undefined) return queried
|
||||||
|
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||||
|
const key = Object.keys(body).find((k) => k.toLowerCase() === 'unityassettarget')
|
||||||
|
const posted = key === undefined ? undefined : body[key]
|
||||||
|
const value = Array.isArray(posted) ? posted[0] : posted
|
||||||
|
return typeof value === 'string' ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The gate every invention write runs through: the caller must be signed in, the
|
* 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 why not —
|
* invention must exist, and it must be theirs. Yields the loaded invention, or why not —
|
||||||
@@ -695,10 +713,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
'`outfitTypes` values are `RecRoom.Avatars.OutfitType` ordinals; the user-generated-',
|
'`outfitTypes` values are `RecRoom.Avatars.OutfitType` ordinals; the user-generated-',
|
||||||
'content tab sends 105 (`CustomShirt`) alone, the storefront tab a dozen slots.',
|
'content tab sends 105 (`CustomShirt`) alone, the storefront tab a dozen slots.',
|
||||||
'`itemTypes` (a separate enum: All -1, None 0, Shirt 1 — not the outfit type),',
|
'`itemTypes` (a separate enum: All -1, None 0, Shirt 1 — not the outfit type),',
|
||||||
'`ordering` (0 is `SearchScoreDescending`), `unityAssetTarget` and `unityAssetVersion`',
|
'`ordering` (0 is `SearchScoreDescending`) and `unityAssetVersion` are accepted and NOT',
|
||||||
'are accepted and NOT yet acted on — nothing records purchase or wear counts to rank',
|
'yet acted on — nothing records purchase or wear counts to rank by, no per-version',
|
||||||
'by, no per-target asset variants are stored, and custom shirts are the only item type',
|
'asset variants are stored, and custom shirts are the only item type there is.',
|
||||||
'there is.',
|
'`unityAssetTarget` IS acted on: 2 (Android/Oculus) serves each save’s',
|
||||||
|
'`UnityAsset`/`UnityAsset2` under `quest/`, the Android build of the same assetbundle;',
|
||||||
|
'0 (PC), any other value, and none at all get the bare filenames as stored.',
|
||||||
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
|
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
|
||||||
'whatever it says.',
|
'whatever it says.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
@@ -753,6 +773,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
'true: only the Coach’s stock items; false: only player-made items; absent: both',
|
'true: only the Coach’s stock items; false: only player-made items; absent: both',
|
||||||
schema: { type: 'boolean' },
|
schema: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'unityAssetTarget',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description:
|
||||||
|
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
|
||||||
|
'Android/Oculus — which is served the `quest/` builds',
|
||||||
|
schema: { type: 'integer' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
|
responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
|
||||||
}),
|
}),
|
||||||
@@ -778,8 +807,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
return Number.isInteger(n) ? n : undefined
|
return Number.isInteger(n) ? n : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json(
|
const items = await searchCustomAvatarItems(c.env.DB, {
|
||||||
await searchCustomAvatarItems(c.env.DB, {
|
|
||||||
searchQuery: c.req.query('searchQuery'),
|
searchQuery: c.req.query('searchQuery'),
|
||||||
outfitTypes,
|
outfitTypes,
|
||||||
includeCoachItems,
|
includeCoachItems,
|
||||||
@@ -788,6 +816,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
skip: int('skip'),
|
skip: int('skip'),
|
||||||
take: int('take'),
|
take: int('take'),
|
||||||
})
|
})
|
||||||
|
// The same switch the bulk lookup makes: a caller asking for the Quest target is
|
||||||
|
// pointed at the `quest/` builds of the assetbundles the saves name.
|
||||||
|
return c.json(
|
||||||
|
isQuestAssetTarget(c.req.query('unityAssetTarget'))
|
||||||
|
? items.map(toQuestCustomAvatarItem)
|
||||||
|
: items
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -845,6 +879,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' +
|
'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' +
|
||||||
'and the same spelling on the query string are both accepted, since the client’s ' +
|
'and the same spelling on the query string are both accepted, since the client’s ' +
|
||||||
'exact encoding here has not been pinned down.\n\n' +
|
'exact encoding here has not been pinned down.\n\n' +
|
||||||
|
'A caller asking for the Quest build — `unityAssetTarget` 2 (Android/Oculus), on ' +
|
||||||
|
'the query string or in the form — is served each save’s `UnityAsset`/`UnityAsset2` ' +
|
||||||
|
'under `quest/`, the Android build of the same assetbundle. Target 0 (PC), any ' +
|
||||||
|
'other value, and no target at all get the bare filenames as stored.\n\n' +
|
||||||
'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' +
|
'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' +
|
||||||
'client has been seen posting more than a screen could draw, and a miss is already ' +
|
'client has been seen posting more than a screen could draw, and a miss is already ' +
|
||||||
'not an error here.',
|
'not an error here.',
|
||||||
@@ -870,8 +908,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
// Unpublished items are held back from everyone but their creator — the same rule
|
// Unpublished items are held back from everyone but their creator — the same rule
|
||||||
// the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
|
// the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
|
||||||
// through this route that the feeds hide.
|
// through this route that the feeds hide.
|
||||||
|
const visible = items.filter(
|
||||||
|
(item) => item.Accessibility !== 0 || item.CreatorAccountId === id
|
||||||
|
)
|
||||||
|
// A Quest can't load the PC assetbundles the saves name; its builds sit beside them
|
||||||
|
// under `quest/`, so the names are pointed there for a caller asking for that target.
|
||||||
return c.json(
|
return c.json(
|
||||||
items.filter((item) => item.Accessibility !== 0 || item.CreatorAccountId === id)
|
isQuestAssetTarget(await bulkUnityAssetTarget(c))
|
||||||
|
? visible.map(toQuestCustomAvatarItem)
|
||||||
|
: visible
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||||
importCustomAvatarItem,
|
importCustomAvatarItem,
|
||||||
} from '../../custom-avatar-items-db'
|
} from '../../custom-avatar-items-db'
|
||||||
|
import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load'
|
||||||
import {
|
import {
|
||||||
countGoing,
|
countGoing,
|
||||||
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
|
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
|
||||||
@@ -1340,6 +1341,56 @@ describe('public endpoints', () => {
|
|||||||
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the first-party load blanks a save’s assetbundle hashes and leaves the names alone', () => {
|
||||||
|
// The export's hashes are the PC builds'. The same stored save is served to a Quest
|
||||||
|
// pointed at the `quest/` build, which fails the client's check against a PC hash — so
|
||||||
|
// the load stores them blank. A null hash (no second bundle) stays null.
|
||||||
|
const literal = customAvatarItemRowLiteral({
|
||||||
|
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||||
|
CreatorAccountId: 9001,
|
||||||
|
CurrentSaves: [
|
||||||
|
{
|
||||||
|
BodyType: 1,
|
||||||
|
ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||||
|
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||||
|
UnityAssetHash: 'b1946ac92492d2347c6235b4d2611184',
|
||||||
|
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
|
||||||
|
UnityAsset2Hash: '591785b794601e212b260e25925636fd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BodyType: 2,
|
||||||
|
ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png',
|
||||||
|
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
|
||||||
|
UnityAssetHash: '',
|
||||||
|
UnityAsset2: null,
|
||||||
|
UnityAsset2Hash: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(JSON.parse(literal.slice(1, -1))).toEqual({
|
||||||
|
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||||
|
CreatorAccountId: 1,
|
||||||
|
CurrentSaves: [
|
||||||
|
{
|
||||||
|
BodyType: 1,
|
||||||
|
ThumbnailFileName: 'avatar/f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||||
|
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||||
|
UnityAssetHash: '',
|
||||||
|
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
|
||||||
|
UnityAsset2Hash: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BodyType: 2,
|
||||||
|
ThumbnailFileName: 'avatar/div4cdxc5b7ameui0d5h8ickl.png',
|
||||||
|
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
|
||||||
|
UnityAssetHash: '',
|
||||||
|
UnityAsset2: null,
|
||||||
|
UnityAsset2Hash: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/customAvatarItems/v1/search serves an imported first-party item as exported', async () => {
|
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()
|
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||||
|
|
||||||
@@ -1441,6 +1492,24 @@ describe('public endpoints', () => {
|
|||||||
)
|
)
|
||||||
expect(store).toEqual([{ ...wings, PurchaseInfo: null }])
|
expect(store).toEqual([{ ...wings, PurchaseInfo: null }])
|
||||||
|
|
||||||
|
// The same query from a Quest (`unityAssetTarget=2`, Android/Oculus) serves the same
|
||||||
|
// record with its assetbundles pointed at the `quest/` builds, and nothing else moved.
|
||||||
|
const questStore = await search(
|
||||||
|
'?outfitTypes=100&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
|
||||||
|
'&take=100&unityAssetTarget=2&unityAssetVersion=3'
|
||||||
|
)
|
||||||
|
expect(questStore).toEqual([
|
||||||
|
{
|
||||||
|
...wings,
|
||||||
|
PurchaseInfo: null,
|
||||||
|
CurrentSaves: wings.CurrentSaves.map((save) => ({
|
||||||
|
...save,
|
||||||
|
UnityAsset: `quest/${save.UnityAsset}`,
|
||||||
|
UnityAsset2: `quest/${save.UnityAsset2}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
// Text search matches it by name and by description, and by its id typed whole.
|
// 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=skeletal')).map((i) => i.Name)).toEqual(['Skeletal Wings'])
|
||||||
expect((await search('?searchQuery=connection%20point')).map((i) => i.Name)).toEqual([
|
expect((await search('?searchQuery=connection%20point')).map((i) => i.Name)).toEqual([
|
||||||
@@ -1484,6 +1553,31 @@ describe('public endpoints', () => {
|
|||||||
const [got] = (await bulk.json()) as Array<{ CurrentSaves: unknown[]; Price: number }>
|
const [got] = (await bulk.json()) as Array<{ CurrentSaves: unknown[]; Price: number }>
|
||||||
expect(got.CurrentSaves).toHaveLength(2)
|
expect(got.CurrentSaves).toHaveLength(2)
|
||||||
expect(got.Price).toBe(5000)
|
expect(got.Price).toBe(5000)
|
||||||
|
|
||||||
|
// A caller asking for the Quest build (`unityAssetTarget` 2, Android/Oculus) is pointed at
|
||||||
|
// the Quest builds of the same assetbundles, under `quest/` — whether the target rides on
|
||||||
|
// the query string or in the form. Target 0 (PC) and no target get the names as stored.
|
||||||
|
const bulkAssets = async (query: string, form: string[][] = []) => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk${query}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
...(await bearer('42')),
|
||||||
|
},
|
||||||
|
body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId], ...form]),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const [item] = (await res.json()) as Array<{
|
||||||
|
CurrentSaves: Array<{ UnityAsset: string; UnityAsset2: string | null }>
|
||||||
|
}>
|
||||||
|
return item.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
|
||||||
|
}
|
||||||
|
const stored = wings.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
|
||||||
|
const quest = stored.map((names) => names.map((name) => `quest/${name}`))
|
||||||
|
expect(await bulkAssets('')).toEqual(stored)
|
||||||
|
expect(await bulkAssets('?unityAssetTarget=0')).toEqual(stored)
|
||||||
|
expect(await bulkAssets('?unityAssetTarget=2')).toEqual(quest)
|
||||||
|
expect(await bulkAssets('', [['unityAssetTarget', '2']])).toEqual(quest)
|
||||||
})
|
})
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } fr
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getCustomAvatarItems,
|
getCustomAvatarItems,
|
||||||
|
isQuestAssetTarget,
|
||||||
|
toQuestCustomAvatarItem,
|
||||||
toUgcPurchasable,
|
toUgcPurchasable,
|
||||||
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
||||||
} from '../../api/src/custom-avatar-items-db'
|
} from '../../api/src/custom-avatar-items-db'
|
||||||
@@ -2661,6 +2663,9 @@ const app = new Hono<App>({ strict: false })
|
|||||||
'`CustomAvatarItem` record out of the `custom_avatar_item` table, oldest first by when',
|
'`CustomAvatarItem` record out of the `custom_avatar_item` table, oldest first by when',
|
||||||
'it became theirs, in the `{ Results, TotalResults }` envelope. No paging is applied',
|
'it became theirs, in the `{ Results, TotalResults }` envelope. No paging is applied',
|
||||||
'(the client sends none), so `TotalResults` is the list’s length.',
|
'(the client sends none), so `TotalResults` is the list’s length.',
|
||||||
|
'`unityAssetTarget` 2 (Android/Oculus) serves each save’s `UnityAsset`/`UnityAsset2`',
|
||||||
|
'under `quest/`, the Android build of the same assetbundle — the same switch `api`’s',
|
||||||
|
'search and bulk reads make; 0 (PC), any other value, and none get the names as stored.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
responses: {
|
responses: {
|
||||||
@@ -2671,7 +2676,13 @@ const app = new Hono<App>({ strict: false })
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const Results = await getOwnedCustomAvatarItems(c.env.DB, id)
|
const owned = await getOwnedCustomAvatarItems(c.env.DB, id)
|
||||||
|
// The client renders what the player is WEARING from this list, and it is read before
|
||||||
|
// any `api` lookup — so a Quest served PC names here downloads PC assetbundles however
|
||||||
|
// the search and bulk reads answer.
|
||||||
|
const Results = isQuestAssetTarget(c.req.query('unityAssetTarget'))
|
||||||
|
? owned.map(toQuestCustomAvatarItem)
|
||||||
|
: owned
|
||||||
return c.json({ Results, TotalResults: Results.length })
|
return c.json({ Results, TotalResults: Results.length })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createCustomAvatarItem,
|
createCustomAvatarItem,
|
||||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||||
|
importCustomAvatarItem,
|
||||||
} from '../../../../api/src/custom-avatar-items-db'
|
} from '../../../../api/src/custom-avatar-items-db'
|
||||||
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
||||||
// The notification-type ids the hub carries, from the worker that owns them — asserting
|
// The notification-type ids the hub carries, from the worker that owns them — asserting
|
||||||
@@ -588,6 +589,56 @@ describe('econ endpoints', () => {
|
|||||||
],
|
],
|
||||||
TotalResults: 4,
|
TotalResults: 4,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A first-party item is rendered from its saves' assetbundles. A caller asking for the
|
||||||
|
// Quest target (`unityAssetTarget=2`, Android/Oculus) is pointed at the `quest/` builds;
|
||||||
|
// target 0 (PC) gets the names as stored.
|
||||||
|
const save = {
|
||||||
|
CustomAvatarItemSaveId: 1,
|
||||||
|
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||||
|
UnityAssetId: crypto.randomUUID(),
|
||||||
|
BodyType: 2,
|
||||||
|
OutfitType: 100,
|
||||||
|
QAState: 0,
|
||||||
|
CreatedAt: '2024-09-26T21:32:48.523Z',
|
||||||
|
ModifiedAt: '2024-09-26T21:32:48.523Z',
|
||||||
|
Description: null,
|
||||||
|
ThumbnailFileName: 'avatar/f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||||
|
AdditionalConfiguration: '{}',
|
||||||
|
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||||
|
UnityAssetHash: 'hash-a',
|
||||||
|
UnityAsset2: null,
|
||||||
|
UnityAsset2Hash: null,
|
||||||
|
}
|
||||||
|
const wings = await importCustomAvatarItem(env.DB, {
|
||||||
|
...first,
|
||||||
|
CustomAvatarItemId: save.CustomAvatarItemId,
|
||||||
|
RankedEntityId: save.CustomAvatarItemId,
|
||||||
|
CreatorAccountId: 1,
|
||||||
|
Name: 'Skeletal Wings',
|
||||||
|
BaseAvatarItemId: null,
|
||||||
|
BaseAvatarItemColor: null,
|
||||||
|
DesignFilename: null,
|
||||||
|
ThumbnailImageFilename: null,
|
||||||
|
OutfitType: 100,
|
||||||
|
CurrentSaves: [save],
|
||||||
|
})
|
||||||
|
await grantCustomAvatarItem(env.DB, 612, wings.CustomAvatarItemId)
|
||||||
|
const ownedAssets = async (query: string) => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned${query}`, {
|
||||||
|
headers: await bearer('612'),
|
||||||
|
})
|
||||||
|
const { Results } = (await res.json()) as {
|
||||||
|
Results: Array<{ CurrentSaves: Array<{ UnityAsset: string; UnityAsset2: string | null }> }>
|
||||||
|
}
|
||||||
|
return Results[0].CurrentSaves.map((s) => [s.UnityAsset, s.UnityAsset2])
|
||||||
|
}
|
||||||
|
expect(await ownedAssets('?skip=0&take=1000&unityAssetTarget=0')).toEqual([
|
||||||
|
[save.UnityAsset, null],
|
||||||
|
])
|
||||||
|
expect(await ownedAssets('?skip=0&take=1000&unityAssetTarget=2&unityAssetVersion=3')).toEqual([
|
||||||
|
[`quest/${save.UnityAsset}`, null],
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/objectives/v1/myprogress returns the default progress (no auth)', async () => {
|
test('GET /api/objectives/v1/myprogress returns the default progress (no auth)', async () => {
|
||||||
|
|||||||
@@ -270,10 +270,11 @@ const CUSTOM_AVATAR_ITEM_EXPORT = 'apps/econ/static/db/2025-1-cai.json'
|
|||||||
* worker). Like `runx catalog load`: the table's STRUCTURE is a migration, its first-party
|
* worker). Like `runx catalog load`: the table's STRUCTURE is a migration, its first-party
|
||||||
* CONTENTS are not, and this is the reload. Each record lands as the row's JSON with its
|
* CONTENTS are not, and this is the reload. Each record lands as the row's JSON with its
|
||||||
* `CreatorAccountId` forced to the Coach account (1) — what makes it stock content here — and
|
* `CreatorAccountId` forced to the Coach account (1) — what makes it stock content here — and
|
||||||
* each save's `ThumbnailFileName` put under `avatar/` (the statements come from api's
|
* each save's `ThumbnailFileName` put under `avatar/` and its assetbundle hashes blanked — they
|
||||||
* `custom-avatar-items-load.ts`, the same ones the migration generator writes), an id already
|
* are the PC builds' hashes, and the same save serves the Quest builds too. (The statements
|
||||||
* present is REPLACED, and nothing is deleted — the players' own shirts share the table and an
|
* come from api's `custom-avatar-items-load.ts`, the same ones the migration generator writes.)
|
||||||
* export never mentions them. Re-running is always safe.
|
* An id already present is REPLACED, and nothing is deleted — the players' own shirts share the
|
||||||
|
* table and an export never mentions them. Re-running is always safe.
|
||||||
*/
|
*/
|
||||||
const caiLoad = new Command('cai-load')
|
const caiLoad = new Command('cai-load')
|
||||||
.description('Load first-party custom avatar items from an export JSON (merges; never deletes)')
|
.description('Load first-party custom avatar items from an export JSON (merges; never deletes)')
|
||||||
|
|||||||
Reference in New Issue
Block a user