diff --git a/CLAUDE.md b/CLAUDE.md index fd06eb3..55c523e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,7 +203,8 @@ inconsistency here without checking the client first. 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 except that `CreatorAccountId` is forced to 1, the Coach — which - is what files it as stock content and whom a sale pays (`just cai-load` loads it; + is what files it as stock content and whom a sale pays — and each save's `ThumbnailFileName` + is put under `avatar/`, where this server serves it from (`just cai-load` loads it; `apps/api/scripts/import-custom-avatar-items.ts` writes a 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 diff --git a/CLI.md b/CLI.md index a8f9bc7..b4822b2 100644 --- a/CLI.md +++ b/CLI.md @@ -89,14 +89,15 @@ Loads an export of `CustomAvatarItem` records (a JSON array, a `{ Results }` pag record) into the `custom_avatar_item` table, each stored as the row's JSON with its `CreatorAccountId` forced to 1, the Coach account, whatever the export said: that is what files it as stock content (the storefront tab searches by creator) and what a purchase -pays. It MERGES: an id already in the table has its row replaced, and nothing is deleted, so the +pays. Each save's `ThumbnailFileName` is put under `avatar/`, where this server serves +those images from (a name already prefixed is left alone). It MERGES: an id already in the table has its row replaced, and nothing is deleted, so the players' own shirts, which share the table, are untouched. Re-running is safe. The load is verified by counting rows and probing ids from across the export; it fails loudly rather than report a partial load. `--dry-run` reads and validates the export and prints the statement count without writing. -The saves in an export name assetbundles and thumbnails by bare filename; the load stores -the names only. +The saves in an export name their assetbundles by bare filename; the load stores the +names only. ### `lookup` — print an account diff --git a/apps/api/scripts/import-custom-avatar-items.ts b/apps/api/scripts/import-custom-avatar-items.ts index ede6f1a..abf939f 100644 --- a/apps/api/scripts/import-custom-avatar-items.ts +++ b/apps/api/scripts/import-custom-avatar-items.ts @@ -12,11 +12,12 @@ * For a load that needs no migration and no deploy — the usual case — use * `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 row's JSON minus `PurchaseInfo` and with `CreatorAccountId` forced to the Coach account - * (1), an existing id REPLACED, nothing deleted. + * 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 + * deleted. * - * 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. + * The saves name assetbundles 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' diff --git a/apps/api/src/custom-avatar-items-load.ts b/apps/api/src/custom-avatar-items-load.ts index e50d966..d2a3995 100644 --- a/apps/api/src/custom-avatar-items-load.ts +++ b/apps/api/src/custom-avatar-items-load.ts @@ -9,10 +9,10 @@ * mapping with no imports, so both sides can use it. * * A row is the record as JSON (see 0022_custom_avatar_item_json.sql), so a load is the export - * verbatim: no per-field mapping, and a field the source adds later rides along. Two things + * verbatim: no per-field mapping, and a field the source adds later rides along. Three things * change on the way in: `PurchaseInfo`, a store-side projection the worker fills at read time, - * is stripped, and `CreatorAccountId` is FORCED to the Coach account — see - * {@link customAvatarItemRowLiteral}. + * is stripped; `CreatorAccountId` is FORCED to the Coach account; and each save's + * `ThumbnailFileName` is put under the `avatar/` prefix — see {@link customAvatarItemRowLiteral}. */ /** @@ -23,6 +23,13 @@ */ export const COACH_ACCOUNT_ID = 1 +/** + * Where a save's thumbnail is served from. The export names it bare + * (`f2y1ndzuvm5ke2hjmn4cwfwfl.png`); this server keeps those images under `avatar/` in the + * image bucket, so the name is prefixed on the way in and the client asks for the right key. + */ +export const SAVE_THUMBNAIL_PREFIX = 'avatar/' + /** The least an export record must carry to be a row. Everything else is kept as-is. */ export interface CustomAvatarItemExportRecord { CustomAvatarItemId: string @@ -60,18 +67,47 @@ export function readCustomAvatarItemExport(parsed: unknown): CustomAvatarItemExp } /** - * One record as the SQL string literal its row holds: the JSON, minus `PurchaseInfo`, with - * `CreatorAccountId` overridden to {@link COACH_ACCOUNT_ID} whatever the export said. An + * A save's `ThumbnailFileName` under {@link SAVE_THUMBNAIL_PREFIX}. Idempotent — a name that + * already carries the prefix is left alone — so loading a file that was itself dumped from + * this table does not stack a second `avatar/`. Anything that is not a non-empty string + * (null, absent) is returned as it came. + */ +export function prefixSaveThumbnail(name: unknown): unknown { + if (typeof name !== 'string' || name === '') return name + return name.startsWith(SAVE_THUMBNAIL_PREFIX) ? name : SAVE_THUMBNAIL_PREFIX + name +} + +/** + * One record as the SQL string literal its row holds: the JSON, minus `PurchaseInfo`, with two + * fields rewritten. + * + * `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 * here: the storefront tab finds stock items by `creator_account_id = 1` * (`includeCoachItems=True`), and a sale pays the creator. An export from another server * carries THAT server's creator ids — an item filed under one of those would vanish from the * storefront into the user-generated tab, and its price would be paid to whichever local * account happened to share the number. + * + * 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 + * all — is untouched. */ export function customAvatarItemRowLiteral(record: CustomAvatarItemExportRecord): string { const { PurchaseInfo: _purchaseInfo, ...stored } = record - const row = { ...stored, CreatorAccountId: COACH_ACCOUNT_ID } + const saves = Array.isArray(stored.CurrentSaves) + ? stored.CurrentSaves.map((save: unknown) => + save && typeof save === 'object' && !Array.isArray(save) + ? { + ...(save as Record), + ThumbnailFileName: prefixSaveThumbnail( + (save as { ThumbnailFileName?: unknown }).ThumbnailFileName + ), + } + : save + ) + : stored.CurrentSaves + const row = { ...stored, CreatorAccountId: COACH_ACCOUNT_ID, CurrentSaves: saves } return `'${JSON.stringify(row).replaceAll("'", "''")}'` } diff --git a/packages/tools/src/cmd/admin.cmd.ts b/packages/tools/src/cmd/admin.cmd.ts index 2c6dd39..d1a870c 100644 --- a/packages/tools/src/cmd/admin.cmd.ts +++ b/packages/tools/src/cmd/admin.cmd.ts @@ -269,10 +269,11 @@ const CUSTOM_AVATAR_ITEM_EXPORT = 'apps/econ/static/db/2025-1-cai.json' * Load the first-party custom avatar items into `custom_avatar_item` (owned by the `api` * 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 - * `CreatorAccountId` forced to the Coach account (1) — what makes it stock content here — (the - * statements come from api's `custom-avatar-items-load.ts`, the same ones the migration - * generator writes), 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. + * `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 + * `custom-avatar-items-load.ts`, the same ones the migration generator writes), 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') .description('Load first-party custom avatar items from an export JSON (merges; never deletes)')