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

@@ -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 {}

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class EquipItemDto {
@IsUUID()
characterItemId!: string;
}

View 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);
}
}

View 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 {}

View 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 });
});
});
});

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,
},
};
}
}