diff --git a/src/cosmetics-data-source.ts b/src/cosmetics-data-source.ts index 8dd2585..2ea4444 100644 --- a/src/cosmetics-data-source.ts +++ b/src/cosmetics-data-source.ts @@ -4,6 +4,7 @@ import { DataSource, DataSourceOptions } from "typeorm"; import { config } from "./evolution-types/src/config"; import { CosmeticEntity } from "./modules/catalog/infrastructure/CosmeticEntity"; import { EntitlementEntity } from "./modules/entitlements/infrastructure/EntitlementEntity"; +import { UserEmoteBarEntity } from "./modules/emote-bar/infrastructure/UserEmoteBarEntity"; import { UserLoadoutEntity } from "./modules/loadout/infrastructure/UserLoadoutEntity"; // Cosmetics owns its own DataSource and migration history, isolated from the shared @@ -21,7 +22,7 @@ const options: DataSourceOptions = { database: config.postgres.database, synchronize: false, logging: true, - entities: [CosmeticEntity, UserLoadoutEntity, EntitlementEntity], + entities: [CosmeticEntity, UserLoadoutEntity, EntitlementEntity, UserEmoteBarEntity], subscribers: [], migrations: [join(__dirname, "/migrations/*.ts")], migrationsTableName: "cosmetics_migrations", diff --git a/src/migrations/1781700000000-add-emote-pack-cosmetic-type.ts b/src/migrations/1781700000000-add-emote-pack-cosmetic-type.ts new file mode 100644 index 0000000..82f07ab --- /dev/null +++ b/src/migrations/1781700000000-add-emote-pack-cosmetic-type.ts @@ -0,0 +1,24 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Adds the EMOTE_PACK cosmetic type: a folder of Lottie emotes described by a + * `pack.json`, owned like any other cosmetic but never equipped in the loadout. + * + * No row is inserted here. `ALTER TYPE ... ADD VALUE` runs inside a + * transaction on PG >= 12, but the freshly added value cannot be USED in that + * same transaction. + */ +export class AddEmotePackCosmeticType1781700000000 implements MigrationInterface { + name = "AddEmotePackCosmeticType1781700000000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE "cosmetic_type_enum" ADD VALUE IF NOT EXISTS 'EMOTE_PACK'`); + } + + async down(queryRunner: QueryRunner): Promise { + // Postgres cannot remove a single enum value without recreating the whole + // type and rewriting every column that uses it. Deleting the rows is the + // reversible part; the value itself is left in place, inert. + await queryRunner.query(`DELETE FROM "cosmetics" WHERE "type" = 'EMOTE_PACK'`); + } +} diff --git a/src/migrations/1781800000000-create-user-emote-bars.ts b/src/migrations/1781800000000-create-user-emote-bars.ts new file mode 100644 index 0000000..6284eb1 --- /dev/null +++ b/src/migrations/1781800000000-create-user-emote-bars.ts @@ -0,0 +1,26 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * One row per user holding the 8 emote refs of their bar, in slot order. The bar is always + * read and replaced as a whole, so a single array column makes every save one atomic upsert. + * The foreign key to users(id) is raw SQL so this DataSource never maps the shared users table. + */ +export class CreateUserEmoteBars1781800000000 implements MigrationInterface { + name = "CreateUserEmoteBars1781800000000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_emote_bars" ("user_id" character varying NOT NULL, "emote_refs" character varying(48) array NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_user_emote_bars" PRIMARY KEY ("user_id"), CONSTRAINT "CHK_user_emote_bars_slots" CHECK (cardinality("emote_refs") = 8))`, + ); + await queryRunner.query( + `ALTER TABLE "user_emote_bars" ADD CONSTRAINT "FK_user_emote_bars_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_emote_bars" DROP CONSTRAINT "FK_user_emote_bars_user"`, + ); + await queryRunner.query(`DROP TABLE "user_emote_bars"`); + } +} diff --git a/src/modules/catalog/application/PublishCosmetic.ts b/src/modules/catalog/application/PublishCosmetic.ts index baaf44c..d5d21df 100644 --- a/src/modules/catalog/application/PublishCosmetic.ts +++ b/src/modules/catalog/application/PublishCosmetic.ts @@ -5,8 +5,12 @@ import { Cosmetic } from "../domain/Cosmetic"; import type { CosmeticRepository } from "../domain/CosmeticRepository"; import { CosmeticTier } from "../domain/CosmeticTier"; import { CosmeticType } from "../domain/CosmeticType"; +import { assertEmotePackFiles, MAX_EMOTE_PACK_FILES } from "../domain/EmotePack"; const MAX_FILES = 8; +const MAX_FILES_BY_TYPE: Partial> = { + [CosmeticType.EMOTE_PACK]: MAX_EMOTE_PACK_FILES, +}; const MAX_TOTAL_BYTES = 25 * 1024 * 1024; const SAFE_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const SAFE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -18,6 +22,7 @@ const PREFIX_BY_TYPE: Partial> = { [CosmeticType.PLAYMAT]: "playmats", [CosmeticType.AVATAR]: "avatars", [CosmeticType.LANE]: "lanes", + [CosmeticType.EMOTE_PACK]: "emote-packs", }; export interface PublishCosmeticFile { @@ -72,6 +77,10 @@ function assertAssetRef(type: CosmeticType, assetRef: string): void { } } +function slugOf(assetRef: string): string { + return assetRef.split("/")[1] ?? ""; +} + function assertRequiredFiles(type: CosmeticType, names: ReadonlySet): void { const lower = new Set(Array.from(names, (name) => name.toLowerCase())); if ((type === CosmeticType.SLEEVE || type === CosmeticType.AVATAR) && !lower.has("render.jpg")) { @@ -100,8 +109,9 @@ export class PublishCosmetic { async run(input: PublishCosmeticInput): Promise { assertAssetRef(input.type, input.assetRef); - if (input.files.length === 0 || input.files.length > MAX_FILES) { - throw new InvalidArgumentError(`A cosmetic requires between 1 and ${MAX_FILES} files`); + const maxFiles = MAX_FILES_BY_TYPE[input.type] ?? MAX_FILES; + if (input.files.length === 0 || input.files.length > maxFiles) { + throw new InvalidArgumentError(`A cosmetic requires between 1 and ${maxFiles} files`); } const names = new Set(); @@ -124,6 +134,9 @@ export class PublishCosmetic { throw new InvalidArgumentError("Cosmetic assets exceed the 25 MB upload limit"); } assertRequiredFiles(input.type, names); + if (input.type === CosmeticType.EMOTE_PACK) { + assertEmotePackFiles(slugOf(input.assetRef), input.files); + } const duplicate = (await this.cosmetics.findAll()).find( (cosmetic) => cosmetic.assetRef === input.assetRef, diff --git a/src/modules/catalog/domain/CosmeticType.ts b/src/modules/catalog/domain/CosmeticType.ts index b3fd9d1..74f2ad0 100644 --- a/src/modules/catalog/domain/CosmeticType.ts +++ b/src/modules/catalog/domain/CosmeticType.ts @@ -8,4 +8,11 @@ export enum CosmeticType { TITLE = "TITLE", /** The frame a player's zones wear, worn beside the playmat. */ LANE = "LANE", + /** A set of Lottie emotes a player can place on the emote bar. */ + EMOTE_PACK = "EMOTE_PACK", +} + +/** Types worn through the one-item-per-type loadout; an EMOTE_PACK is picked emote by emote. */ +export function isEquippableType(type: CosmeticType): boolean { + return type !== CosmeticType.EMOTE_PACK; } diff --git a/src/modules/catalog/domain/EmotePack.ts b/src/modules/catalog/domain/EmotePack.ts new file mode 100644 index 0000000..a6b6592 --- /dev/null +++ b/src/modules/catalog/domain/EmotePack.ts @@ -0,0 +1,126 @@ +import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; +import { assertVectorLottie } from "./LottieAnimation"; + +export const EMOTE_PACK_MANIFEST = "pack.json"; +export const MAX_EMOTES_PER_PACK = 12; +/** The manifest plus one Lottie per emote. */ +export const MAX_EMOTE_PACK_FILES = MAX_EMOTES_PER_PACK + 1; + +const MANIFEST_VERSION = 1; +const MAX_KEY_LENGTH = 20; +const MAX_LABEL_LENGTH = 32; +/** `/` travels over the duel wire as the emote reference. */ +const MAX_REF_BYTES = 48; +const EMOTE_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export interface EmotePackFile { + readonly name: string; + readonly bytes: Uint8Array; +} + +interface ManifestEmote { + readonly key: string; + readonly label: string; + readonly file: string; +} + +function parseManifest(bytes: Uint8Array): unknown { + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} is not valid JSON`); + } +} + +function parseEmote(raw: unknown, slug: string): ManifestEmote { + const entry = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const { key, label, file } = entry; + + if (typeof key !== "string" || key.length > MAX_KEY_LENGTH || !EMOTE_KEY.test(key)) { + throw new InvalidArgumentError( + `${EMOTE_PACK_MANIFEST} emote key ${JSON.stringify(key)} must be 1 to ${MAX_KEY_LENGTH} lowercase kebab-case characters`, + ); + } + const trimmedLabel = typeof label === "string" ? label.trim() : ""; + if (trimmedLabel.length < 1 || trimmedLabel.length > MAX_LABEL_LENGTH) { + throw new InvalidArgumentError( + `${EMOTE_PACK_MANIFEST} emote "${key}" needs a label of 1 to ${MAX_LABEL_LENGTH} characters`, + ); + } + if (file !== `${key}.json`) { + throw new InvalidArgumentError( + `${EMOTE_PACK_MANIFEST} emote "${key}" must use file "${key}.json"`, + ); + } + const ref = `${slug}/${key}`; + if (new TextEncoder().encode(ref).byteLength > MAX_REF_BYTES) { + throw new InvalidArgumentError( + `${EMOTE_PACK_MANIFEST} emote ref "${ref}" exceeds ${MAX_REF_BYTES} bytes`, + ); + } + return { key, label: trimmedLabel, file }; +} + +function parseEmotes(manifest: unknown, slug: string): ManifestEmote[] { + const root = (typeof manifest === "object" && manifest !== null ? manifest : {}) as Record< + string, + unknown + >; + if (root.version !== MANIFEST_VERSION) { + throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} version must be ${MANIFEST_VERSION}`); + } + if (!Array.isArray(root.emotes)) { + throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} requires an emotes array`); + } + if (root.emotes.length < 1 || root.emotes.length > MAX_EMOTES_PER_PACK) { + throw new InvalidArgumentError( + `EMOTE_PACK requires between 1 and ${MAX_EMOTES_PER_PACK} emotes`, + ); + } + + const seen = new Set(); + return root.emotes.map((raw) => { + const emote = parseEmote(raw, slug); + if (seen.has(emote.key)) { + throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} repeats emote key "${emote.key}"`); + } + seen.add(emote.key); + return emote; + }); +} + +/** + * Checks that an emote pack is exactly one `pack.json` plus the Lottie files it lists, + * and that every Lottie is valid vector-only animation. + */ +export function assertEmotePackFiles(slug: string, files: readonly EmotePackFile[]): void { + for (const file of files) { + if (file.name !== EMOTE_PACK_MANIFEST && !file.name.endsWith(".json")) { + throw new InvalidArgumentError( + `EMOTE_PACK file "${file.name}" must be ${EMOTE_PACK_MANIFEST} or a .json Lottie`, + ); + } + } + + const manifests = files.filter((file) => file.name === EMOTE_PACK_MANIFEST); + if (manifests.length !== 1) { + throw new InvalidArgumentError(`EMOTE_PACK requires exactly one ${EMOTE_PACK_MANIFEST}`); + } + const emotes = parseEmotes(parseManifest(manifests[0].bytes), slug); + + const lottieFiles = files.filter((file) => file.name !== EMOTE_PACK_MANIFEST); + const listed = new Set(emotes.map((emote) => emote.file)); + for (const emote of emotes) { + if (!lottieFiles.some((file) => file.name === emote.file)) { + throw new InvalidArgumentError( + `${EMOTE_PACK_MANIFEST} lists "${emote.file}" but it was not uploaded`, + ); + } + } + for (const file of lottieFiles) { + if (!listed.has(file.name)) { + throw new InvalidArgumentError(`"${file.name}" is not listed in ${EMOTE_PACK_MANIFEST}`); + } + assertVectorLottie(file.name, file.bytes); + } +} diff --git a/src/modules/catalog/domain/LottieAnimation.ts b/src/modules/catalog/domain/LottieAnimation.ts new file mode 100644 index 0000000..bb4b124 --- /dev/null +++ b/src/modules/catalog/domain/LottieAnimation.ts @@ -0,0 +1,111 @@ +import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; + +export const MAX_LOTTIE_BYTES = 200 * 1024; +export const MAX_LOTTIE_SECONDS = 3; +export const MAX_LOTTIE_DEPTH = 64; + +const TEXT_LAYER_TYPE = 5; +const VERSION = /^\d+(?:\.\d+)*$/; + +type Json = Record; + +function isObject(value: unknown): value is Json { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +// Lottie keeps expressions in an `x` string next to the animated value it drives, at any +// depth, so the whole tree has to be walked. The walk is iterative and bounded so hostile +// nesting inside the size cap is rejected instead of exhausting the stack. +function scanTree(fileName: string, root: unknown): { hasExpression: boolean } { + const pending: Array<{ node: unknown; depth: number }> = [{ node: root, depth: 0 }]; + for (let entry = pending.pop(); entry; entry = pending.pop()) { + const { node, depth } = entry; + if (typeof node !== "object" || node === null) continue; + if (depth > MAX_LOTTIE_DEPTH) { + throw new InvalidArgumentError(`${fileName} is nested too deeply`); + } + if (Array.isArray(node)) { + for (const child of node) pending.push({ node: child, depth: depth + 1 }); + continue; + } + for (const [key, value] of Object.entries(node)) { + if (key === "x" && typeof value === "string") return { hasExpression: true }; + pending.push({ node: value, depth: depth + 1 }); + } + } + return { hasExpression: false }; +} + +function layerGroups(rootLayers: unknown[], assets: unknown[]): unknown[][] { + const groups: unknown[][] = [rootLayers]; + for (const asset of assets) { + if (isObject(asset) && Array.isArray(asset.layers)) groups.push(asset.layers); + } + return groups; +} + +/** + * Accepts only the vector subset the client renders safely with lottie-web's SVG + * renderer: no raster or external assets, no text or fonts, no expressions, and a + * bounded size and duration. + */ +export function assertVectorLottie(fileName: string, bytes: Uint8Array): void { + if (bytes.byteLength > MAX_LOTTIE_BYTES) { + throw new InvalidArgumentError(`${fileName} exceeds ${MAX_LOTTIE_BYTES / 1024} KB`); + } + + let animation: unknown; + try { + animation = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new InvalidArgumentError(`${fileName} is not valid JSON`); + } + if (!isObject(animation)) { + throw new InvalidArgumentError(`${fileName} must be a JSON object`); + } + + if (typeof animation.v !== "string" || !VERSION.test(animation.v)) { + throw new InvalidArgumentError(`${fileName} must declare a version string in v`); + } + if (!isFiniteNumber(animation.fr) || animation.fr <= 0) { + throw new InvalidArgumentError(`${fileName} requires a frame rate fr greater than 0`); + } + const { ip, op, w, h } = animation; + if (!isFiniteNumber(ip) || !isFiniteNumber(op) || !isFiniteNumber(w) || !isFiniteNumber(h)) { + throw new InvalidArgumentError(`${fileName} requires numeric ip, op, w and h`); + } + if (op <= ip) { + throw new InvalidArgumentError(`${fileName} requires op greater than ip`); + } + if (!Array.isArray(animation.layers)) { + throw new InvalidArgumentError(`${fileName} requires a layers array`); + } + if ((op - ip) / animation.fr > MAX_LOTTIE_SECONDS) { + throw new InvalidArgumentError(`${fileName} lasts longer than ${MAX_LOTTIE_SECONDS} seconds`); + } + + const assets = animation.assets ?? []; + if (!Array.isArray(assets)) { + throw new InvalidArgumentError(`${fileName} requires assets to be an array`); + } + if (assets.some((asset) => isObject(asset) && ("p" in asset || "u" in asset))) { + throw new InvalidArgumentError(`${fileName} embeds an image or external asset`); + } + if ("fonts" in animation || "chars" in animation) { + throw new InvalidArgumentError(`${fileName} must not declare fonts or chars`); + } + if ( + layerGroups(animation.layers, assets).some((layers) => + layers.some((layer) => isObject(layer) && layer.ty === TEXT_LAYER_TYPE), + ) + ) { + throw new InvalidArgumentError(`${fileName} contains a text layer`); + } + if (scanTree(fileName, animation).hasExpression) { + throw new InvalidArgumentError(`${fileName} contains an expression`); + } +} diff --git a/src/modules/emote-bar/domain/EmoteBar.ts b/src/modules/emote-bar/domain/EmoteBar.ts new file mode 100644 index 0000000..9f26ab8 --- /dev/null +++ b/src/modules/emote-bar/domain/EmoteBar.ts @@ -0,0 +1,32 @@ +import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; +import { DEFAULT_EMOTE_IDS, EmoteRef } from "./EmoteRef"; + +export const EMOTE_BAR_SLOTS = 8; + +// A player's emote bar: exactly 8 distinct emotes, one per slot, in display order. +export class EmoteBar { + private constructor(private readonly slots: readonly EmoteRef[]) {} + + static defaults(): EmoteBar { + return EmoteBar.create(DEFAULT_EMOTE_IDS); + } + + static create(refs: readonly string[]): EmoteBar { + if (refs.length !== EMOTE_BAR_SLOTS) { + throw new InvalidArgumentError(`Emote bar requires exactly ${EMOTE_BAR_SLOTS} slots`); + } + const parsed = refs.map((raw) => EmoteRef.parse(raw)); + const seen = new Set(); + for (const ref of parsed) { + if (seen.has(ref.toString())) { + throw new InvalidArgumentError(`Emote bar repeats "${ref}"`); + } + seen.add(ref.toString()); + } + return new EmoteBar(parsed); + } + + refs(): readonly EmoteRef[] { + return this.slots; + } +} diff --git a/src/modules/emote-bar/domain/EmoteBarRepository.ts b/src/modules/emote-bar/domain/EmoteBarRepository.ts new file mode 100644 index 0000000..baae6df --- /dev/null +++ b/src/modules/emote-bar/domain/EmoteBarRepository.ts @@ -0,0 +1,9 @@ +import { EmoteBar } from "./EmoteBar"; + +export interface EmoteBarRepository { + /** The saved bar, or null when the user never saved one. */ + findByUserId(userId: string): Promise; + + /** Replaces the whole bar atomically. */ + save(userId: string, bar: EmoteBar): Promise; +} diff --git a/src/modules/emote-bar/domain/EmoteRef.ts b/src/modules/emote-bar/domain/EmoteRef.ts new file mode 100644 index 0000000..68ef82a --- /dev/null +++ b/src/modules/emote-bar/domain/EmoteRef.ts @@ -0,0 +1,54 @@ +import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; + +/** Emotes bundled with the client; they need no pack and no asset URL. */ +export const DEFAULT_EMOTE_IDS: readonly string[] = [ + "wave", + "thumbsup", + "fire", + "thinking", + "cool", + "mindblown", + "sweat", + "cry", +]; + +const MAX_REF_BYTES = 48; +const MAX_KEY_LENGTH = 20; +const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * Names one emote: either a default id (`wave`) or `/`. The string + * form is what the duel wire carries, so it is capped at 48 bytes. + */ +export class EmoteRef { + private constructor( + private readonly raw: string, + public readonly packSlug: string | null, + public readonly emoteKey: string | null, + ) {} + + static parse(raw: string): EmoteRef { + if (DEFAULT_EMOTE_IDS.includes(raw)) return new EmoteRef(raw, null, null); + + const parts = raw.split("/"); + const [slug, key] = parts; + const wellFormed = + parts.length === 2 && + KEBAB.test(slug) && + KEBAB.test(key) && + key.length <= MAX_KEY_LENGTH && + new TextEncoder().encode(raw).byteLength <= MAX_REF_BYTES; + if (!wellFormed) { + throw new InvalidArgumentError(`Invalid emote reference "${raw}"`); + } + return new EmoteRef(raw, slug, key); + } + + get isDefault(): boolean { + return this.packSlug === null; + } + + toString(): string { + return this.raw; + } +} diff --git a/src/modules/emote-bar/infrastructure/EmoteBarPostgresRepository.ts b/src/modules/emote-bar/infrastructure/EmoteBarPostgresRepository.ts new file mode 100644 index 0000000..4065e3b --- /dev/null +++ b/src/modules/emote-bar/infrastructure/EmoteBarPostgresRepository.ts @@ -0,0 +1,18 @@ +import { cosmeticsDataSource } from "../../../cosmetics-data-source"; +import { EmoteBar } from "../domain/EmoteBar"; +import { EmoteBarRepository } from "../domain/EmoteBarRepository"; +import { UserEmoteBarEntity } from "./UserEmoteBarEntity"; + +export class EmoteBarPostgresRepository implements EmoteBarRepository { + async findByUserId(userId: string): Promise { + const row = await cosmeticsDataSource.getRepository(UserEmoteBarEntity).findOneBy({ userId }); + return row ? EmoteBar.create(row.emoteRefs) : null; + } + + async save(userId: string, bar: EmoteBar): Promise { + // Upsert by primary key: the whole bar is replaced in a single statement. + await cosmeticsDataSource + .getRepository(UserEmoteBarEntity) + .save({ userId, emoteRefs: bar.refs().map(String) }); + } +} diff --git a/src/modules/emote-bar/infrastructure/UserEmoteBarEntity.ts b/src/modules/emote-bar/infrastructure/UserEmoteBarEntity.ts new file mode 100644 index 0000000..3d196b0 --- /dev/null +++ b/src/modules/emote-bar/infrastructure/UserEmoteBarEntity.ts @@ -0,0 +1,15 @@ +import { Column, Entity, PrimaryColumn, UpdateDateColumn } from "typeorm"; + +// One row per user; emote_refs holds the 8 slots in order. A CHECK constraint keeps it at 8. +@Entity({ name: "user_emote_bars" }) +export class UserEmoteBarEntity { + // FK to users(id), which is varchar in the shared schema — not uuid. + @PrimaryColumn({ name: "user_id", type: "varchar" }) + userId: string; + + @Column({ name: "emote_refs", type: "varchar", length: 48, array: true }) + emoteRefs: string[]; + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt: Date; +} diff --git a/src/modules/loadout/application/EquipCosmetic.ts b/src/modules/loadout/application/EquipCosmetic.ts index f1ad9e8..626312c 100644 --- a/src/modules/loadout/application/EquipCosmetic.ts +++ b/src/modules/loadout/application/EquipCosmetic.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "../../../shared/errors/ForbiddenError"; import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; import { NotFoundError } from "../../../shared/errors/NotFoundError"; import { CosmeticRepository } from "../../catalog/domain/CosmeticRepository"; -import { CosmeticType } from "../../catalog/domain/CosmeticType"; +import { CosmeticType, isEquippableType } from "../../catalog/domain/CosmeticType"; import { EntitlementsGatekeeper } from "../../entitlements/application/EntitlementsGatekeeper"; import { LoadoutRepository } from "../domain/LoadoutRepository"; @@ -26,6 +26,11 @@ export class EquipCosmetic { if (!cosmetic || !cosmetic.active) { throw new NotFoundError(`Cosmetic ${cosmeticId} not found`); } + if (!isEquippableType(cosmetic.type)) { + throw new InvalidArgumentError( + `${cosmetic.type} cosmetics cannot be equipped in the loadout`, + ); + } if (cosmetic.type !== cosmeticType) { throw new InvalidArgumentError( `Cosmetic ${cosmeticId} cannot be equipped in the ${cosmeticType} slot`, diff --git a/src/server/routes/admin-cosmetics-router.ts b/src/server/routes/admin-cosmetics-router.ts index 71aa043..8764eea 100644 --- a/src/server/routes/admin-cosmetics-router.ts +++ b/src/server/routes/admin-cosmetics-router.ts @@ -100,7 +100,7 @@ export function createAdminCosmeticsRouter(deps: AdminCosmeticsRouterDependencie tier: t.Enum(CosmeticTier), assetRef: t.String({ minLength: 1, maxLength: 180 }), displayName: t.String({ minLength: 1, maxLength: 80 }), - files: t.Files({ minItems: 1, maxItems: 8, maxSize: "12m" }), + files: t.Files({ minItems: 1, maxItems: 13, maxSize: "12m" }), }), detail: { tags: ["Cosmetics Admin"], diff --git a/tests/unit/modules/catalog/application/PublishCosmetic.test.ts b/tests/unit/modules/catalog/application/PublishCosmetic.test.ts index 1e48c2c..22642cd 100644 --- a/tests/unit/modules/catalog/application/PublishCosmetic.test.ts +++ b/tests/unit/modules/catalog/application/PublishCosmetic.test.ts @@ -176,4 +176,100 @@ describe("PublishCosmetic", () => { }), ).rejects.toThrow("assetRef for LANE must match lanes/kebab-case-slug/"); }); + describe("EMOTE_PACK", () => { + const encoder = new TextEncoder(); + const lottie = encoder.encode( + JSON.stringify({ v: "5.7.4", fr: 30, ip: 0, op: 30, w: 100, h: 100, layers: [] }), + ); + const pack = (keys: string[]) => [ + { + name: "pack.json", + bytes: encoder.encode( + JSON.stringify({ + version: 1, + emotes: keys.map((key) => ({ key, label: key, file: `${key}.json` })), + }), + ), + }, + ...keys.map((key) => ({ name: `${key}.json`, bytes: lottie })), + ]; + const emotePack = (files: ReturnType, assetRef = "emote-packs/halloween/") => ({ + type: CosmeticType.EMOTE_PACK, + tier: CosmeticTier.EXCLUSIVE, + assetRef, + displayName: "Halloween Duelist", + files, + }); + + it("publishes pack.json and its Lottie files under emote-packs//", async () => { + const { publish, uploads, saved } = setup(); + + const result = await publish.run(emotePack(pack(["attack", "ghost-wave"]))); + + expect(uploads.map((upload) => [upload.key, upload.contentType])).toEqual([ + ["emote-packs/halloween/pack.json", "application/json"], + ["emote-packs/halloween/attack.json", "application/json"], + ["emote-packs/halloween/ghost-wave.json", "application/json"], + ]); + expect(result.assetFiles).toEqual(["pack.json", "attack.json", "ghost-wave.json"]); + expect(saved).toHaveLength(1); + }); + + it("accepts 13 files: pack.json plus 12 emotes", async () => { + const { publish, uploads } = setup(); + const keys = Array.from({ length: 12 }, (_, index) => `e${index}`); + + await publish.run(emotePack(pack(keys))); + + expect(uploads).toHaveLength(13); + }); + + it("keeps the 8 file limit for other types", async () => { + const { publish } = setup(); + const files = Array.from({ length: 9 }, (_, index) => ({ + name: `f${index}.png`, + bytes: new Uint8Array([1]), + })); + + await expect( + publish.run({ + type: CosmeticType.LANE, + tier: CosmeticTier.STANDARD, + assetRef: "lanes/stone/", + displayName: "Stone", + files, + }), + ).rejects.toThrow("A cosmetic requires between 1 and 8 files"); + }); + + it("rejects a pack with an invalid manifest or Lottie, before touching storage", async () => { + const { publish, uploads, saved } = setup(); + const files = pack(["attack"]); + files[1] = { name: "attack.json", bytes: encoder.encode('{"v":"5.7.4"}') }; + + await expect(publish.run(emotePack(files))).rejects.toThrow( + "attack.json requires a frame rate fr greater than 0", + ); + + expect(uploads).toHaveLength(0); + expect(saved).toHaveLength(0); + }); + + it("rejects an assetRef outside emote-packs//", async () => { + const { publish } = setup(); + + await expect(publish.run(emotePack(pack(["attack"]), "lanes/halloween/"))).rejects.toThrow( + "assetRef for EMOTE_PACK must match emote-packs/kebab-case-slug/", + ); + }); + + it("rejects an emote ref over 48 bytes using the slug from the assetRef", async () => { + const { publish } = setup(); + const slug = "s".repeat(30); + + await expect( + publish.run(emotePack(pack(["k".repeat(19)]), `emote-packs/${slug}/`)), + ).rejects.toThrow("exceeds 48 bytes"); + }); + }); }); diff --git a/tests/unit/modules/catalog/domain/EmotePack.test.ts b/tests/unit/modules/catalog/domain/EmotePack.test.ts new file mode 100644 index 0000000..3b4d3c4 --- /dev/null +++ b/tests/unit/modules/catalog/domain/EmotePack.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "bun:test"; + +import { + assertEmotePackFiles, + MAX_EMOTE_PACK_FILES, +} from "../../../../../src/modules/catalog/domain/EmotePack"; + +const encoder = new TextEncoder(); + +function lottie(): Uint8Array { + return encoder.encode( + JSON.stringify({ v: "5.7.4", fr: 30, ip: 0, op: 30, w: 100, h: 100, layers: [] }), + ); +} + +function manifest(emotes: unknown, version: unknown = 1): { name: string; bytes: Uint8Array } { + return { name: "pack.json", bytes: encoder.encode(JSON.stringify({ version, emotes })) }; +} + +function emote(key: string, label = key, file = `${key}.json`) { + return { key, label, file }; +} + +function files(keys: string[]) { + return keys.map((key) => ({ name: `${key}.json`, bytes: lottie() })); +} + +function reject(slug: string, input: Parameters[1], message: string) { + expect(() => assertEmotePackFiles(slug, input)).toThrow(message); +} + +describe("assertEmotePackFiles", () => { + it("accepts a manifest whose emotes match the uploaded Lottie files", () => { + expect(() => + assertEmotePackFiles("halloween", [ + manifest([emote("attack", "Attack"), emote("ghost-wave", "Ghost wave")]), + ...files(["attack", "ghost-wave"]), + ]), + ).not.toThrow(); + }); + + it("allows 13 files: pack.json plus 12 emotes", () => { + const keys = Array.from({ length: 12 }, (_, index) => `e${index}`); + expect(MAX_EMOTE_PACK_FILES).toBe(13); + expect(() => + assertEmotePackFiles("big", [manifest(keys.map((key) => emote(key))), ...files(keys)]), + ).not.toThrow(); + }); + + it("rejects a pack without pack.json", () => { + reject("halloween", files(["attack"]), "EMOTE_PACK requires exactly one pack.json"); + }); + + it("rejects a pack with no Lottie files", () => { + reject("halloween", [manifest([])], "EMOTE_PACK requires between 1 and 12 emotes"); + }); + + it("rejects more than 12 Lottie files", () => { + const keys = Array.from({ length: 13 }, (_, index) => `e${index}`); + reject( + "big", + [manifest(keys.map((key) => emote(key))), ...files(keys)], + "EMOTE_PACK requires between 1 and 12 emotes", + ); + }); + + it("rejects an uploaded file that is not .json", () => { + reject( + "halloween", + [manifest([emote("attack")]), ...files(["attack"]), { name: "attack.png", bytes: lottie() }], + 'EMOTE_PACK file "attack.png" must be pack.json or a .json Lottie', + ); + }); + + it("rejects a pack.json that is not valid JSON", () => { + reject( + "halloween", + [{ name: "pack.json", bytes: encoder.encode("{") }, ...files(["attack"])], + "pack.json is not valid JSON", + ); + }); + + it("rejects an unsupported manifest version", () => { + reject( + "halloween", + [manifest([emote("attack")], 2), ...files(["attack"])], + "pack.json version must be 1", + ); + }); + + it("rejects a manifest without an emotes array", () => { + reject( + "halloween", + [manifest("nope"), ...files(["attack"])], + "pack.json requires an emotes array", + ); + }); + + it("rejects emote keys that are not kebab-case or are too long", () => { + for (const key of ["Attack", "a_b", "-a", "a--b", "", "k".repeat(21)]) { + reject( + "halloween", + [manifest([emote(key, "x", "x.json")]), ...files(["x"])], + "pack.json emote key", + ); + } + }); + + it("rejects duplicate keys", () => { + reject( + "halloween", + [manifest([emote("attack"), emote("attack")]), ...files(["attack"])], + 'pack.json repeats emote key "attack"', + ); + }); + + it("rejects labels that are empty after trimming or longer than 32 characters", () => { + reject( + "halloween", + [manifest([emote("attack", " ")]), ...files(["attack"])], + 'pack.json emote "attack" needs a label of 1 to 32 characters', + ); + reject( + "halloween", + [manifest([emote("attack", "l".repeat(33))]), ...files(["attack"])], + 'pack.json emote "attack" needs a label of 1 to 32 characters', + ); + }); + + it("rejects a file that is not .json", () => { + reject( + "halloween", + [manifest([emote("attack", "Attack", "other.json")]), ...files(["attack"])], + 'pack.json emote "attack" must use file "attack.json"', + ); + }); + + it("rejects a listed emote whose file was not uploaded", () => { + reject( + "halloween", + [manifest([emote("attack"), emote("wave")]), ...files(["attack"])], + 'pack.json lists "wave.json" but it was not uploaded', + ); + }); + + it("rejects an uploaded Lottie that pack.json does not list", () => { + reject( + "halloween", + [manifest([emote("attack")]), ...files(["attack", "extra"])], + '"extra.json" is not listed in pack.json', + ); + }); + + it("rejects an emote ref above 48 bytes", () => { + const slug = "s".repeat(30); + const key = "k".repeat(19); + reject( + slug, + [manifest([emote(key)]), ...files([key])], + `emote ref "${slug}/${key}" exceeds 48 bytes`, + ); + }); + + it("accepts an emote ref of exactly 48 bytes", () => { + const slug = "s".repeat(30); + const key = "k".repeat(17); + expect(() => + assertEmotePackFiles(slug, [manifest([emote(key)]), ...files([key])]), + ).not.toThrow(); + }); + + it("validates every Lottie file", () => { + reject( + "halloween", + [manifest([emote("attack")]), { name: "attack.json", bytes: encoder.encode("[]") }], + "attack.json must be a JSON object", + ); + }); +}); diff --git a/tests/unit/modules/catalog/domain/LottieAnimation.test.ts b/tests/unit/modules/catalog/domain/LottieAnimation.test.ts new file mode 100644 index 0000000..22dea94 --- /dev/null +++ b/tests/unit/modules/catalog/domain/LottieAnimation.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "bun:test"; + +import { + assertVectorLottie, + MAX_LOTTIE_BYTES, + MAX_LOTTIE_SECONDS, +} from "../../../../../src/modules/catalog/domain/LottieAnimation"; + +function lottie(overrides: Record = {}): Uint8Array { + return new TextEncoder().encode( + JSON.stringify({ + v: "5.7.4", + fr: 30, + ip: 0, + op: 60, + w: 512, + h: 512, + assets: [], + layers: [{ ty: 4, nm: "shape", shapes: [] }], + ...overrides, + }), + ); +} + +function reject(bytes: Uint8Array, message: string): void { + expect(() => assertVectorLottie("wave.json", bytes)).toThrow(message); +} + +describe("assertVectorLottie", () => { + it("accepts a vector-only animation", () => { + expect(() => assertVectorLottie("wave.json", lottie())).not.toThrow(); + }); + + it("accepts precomposition assets that only hold layers", () => { + const bytes = lottie({ assets: [{ id: "comp_0", layers: [{ ty: 4, shapes: [] }] }] }); + expect(() => assertVectorLottie("wave.json", bytes)).not.toThrow(); + }); + + it("rejects a file above the size cap", () => { + reject(new Uint8Array(MAX_LOTTIE_BYTES + 1), "wave.json exceeds 200 KB"); + }); + + it("rejects bytes that are not JSON", () => { + reject(new TextEncoder().encode("{nope"), "wave.json is not valid JSON"); + }); + + it("rejects JSON that is not an object", () => { + reject(new TextEncoder().encode("[]"), "wave.json must be a JSON object"); + }); + + it("rejects a missing or non-string version", () => { + reject(lottie({ v: 5 }), "wave.json must declare a version string in v"); + reject(lottie({ v: "latest" }), "wave.json must declare a version string in v"); + }); + + it("rejects a non-positive frame rate", () => { + reject(lottie({ fr: 0 }), "wave.json requires a frame rate fr greater than 0"); + }); + + it("rejects missing numeric bounds", () => { + reject(lottie({ ip: "0" }), "wave.json requires numeric ip, op, w and h"); + reject(lottie({ w: undefined }), "wave.json requires numeric ip, op, w and h"); + }); + + it("rejects an out point that is not after the in point", () => { + reject(lottie({ ip: 10, op: 10 }), "wave.json requires op greater than ip"); + }); + + it("rejects missing layers", () => { + reject(lottie({ layers: undefined }), "wave.json requires a layers array"); + }); + + it("rejects an animation longer than 3 seconds", () => { + expect(MAX_LOTTIE_SECONDS).toBe(3); + reject(lottie({ fr: 30, ip: 0, op: 91 }), "wave.json lasts longer than 3 seconds"); + }); + + it("accepts an animation of exactly 3 seconds", () => { + expect(() => assertVectorLottie("wave.json", lottie({ fr: 30, ip: 0, op: 90 }))).not.toThrow(); + }); + + it("rejects embedded image assets", () => { + const bytes = lottie({ assets: [{ id: "image_0", p: "data:image/png;base64,AAAA", u: "" }] }); + reject(bytes, "wave.json embeds an image or external asset"); + }); + + it("rejects external asset references", () => { + reject( + lottie({ assets: [{ id: "image_0", u: "https://example.com/" }] }), + "wave.json embeds an image or external asset", + ); + }); + + it("rejects fonts and glyph data", () => { + reject(lottie({ fonts: { list: [] } }), "wave.json must not declare fonts or chars"); + reject(lottie({ chars: [] }), "wave.json must not declare fonts or chars"); + }); + + it("rejects text layers at the root and inside precompositions", () => { + reject(lottie({ layers: [{ ty: 5 }] }), "wave.json contains a text layer"); + reject( + lottie({ assets: [{ id: "comp_0", layers: [{ ty: 5 }] }] }), + "wave.json contains a text layer", + ); + }); + + it("rejects expressions anywhere in the tree", () => { + const bytes = lottie({ + layers: [{ ty: 4, ks: { r: { a: 0, k: 0, x: "time * 10" } }, shapes: [] }], + }); + reject(bytes, "wave.json contains an expression"); + }); + + it("does not mistake a numeric x property for an expression", () => { + const bytes = lottie({ layers: [{ ty: 4, shapes: [{ ty: "rc", x: 3 }] }] }); + expect(() => assertVectorLottie("wave.json", bytes)).not.toThrow(); + }); + + it("rejects deeply nested JSON as a validation error instead of overflowing the stack", () => { + const depth = 10_000; + const nested = JSON.parse(`${"[".repeat(depth)}${"]".repeat(depth)}`); + const bytes = lottie({ layers: [{ ty: 4, shapes: nested }] }); + reject(bytes, "wave.json is nested too deeply"); + }); + + it("accepts nesting just inside the depth limit", () => { + const depth = 40; + const nested = JSON.parse(`${"[".repeat(depth)}${"]".repeat(depth)}`); + const bytes = lottie({ layers: [{ ty: 4, shapes: nested }] }); + expect(() => assertVectorLottie("wave.json", bytes)).not.toThrow(); + }); +}); diff --git a/tests/unit/modules/emote-bar/domain/EmoteBar.test.ts b/tests/unit/modules/emote-bar/domain/EmoteBar.test.ts new file mode 100644 index 0000000..5755c7c --- /dev/null +++ b/tests/unit/modules/emote-bar/domain/EmoteBar.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; + +import { EmoteBar } from "../../../../../src/modules/emote-bar/domain/EmoteBar"; +import { DEFAULT_EMOTE_IDS } from "../../../../../src/modules/emote-bar/domain/EmoteRef"; + +describe("EmoteBar", () => { + it("defaults to the bundled emotes in order", () => { + expect(EmoteBar.defaults().refs().map(String)).toEqual([...DEFAULT_EMOTE_IDS]); + }); + + it("keeps the order of the refs it is built from", () => { + const refs = ["pack/a", "cry", "pack/b", "wave", "fire", "cool", "sweat", "thumbsup"]; + + expect(EmoteBar.create(refs).refs().map(String)).toEqual(refs); + }); + + it("rejects a bar that does not hold exactly 8 slots", () => { + expect(() => EmoteBar.create(["wave"])).toThrow("Emote bar requires exactly 8 slots"); + expect(() => EmoteBar.create([...DEFAULT_EMOTE_IDS, "pack/a"])).toThrow( + "Emote bar requires exactly 8 slots", + ); + }); + + it("rejects a repeated emote", () => { + const refs = [...DEFAULT_EMOTE_IDS.slice(0, 7), "wave"]; + + expect(() => EmoteBar.create(refs)).toThrow('Emote bar repeats "wave"'); + }); + + it("rejects a malformed ref naming it", () => { + const refs = [...DEFAULT_EMOTE_IDS.slice(0, 7), "Bad/Ref"]; + + expect(() => EmoteBar.create(refs)).toThrow('Invalid emote reference "Bad/Ref"'); + }); +}); diff --git a/tests/unit/modules/emote-bar/domain/EmoteRef.test.ts b/tests/unit/modules/emote-bar/domain/EmoteRef.test.ts new file mode 100644 index 0000000..6cd8b17 --- /dev/null +++ b/tests/unit/modules/emote-bar/domain/EmoteRef.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; + +import { DEFAULT_EMOTE_IDS, EmoteRef } from "../../../../../src/modules/emote-bar/domain/EmoteRef"; + +describe("EmoteRef", () => { + it("lists the bundled default emotes in bar order", () => { + expect(DEFAULT_EMOTE_IDS).toEqual([ + "wave", + "thumbsup", + "fire", + "thinking", + "cool", + "mindblown", + "sweat", + "cry", + ]); + }); + + it("parses a default emote id", () => { + const ref = EmoteRef.parse("fire"); + + expect(ref.isDefault).toBe(true); + expect(ref.packSlug).toBeNull(); + expect(ref.emoteKey).toBeNull(); + expect(ref.toString()).toBe("fire"); + }); + + it("parses a pack emote reference", () => { + const ref = EmoteRef.parse("halloween-duelist/ghost-wave"); + + expect(ref.isDefault).toBe(false); + expect(ref.packSlug).toBe("halloween-duelist"); + expect(ref.emoteKey).toBe("ghost-wave"); + expect(ref.toString()).toBe("halloween-duelist/ghost-wave"); + }); + + it.each([ + "", + "unknown", + "Wave", + "/wave", + "pack/", + "pack/a/b", + "Pack/wave", + "pack/Wave", + "pack_one/wave", + "-pack/wave", + "pack-/wave", + "pack--x/wave", + "pack/a b", + `pack/${"k".repeat(21)}`, + `${"s".repeat(40)}/${"k".repeat(10)}`, + ])("rejects the malformed reference %p", (raw) => { + expect(() => EmoteRef.parse(raw)).toThrow(`Invalid emote reference "${raw}"`); + }); + + it("accepts a key of exactly 20 characters", () => { + expect(() => EmoteRef.parse(`pack/${"k".repeat(20)}`)).not.toThrow(); + }); + + it("accepts a reference of exactly 48 bytes", () => { + const raw = `${"s".repeat(27)}/${"k".repeat(20)}`; + expect(raw.length).toBe(48); + expect(() => EmoteRef.parse(raw)).not.toThrow(); + }); +}); diff --git a/tests/unit/modules/loadout/application/EquipCosmetic.test.ts b/tests/unit/modules/loadout/application/EquipCosmetic.test.ts index d8ac56a..ab3066c 100644 --- a/tests/unit/modules/loadout/application/EquipCosmetic.test.ts +++ b/tests/unit/modules/loadout/application/EquipCosmetic.test.ts @@ -117,6 +117,24 @@ describe("EquipCosmetic", () => { ).rejects.toBeInstanceOf(InvalidArgumentError); }); + it("rejects equipping an EMOTE_PACK because it has no loadout slot", async () => { + const { equip, saved } = build({ + found: Cosmetic.from({ + id: "pack-1", + type: CosmeticType.EMOTE_PACK, + tier: CosmeticTier.STANDARD, + assetRef: "emote-packs/halloween/", + displayName: "Halloween", + active: true, + }), + }); + + await expect( + equip.run({ userId: "user-1", cosmeticType: CosmeticType.EMOTE_PACK, cosmeticId: "pack-1" }), + ).rejects.toBeInstanceOf(InvalidArgumentError); + expect(saved).toHaveLength(0); + }); + it("equips an AVATAR the user is entitled to", async () => { const { equip, saved } = build({ found: avatar() });