import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { CharacterStatsService } from '../characters/character-stats.service'; import { CharacterVitalsService } from '../characters/character-vitals.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, itemNotEquippable, itemNotOwned, } from './equipment.errors'; export interface EquipmentSlotItemDto { characterItemId: string; item: { key: string; name: string; rarity: ItemRarity; iconPath: string; }; } export type EquipmentSlotsDto = Record< EquipmentSlot, EquipmentSlotItemDto | null >; 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, private readonly characterVitals: CharacterVitalsService, ) {} 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(); } const statsBeforeChange = await this.characterStats.calculate(character, manager); this.characterVitals.settle(character, statsBeforeChange.maxHp); await characters.save(character); 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, }, }; } }