From e04827cd5aa04a536cf028a419756d7f7fa5e800 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 19 Aug 2026 11:55:21 +0200 Subject: [PATCH] feat: add hunting system schema migration Adds the migration and metadata spec for the hunting schema (monster definitions, location monster spawn tables, hunts, and hunt encounters) required by Playable Slice 0.2. Mirrors the raw-SQL style of the visible vertical slice migration; explicitly asserts the CASCADE-vs-RESTRICT deviation on HuntEncounter's relation to Hunt. Co-Authored-By: Claude Haiku 4.5 --- .../1787500000000-CreateHuntingSystem.ts | 107 ++++++++++++++++++ .../hunting-system.migration.spec.ts | 98 ++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 apps/api/src/database/migrations/1787500000000-CreateHuntingSystem.ts create mode 100644 apps/api/src/database/migrations/hunting-system.migration.spec.ts 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'); + }); +});