feat(api): add equipment API (GET/POST /api/equipment)
This commit is contained in:
@@ -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 {}
|
||||
|
||||
6
apps/api/src/equipment/dto/equip-item.dto.ts
Normal file
6
apps/api/src/equipment/dto/equip-item.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class EquipItemDto {
|
||||
@IsUUID()
|
||||
characterItemId!: string;
|
||||
}
|
||||
19
apps/api/src/equipment/equipment.controller.ts
Normal file
19
apps/api/src/equipment/equipment.controller.ts
Normal file
@@ -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<EquipmentResponseDto> {
|
||||
return this.equipmentService.getEquipment(DEMO_CHARACTER_ID);
|
||||
}
|
||||
|
||||
@Post()
|
||||
equip(@Body() request: EquipItemDto): Promise<EquipmentResponseDto> {
|
||||
return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId);
|
||||
}
|
||||
}
|
||||
21
apps/api/src/equipment/equipment.module.ts
Normal file
21
apps/api/src/equipment/equipment.module.ts
Normal file
@@ -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 {}
|
||||
431
apps/api/src/equipment/equipment.service.spec.ts
Normal file
431
apps/api/src/equipment/equipment.service.spec.ts
Normal file
@@ -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<T extends { id: string }> {
|
||||
constructor(
|
||||
private readonly state: State,
|
||||
private readonly target: EntityTarget<T>,
|
||||
private readonly dataSource: FakeDataSource,
|
||||
) {}
|
||||
|
||||
findOne(options: {
|
||||
where: Partial<T>;
|
||||
relations?: Record<string, unknown>;
|
||||
lock?: { mode: string };
|
||||
}): Promise<T | null> {
|
||||
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<T>): Promise<T | null> {
|
||||
return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
|
||||
}
|
||||
|
||||
find(options: { where: Partial<T>; relations?: Record<string, unknown> }): Promise<T[]> {
|
||||
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>): T {
|
||||
return { ...values } as T;
|
||||
}
|
||||
|
||||
save(entity: T): Promise<T> {
|
||||
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<string, unknown>): T {
|
||||
if (!relations) {
|
||||
return row;
|
||||
}
|
||||
const copy = { ...row } as T & Record<string, unknown>;
|
||||
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<T>): 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<string, number>();
|
||||
constructor(public state: State) {}
|
||||
|
||||
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
||||
return new FakeRepository(this.state, target, this);
|
||||
}
|
||||
|
||||
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
|
||||
return work({
|
||||
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
|
||||
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> = {}): 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> = {}): Character {
|
||||
return {
|
||||
id: CHARACTER_ID,
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
...overrides,
|
||||
} as Character;
|
||||
}
|
||||
|
||||
function createHarness(state: Partial<State> = {}) {
|
||||
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<unknown>, code: string): Promise<void> {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
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