diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c8be7e0..46bf13b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { CharactersModule } from './characters/characters.module'; import { CombatModule } from './combat/combat.module'; import { DatabaseModule } from './database/database.module'; +import { EquipmentModule } from './equipment/equipment.module'; import { HealthModule } from './health/health.module'; import { HuntingModule } from './hunting/hunting.module'; import { TravelModule } from './travel/travel.module'; @@ -16,6 +17,7 @@ import { WorldModule } from './world/world.module'; WorldModule, HuntingModule, CombatModule, + EquipmentModule, ], }) export class AppModule {} diff --git a/apps/api/src/equipment/dto/equip-item.dto.ts b/apps/api/src/equipment/dto/equip-item.dto.ts new file mode 100644 index 0000000..69e2530 --- /dev/null +++ b/apps/api/src/equipment/dto/equip-item.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class EquipItemDto { + @IsUUID() + characterItemId!: string; +} diff --git a/apps/api/src/equipment/equipment.controller.ts b/apps/api/src/equipment/equipment.controller.ts new file mode 100644 index 0000000..1a42a90 --- /dev/null +++ b/apps/api/src/equipment/equipment.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { EquipItemDto } from './dto/equip-item.dto'; +import { EquipmentResponseDto, EquipmentService } from './equipment.service'; + +@Controller('equipment') +export class EquipmentController { + constructor(private readonly equipmentService: EquipmentService) {} + + @Get() + getEquipment(): Promise { + return this.equipmentService.getEquipment(DEMO_CHARACTER_ID); + } + + @Post() + equip(@Body() request: EquipItemDto): Promise { + return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId); + } +} diff --git a/apps/api/src/equipment/equipment.module.ts b/apps/api/src/equipment/equipment.module.ts new file mode 100644 index 0000000..48aafe5 --- /dev/null +++ b/apps/api/src/equipment/equipment.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharactersModule } from '../characters/characters.module'; +import { Character } from '../characters/entities/character.entity'; +import { Combat } from '../combat/entities/combat.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { CharacterEquipment } from './entities/character-equipment.entity'; +import { EquipmentController } from './equipment.controller'; +import { EquipmentService } from './equipment.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Character, Combat, CharacterItem, ItemDefinition, CharacterEquipment]), + CharactersModule, + ], + controllers: [EquipmentController], + providers: [EquipmentService], + exports: [EquipmentService], +}) +export class EquipmentModule {} diff --git a/apps/api/src/equipment/equipment.service.spec.ts b/apps/api/src/equipment/equipment.service.spec.ts new file mode 100644 index 0000000..5536a9d --- /dev/null +++ b/apps/api/src/equipment/equipment.service.spec.ts @@ -0,0 +1,431 @@ +import { DataSource, EntityManager, EntityTarget } from 'typeorm'; +import { CharacterStatsService } from '../characters/character-stats.service'; +import { Character } from '../characters/entities/character.entity'; +import { CombatStatus } from '../combat/combat-status.enum'; +import { Combat } from '../combat/entities/combat.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { EquipmentSlot } from '../items/equipment-slot.enum'; +import { ItemRarity } from '../items/item-rarity.enum'; +import { ItemType } from '../items/item-type.enum'; +import { CharacterEquipment } from './entities/character-equipment.entity'; +import { EquipmentDomainError } from './equipment.errors'; +import { EquipmentService } from './equipment.service'; + +const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002'; +const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001'; +const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002'; +const BANDIT_HOOD_ITEM_ID = '70000000-0000-4000-8000-000000000003'; + +interface State { + characters: Character[]; + itemDefinitions: ItemDefinition[]; + characterItems: CharacterItem[]; + characterEquipment: CharacterEquipment[]; + combats: Combat[]; +} + +class FakeRepository { + constructor( + private readonly state: State, + private readonly target: EntityTarget, + private readonly dataSource: FakeDataSource, + ) {} + + findOne(options: { + where: Partial; + relations?: Record; + lock?: { mode: string }; + }): Promise { + const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null; + return Promise.resolve(row ? this.withRelations(row, options.relations) : null); + } + + findOneBy(where: Partial): Promise { + return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null); + } + + find(options: { where: Partial; relations?: Record }): Promise { + const matched = this.rows().filter((row) => this.matches(row, options.where)); + return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations))); + } + + create(values: Partial): T { + return { ...values } as T; + } + + save(entity: T): Promise { + if (!entity.id) { + entity.id = this.dataSource.nextId(this.targetName()); + } + const rows = this.rows(); + const index = rows.findIndex((row) => row.id === entity.id); + if (index === -1) { + rows.push(entity); + } else { + rows[index] = entity; + } + return Promise.resolve(entity); + } + + private withRelations(row: T, relations?: Record): T { + if (!relations) { + return row; + } + const copy = { ...row } as T & Record; + if (this.target === CharacterItem && relations['itemDefinition']) { + const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId; + copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId); + } + if (this.target === CharacterEquipment && relations['characterItem']) { + const characterItemId = (row as unknown as CharacterEquipment).characterItemId; + const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId); + copy['characterItem'] = characterItem + ? { + ...characterItem, + itemDefinition: this.state.itemDefinitions.find( + (d) => d.id === characterItem.itemDefinitionId, + ), + } + : undefined; + } + return copy as T; + } + + private rows(): T[] { + if (this.target === Character) return this.state.characters as T[]; + if (this.target === ItemDefinition) return this.state.itemDefinitions as T[]; + if (this.target === CharacterItem) return this.state.characterItems as T[]; + if (this.target === CharacterEquipment) return this.state.characterEquipment as T[]; + if (this.target === Combat) return this.state.combats as T[]; + throw new Error(`Unsupported repository ${this.targetName()}`); + } + + private matches(row: T, where: Partial): boolean { + return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + } + + private targetName(): string { + return typeof this.target === 'function' ? this.target.name : 'EntitySchema'; + } +} + +class FakeDataSource { + private readonly idCounters = new Map(); + constructor(public state: State) {} + + getRepository(target: EntityTarget) { + return new FakeRepository(this.state, target, this); + } + + async transaction(work: (manager: EntityManager) => Promise): Promise { + return work({ + getRepository: (target: EntityTarget) => + this.getRepository(target), + } as unknown as EntityManager); + } + + nextId(targetName: string): string { + const next = (this.idCounters.get(targetName) ?? 0) + 1; + this.idCounters.set(targetName, next); + return `${targetName.toLowerCase()}-generated-${next}`; + } +} + +function itemDefinition(overrides: Partial = {}): ItemDefinition { + return { + id: 'def-worn-sword', + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + description: '', + type: ItemType.WEAPON, + equipmentSlot: EquipmentSlot.WEAPON, + rarity: ItemRarity.COMMON, + tier: 1, + requiredLevel: 1, + weaponDamage: 8, + bonusHp: 0, + bonusAttack: 0, + bonusArmor: 0, + sellPrice: 0, + iconPath: '/images/items/worn-short-sword.png', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as ItemDefinition; +} + +function character(overrides: Partial = {}): Character { + return { + id: CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + baseHp: 100, + baseAttack: 6, + currentHp: 100, + ...overrides, + } as Character; +} + +function createHarness(state: Partial = {}) { + const fullState: State = { + characters: [character()], + itemDefinitions: [], + characterItems: [], + characterEquipment: [], + combats: [], + ...state, + }; + const dataSource = new FakeDataSource(fullState); + const characterStats = new CharacterStatsService(dataSource as unknown as DataSource); + const service = new EquipmentService(dataSource as unknown as DataSource, characterStats); + return { state: fullState, service }; +} + +async function expectEquipmentDomainError(promise: Promise, code: string): Promise { + let error: unknown; + try { + await promise; + } catch (cause) { + error = cause; + } + expect(error).toBeInstanceOf(EquipmentDomainError); + if (!(error instanceof EquipmentDomainError)) { + throw new Error('Expected EquipmentDomainError'); + } + expect(error.code).toBe(code); +} + +describe('EquipmentService', () => { + describe('equip', () => { + it('equips an owned weapon into the WEAPON slot', async () => { + const wornSword = itemDefinition(); + const { state, service } = createHarness({ + itemDefinitions: [wornSword], + characterItems: [ + { + id: WORN_SWORD_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: wornSword.id, + quantity: 1, + } as CharacterItem, + ], + }); + + const result = await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID); + + expect(result.slots.WEAPON).toEqual({ + characterItemId: WORN_SWORD_ITEM_ID, + item: { + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + rarity: 'COMMON', + iconPath: '/images/items/worn-short-sword.png', + }, + }); + expect(state.characterEquipment).toHaveLength(1); + }); + + it('replaces the equipped weapon without deleting the old CharacterItem', async () => { + const wornSword = itemDefinition(); + const banditBlade = itemDefinition({ + id: 'def-bandit-blade', + key: 'bandit-blade', + name: 'Räuberklinge', + weaponDamage: 11, + bonusAttack: 1, + iconPath: '/images/items/bandit-blade.png', + }); + const { state, service } = createHarness({ + itemDefinitions: [wornSword, banditBlade], + characterItems: [ + { + id: WORN_SWORD_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: wornSword.id, + quantity: 1, + } as CharacterItem, + { + id: BANDIT_BLADE_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: banditBlade.id, + quantity: 1, + } as CharacterItem, + ], + characterEquipment: [ + { + id: 'equip-1', + characterId: CHARACTER_ID, + slot: EquipmentSlot.WEAPON, + characterItemId: WORN_SWORD_ITEM_ID, + } as CharacterEquipment, + ], + }); + + const result = await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); + + expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID); + expect(state.characterEquipment).toHaveLength(1); + expect(state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID)).toBeDefined(); + }); + + it('rejects equipping an item owned by a different character', async () => { + const wornSword = itemDefinition(); + const { service } = createHarness({ + characters: [character(), character({ id: OTHER_CHARACTER_ID })], + itemDefinitions: [wornSword], + characterItems: [ + { + id: WORN_SWORD_ITEM_ID, + characterId: OTHER_CHARACTER_ID, + itemDefinitionId: wornSword.id, + quantity: 1, + } as CharacterItem, + ], + }); + + await expectEquipmentDomainError( + service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID), + 'ITEM_NOT_OWNED', + ); + }); + + it('rejects equipping an unknown CharacterItem id', async () => { + const { service } = createHarness(); + + await expectEquipmentDomainError( + service.equip(CHARACTER_ID, 'unknown-item'), + 'CHARACTER_ITEM_NOT_FOUND', + ); + }); + + it('rejects equipping an item above the character level', async () => { + const highLevelHelm = itemDefinition({ + id: BANDIT_HOOD_ITEM_ID, + key: 'bandit-hood', + equipmentSlot: EquipmentSlot.HEAD, + requiredLevel: 5, + }); + const { service } = createHarness({ + itemDefinitions: [highLevelHelm], + characterItems: [ + { + id: BANDIT_HOOD_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: highLevelHelm.id, + quantity: 1, + } as CharacterItem, + ], + }); + + await expectEquipmentDomainError( + service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID), + 'ITEM_LEVEL_REQUIREMENT_NOT_MET', + ); + }); + + it('rejects equipping a non-equippable item', async () => { + const material = itemDefinition({ + id: 'def-ash-pelt', + key: 'ash-pelt', + type: ItemType.MATERIAL, + equipmentSlot: null, + }); + const { service } = createHarness({ + itemDefinitions: [material], + characterItems: [ + { + id: 'item-ash-pelt', + characterId: CHARACTER_ID, + itemDefinitionId: material.id, + quantity: 1, + } as CharacterItem, + ], + }); + + await expectEquipmentDomainError( + service.equip(CHARACTER_ID, 'item-ash-pelt'), + 'ITEM_NOT_EQUIPPABLE', + ); + }); + + it('never produces two equipped weapons when the same slot is equipped repeatedly', async () => { + const wornSword = itemDefinition(); + const banditBlade = itemDefinition({ + id: 'def-bandit-blade', + key: 'bandit-blade', + weaponDamage: 11, + bonusAttack: 1, + iconPath: '/images/items/bandit-blade.png', + }); + const { state, service } = createHarness({ + itemDefinitions: [wornSword, banditBlade], + characterItems: [ + { + id: WORN_SWORD_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: wornSword.id, + quantity: 1, + } as CharacterItem, + { + id: BANDIT_BLADE_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: banditBlade.id, + quantity: 1, + } as CharacterItem, + ], + }); + + // Sequential repeats stand in for the concurrent case here (a real race + // is guarded by the DB's UNIQUE(character_id, slot) constraint from + // Task 1, which a synchronous fake repository cannot exercise). + await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID); + await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); + await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); + + expect(state.characterEquipment).toHaveLength(1); + expect(state.characterEquipment[0].characterItemId).toBe(BANDIT_BLADE_ITEM_ID); + }); + + it('rejects equipping while the character has an active combat', async () => { + const wornSword = itemDefinition(); + const { service } = createHarness({ + itemDefinitions: [wornSword], + characterItems: [ + { + id: WORN_SWORD_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: wornSword.id, + quantity: 1, + } as CharacterItem, + ], + combats: [{ id: 'combat-1', characterId: CHARACTER_ID, status: CombatStatus.ACTIVE } as Combat], + }); + + await expectEquipmentDomainError( + service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID), + 'CHARACTER_IN_COMBAT', + ); + }); + }); + + describe('getEquipment', () => { + it('returns empty slots and base stats when nothing is equipped', async () => { + const { service } = createHarness(); + + const result = await service.getEquipment(CHARACTER_ID); + + expect(result.slots).toEqual({ + WEAPON: null, + HEAD: null, + CHEST: null, + HANDS: null, + LEGS: null, + FEET: null, + AMULET: null, + }); + expect(result.stats).toEqual({ maxHp: 100, attack: 6, weaponDamage: 0, armor: 0 }); + }); + }); +}); diff --git a/apps/api/src/equipment/equipment.service.ts b/apps/api/src/equipment/equipment.service.ts new file mode 100644 index 0000000..5fd094a --- /dev/null +++ b/apps/api/src/equipment/equipment.service.ts @@ -0,0 +1,168 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { CharacterStatsService } from '../characters/character-stats.service'; +import { Character } from '../characters/entities/character.entity'; +import { CombatStatus } from '../combat/combat-status.enum'; +import { Combat } from '../combat/entities/combat.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { EquipmentSlot } from '../items/equipment-slot.enum'; +import { ItemRarity } from '../items/item-rarity.enum'; +import { CharacterEquipment } from './entities/character-equipment.entity'; +import { + characterInCombat, + characterItemNotFound, + characterNotFound, + itemLevelRequirementNotMet, + itemNotEquippable, + itemNotOwned, +} from './equipment.errors'; + +export interface EquipmentSlotItemDto { + characterItemId: string; + item: { + key: string; + name: string; + rarity: ItemRarity; + iconPath: string; + }; +} + +export type EquipmentSlotsDto = Record; + +export interface EquipmentStatsDto { + maxHp: number; + attack: number; + weaponDamage: number; + armor: number; +} + +export interface EquipmentResponseDto { + slots: EquipmentSlotsDto; + stats: EquipmentStatsDto; +} + +type RepositoryScope = Pick; + +@Injectable() +export class EquipmentService { + constructor( + private readonly dataSource: DataSource, + private readonly characterStats: CharacterStatsService, + ) {} + + async getEquipment(characterId: string): Promise { + const character = await this.dataSource + .getRepository(Character) + .findOneBy({ id: characterId }); + if (!character) { + throw characterNotFound(); + } + return this.buildResponse(character, this.dataSource); + } + + /** + * Equips (or replaces) one slot for `characterId` with `characterItemId` + * (spec §14, §28). Runs in one transaction: the old item is unequipped by + * being overwritten, never deleted (spec §15). + */ + async equip(characterId: string, characterItemId: string): Promise { + return this.dataSource.transaction(async (manager) => { + const characters = manager.getRepository(Character); + const combats = manager.getRepository(Combat); + const characterItems = manager.getRepository(CharacterItem); + const equipmentRepo = manager.getRepository(CharacterEquipment); + + const character = await characters.findOne({ + where: { id: characterId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!character) { + throw characterNotFound(); + } + + const activeCombat = await combats.findOne({ + where: { characterId, status: CombatStatus.ACTIVE }, + }); + if (activeCombat) { + throw characterInCombat(); + } + + const characterItem = await characterItems.findOne({ + where: { id: characterItemId }, + relations: { itemDefinition: true }, + }); + if (!characterItem) { + throw characterItemNotFound(); + } + if (characterItem.characterId !== characterId) { + throw itemNotOwned(); + } + + const definition = characterItem.itemDefinition; + if (!definition.equipmentSlot) { + throw itemNotEquippable(); + } + if (definition.requiredLevel > character.level) { + throw itemLevelRequirementNotMet(); + } + + const existing = await equipmentRepo.findOne({ + where: { characterId, slot: definition.equipmentSlot }, + lock: { mode: 'pessimistic_write' }, + }); + if (existing) { + existing.characterItemId = characterItem.id; + await equipmentRepo.save(existing); + } else { + await equipmentRepo.save( + equipmentRepo.create({ + characterId, + slot: definition.equipmentSlot, + characterItemId: characterItem.id, + }), + ); + } + + return this.buildResponse(character, manager); + }); + } + + private async buildResponse( + character: Character, + scope: RepositoryScope, + ): Promise { + const equipped = await scope.getRepository(CharacterEquipment).find({ + where: { characterId: character.id }, + relations: { characterItem: { itemDefinition: true } }, + }); + + const slots = Object.fromEntries( + Object.values(EquipmentSlot).map((slot) => [slot, null]), + ) as EquipmentSlotsDto; + + for (const row of equipped) { + const definition = row.characterItem.itemDefinition; + slots[row.slot] = { + characterItemId: row.characterItemId, + item: { + key: definition.key, + name: definition.name, + rarity: definition.rarity, + iconPath: definition.iconPath, + }, + }; + } + + const stats = await this.characterStats.calculate(character, scope); + + return { + slots, + stats: { + maxHp: stats.maxHp, + attack: stats.attack, + weaponDamage: stats.weaponDamage, + armor: stats.armor, + }, + }; + } +}