diff --git a/src/migrations/1781100000000-add_companion_cosmetic_type.ts b/src/migrations/1781100000000-add_companion_cosmetic_type.ts new file mode 100644 index 0000000..72c8289 --- /dev/null +++ b/src/migrations/1781100000000-add_companion_cosmetic_type.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +// Adds the COMPANION cosmetic type and the nullable `animation` jsonb column that only +// COMPANION cosmetics populate. No COMPANION rows are inserted here: `ALTER TYPE ... ADD +// VALUE` runs inside a transaction on PG >= 12, but the freshly added enum value cannot +// be USED in the same transaction, so seeding companion data is deferred to the seed once +// the R2 assets exist. +export class AddCompanionCosmeticType1781100000000 implements MigrationInterface { + name = "AddCompanionCosmeticType1781100000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE "cosmetic_type_enum" ADD VALUE IF NOT EXISTS 'COMPANION'`); + await queryRunner.query(`ALTER TABLE "cosmetics" ADD COLUMN "animation" jsonb`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cosmetics" DROP COLUMN "animation"`); + // Postgres cannot remove a single enum value without recreating the whole type + // (and rewriting every column that uses it), which is fragile and risky. The + // 'COMPANION' value is therefore intentionally left in place on down. + } +} diff --git a/src/modules/catalog/application/GetCosmeticsCatalog.ts b/src/modules/catalog/application/GetCosmeticsCatalog.ts index fad8280..8ef54aa 100644 --- a/src/modules/catalog/application/GetCosmeticsCatalog.ts +++ b/src/modules/catalog/application/GetCosmeticsCatalog.ts @@ -1,5 +1,6 @@ import { AssetUrlSigner } from "../../assets/domain/AssetUrlSigner"; import { EntitlementsGatekeeper } from "../../entitlements/application/EntitlementsGatekeeper"; +import type { CompanionAnimationDescriptor } from "../domain/CompanionAnimation"; import { CosmeticRepository } from "../domain/CosmeticRepository"; import { CosmeticTier } from "../domain/CosmeticTier"; import { CosmeticType } from "../domain/CosmeticType"; @@ -10,6 +11,7 @@ export interface CatalogCosmetic { tier: CosmeticTier; displayName: string; assets: Record; + animation?: CompanionAnimationDescriptor; } export interface CatalogFilters { @@ -45,6 +47,7 @@ export class GetCosmeticsCatalog { tier: cosmetic.tier, displayName: cosmetic.displayName, assets: await this.signer.signManifest(cosmetic.assetRef), + animation: cosmetic.animation, })), ); } diff --git a/src/modules/catalog/application/SeedStandardCosmetics.ts b/src/modules/catalog/application/SeedStandardCosmetics.ts index b9ace40..7acfa4f 100644 --- a/src/modules/catalog/application/SeedStandardCosmetics.ts +++ b/src/modules/catalog/application/SeedStandardCosmetics.ts @@ -28,6 +28,7 @@ export class SeedStandardCosmetics { tier: item.tier, assetRef: item.assetRef, displayName: item.displayName, + animation: item.animation, }), ); created++; diff --git a/src/modules/catalog/application/standardCosmetics.ts b/src/modules/catalog/application/standardCosmetics.ts index a4bef4c..29d0f80 100644 --- a/src/modules/catalog/application/standardCosmetics.ts +++ b/src/modules/catalog/application/standardCosmetics.ts @@ -1,3 +1,4 @@ +import type { CompanionAnimationDescriptor } from "../domain/CompanionAnimation"; import { CosmeticTier } from "../domain/CosmeticTier"; import { CosmeticType } from "../domain/CosmeticType"; @@ -6,15 +7,71 @@ export interface StandardCosmeticSeed { tier: CosmeticTier; assetRef: string; displayName: string; + // Only COMPANION assets carry an animation descriptor; left undefined for every + // other type (enforced by Cosmetic.create). + animation?: CompanionAnimationDescriptor; } +// KayKit companion characters. The animation descriptor uses the external-rig strategy: +// the character .glb is self-contained, and the manifest (character.glb + rig.glb + +// preview.jpg) is built at request time from the R2 prefix, so the seed only stores the +// assetRef plus this descriptor. Clip names and the rig basename are validated against +// the real KayKit files. +const COMPANION_ANIMATION: CompanionAnimationDescriptor = { + rigFile: "Rig_Medium_General.glb", + clips: { + idle: "Idle_A", + spawn: "Spawn_Ground", + speak: "Interact", + hit: "Hit_A", + summon: "Use_Item", + attack: "Throw", + cast: "Use_Item", + defeat: "Death_A", + }, +}; + +export const KAYKIT_COMPANIONS: StandardCosmeticSeed[] = [ + { + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-warrior/", + displayName: "Warrior", + animation: COMPANION_ANIMATION, + }, + { + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-rogue/", + displayName: "Rogue", + animation: COMPANION_ANIMATION, + }, + { + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-minion/", + displayName: "Minion", + animation: COMPANION_ANIMATION, + }, + // Mage is the client's bundled offline default (STANDARD_COMPANION). It is still hosted + // server-side so a player can equip it explicitly and opponents/spectators see it through + // the public loadout, exactly like the other companions. + { + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-mage/", + displayName: "Mage", + animation: COMPANION_ANIMATION, + }, +]; + // The cosmetic set seeded on bootstrap. asset_ref is the R2 folder prefix; the -// individual files (render/preview for sleeves, gltf/bin/texture for playmats) -// live under it and are resolved at serve time. `tier` gates visibility/usage: -// anonymous players see STANDARD only; REGISTERED requires an account. NOTE: the -// seed only INSERTS missing rows (matched by asset_ref) — it never updates the -// tier of an already-seeded cosmetic, so changing an existing tier needs a -// data migration (see SetSleeveTiers). +// individual files (render/preview for sleeves, gltf/bin/texture for playmats, +// character.glb/rig.glb/preview.jpg for companions) live under it and are resolved at +// serve time. `tier` gates visibility/usage: anonymous players see STANDARD only; +// REGISTERED requires an account. NOTE: the seed only INSERTS missing rows (matched by +// asset_ref) — it never updates the tier of an already-seeded cosmetic, so changing an +// existing tier needs a data migration (see SetSleeveTiers). export const STANDARD_COSMETICS: StandardCosmeticSeed[] = [ { type: CosmeticType.SLEEVE, @@ -106,4 +163,6 @@ export const STANDARD_COSMETICS: StandardCosmeticSeed[] = [ assetRef: "avatars/evolution-black/", displayName: "Evolution Black", }, + // Companions are live now that their assets are uploaded to R2. + ...KAYKIT_COMPANIONS, ]; diff --git a/src/modules/catalog/domain/CompanionAnimation.ts b/src/modules/catalog/domain/CompanionAnimation.ts new file mode 100644 index 0000000..de06950 --- /dev/null +++ b/src/modules/catalog/domain/CompanionAnimation.ts @@ -0,0 +1,16 @@ +export type CompanionRole = + | "idle" + | "spawn" + | "speak" + | "hit" + | "summon" + | "attack" + | "cast" + | "defeat"; + +export interface CompanionAnimationDescriptor { + clips?: Partial>; // role -> exact animation-group name in the file + rigFile?: string; // external rig basename, e.g. "Rig_Medium_General.glb" + targetHeight?: number; // bbox target world height + orientationOffsetY?: number; // per-model Y-rotation offset +} diff --git a/src/modules/catalog/domain/Cosmetic.ts b/src/modules/catalog/domain/Cosmetic.ts index 45ee7ba..60b8880 100644 --- a/src/modules/catalog/domain/Cosmetic.ts +++ b/src/modules/catalog/domain/Cosmetic.ts @@ -1,4 +1,5 @@ import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; +import type { CompanionAnimationDescriptor } from "./CompanionAnimation"; import { CosmeticTier } from "./CosmeticTier"; import { CosmeticType } from "./CosmeticType"; @@ -10,6 +11,7 @@ export class Cosmetic { public readonly assetRef: string, public readonly displayName: string, public readonly active: boolean, + public readonly animation?: CompanionAnimationDescriptor, ) {} static create({ @@ -18,12 +20,14 @@ export class Cosmetic { tier, assetRef, displayName, + animation, }: { id: string; type: CosmeticType; tier: CosmeticTier; assetRef: string; displayName: string; + animation?: CompanionAnimationDescriptor; }): Cosmetic { if (!assetRef.trim()) { throw new InvalidArgumentError("assetRef cannot be empty"); @@ -36,8 +40,13 @@ export class Cosmetic { if (!displayName.trim()) { throw new InvalidArgumentError("displayName cannot be empty"); } + // The animation descriptor is exclusive to COMPANION assets — it has no meaning + // for any other type, so carrying it elsewhere is a programming error. + if (animation !== undefined && type !== CosmeticType.COMPANION) { + throw new InvalidArgumentError("animation is only allowed for COMPANION cosmetics"); + } - return new Cosmetic(id, type, tier, assetRef, displayName, true); + return new Cosmetic(id, type, tier, assetRef, displayName, true, animation); } static from(data: { @@ -47,6 +56,7 @@ export class Cosmetic { assetRef: string; displayName: string; active: boolean; + animation?: CompanionAnimationDescriptor; }): Cosmetic { return new Cosmetic( data.id, @@ -55,6 +65,7 @@ export class Cosmetic { data.assetRef, data.displayName, data.active, + data.animation, ); } @@ -65,6 +76,7 @@ export class Cosmetic { assetRef: string; displayName: string; active: boolean; + animation?: CompanionAnimationDescriptor; } { return { id: this.id, @@ -73,6 +85,7 @@ export class Cosmetic { assetRef: this.assetRef, displayName: this.displayName, active: this.active, + animation: this.animation, }; } } diff --git a/src/modules/catalog/domain/CosmeticType.ts b/src/modules/catalog/domain/CosmeticType.ts index b632436..2b2361e 100644 --- a/src/modules/catalog/domain/CosmeticType.ts +++ b/src/modules/catalog/domain/CosmeticType.ts @@ -6,4 +6,5 @@ export enum CosmeticType { SUMMON_EFFECT = "SUMMON_EFFECT", MUSIC = "MUSIC", TITLE = "TITLE", + COMPANION = "COMPANION", } diff --git a/src/modules/catalog/infrastructure/CosmeticEntity.ts b/src/modules/catalog/infrastructure/CosmeticEntity.ts index c0856f7..1849598 100644 --- a/src/modules/catalog/infrastructure/CosmeticEntity.ts +++ b/src/modules/catalog/infrastructure/CosmeticEntity.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, } from "typeorm"; +import type { CompanionAnimationDescriptor } from "../domain/CompanionAnimation"; import { CosmeticTier } from "../domain/CosmeticTier"; import { CosmeticType } from "../domain/CosmeticType"; @@ -29,6 +30,9 @@ export class CosmeticEntity { @Column({ default: true }) active: boolean; + @Column({ name: "animation", type: "jsonb", nullable: true }) + animation?: CompanionAnimationDescriptor | null; + @CreateDateColumn({ name: "created_at" }) createdAt: Date; diff --git a/src/modules/catalog/infrastructure/CosmeticPostgresRepository.ts b/src/modules/catalog/infrastructure/CosmeticPostgresRepository.ts index 3fcd6ca..2720f05 100644 --- a/src/modules/catalog/infrastructure/CosmeticPostgresRepository.ts +++ b/src/modules/catalog/infrastructure/CosmeticPostgresRepository.ts @@ -3,6 +3,11 @@ import { Cosmetic } from "../domain/Cosmetic"; import { CosmeticRepository } from "../domain/CosmeticRepository"; import { CosmeticEntity } from "./CosmeticEntity"; +// The id column is uuid; querying it with a malformed value makes Postgres throw +// `invalid input syntax for type uuid`, which would surface as a 500. A non-uuid id +// can never match a row, so we treat it as "not found" and skip the query entirely. +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + export class CosmeticPostgresRepository implements CosmeticRepository { async findAll(): Promise { const repository = cosmeticsDataSource.getRepository(CosmeticEntity); @@ -16,11 +21,16 @@ export class CosmeticPostgresRepository implements CosmeticRepository { assetRef: entity.assetRef, displayName: entity.displayName, active: entity.active, + animation: entity.animation ?? undefined, }), ); } async findById(id: string): Promise { + if (!UUID_PATTERN.test(id)) { + return null; + } + const repository = cosmeticsDataSource.getRepository(CosmeticEntity); const entity = await repository.findOne({ where: { id } }); @@ -35,6 +45,7 @@ export class CosmeticPostgresRepository implements CosmeticRepository { assetRef: entity.assetRef, displayName: entity.displayName, active: entity.active, + animation: entity.animation ?? undefined, }); } @@ -49,6 +60,7 @@ export class CosmeticPostgresRepository implements CosmeticRepository { assetRef: data.assetRef, displayName: data.displayName, active: data.active, + animation: data.animation ?? null, }); await repository.save(entity); diff --git a/src/modules/loadout/application/GetMyLoadout.ts b/src/modules/loadout/application/GetMyLoadout.ts index 4a48280..b37838f 100644 --- a/src/modules/loadout/application/GetMyLoadout.ts +++ b/src/modules/loadout/application/GetMyLoadout.ts @@ -1,4 +1,5 @@ import { AssetUrlSigner } from "../../assets/domain/AssetUrlSigner"; +import type { CompanionAnimationDescriptor } from "../../catalog/domain/CompanionAnimation"; import { CosmeticRepository } from "../../catalog/domain/CosmeticRepository"; import { CosmeticType } from "../../catalog/domain/CosmeticType"; import { LoadoutRepository } from "../domain/LoadoutRepository"; @@ -7,6 +8,7 @@ export interface MyLoadoutSlot { cosmeticType: CosmeticType; cosmeticId: string; assets: Record; + animation?: CompanionAnimationDescriptor; } export class GetMyLoadout { @@ -26,6 +28,7 @@ export class GetMyLoadout { cosmeticType: item.cosmeticType, cosmeticId: item.cosmeticId, assets: cosmetic ? await this.signer.signManifest(cosmetic.assetRef) : {}, + animation: cosmetic?.animation, }; }), ); diff --git a/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts b/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts index 7ff4899..c2ae994 100644 --- a/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts +++ b/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts @@ -61,6 +61,19 @@ const retiredSleeve = Cosmetic.from({ displayName: "Retired", active: false, }); +const companionAnimation = { + rigFile: "Rig_Medium_General.glb", + clips: { idle: "Idle_A", attack: "Throw" }, +}; +const companion = Cosmetic.from({ + id: "companion-1", + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-warrior/", + displayName: "Warrior", + active: true, + animation: companionAnimation, +}); const signer: AssetUrlSigner = { sign: () => "", @@ -226,6 +239,25 @@ describe("GetCosmeticsCatalog", () => { expect(result.map((c) => c.id)).not.toContain("retired-sleeve"); }); + it("surfaces the animation descriptor for a COMPANION cosmetic", async () => { + const catalog = catalogOf([companion]); + + const result = await catalog.run({}, null); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe(CosmeticType.COMPANION); + expect(result[0].animation).toEqual(companionAnimation); + }); + + it("filters the catalog by COMPANION type", async () => { + const catalog = catalogOf([sleeve, companion]); + + const result = await catalog.run({ type: CosmeticType.COMPANION }, null); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("companion-1"); + }); + it("N+1 guard: only one accessFor call regardless of cosmetic count", async () => { // Use a spy gatekeeper to count accessFor calls. let accessForCalls = 0; diff --git a/tests/unit/modules/catalog/application/SeedStandardCosmetics.test.ts b/tests/unit/modules/catalog/application/SeedStandardCosmetics.test.ts index 13675d5..f7c4f5d 100644 --- a/tests/unit/modules/catalog/application/SeedStandardCosmetics.test.ts +++ b/tests/unit/modules/catalog/application/SeedStandardCosmetics.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "bun:test"; import { SeedStandardCosmetics } from "../../../../../src/modules/catalog/application/SeedStandardCosmetics"; -import { STANDARD_COSMETICS } from "../../../../../src/modules/catalog/application/standardCosmetics"; +import { + KAYKIT_COMPANIONS, + STANDARD_COSMETICS, +} from "../../../../../src/modules/catalog/application/standardCosmetics"; import { Cosmetic } from "../../../../../src/modules/catalog/domain/Cosmetic"; import { CosmeticRepository } from "../../../../../src/modules/catalog/domain/CosmeticRepository"; +import { CosmeticType } from "../../../../../src/modules/catalog/domain/CosmeticType"; function fakeRepository(existing: Cosmetic[]): { repository: CosmeticRepository; @@ -56,4 +60,45 @@ describe("SeedStandardCosmetics", () => { expect(saved).toHaveLength(STANDARD_COSMETICS.length - 1); expect(saved.some((c) => c.assetRef === first.assetRef)).toBe(false); }); + + // Assets are uploaded to R2, so KAYKIT_COMPANIONS now ships in the active seed. These + // assertions lock the seed data shape and that the companions are actually enabled. + describe("KAYKIT_COMPANIONS", () => { + it("registers all four companion entries in the active seed", () => { + expect(KAYKIT_COMPANIONS).toHaveLength(4); + for (const entry of KAYKIT_COMPANIONS) { + expect(STANDARD_COSMETICS).toContain(entry); + } + // Mage ships as the client's offline default, yet it is still hosted server-side + // so it is equippable and visible to opponents like every other companion. + expect(STANDARD_COSMETICS.map((entry) => entry.assetRef)).toContain( + "companions/kaykit-mage/", + ); + }); + + it("each entry is a COMPANION folder prefix with a usable animation descriptor", () => { + for (const entry of KAYKIT_COMPANIONS) { + expect(entry.type).toBe(CosmeticType.COMPANION); + expect(entry.assetRef.endsWith("/")).toBe(true); + expect(entry.animation?.rigFile).toBeTruthy(); + expect(Object.keys(entry.animation?.clips ?? {}).length).toBeGreaterThan(0); + } + }); + + it("builds a COMPANION cosmetic carrying its animation through Cosmetic.create (seed path)", () => { + for (const entry of KAYKIT_COMPANIONS) { + const cosmetic = Cosmetic.create({ + id: crypto.randomUUID(), + type: entry.type, + tier: entry.tier, + assetRef: entry.assetRef, + displayName: entry.displayName, + animation: entry.animation, + }); + + expect(cosmetic.type).toBe(CosmeticType.COMPANION); + expect(cosmetic.animation).toEqual(entry.animation); + } + }); + }); }); diff --git a/tests/unit/modules/catalog/domain/Cosmetic.test.ts b/tests/unit/modules/catalog/domain/Cosmetic.test.ts new file mode 100644 index 0000000..e50f40b --- /dev/null +++ b/tests/unit/modules/catalog/domain/Cosmetic.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; + +import { CompanionAnimationDescriptor } from "../../../../../src/modules/catalog/domain/CompanionAnimation"; +import { Cosmetic } from "../../../../../src/modules/catalog/domain/Cosmetic"; +import { CosmeticTier } from "../../../../../src/modules/catalog/domain/CosmeticTier"; +import { CosmeticType } from "../../../../../src/modules/catalog/domain/CosmeticType"; +import { InvalidArgumentError } from "../../../../../src/shared/errors/InvalidArgumentError"; + +const animation: CompanionAnimationDescriptor = { + rigFile: "Rig_Medium_General.glb", + targetHeight: 1.6, + orientationOffsetY: 0, + clips: { + idle: "Idle_A", + spawn: "Spawn_Ground", + attack: "Throw", + }, +}; + +describe("Cosmetic", () => { + it("creates a COMPANION carrying an animation descriptor", () => { + const cosmetic = Cosmetic.create({ + id: "companion-1", + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-warrior/", + displayName: "Warrior", + animation, + }); + + expect(cosmetic.type).toBe(CosmeticType.COMPANION); + expect(cosmetic.animation).toEqual(animation); + }); + + it("round-trips the animation descriptor through from + toPrimitives", () => { + const cosmetic = Cosmetic.from({ + id: "companion-1", + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-warrior/", + displayName: "Warrior", + active: true, + animation, + }); + + expect(cosmetic.toPrimitives().animation).toEqual(animation); + }); + + it("leaves animation undefined for non-COMPANION cosmetics", () => { + const cosmetic = Cosmetic.create({ + id: "sleeve-1", + type: CosmeticType.SLEEVE, + tier: CosmeticTier.STANDARD, + assetRef: "sleeves/a/", + displayName: "A", + }); + + expect(cosmetic.animation).toBeUndefined(); + expect(cosmetic.toPrimitives().animation).toBeUndefined(); + }); + + it("rejects an animation descriptor on a non-COMPANION cosmetic", () => { + expect(() => + Cosmetic.create({ + id: "sleeve-1", + type: CosmeticType.SLEEVE, + tier: CosmeticTier.STANDARD, + assetRef: "sleeves/a/", + displayName: "A", + animation, + }), + ).toThrow(InvalidArgumentError); + }); +}); diff --git a/tests/unit/modules/catalog/infrastructure/CosmeticPostgresRepository.test.ts b/tests/unit/modules/catalog/infrastructure/CosmeticPostgresRepository.test.ts new file mode 100644 index 0000000..574ac8e --- /dev/null +++ b/tests/unit/modules/catalog/infrastructure/CosmeticPostgresRepository.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; + +import { cosmeticsDataSource } from "../../../../../src/cosmetics-data-source"; +import { Cosmetic } from "../../../../../src/modules/catalog/domain/Cosmetic"; +import { CosmeticTier } from "../../../../../src/modules/catalog/domain/CosmeticTier"; +import { CosmeticType } from "../../../../../src/modules/catalog/domain/CosmeticType"; +import { CosmeticPostgresRepository } from "../../../../../src/modules/catalog/infrastructure/CosmeticPostgresRepository"; + +function stubRepository(fake: { findOne: ReturnType }) { + return spyOn(cosmeticsDataSource, "getRepository").mockReturnValue(fake as never); +} + +describe("CosmeticPostgresRepository", () => { + it("returns null for a non-uuid id without hitting the database", async () => { + // The id column is uuid: Postgres throws `invalid input syntax for type uuid` if a + // malformed id ever reaches the query, surfacing as a 500. The repository honors its + // `Cosmetic | null` contract by short-circuiting before the column is ever queried. + const findOne = mock(() => { + throw new Error("findOne must not be called for a non-uuid id"); + }); + const spy = stubRepository({ findOne }); + + const result = await new CosmeticPostgresRepository().findById("skeleton-mage"); + + expect(result).toBeNull(); + expect(findOne).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + + it("maps the animation descriptor when loading a COMPANION by uuid", async () => { + const animation = { rigFile: "Rig_Medium_General.glb", clips: { idle: "Idle_A" } }; + const id = "11111111-1111-4111-8111-111111111111"; + const findOne = mock(async () => ({ + id, + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-warrior/", + displayName: "Warrior", + active: true, + animation, + })); + const spy = stubRepository({ findOne }); + + const result = await new CosmeticPostgresRepository().findById(id); + + expect(result).toBeInstanceOf(Cosmetic); + expect(result?.animation).toEqual(animation); + + spy.mockRestore(); + }); +}); diff --git a/tests/unit/modules/loadout/application/EquipCosmetic.test.ts b/tests/unit/modules/loadout/application/EquipCosmetic.test.ts index d21eafd..09423bb 100644 --- a/tests/unit/modules/loadout/application/EquipCosmetic.test.ts +++ b/tests/unit/modules/loadout/application/EquipCosmetic.test.ts @@ -26,6 +26,18 @@ function cosmetic(tier: CosmeticTier): Cosmetic { }); } +function companion(): Cosmetic { + return Cosmetic.from({ + id: "companion-1", + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-warrior/", + displayName: "Warrior", + active: true, + animation: { rigFile: "Rig_Medium_General.glb", clips: { idle: "Idle_A" } }, + }); +} + function build(options: { found: Cosmetic | null; entitlements?: Entitlement[] }) { const cosmetics: CosmeticRepository = { findAll: async () => [], @@ -89,6 +101,37 @@ describe("EquipCosmetic", () => { ).rejects.toBeInstanceOf(InvalidArgumentError); }); + it("equips a COMPANION the user is entitled to", async () => { + const { equip, saved } = build({ found: companion() }); + + await equip.run({ + userId: "user-1", + cosmeticType: CosmeticType.COMPANION, + cosmeticId: "companion-1", + }); + + expect(saved).toHaveLength(1); + expect(saved[0].equippedCosmeticId(CosmeticType.COMPANION)).toBe("companion-1"); + }); + + // An unknown/non-uuid cosmeticId must resolve to a graceful NotFound (4xx), never a + // 500. The Postgres id column is uuid, so a value like "skeleton-mage" would make the + // repository throw `invalid input syntax for type uuid`; the repository guard turns + // that into the contract's null, so the use case reports NotFound exactly as it does + // for any missing cosmetic. + it("rejects a non-uuid cosmeticId with NotFound instead of failing hard", async () => { + const { equip, saved } = build({ found: null }); + + await expect( + equip.run({ + userId: "user-1", + cosmeticType: CosmeticType.COMPANION, + cosmeticId: "skeleton-mage", + }), + ).rejects.toBeInstanceOf(NotFoundError); + expect(saved).toHaveLength(0); + }); + // --- COSMETIC grant regression (spec: catalog scenario 6) --- it("allows equipping a DONOR cosmetic when user holds a COSMETIC grant for it", async () => { diff --git a/tests/unit/modules/loadout/application/GetMyLoadout.test.ts b/tests/unit/modules/loadout/application/GetMyLoadout.test.ts index c72cd6b..417335a 100644 --- a/tests/unit/modules/loadout/application/GetMyLoadout.test.ts +++ b/tests/unit/modules/loadout/application/GetMyLoadout.test.ts @@ -17,6 +17,24 @@ const sleeve = Cosmetic.from({ displayName: "A", active: true, }); +const companionAnimation = { + rigFile: "Rig_Medium_General.glb", + clips: { idle: "Idle_A", attack: "Throw" }, +}; +const companion = Cosmetic.from({ + id: "companion-1", + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-warrior/", + displayName: "Warrior", + active: true, + animation: companionAnimation, +}); + +const catalog = new Map([ + [sleeve.id, sleeve], + [companion.id, companion], +]); function build(loadout: Loadout) { const loadouts: LoadoutRepository = { @@ -24,8 +42,8 @@ function build(loadout: Loadout) { save: async () => undefined, }; const cosmetics: CosmeticRepository = { - findAll: async () => [sleeve], - findById: async (id) => (id === sleeve.id ? sleeve : null), + findAll: async () => [...catalog.values()], + findById: async (id) => catalog.get(id) ?? null, save: async () => undefined, }; const signer: AssetUrlSigner = { @@ -56,4 +74,19 @@ describe("GetMyLoadout", () => { expect(result).toEqual([]); }); + + it("includes both signed assets and the animation descriptor for an equipped COMPANION", async () => { + const loadout = Loadout.from("user-1", [ + { cosmeticType: CosmeticType.COMPANION, cosmeticId: "companion-1" }, + ]); + + const result = await build(loadout).run("user-1"); + + expect(result).toHaveLength(1); + expect(result[0].cosmeticType).toBe(CosmeticType.COMPANION); + expect(result[0].assets).toEqual({ + "render.jpg": "signed:companions/kaykit-warrior/render.jpg", + }); + expect(result[0].animation).toEqual(companionAnimation); + }); }); diff --git a/tests/unit/modules/loadout/application/GetPublicLoadout.test.ts b/tests/unit/modules/loadout/application/GetPublicLoadout.test.ts index fb993da..2c2a5f9 100644 --- a/tests/unit/modules/loadout/application/GetPublicLoadout.test.ts +++ b/tests/unit/modules/loadout/application/GetPublicLoadout.test.ts @@ -20,16 +20,34 @@ const sleeve = Cosmetic.from({ displayName: "A", active: true, }); +const companionAnimation = { + rigFile: "Rig_Medium_General.glb", + clips: { idle: "Idle_A", attack: "Throw" }, +}; +const companion = Cosmetic.from({ + id: "companion-1", + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-warrior/", + displayName: "Warrior", + active: true, + animation: companionAnimation, +}); -function build(directory: UserDirectory): GetPublicLoadout { +const catalog = new Map([ + [sleeve.id, sleeve], + [companion.id, companion], +]); + +function build(directory: UserDirectory, equipped: Cosmetic = sleeve): GetPublicLoadout { const loadouts: LoadoutRepository = { findByUserId: async (userId) => - Loadout.from(userId, [{ cosmeticType: CosmeticType.SLEEVE, cosmeticId: "cosmetic-1" }]), + Loadout.from(userId, [{ cosmeticType: equipped.type, cosmeticId: equipped.id }]), save: async () => undefined, }; const cosmetics: CosmeticRepository = { - findAll: async () => [sleeve], - findById: async (id) => (id === sleeve.id ? sleeve : null), + findAll: async () => [...catalog.values()], + findById: async (id) => catalog.get(id) ?? null, save: async () => undefined, }; const signer: AssetUrlSigner = { @@ -53,6 +71,18 @@ describe("GetPublicLoadout", () => { expect(result[0].assets).toEqual({ "render.jpg": "signed:sleeves/a/render.jpg" }); }); + it("carries the COMPANION animation descriptor through the public gate (opponent/spectator render)", async () => { + const directory: UserDirectory = { + findUserIdByUsername: async (username) => (username === "rival" ? "user-rival" : null), + }; + + const result = await build(directory, companion).run("rival"); + + expect(result).toHaveLength(1); + expect(result[0].cosmeticType).toBe(CosmeticType.COMPANION); + expect(result[0].animation).toEqual(companionAnimation); + }); + it("throws NotFound when the username does not exist (client falls back to standard)", async () => { const directory: UserDirectory = { findUserIdByUsername: async () => null,