From 1a50e817f14ace94a09dcedee2e409fd5192f2b5 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Fri, 21 Aug 2026 08:48:54 +0200 Subject: [PATCH] feat(renown): migrate Character/ItemDefinition schema and add Reputation/Renown/TurnIn tables Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UT2tLMQ2HfbWGkZyotocKm --- ...1791000000000-CreateRenownAndReputation.ts | 185 ++++++++++++++++++ .../renown-and-reputation.migration.spec.ts | 134 +++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 apps/api/src/database/migrations/1791000000000-CreateRenownAndReputation.ts create mode 100644 apps/api/src/database/migrations/renown-and-reputation.migration.spec.ts diff --git a/apps/api/src/database/migrations/1791000000000-CreateRenownAndReputation.ts b/apps/api/src/database/migrations/1791000000000-CreateRenownAndReputation.ts new file mode 100644 index 0000000..dd7661a --- /dev/null +++ b/apps/api/src/database/migrations/1791000000000-CreateRenownAndReputation.ts @@ -0,0 +1,185 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateRenownAndReputation1791000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // --- Character: level/experience -> renown (spec §13, design R1/R6) --- + await queryRunner.query( + 'ALTER TABLE "characters" ADD COLUMN "renown" integer NOT NULL DEFAULT 1', + ); + await queryRunner.query( + 'UPDATE "characters" SET "renown" = LEAST(GREATEST("level", 1), 15)', + ); + await queryRunner.query( + 'ALTER TABLE "characters" ADD CONSTRAINT "CHK_characters_renown" CHECK ("renown" >= 1 AND "renown" <= 15)', + ); + await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "level"'); + await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "experience"'); + + // --- ItemDefinition: drop requiredLevel (spec §14, design R4) --- + await queryRunner.query( + 'ALTER TABLE "item_definitions" DROP COLUMN "required_level"', + ); + + // --- ItemType enum rebuild (spec §16, design R3): WEAPON/ARMOR -> --- + // EQUIPMENT, MATERIAL -> TRADE_GOOD, add TROPHY/QUEST_ITEM. Postgres has + // no ALTER TYPE ... RENAME VALUE across all supported versions here, so + // this converts the column to text, migrates the data, rebuilds the + // type, and converts back -- the same technique already used by + // 1790000000000-ExtendCombatEventTypes.ts's down(). + await queryRunner.query( + 'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE varchar USING "type"::text', + ); + await queryRunner.query( + `UPDATE "item_definitions" SET "type" = 'EQUIPMENT' WHERE "type" IN ('WEAPON', 'ARMOR')`, + ); + await queryRunner.query( + `UPDATE "item_definitions" SET "type" = 'TRADE_GOOD' WHERE "type" = 'MATERIAL'`, + ); + await queryRunner.query('DROP TYPE "item_type_enum"'); + await queryRunner.query( + `CREATE TYPE "item_type_enum" AS ENUM ('EQUIPMENT', 'TRADE_GOOD', 'TROPHY', 'QUEST_ITEM', 'CONSUMABLE')`, + ); + await queryRunner.query( + 'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE "item_type_enum" USING "type"::"item_type_enum"', + ); + + // --- CombatReward: drop the XP audit column (spec §1, design R8) --- + await queryRunner.query( + 'ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"', + ); + + // --- ReputationFaction (spec §9) --- + await queryRunner.query(`CREATE TABLE "reputation_factions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "key" character varying(100) NOT NULL, + "name" character varying(150) NOT NULL, + "description" text NOT NULL, + "region_key" character varying(100) NOT NULL, + "enabled" boolean NOT NULL DEFAULT true, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_reputation_factions" PRIMARY KEY ("id") + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_reputation_factions_key" ON "reputation_factions" ("key")', + ); + + // --- CharacterReputation (spec §9) --- + await queryRunner.query(`CREATE TABLE "character_reputation" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "character_id" uuid NOT NULL, + "faction_id" uuid NOT NULL, + "reputation" integer NOT NULL DEFAULT 0, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_character_reputation" PRIMARY KEY ("id"), + CONSTRAINT "FK_character_reputation_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_character_reputation_faction" FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_character_reputation_character_faction" ON "character_reputation" ("character_id", "faction_id")', + ); + + // --- RenownMilestoneDefinition (spec §5) --- + await queryRunner.query(`CREATE TABLE "renown_milestone_definitions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "key" character varying(100) NOT NULL, + "name" character varying(150) NOT NULL, + "description" text NOT NULL, + "renown_reward" integer NOT NULL, + "repeatable" boolean NOT NULL DEFAULT false, + "enabled" boolean NOT NULL DEFAULT true, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_renown_milestone_definitions" PRIMARY KEY ("id") + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key" ON "renown_milestone_definitions" ("key")', + ); + + // --- CharacterRenownMilestone (spec §5) --- + await queryRunner.query(`CREATE TABLE "character_renown_milestones" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "character_id" uuid NOT NULL, + "milestone_id" uuid NOT NULL, + "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "times_completed" integer NOT NULL DEFAULT 1, + CONSTRAINT "PK_character_renown_milestones" PRIMARY KEY ("id"), + CONSTRAINT "FK_character_renown_milestones_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_character_renown_milestones_milestone" FOREIGN KEY ("milestone_id") REFERENCES "renown_milestone_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_character_renown_milestones_character_milestone" ON "character_renown_milestones" ("character_id", "milestone_id")', + ); + + // --- TurnInDefinition (spec §19) --- + await queryRunner.query(`CREATE TABLE "turn_in_definitions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "key" character varying(100) NOT NULL, + "item_definition_id" uuid NOT NULL, + "faction_id" uuid NOT NULL, + "silver_reward_per_item" integer NOT NULL, + "reputation_reward_per_item" integer NOT NULL, + "repeatable" boolean NOT NULL DEFAULT true, + "enabled" boolean NOT NULL DEFAULT true, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_turn_in_definitions" PRIMARY KEY ("id"), + CONSTRAINT "FK_turn_in_definitions_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION, + CONSTRAINT "FK_turn_in_definitions_faction" FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_turn_in_definitions_key" ON "turn_in_definitions" ("key")', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE "turn_in_definitions"'); + + await queryRunner.query( + 'DROP INDEX "IDX_character_renown_milestones_character_milestone"', + ); + await queryRunner.query('DROP TABLE "character_renown_milestones"'); + await queryRunner.query('DROP INDEX "IDX_renown_milestone_definitions_key"'); + await queryRunner.query('DROP TABLE "renown_milestone_definitions"'); + + await queryRunner.query('DROP INDEX "IDX_character_reputation_character_faction"'); + await queryRunner.query('DROP TABLE "character_reputation"'); + await queryRunner.query('DROP INDEX "IDX_reputation_factions_key"'); + await queryRunner.query('DROP TABLE "reputation_factions"'); + + await queryRunner.query( + 'ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted" integer NOT NULL DEFAULT 0', + ); + + await queryRunner.query( + 'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE varchar USING "type"::text', + ); + await queryRunner.query('DROP TYPE "item_type_enum"'); + await queryRunner.query( + "CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')", + ); + await queryRunner.query( + `UPDATE "item_definitions" SET "type" = 'MATERIAL' WHERE "type" = 'TRADE_GOOD'`, + ); + // TROPHY/QUEST_ITEM/EQUIPMENT have no clean pre-image; best-effort revert + // for genuinely disposable dev data (design R5). + await queryRunner.query( + `UPDATE "item_definitions" SET "type" = 'WEAPON' WHERE "type" IN ('EQUIPMENT', 'TROPHY', 'QUEST_ITEM')`, + ); + await queryRunner.query( + 'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE "item_type_enum" USING "type"::"item_type_enum"', + ); + await queryRunner.query( + 'ALTER TABLE "item_definitions" ADD COLUMN "required_level" integer NOT NULL DEFAULT 1', + ); + + await queryRunner.query('ALTER TABLE "characters" ADD COLUMN "level" integer NOT NULL DEFAULT 1'); + await queryRunner.query( + 'ALTER TABLE "characters" ADD COLUMN "experience" integer NOT NULL DEFAULT 0', + ); + await queryRunner.query('UPDATE "characters" SET "level" = "renown"'); + await queryRunner.query('ALTER TABLE "characters" DROP CONSTRAINT "CHK_characters_renown"'); + await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "renown"'); + } +} diff --git a/apps/api/src/database/migrations/renown-and-reputation.migration.spec.ts b/apps/api/src/database/migrations/renown-and-reputation.migration.spec.ts new file mode 100644 index 0000000..31179cf --- /dev/null +++ b/apps/api/src/database/migrations/renown-and-reputation.migration.spec.ts @@ -0,0 +1,134 @@ +import { QueryRunner } from 'typeorm'; +import { CreateRenownAndReputation1791000000000 } from './1791000000000-CreateRenownAndReputation'; + +describe('CreateRenownAndReputation1791000000000', () => { + async function runUp() { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new CreateRenownAndReputation1791000000000(); + await migration.up(queryRunner); + return query.mock.calls.map(([sql]) => sql as string); + } + + async function runUpThenDown() { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new CreateRenownAndReputation1791000000000(); + await migration.up(queryRunner); + const upCount = query.mock.calls.length; + await migration.down(queryRunner); + return query.mock.calls.slice(upCount).map(([sql]) => sql as string); + } + + it('replaces level/experience with a renown column on characters', async () => { + const up = await runUp(); + + expect(up).toEqual( + expect.arrayContaining([ + expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "renown"'), + expect.stringContaining('DROP COLUMN "level"'), + expect.stringContaining('DROP COLUMN "experience"'), + ]), + ); + }); + + it('clamps existing level into the 1-15 Renown range before dropping it', async () => { + const up = await runUp(); + + expect(up).toEqual( + expect.arrayContaining([expect.stringContaining('LEAST(GREATEST("level", 1), 15)')]), + ); + }); + + it('enforces the renown range at the database level', async () => { + const up = await runUp(); + + expect(up).toEqual( + expect.arrayContaining([ + expect.stringContaining('CHK_characters_renown'), + ]), + ); + }); + + it('drops required_level from item_definitions', async () => { + const up = await runUp(); + + expect(up).toEqual( + expect.arrayContaining([ + expect.stringContaining('ALTER TABLE "item_definitions" DROP COLUMN "required_level"'), + ]), + ); + }); + + it('rebuilds item_type_enum with exactly the five Slice 0.6.5 values, migrating existing rows', async () => { + const up = await runUp(); + + expect(up).toEqual( + expect.arrayContaining([ + expect.stringContaining(`SET "type" = 'EQUIPMENT' WHERE "type" IN ('WEAPON', 'ARMOR')`), + expect.stringContaining(`SET "type" = 'TRADE_GOOD' WHERE "type" = 'MATERIAL'`), + expect.stringContaining('DROP TYPE "item_type_enum"'), + expect.stringContaining( + `CREATE TYPE "item_type_enum" AS ENUM ('EQUIPMENT', 'TRADE_GOOD', 'TROPHY', 'QUEST_ITEM', 'CONSUMABLE')`, + ), + ]), + ); + }); + + it('drops experience_granted from combat_rewards', async () => { + const up = await runUp(); + + expect(up).toEqual( + expect.arrayContaining([ + expect.stringContaining('ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"'), + ]), + ); + }); + + it('creates all five new tables with their unique constraints', async () => { + const up = await runUp(); + + expect(up).toEqual( + expect.arrayContaining([ + expect.stringContaining('CREATE TABLE "reputation_factions"'), + expect.stringContaining('CREATE UNIQUE INDEX "IDX_reputation_factions_key"'), + expect.stringContaining('CREATE TABLE "character_reputation"'), + expect.stringContaining( + 'CREATE UNIQUE INDEX "IDX_character_reputation_character_faction" ON "character_reputation" ("character_id", "faction_id")', + ), + expect.stringContaining('CREATE TABLE "renown_milestone_definitions"'), + expect.stringContaining('CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key"'), + expect.stringContaining('CREATE TABLE "character_renown_milestones"'), + expect.stringContaining( + 'CREATE UNIQUE INDEX "IDX_character_renown_milestones_character_milestone" ON "character_renown_milestones" ("character_id", "milestone_id")', + ), + expect.stringContaining('CREATE TABLE "turn_in_definitions"'), + expect.stringContaining('CREATE UNIQUE INDEX "IDX_turn_in_definitions_key"'), + ]), + ); + }); + + it('down() reverses every up() step in exact opposite order, dropping the five new tables first', async () => { + const down = await runUpThenDown(); + + expect(down).toEqual( + expect.arrayContaining([ + expect.stringContaining('DROP TABLE "turn_in_definitions"'), + expect.stringContaining('DROP TABLE "character_renown_milestones"'), + expect.stringContaining('DROP TABLE "renown_milestone_definitions"'), + expect.stringContaining('DROP TABLE "character_reputation"'), + expect.stringContaining('DROP TABLE "reputation_factions"'), + expect.stringContaining('ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted"'), + expect.stringContaining('ALTER TABLE "item_definitions" ADD COLUMN "required_level"'), + expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "level"'), + expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "experience"'), + expect.stringContaining('ALTER TABLE "characters" DROP COLUMN "renown"'), + ]), + ); + // The dropped turn_in_definitions table must be the very first statement + // in down() -- it's the last thing up() created, and it has FK + // dependencies on reputation_factions/item_definitions that must be + // gone before those tables can be touched. + expect(down[0]).toContain('DROP TABLE "turn_in_definitions"'); + }); +});