Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/migrations/1781100000000-add_companion_cosmetic_type.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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.
}
}
3 changes: 3 additions & 0 deletions src/modules/catalog/application/GetCosmeticsCatalog.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,6 +11,7 @@ export interface CatalogCosmetic {
tier: CosmeticTier;
displayName: string;
assets: Record<string, string>;
animation?: CompanionAnimationDescriptor;
}

export interface CatalogFilters {
Expand Down Expand Up @@ -45,6 +47,7 @@ export class GetCosmeticsCatalog {
tier: cosmetic.tier,
displayName: cosmetic.displayName,
assets: await this.signer.signManifest(cosmetic.assetRef),
animation: cosmetic.animation,
})),
);
}
Expand Down
1 change: 1 addition & 0 deletions src/modules/catalog/application/SeedStandardCosmetics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export class SeedStandardCosmetics {
tier: item.tier,
assetRef: item.assetRef,
displayName: item.displayName,
animation: item.animation,
}),
);
created++;
Expand Down
71 changes: 65 additions & 6 deletions src/modules/catalog/application/standardCosmetics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CompanionAnimationDescriptor } from "../domain/CompanionAnimation";
import { CosmeticTier } from "../domain/CosmeticTier";
import { CosmeticType } from "../domain/CosmeticType";

Expand All @@ -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,
Expand Down Expand Up @@ -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,
];
16 changes: 16 additions & 0 deletions src/modules/catalog/domain/CompanionAnimation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type CompanionRole =
| "idle"
| "spawn"
| "speak"
| "hit"
| "summon"
| "attack"
| "cast"
| "defeat";

export interface CompanionAnimationDescriptor {
clips?: Partial<Record<CompanionRole, string>>; // 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
}
15 changes: 14 additions & 1 deletion src/modules/catalog/domain/Cosmetic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError";
import type { CompanionAnimationDescriptor } from "./CompanionAnimation";
import { CosmeticTier } from "./CosmeticTier";
import { CosmeticType } from "./CosmeticType";

Expand All @@ -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({
Expand All @@ -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");
Expand All @@ -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: {
Expand All @@ -47,6 +56,7 @@ export class Cosmetic {
assetRef: string;
displayName: string;
active: boolean;
animation?: CompanionAnimationDescriptor;
}): Cosmetic {
return new Cosmetic(
data.id,
Expand All @@ -55,6 +65,7 @@ export class Cosmetic {
data.assetRef,
data.displayName,
data.active,
data.animation,
);
}

Expand All @@ -65,6 +76,7 @@ export class Cosmetic {
assetRef: string;
displayName: string;
active: boolean;
animation?: CompanionAnimationDescriptor;
} {
return {
id: this.id,
Expand All @@ -73,6 +85,7 @@ export class Cosmetic {
assetRef: this.assetRef,
displayName: this.displayName,
active: this.active,
animation: this.animation,
};
}
}
1 change: 1 addition & 0 deletions src/modules/catalog/domain/CosmeticType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export enum CosmeticType {
SUMMON_EFFECT = "SUMMON_EFFECT",
MUSIC = "MUSIC",
TITLE = "TITLE",
COMPANION = "COMPANION",
}
4 changes: 4 additions & 0 deletions src/modules/catalog/infrastructure/CosmeticEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
UpdateDateColumn,
} from "typeorm";

import type { CompanionAnimationDescriptor } from "../domain/CompanionAnimation";
import { CosmeticTier } from "../domain/CosmeticTier";
import { CosmeticType } from "../domain/CosmeticType";

Expand All @@ -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;

Expand Down
12 changes: 12 additions & 0 deletions src/modules/catalog/infrastructure/CosmeticPostgresRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cosmetic[]> {
const repository = cosmeticsDataSource.getRepository(CosmeticEntity);
Expand All @@ -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<Cosmetic | null> {
if (!UUID_PATTERN.test(id)) {
return null;
}

const repository = cosmeticsDataSource.getRepository(CosmeticEntity);
const entity = await repository.findOne({ where: { id } });

Expand All @@ -35,6 +45,7 @@ export class CosmeticPostgresRepository implements CosmeticRepository {
assetRef: entity.assetRef,
displayName: entity.displayName,
active: entity.active,
animation: entity.animation ?? undefined,
});
}

Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/modules/loadout/application/GetMyLoadout.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -7,6 +8,7 @@ export interface MyLoadoutSlot {
cosmeticType: CosmeticType;
cosmeticId: string;
assets: Record<string, string>;
animation?: CompanionAnimationDescriptor;
}

export class GetMyLoadout {
Expand All @@ -26,6 +28,7 @@ export class GetMyLoadout {
cosmeticType: item.cosmeticType,
cosmeticId: item.cosmeticId,
assets: cosmetic ? await this.signer.signManifest(cosmetic.assetRef) : {},
animation: cosmetic?.animation,
};
}),
);
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => "",
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading