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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
21 changes: 21 additions & 0 deletions src/migrations/1781200000000-add_exclusive_cosmetic_tier.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await queryRunner.query(`ALTER TYPE "cosmetic_tier_enum" ADD VALUE IF NOT EXISTS 'EXCLUSIVE'`);
}

public async down(): Promise<void> {
// 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.
}
}
33 changes: 33 additions & 0 deletions src/modules/catalog/application/standardCosmetics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
];
3 changes: 3 additions & 0 deletions src/modules/catalog/domain/CosmeticTier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
4 changes: 4 additions & 0 deletions src/modules/entitlements/domain/accessTier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ const RANK: Record<CosmeticTier, number> = {
[CosmeticTier.STANDARD]: 0,
[CosmeticTier.REGISTERED]: 1,
[CosmeticTier.DONOR]: 2,
// Above DONOR on purpose: the gatekeeper never assigns EXCLUSIVE as a user tier, so
// tierGrants(<anyUserTier>, 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 {
Expand Down
96 changes: 96 additions & 0 deletions src/scripts/assign-cosmetic.ts
Original file line number Diff line number Diff line change
@@ -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> <assetRef> <source>
//
// 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 <userId> <assetRef> <source>";

async function main(): Promise<void> {
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);
});
Loading