diff --git a/apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts b/apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts new file mode 100644 index 0000000..87db448 --- /dev/null +++ b/apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts @@ -0,0 +1,174 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateLootAndRewards1788600000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Existing characters keep their progression; silver simply starts at 0. + await queryRunner.query( + 'ALTER TABLE "characters" ADD COLUMN "silver" integer NOT NULL DEFAULT 0', + ); + + await queryRunner.query( + "CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')", + ); + await queryRunner.query( + "CREATE TYPE \"equipment_slot_enum\" AS ENUM ('WEAPON', 'HEAD', 'CHEST', 'HANDS', 'LEGS', 'FEET', 'AMULET')", + ); + await queryRunner.query( + "CREATE TYPE \"item_rarity_enum\" AS ENUM ('COMMON', 'RARE', 'EPIC')", + ); + + await queryRunner.query(`CREATE TABLE "item_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, + "type" "item_type_enum" NOT NULL, + "equipment_slot" "equipment_slot_enum", + "rarity" "item_rarity_enum" NOT NULL, + "tier" integer NOT NULL, + "required_level" integer NOT NULL, + "weapon_damage" integer NOT NULL DEFAULT 0, + "bonus_hp" integer NOT NULL DEFAULT 0, + "bonus_attack" integer NOT NULL DEFAULT 0, + "bonus_armor" integer NOT NULL DEFAULT 0, + "sell_price" integer NOT NULL DEFAULT 0, + "icon_path" character varying(255) NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_item_definitions" PRIMARY KEY ("id") + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_item_definitions_key" ON "item_definitions" ("key")', + ); + + await queryRunner.query(`CREATE TABLE "loot_tables" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "key" character varying(100) NOT NULL, + "name" character varying(150) NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_loot_tables" PRIMARY KEY ("id") + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_loot_tables_key" ON "loot_tables" ("key")', + ); + + await queryRunner.query(`CREATE TABLE "loot_table_entries" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "loot_table_id" uuid NOT NULL, + "item_definition_id" uuid NOT NULL, + "position" integer NOT NULL, + "drop_chance" numeric(5,4) NOT NULL, + "min_quantity" integer NOT NULL DEFAULT 1, + "max_quantity" integer NOT NULL DEFAULT 1, + "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_loot_table_entries" PRIMARY KEY ("id"), + CONSTRAINT "CHK_loot_table_entries_drop_chance" CHECK ("drop_chance" >= 0 AND "drop_chance" <= 1), + CONSTRAINT "CHK_loot_table_entries_quantity" CHECK ("min_quantity" >= 1 AND "max_quantity" >= "min_quantity"), + CONSTRAINT "FK_loot_table_entries_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_loot_table_entries_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_position" ON "loot_table_entries" ("loot_table_id", "position")', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_item" ON "loot_table_entries" ("loot_table_id", "item_definition_id")', + ); + + await queryRunner.query( + 'ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id" uuid', + ); + await queryRunner.query( + 'ALTER TABLE "monster_definitions" ADD CONSTRAINT "FK_monster_definitions_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE RESTRICT ON UPDATE NO ACTION', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_monster_definitions_loot_table" ON "monster_definitions" ("loot_table_id")', + ); + + await queryRunner.query(`CREATE TABLE "character_items" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "character_id" uuid NOT NULL, + "item_definition_id" uuid NOT NULL, + "quantity" integer NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_character_items" PRIMARY KEY ("id"), + CONSTRAINT "CHK_character_items_quantity" CHECK ("quantity" >= 1), + CONSTRAINT "FK_character_items_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_character_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_character_items_character_item" ON "character_items" ("character_id", "item_definition_id")', + ); + + await queryRunner.query(`CREATE TABLE "combat_rewards" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "combat_id" uuid NOT NULL, + "character_id" uuid NOT NULL, + "experience_granted" integer NOT NULL, + "silver_granted" integer NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_combat_rewards" PRIMARY KEY ("id"), + CONSTRAINT "FK_combat_rewards_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_combat_rewards_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + // The database half of the "one reward per combat" invariant (spec ยง7). + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_combat_rewards_combat" ON "combat_rewards" ("combat_id")', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_combat_rewards_character" ON "combat_rewards" ("character_id")', + ); + + await queryRunner.query(`CREATE TABLE "combat_reward_items" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "combat_reward_id" uuid NOT NULL, + "character_item_id" uuid NOT NULL, + "item_definition_id" uuid NOT NULL, + "quantity" integer NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_combat_reward_items" PRIMARY KEY ("id"), + CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1), + CONSTRAINT "FK_combat_reward_items_reward" FOREIGN KEY ("combat_reward_id") REFERENCES "combat_rewards"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_combat_reward_items_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE RESTRICT ON UPDATE NO ACTION, + CONSTRAINT "FK_combat_reward_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE INDEX "IDX_combat_reward_items_reward" ON "combat_reward_items" ("combat_reward_id")', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item" ON "combat_reward_items" ("combat_reward_id", "item_definition_id")', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward_item"'); + await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward"'); + await queryRunner.query('DROP TABLE "combat_reward_items"'); + await queryRunner.query('DROP INDEX "IDX_combat_rewards_character"'); + await queryRunner.query('DROP INDEX "IDX_combat_rewards_combat"'); + await queryRunner.query('DROP TABLE "combat_rewards"'); + await queryRunner.query('DROP INDEX "IDX_character_items_character_item"'); + await queryRunner.query('DROP TABLE "character_items"'); + await queryRunner.query('DROP INDEX "IDX_monster_definitions_loot_table"'); + await queryRunner.query( + 'ALTER TABLE "monster_definitions" DROP CONSTRAINT "FK_monster_definitions_loot_table"', + ); + await queryRunner.query( + 'ALTER TABLE "monster_definitions" DROP COLUMN "loot_table_id"', + ); + await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_item"'); + await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_position"'); + await queryRunner.query('DROP TABLE "loot_table_entries"'); + await queryRunner.query('DROP INDEX "IDX_loot_tables_key"'); + await queryRunner.query('DROP TABLE "loot_tables"'); + await queryRunner.query('DROP INDEX "IDX_item_definitions_key"'); + await queryRunner.query('DROP TABLE "item_definitions"'); + await queryRunner.query('DROP TYPE "item_rarity_enum"'); + await queryRunner.query('DROP TYPE "equipment_slot_enum"'); + await queryRunner.query('DROP TYPE "item_type_enum"'); + await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "silver"'); + } +}