diff --git a/apps/api/src/database/migrations/1789000000000-CreateEquipment.ts b/apps/api/src/database/migrations/1789000000000-CreateEquipment.ts new file mode 100644 index 0000000..424bea7 --- /dev/null +++ b/apps/api/src/database/migrations/1789000000000-CreateEquipment.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateEquipment1789000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Reuses the "equipment_slot_enum" type created by CreateLootAndRewards. + await queryRunner.query(`CREATE TABLE "character_equipment" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "character_id" uuid NOT NULL, + "slot" "equipment_slot_enum" NOT NULL, + "character_item_id" uuid 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_equipment" PRIMARY KEY ("id"), + CONSTRAINT "FK_character_equipment_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_character_equipment_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_character_equipment_character_slot" ON "character_equipment" ("character_id", "slot")', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_character_equipment_character_item" ON "character_equipment" ("character_item_id")', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DROP INDEX "IDX_character_equipment_character_item"', + ); + await queryRunner.query( + 'DROP INDEX "IDX_character_equipment_character_slot"', + ); + await queryRunner.query('DROP TABLE "character_equipment"'); + } +} diff --git a/apps/api/src/database/migrations/equipment.migration.spec.ts b/apps/api/src/database/migrations/equipment.migration.spec.ts new file mode 100644 index 0000000..fdd59c7 --- /dev/null +++ b/apps/api/src/database/migrations/equipment.migration.spec.ts @@ -0,0 +1,52 @@ +import 'reflect-metadata'; +import { getMetadataArgsStorage } from 'typeorm'; +import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity'; + +describe('character_equipment schema', () => { + it('stores slot as a non-nullable equipment_slot_enum column', () => { + const metadata = getMetadataArgsStorage(); + const column = metadata.columns.find( + (candidate) => + candidate.target === CharacterEquipment && candidate.propertyName === 'slot', + ); + + expect(column).toBeDefined(); + expect(column?.options.type).toBe('enum'); + expect(column?.options.enumName).toBe('equipment_slot_enum'); + expect(column?.options.nullable).toBeFalsy(); + }); + + it('enforces one equipped item per character per slot', () => { + const metadata = getMetadataArgsStorage(); + const index = metadata.indices.find( + (candidate) => + candidate.target === CharacterEquipment && + candidate.columns?.includes('characterId') && + candidate.columns?.includes('slot'), + ); + + expect(index).toBeDefined(); + const indexMetadata = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; + expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true); + }); + + it('forbids one CharacterItem from occupying more than one equipment slot', () => { + const metadata = getMetadataArgsStorage(); + const index = metadata.indices.find( + (candidate) => + candidate.target === CharacterEquipment && + candidate.columns?.length === 1 && + candidate.columns?.includes('characterItemId'), + ); + + expect(index).toBeDefined(); + const indexMetadata = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; + expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true); + }); +}); diff --git a/apps/api/src/equipment/entities/character-equipment.entity.ts b/apps/api/src/equipment/entities/character-equipment.entity.ts new file mode 100644 index 0000000..ef0ae07 --- /dev/null +++ b/apps/api/src/equipment/entities/character-equipment.entity.ts @@ -0,0 +1,59 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { CharacterItem } from '../../items/entities/character-item.entity'; +import { EquipmentSlot } from '../../items/equipment-slot.enum'; + +/** + * One equipped item in one slot for one character (spec §12). + * + * `characterItemId` must belong to `characterId` — enforced by + * `EquipmentService`, never by the client (spec §13). + */ +@Entity({ name: 'character_equipment' }) +@Index('IDX_character_equipment_character_slot', ['characterId', 'slot'], { + unique: true, +}) +@Index('IDX_character_equipment_character_item', ['characterItemId'], { + unique: true, +}) +export class CharacterEquipment { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ + name: 'slot', + type: 'enum', + enum: EquipmentSlot, + enumName: 'equipment_slot_enum', + }) + slot!: EquipmentSlot; + + @Column({ name: 'character_item_id', type: 'uuid' }) + characterItemId!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @ManyToOne(() => Character, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'character_id' }) + character!: Character; + + @ManyToOne(() => CharacterItem, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'character_item_id' }) + characterItem!: CharacterItem; +}