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 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-19 11:55:21 +02:00
parent 0c2f079a6e
commit e04827cd5a
2 changed files with 205 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateHuntingSystem1787500000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"');
}
}

View File

@@ -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');
});
});