feat(api): add character_equipment table and entity
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateEquipment1789000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/api/src/database/migrations/equipment.migration.spec.ts
Normal file
52
apps/api/src/database/migrations/equipment.migration.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user