feat(renown): migrate Character/ItemDefinition schema and add Reputation/Renown/TurnIn tables
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UT2tLMQ2HfbWGkZyotocKm
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateRenownAndReputation1791000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// --- 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<void> {
|
||||
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"');
|
||||
}
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user