feat(api): add equipment API (GET/POST /api/equipment)

This commit is contained in:
Bastian Wagner
2026-08-20 16:39:23 +02:00
parent f45f4b03cb
commit 9df740e7d4
6 changed files with 647 additions and 0 deletions

View File

@@ -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<EquipmentSlot, EquipmentSlotItemDto | null>;
export interface EquipmentStatsDto {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
export interface EquipmentResponseDto {
slots: EquipmentSlotsDto;
stats: EquipmentStatsDto;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
@Injectable()
export class EquipmentService {
constructor(
private readonly dataSource: DataSource,
private readonly characterStats: CharacterStatsService,
) {}
async getEquipment(characterId: string): Promise<EquipmentResponseDto> {
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<EquipmentResponseDto> {
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<EquipmentResponseDto> {
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,
},
};
}
}