diff --git a/package.json b/package.json index 4c846f8..ea38ff0 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "migration:cosmetics:run": "bun run src/scripts/run-cosmetics-migrations.ts", "migration:cosmetics:revert": "bun run src/scripts/run-cosmetics-migrations.ts --revert", "seed:cosmetics": "bun run src/scripts/seed-cosmetics.ts", + "assign:cosmetic": "bun run src/scripts/assign-cosmetic.ts", "lint": "biome check .", "lint:fix": "biome check --write .", "format": "biome format --write .", diff --git a/src/migrations/1781200000000-add_exclusive_cosmetic_tier.ts b/src/migrations/1781200000000-add_exclusive_cosmetic_tier.ts new file mode 100644 index 0000000..4706e60 --- /dev/null +++ b/src/migrations/1781200000000-add_exclusive_cosmetic_tier.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +// Adds the EXCLUSIVE cosmetic tier. It ranks above every user tier the gatekeeper can +// assign (STANDARD/REGISTERED/DONOR), so no tier ever grants an EXCLUSIVE cosmetic — +// access comes solely from a per-user COSMETIC entitlement. No rows are inserted here: +// `ALTER TYPE ... ADD VALUE` runs in a transaction on PG >= 12, but the freshly added +// enum value cannot be USED in the same transaction, so seeding EXCLUSIVE cosmetics is +// deferred to the seed (matched by asset_ref, idempotent). +export class AddExclusiveCosmeticTier1781200000000 implements MigrationInterface { + name = "AddExclusiveCosmeticTier1781200000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE "cosmetic_tier_enum" ADD VALUE IF NOT EXISTS 'EXCLUSIVE'`); + } + + public async down(): Promise { + // 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 + // 'EXCLUSIVE' value is therefore intentionally left in place on down. + } +} diff --git a/src/modules/catalog/application/standardCosmetics.ts b/src/modules/catalog/application/standardCosmetics.ts index 29d0f80..d86e934 100644 --- a/src/modules/catalog/application/standardCosmetics.ts +++ b/src/modules/catalog/application/standardCosmetics.ts @@ -65,6 +65,36 @@ export const KAYKIT_COMPANIONS: StandardCosmeticSeed[] = [ }, ]; +// Terminator — first EXCLUSIVE companion. Uses its own animation (not the shared KayKit +// descriptor). +// WARNING: the seed is INSERT-ONLY (matched by asset_ref) — it never updates an already +// seeded row. So the clip names and rigFile below must be the REAL animation-group names +// from the Terminator .glb BEFORE the first seed run; fixing them afterwards needs a data +// migration. The placeholders are marked TODO on purpose. +const TERMINATOR_ANIMATION: CompanionAnimationDescriptor = { + rigFile: "TODO_terminator_rig.glb", // TODO: real external rig basename + clips: { + idle: "TODO_idle", + spawn: "TODO_spawn", + speak: "TODO_speak", + hit: "TODO_hit", + summon: "TODO_summon", + attack: "TODO_attack", + cast: "TODO_cast", + defeat: "TODO_defeat", + }, +}; + +export const EXCLUSIVE_COMPANIONS: StandardCosmeticSeed[] = [ + { + type: CosmeticType.COMPANION, + tier: CosmeticTier.EXCLUSIVE, + assetRef: "companions/terminator/", + displayName: "Terminator", + animation: TERMINATOR_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, // character.glb/rig.glb/preview.jpg for companions) live under it and are resolved at @@ -165,4 +195,7 @@ export const STANDARD_COSMETICS: StandardCosmeticSeed[] = [ }, // Companions are live now that their assets are uploaded to R2. ...KAYKIT_COMPANIONS, + // Exclusive companions: EXCLUSIVE tier is granted by NO user tier, so these are only + // obtainable through a per-user COSMETIC entitlement (see scripts/assign-cosmetic.ts). + ...EXCLUSIVE_COMPANIONS, ]; diff --git a/src/modules/catalog/domain/CosmeticTier.ts b/src/modules/catalog/domain/CosmeticTier.ts index a69d306..a4c969b 100644 --- a/src/modules/catalog/domain/CosmeticTier.ts +++ b/src/modules/catalog/domain/CosmeticTier.ts @@ -2,4 +2,7 @@ export enum CosmeticTier { STANDARD = "STANDARD", REGISTERED = "REGISTERED", DONOR = "DONOR", + // Ranks above every user tier the gatekeeper can assign, so no tier ever grants it. + // Access to an EXCLUSIVE cosmetic comes solely from a per-user COSMETIC entitlement. + EXCLUSIVE = "EXCLUSIVE", } diff --git a/src/modules/entitlements/domain/accessTier.ts b/src/modules/entitlements/domain/accessTier.ts index beeaaa6..4a7ab6f 100644 --- a/src/modules/entitlements/domain/accessTier.ts +++ b/src/modules/entitlements/domain/accessTier.ts @@ -6,6 +6,10 @@ const RANK: Record = { [CosmeticTier.STANDARD]: 0, [CosmeticTier.REGISTERED]: 1, [CosmeticTier.DONOR]: 2, + // Above DONOR on purpose: the gatekeeper never assigns EXCLUSIVE as a user tier, so + // tierGrants(, EXCLUSIVE) is always false. The only access path is an + // explicit per-user COSMETIC entitlement. + [CosmeticTier.EXCLUSIVE]: 3, }; export function tierGrants(userTier: CosmeticTier, requiredTier: CosmeticTier): boolean { diff --git a/src/scripts/assign-cosmetic.ts b/src/scripts/assign-cosmetic.ts new file mode 100644 index 0000000..156c93b --- /dev/null +++ b/src/scripts/assign-cosmetic.ts @@ -0,0 +1,96 @@ +import { cosmeticsDataSource } from "../cosmetics-data-source"; +import { CosmeticPostgresRepository } from "../modules/catalog/infrastructure/CosmeticPostgresRepository"; +import { Entitlement } from "../modules/entitlements/domain/Entitlement"; +import { EntitlementSource } from "../modules/entitlements/domain/EntitlementSource"; +import { GrantType } from "../modules/entitlements/domain/GrantType"; +import { EntitlementPostgresRepository } from "../modules/entitlements/infrastructure/EntitlementPostgresRepository"; + +// Grants a specific cosmetic to a single user via a per-user COSMETIC entitlement. This is +// the only access path for EXCLUSIVE-tier cosmetics (no user tier grants them). Idempotent: +// re-running for the same user/cosmetic does not create a duplicate grant. +// +// Usage (all arguments are required — no defaults): +// bun run src/scripts/assign-cosmetic.ts +// +// userId the target user's id (users.id is varchar in the shared schema) +// assetRef R2 folder prefix of the cosmetic (e.g. companions/terminator/) +// source EntitlementSource: REGISTRATION | DONATION | PURCHASE | CAMPAIGN +// +// Example: +// bun run src/scripts/assign-cosmetic.ts 1a2b3c companions/terminator/ CAMPAIGN + +const USAGE = "Usage: bun run src/scripts/assign-cosmetic.ts "; + +async function main(): Promise { + const [userId, assetRef, sourceArg] = process.argv.slice(2); + + if (!userId) { + throw new Error(`userId is required. ${USAGE}`); + } + + if (!assetRef) { + throw new Error(`assetRef is required. ${USAGE}`); + } + + if (!sourceArg) { + throw new Error(`source is required. ${USAGE}`); + } + + const source = sourceArg as EntitlementSource; + if (!Object.values(EntitlementSource).includes(source)) { + throw new Error( + `Invalid source "${sourceArg}". Valid values: ${Object.values(EntitlementSource).join(", ")}`, + ); + } + + await cosmeticsDataSource.initialize(); + + try { + const cosmetics = new CosmeticPostgresRepository(); + const all = await cosmetics.findAll(); + const cosmetic = all.find((c) => c.assetRef === assetRef); + + if (!cosmetic) { + throw new Error( + `No cosmetic found with assetRef "${assetRef}". Run \`bun run seed:cosmetics\` first.`, + ); + } + + const entitlements = new EntitlementPostgresRepository(); + const existing = await entitlements.findByUserId(userId); + const alreadyGranted = existing.some( + (e) => e.grantType === GrantType.COSMETIC && e.grantValue === cosmetic.id, + ); + + if (alreadyGranted) { + console.log( + `User ${userId} already has cosmetic "${cosmetic.displayName}" (${cosmetic.id}). Nothing to do.`, + ); + return; + } + + await entitlements.save( + Entitlement.create({ + id: crypto.randomUUID(), + userId, + grantType: GrantType.COSMETIC, + grantValue: cosmetic.id, + source, + expiresAt: null, + }), + ); + + console.log( + `Granted "${cosmetic.displayName}" (${cosmetic.id}) to user ${userId} via ${source}.`, + ); + } finally { + await cosmeticsDataSource.destroy(); + } +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + });