diff --git a/apps/api/src/database/migrations/1787500000000-CreateHuntingSystem.ts b/apps/api/src/database/migrations/1787500000000-CreateHuntingSystem.ts new file mode 100644 index 0000000..4ab3574 --- /dev/null +++ b/apps/api/src/database/migrations/1787500000000-CreateHuntingSystem.ts @@ -0,0 +1,107 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateHuntingSystem1787500000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "monster_definitions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "key" character varying(100) NOT NULL, + "name" character varying(150) NOT NULL, + "level" integer NOT NULL, + "max_hp" integer NOT NULL, + "attack" integer NOT NULL, + "armor" integer NOT NULL, + "experience_reward" integer NOT NULL, + "silver_min" integer NOT NULL, + "silver_max" integer NOT NULL, + "artwork_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_monster_definitions" PRIMARY KEY ("id") + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_monster_definitions_key" ON "monster_definitions" ("key")', + ); + await queryRunner.query( + "CREATE TYPE \"location_monster_encounter_type_enum\" AS ENUM ('NORMAL', 'RARE', 'ELITE', 'BOSS')", + ); + await queryRunner.query(`CREATE TABLE "location_monsters" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "location_id" uuid NOT NULL, + "monster_id" uuid NOT NULL, + "weight" integer NOT NULL, + "encounter_type" "location_monster_encounter_type_enum" NOT NULL DEFAULT 'NORMAL', + "enabled" boolean NOT NULL DEFAULT true, + CONSTRAINT "PK_location_monsters" PRIMARY KEY ("id"), + CONSTRAINT "FK_location_monsters_location" FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION, + CONSTRAINT "FK_location_monsters_monster" FOREIGN KEY ("monster_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE INDEX "IDX_location_monsters_location" ON "location_monsters" ("location_id")', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_location_monsters_monster" ON "location_monsters" ("monster_id")', + ); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_location_monsters_location_monster" + ON "location_monsters" ("location_id", "monster_id")`); + await queryRunner.query( + "CREATE TYPE \"hunt_status_enum\" AS ENUM ('ACTIVE', 'SUPERSEDED')", + ); + await queryRunner.query(`CREATE TABLE "hunts" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "character_id" uuid NOT NULL, + "location_id" uuid NOT NULL, + "status" "hunt_status_enum" NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_hunts" PRIMARY KEY ("id"), + CONSTRAINT "FK_hunts_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION, + CONSTRAINT "FK_hunts_location" FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE INDEX "IDX_hunts_character" ON "hunts" ("character_id")', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_hunts_location" ON "hunts" ("location_id")', + ); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_active_hunt_per_character" + ON "hunts" ("character_id") + WHERE "status" = 'ACTIVE'`); + await queryRunner.query(`CREATE TABLE "hunt_encounters" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "hunt_id" uuid NOT NULL, + "monster_definition_id" uuid NOT NULL, + "position" integer NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_hunt_encounters" PRIMARY KEY ("id"), + CONSTRAINT "FK_hunt_encounters_hunt" FOREIGN KEY ("hunt_id") REFERENCES "hunts"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_hunt_encounters_monster_definition" FOREIGN KEY ("monster_definition_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE INDEX "IDX_hunt_encounters_hunt" ON "hunt_encounters" ("hunt_id")', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_hunt_encounters_monster_definition" ON "hunt_encounters" ("monster_definition_id")', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DROP INDEX "IDX_hunt_encounters_monster_definition"', + ); + await queryRunner.query('DROP INDEX "IDX_hunt_encounters_hunt"'); + await queryRunner.query('DROP TABLE "hunt_encounters"'); + await queryRunner.query('DROP INDEX "IDX_active_hunt_per_character"'); + await queryRunner.query('DROP INDEX "IDX_hunts_location"'); + await queryRunner.query('DROP INDEX "IDX_hunts_character"'); + await queryRunner.query('DROP TABLE "hunts"'); + await queryRunner.query('DROP TYPE "hunt_status_enum"'); + await queryRunner.query( + 'DROP INDEX "IDX_location_monsters_location_monster"', + ); + await queryRunner.query('DROP INDEX "IDX_location_monsters_monster"'); + await queryRunner.query('DROP INDEX "IDX_location_monsters_location"'); + await queryRunner.query('DROP TABLE "location_monsters"'); + await queryRunner.query('DROP TYPE "location_monster_encounter_type_enum"'); + await queryRunner.query('DROP INDEX "IDX_monster_definitions_key"'); + await queryRunner.query('DROP TABLE "monster_definitions"'); + } +} diff --git a/apps/api/src/database/migrations/hunting-system.migration.spec.ts b/apps/api/src/database/migrations/hunting-system.migration.spec.ts new file mode 100644 index 0000000..25ae010 --- /dev/null +++ b/apps/api/src/database/migrations/hunting-system.migration.spec.ts @@ -0,0 +1,98 @@ +import 'reflect-metadata'; +import { getMetadataArgsStorage } from 'typeorm'; +import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; +import { LocationMonster } from '../../monsters/entities/location-monster.entity'; +import { Hunt } from '../../hunting/entities/hunt.entity'; +import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity'; + +describe('hunting system schema', () => { + it('maps the monster definition key index and relationship foreign keys explicitly', () => { + const metadata = getMetadataArgsStorage(); + const monsterKeyIndex = metadata.indices.find((index) => { + const metadataIndex = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; + + return ( + index.target === MonsterDefinition && + index.columns?.includes('key') && + (metadataIndex.options?.unique ?? metadataIndex.unique) === true + ); + }); + + expect(monsterKeyIndex).toBeDefined(); + + const relations = metadata.relations.filter((relation) => + [LocationMonster, Hunt, HuntEncounter].includes( + relation.target as typeof LocationMonster, + ), + ); + const joinColumns = metadata.joinColumns + .filter((joinColumn) => + [LocationMonster, Hunt, HuntEncounter].includes( + joinColumn.target as typeof LocationMonster, + ), + ) + .map((joinColumn) => joinColumn.name); + + expect(joinColumns).toEqual( + expect.arrayContaining([ + 'location_id', + 'monster_id', + 'character_id', + 'location_id', + 'hunt_id', + 'monster_definition_id', + ]), + ); + + expect( + relations.map((relation) => ({ + onDelete: relation.options.onDelete, + propertyName: relation.propertyName, + target: relation.target, + })), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'location', + target: LocationMonster, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'monster', + target: LocationMonster, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'character', + target: Hunt, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'location', + target: Hunt, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'monster', + target: HuntEncounter, + }), + ]), + ); + + // Deliberate deviation from the codebase's usual RESTRICT: HuntEncounter + // rows are owned/composed by their parent Hunt and must be removed along + // with it, so this relation uses CASCADE. Asserted explicitly so a + // future refactor can't silently change it. + const huntEncounterToHunt = relations.find( + (relation) => + relation.target === HuntEncounter && relation.propertyName === 'hunt', + ); + + expect(huntEncounterToHunt).toBeDefined(); + expect(huntEncounterToHunt?.options.onDelete).toBe('CASCADE'); + }); +});