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 { CharactersModule } from './characters/characters.module';
|
||||||
import { CombatModule } from './combat/combat.module';
|
import { CombatModule } from './combat/combat.module';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
|
import { EquipmentModule } from './equipment/equipment.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { HuntingModule } from './hunting/hunting.module';
|
import { HuntingModule } from './hunting/hunting.module';
|
||||||
import { TravelModule } from './travel/travel.module';
|
import { TravelModule } from './travel/travel.module';
|
||||||
@@ -16,6 +17,7 @@ import { WorldModule } from './world/world.module';
|
|||||||
WorldModule,
|
WorldModule,
|
||||||
HuntingModule,
|
HuntingModule,
|
||||||
CombatModule,
|
CombatModule,
|
||||||
|
EquipmentModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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