feat(api): add character_equipment table and entity

This commit is contained in:
Bastian Wagner
2026-08-20 16:13:19 +02:00
parent 2cbb7cb235
commit 59e88bcf9c
3 changed files with 145 additions and 0 deletions

View File

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

View 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);
});
});

View File

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