feat(api): add equipment API (GET/POST /api/equipment)
This commit is contained in:
168
apps/api/src/equipment/equipment.service.ts
Normal file
168
apps/api/src/equipment/equipment.service.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user