mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[store] swap the rro/custom store, update custom shirt outfit ID
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
-- File every custom avatar item under OutfitType 105 (`RecRoom.Avatars.OutfitType.CustomShirt`).
|
||||
-- Creation left `outfit_type` at the column's default of 0 (Hat), and the store's
|
||||
-- user-generated-content tab searches `GET /api/customAvatarItems/v1/search?outfitTypes=105`
|
||||
-- ALONE, so the tab sat empty over a full catalog. The custom shirt is the one kind of custom avatar item the
|
||||
-- client can make, so every row written before this migration is one. Creation now writes
|
||||
-- 105 itself (src/custom-avatar-items-db.ts, OUTFIT_TYPE_CUSTOM_SHIRT).
|
||||
UPDATE custom_avatar_item SET outfit_type = 105 WHERE outfit_type = 0;
|
||||
@@ -60,6 +60,25 @@ export interface CustomAvatarItem {
|
||||
PurchaseInfo: null
|
||||
}
|
||||
|
||||
/**
|
||||
* `RecRoom.Avatars.OutfitType.CustomShirt` — the slot of a custom shirt, the one kind of custom
|
||||
* avatar item the client can make. The enum (unobfuscated in the client) is banded by body
|
||||
* region: head None -1, Hat 0, Hair 2, Ear 3, Eye 10, Beard 20; torso Shoulder 100, Shirt 101,
|
||||
* Waist 102, Neck 103, TeamJersey 104, CustomShirt 105; arms Wrist 200, TeamWrist 203; legs
|
||||
* Legs 300, Feet 301; Roomie_Hat 500, Roomie_Waist 501, Roomie_Eye 502. It reaches the search
|
||||
* as a bare `IEnumerable<int>` — nothing on the wire ties 105 to the name.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* `outfitTypes=105`; the outfit type is the filter, and the two are never cross-mapped.
|
||||
*/
|
||||
export const OUTFIT_TYPE_CUSTOM_SHIRT = 105
|
||||
|
||||
/** What `POST /api/customAvatarItems/v1` needs to create an item. */
|
||||
export interface CreateCustomAvatarItemInput {
|
||||
/** The item's id. Chosen by the caller because the upload keys are derived from it. */
|
||||
@@ -73,6 +92,8 @@ export interface CreateCustomAvatarItemInput {
|
||||
accessibility: number
|
||||
designFilename: string
|
||||
thumbnailImageFilename: string
|
||||
/** The slot the item is worn in; {@link OUTFIT_TYPE_CUSTOM_SHIRT} when left out. */
|
||||
outfitType?: number
|
||||
}
|
||||
|
||||
interface Row {
|
||||
@@ -132,8 +153,8 @@ export async function createCustomAvatarItem(
|
||||
`INSERT INTO custom_avatar_item (
|
||||
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
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
|
||||
created_at, modified_at, outfit_type
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
@@ -147,7 +168,8 @@ export async function createCustomAvatarItem(
|
||||
input.baseAvatarItemColor,
|
||||
input.designFilename,
|
||||
input.thumbnailImageFilename,
|
||||
ts
|
||||
ts,
|
||||
input.outfitType ?? OUTFIT_TYPE_CUSTOM_SHIRT
|
||||
)
|
||||
.first<Row>()
|
||||
if (!row) throw new Error('custom_avatar_item insert returned no row')
|
||||
@@ -264,7 +286,13 @@ export interface CustomAvatarItemSearch {
|
||||
* the full set of types it can render, so an absent parameter is "everything", not "nothing".
|
||||
*/
|
||||
outfitTypes?: number[]
|
||||
/** Whether items authored by the Coach — this server's stock content — are included. */
|
||||
/**
|
||||
* WHICH SIDE of the catalog to serve, not whether to add one to the other: `true` is the
|
||||
* Coach's stock content ONLY, `false` is player-made content ONLY, and undefined is both.
|
||||
* The client browses the store's two tabs with this — its storefront call says `True`
|
||||
* and its user-generated-content call says `False` — so "include" reads as a toggle
|
||||
* between them, never as a superset.
|
||||
*/
|
||||
includeCoachItems?: boolean
|
||||
/** Lowest price to include, inclusive. */
|
||||
minPrice?: number
|
||||
@@ -295,6 +323,11 @@ export const SEARCH_MAX_TAKE = 200
|
||||
* how an empty IN () clause reads in SQL: the client sends every type it can render, so treating
|
||||
* an absent parameter as "match nothing" would empty the store.
|
||||
*
|
||||
* `includeCoachItems` SPLITS the catalog by author rather than widening it: `true` is the Coach's
|
||||
* items alone, `false` everyone else's alone, absent both. The client's storefront tab sends
|
||||
* `True` and its user-generated-content tab sends `False`, and the two must not overlap — read
|
||||
* as "stock plus players'", the storefront would show every player's item too.
|
||||
*
|
||||
* Ordered by recency because there is nothing else to order by — no purchase counts, no wear
|
||||
* counts, no ratings are recorded — which is the same stand-in the `hot` feed makes. The
|
||||
* `custom_avatar_item_id` tiebreak is what makes paging stable: without it, two items sharing a
|
||||
@@ -327,7 +360,9 @@ export async function searchCustomAvatarItems(
|
||||
if (outfitTypes.length > 0) {
|
||||
where.push(`outfit_type IN (${outfitTypes.map((t) => bind(t)).join(', ')})`)
|
||||
}
|
||||
if (search.includeCoachItems === false) {
|
||||
if (search.includeCoachItems === true) {
|
||||
where.push(`creator_account_id = ${bind(COACH_ACCOUNT_ID)}`)
|
||||
} else if (search.includeCoachItems === false) {
|
||||
where.push(`creator_account_id != ${bind(COACH_ACCOUNT_ID)}`)
|
||||
}
|
||||
if (search.minPrice !== undefined) where.push(`price >= ${bind(search.minPrice)}`)
|
||||
|
||||
@@ -160,9 +160,7 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
|
||||
async function creatorsInventionResult(
|
||||
c: Context<App>,
|
||||
inventionId: number
|
||||
): Promise<
|
||||
{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }
|
||||
> {
|
||||
): 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 }
|
||||
@@ -218,7 +216,10 @@ function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: num
|
||||
})
|
||||
return rejected
|
||||
? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected }
|
||||
: { tags: normalizeInventionTags(autoTags, customTags), tagResult: INVENTION_TAG_RESULT.success }
|
||||
: {
|
||||
tags: normalizeInventionTags(autoTags, customTags),
|
||||
tagResult: INVENTION_TAG_RESULT.success,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,8 +229,7 @@ function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: num
|
||||
* 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 }
|
||||
{ rejection: string } | { invention: SavedInvention; tags: InventionTag[]; tagResult: number }
|
||||
|
||||
/**
|
||||
* The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does
|
||||
@@ -450,7 +450,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
|
||||
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
|
||||
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
|
||||
'error_id }` envelope.\n\n' +
|
||||
'error_id }` envelope. The item is filed under `OutfitType` 105, the custom shirt, ' +
|
||||
'which is what the store’s user-generated-content tab searches for.\n\n' +
|
||||
'The two files go to the shared image bucket (`recflare-img`) under ' +
|
||||
'`avatar-item/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-design.png`; those ' +
|
||||
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.',
|
||||
@@ -682,10 +683,17 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'rather than no results, since the client sends every type it can render.',
|
||||
'`minPrice`/`maxPrice` bound the price, inclusive.',
|
||||
'`skip`/`take` page the results, `take` capped at 200.',
|
||||
'`includeCoachItems=false` leaves out this server’s stock content.',
|
||||
'`itemTypes`, `ordering`, `unityAssetTarget` and `unityAssetVersion` are accepted and',
|
||||
'NOT yet acted on — nothing records purchase or wear counts to rank by, no per-target',
|
||||
'asset variants are stored, and custom avatar items are the only item type there is.',
|
||||
'`includeCoachItems` picks a SIDE of the catalog: `true` serves only the Coach’s',
|
||||
'stock content (account 1), `false` only player-made items, and leaving it out serves',
|
||||
'both. The client’s storefront tab sends `True` and its user-generated-content tab',
|
||||
'`False`, so the two never overlap.',
|
||||
'`outfitTypes` values are `RecRoom.Avatars.OutfitType` ordinals; the user-generated-',
|
||||
'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),',
|
||||
'`ordering` (0 is `SearchScoreDescending`), `unityAssetTarget` and `unityAssetVersion`',
|
||||
'are accepted and NOT yet acted on — nothing records purchase or wear counts to rank',
|
||||
'by, no per-target asset variants are stored, and custom shirts are the only item type',
|
||||
'there is.',
|
||||
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
|
||||
'whatever it says.',
|
||||
].join(' '),
|
||||
@@ -736,7 +744,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
name: 'includeCoachItems',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Include the Coach’s stock items (default true)',
|
||||
description:
|
||||
'true: only the Coach’s stock items; false: only player-made items; absent: both',
|
||||
schema: { type: 'boolean' },
|
||||
},
|
||||
],
|
||||
@@ -751,9 +760,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
?.map((v) => Number.parseInt(v, 10))
|
||||
.filter((n) => Number.isInteger(n))
|
||||
|
||||
// Three-way: `True` is the Coach's side of the store, `False` the players', absent both.
|
||||
// The client capitalises its booleans (`includeCoachItems=True`), so this is folded
|
||||
// before comparing; anything that isn't recognisably false leaves the default alone.
|
||||
const includeCoachItems = c.req.query('includeCoachItems')?.toLowerCase() !== 'false'
|
||||
// before comparing; anything that isn't recognisably true or false is treated as absent.
|
||||
const coach = c.req.query('includeCoachItems')?.toLowerCase()
|
||||
const includeCoachItems = coach === 'true' ? true : coach === 'false' ? false : undefined
|
||||
|
||||
const int = (name: string): number | undefined => {
|
||||
const raw = c.req.query(name)
|
||||
@@ -2027,9 +2038,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
for (const rejection of [
|
||||
name === undefined ? null : inventionNameRejection(name),
|
||||
description === undefined ? null : inventionDescriptionRejection(description),
|
||||
longDescription === undefined
|
||||
? null
|
||||
: inventionLongDescriptionRejection(longDescription),
|
||||
longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription),
|
||||
]) {
|
||||
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
|
||||
}
|
||||
|
||||
@@ -1149,7 +1149,9 @@ describe('public endpoints', () => {
|
||||
new Date(Date.UTC(2026, 7, 1 + i))
|
||||
)
|
||||
made[name] = created.CustomAvatarItemId
|
||||
// Nothing sets outfit_type on creation yet, so set it straight in the table.
|
||||
// Every created item is a custom shirt (OutfitType 105); this test wants a spread of
|
||||
// slots to filter across, so set them straight in the table.
|
||||
expect(created.OutfitType).toBe(105)
|
||||
await env.DB.prepare(
|
||||
'UPDATE custom_avatar_item SET outfit_type = ?2 WHERE custom_avatar_item_id = ?1'
|
||||
)
|
||||
@@ -1199,20 +1201,32 @@ describe('public endpoints', () => {
|
||||
expect(await search('?skip=99&take=10')).toEqual([])
|
||||
expect(await search('?take=0')).toEqual([])
|
||||
|
||||
// The client capitalises its booleans (`includeCoachItems=True`), so the comparison folds
|
||||
// case; only a recognisable "false" turns the stock content off.
|
||||
expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toContain('Coach Hat')
|
||||
expect((await search('?includeCoachItems=false')).map((i) => i.Name)).not.toContain('Coach Hat')
|
||||
// `includeCoachItems` picks a SIDE of the catalog rather than widening it: `True` is the
|
||||
// Coach's stock content alone (the storefront tab), `False` the players' alone (the
|
||||
// user-generated-content tab), and the two never overlap. Absent is both. The client
|
||||
// capitalises its booleans, so the comparison folds case.
|
||||
expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toEqual(['Coach Hat'])
|
||||
expect((await search('?includeCoachItems=false')).map((i) => i.Name)).toEqual([
|
||||
'Trousers A',
|
||||
'Shirt B',
|
||||
'Shirt A',
|
||||
'Hat A',
|
||||
])
|
||||
expect((await search('?includeCoachItems=False')).map((i) => i.Name)).not.toContain('Coach Hat')
|
||||
expect((await search('?includeCoachItems=maybe')).map((i) => i.Name)).toEqual(
|
||||
all.map((i) => i.Name)
|
||||
)
|
||||
|
||||
// The whole query the client actually sends, unchanged — the parameters that aren't acted
|
||||
// on yet must be accepted rather than 400 or throw.
|
||||
// The whole storefront query the client actually sends, unchanged — the parameters that
|
||||
// aren't acted on yet must be accepted rather than 400 or throw, and `includeCoachItems=True`
|
||||
// narrows it to the stock content.
|
||||
const real = 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(real.map((i) => i.Name)).toEqual(all.map((i) => i.Name))
|
||||
expect(real.map((i) => i.Name)).toEqual(['Coach Hat'])
|
||||
// `includePurchaseInfos=True` notwithstanding: nothing prices a custom item here yet, so
|
||||
// the field is null on every item and the parameter changes nothing.
|
||||
expect(real.every((i) => i.PurchaseInfo === null)).toBe(true)
|
||||
@@ -1285,17 +1299,18 @@ describe('public endpoints', () => {
|
||||
// Combined with the text search, since the client sends both together.
|
||||
expect(await search('?searchQuery=room&maxPrice=500')).toEqual(['Cosy Beanie', 'Room Hat'])
|
||||
|
||||
// The whole query the client actually sends. `itemTypes` and the unity asset parameters are
|
||||
// accepted and not acted on; `outfitTypes=105` matches nothing, so this is empty — which is
|
||||
// the filter working, not the search failing.
|
||||
// The whole query the client's user-generated-content tab actually sends. `itemTypes=-1`
|
||||
// ("every item type") and the unity asset parameters are accepted and not acted on;
|
||||
// `outfitTypes=105` is the custom shirt, which is what every created item is filed under,
|
||||
// so the tab finds them. (It once found nothing: creation left `outfit_type` at 0.)
|
||||
expect(
|
||||
await search(
|
||||
'?searchQuery=room&itemTypes=-1&outfitTypes=105&minPrice=0&maxPrice=10000' +
|
||||
'&includePurchaseInfos=True&includeCoachItems=False&ordering=0&skip=0&take=1000' +
|
||||
'&unityAssetTarget=0&unityAssetVersion=3'
|
||||
)
|
||||
).toEqual([])
|
||||
// Same query with the outfit-type filter dropped: the rest of it does match.
|
||||
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||
// Same query with the outfit-type filter dropped matches the same, as it must.
|
||||
expect(
|
||||
await search(
|
||||
'?searchQuery=room&itemTypes=-1&minPrice=0&maxPrice=10000&includePurchaseInfos=True' +
|
||||
|
||||
Reference in New Issue
Block a user