Merge branch 'worktree-playable-slice-0.5-first-upgrade'
# Conflicts: # apps/web/src/app/core/api/game-api.service.ts # apps/web/src/app/features/combat/combat-page/combat-page.component.html # apps/web/src/app/features/combat/combat-page/combat-page.component.scss # apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts # apps/web/src/app/features/combat/combat-page/combat-page.component.ts
This commit is contained in:
@@ -2,8 +2,10 @@ 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 { InventoryModule } from './inventory/inventory.module';
|
||||
import { TravelModule } from './travel/travel.module';
|
||||
import { WorldModule } from './world/world.module';
|
||||
|
||||
@@ -16,6 +18,8 @@ import { WorldModule } from './world/world.module';
|
||||
WorldModule,
|
||||
HuntingModule,
|
||||
CombatModule,
|
||||
EquipmentModule,
|
||||
InventoryModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { CharacterCombatStatsService } from './character-combat-stats.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
describe('CharacterCombatStatsService', () => {
|
||||
it('derives combat stats from the character, with a temporary fixed weapon/armor stand-in', () => {
|
||||
const service = new CharacterCombatStatsService();
|
||||
const character = { baseHp: 100, baseAttack: 6 } as Character;
|
||||
|
||||
expect(service.getStats(character)).toEqual({
|
||||
maxHp: 100,
|
||||
attack: 6,
|
||||
weaponDamage: 8,
|
||||
armor: 6,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
export interface CharacterCombatStats {
|
||||
maxHp: number;
|
||||
attack: number;
|
||||
weaponDamage: number;
|
||||
armor: number;
|
||||
}
|
||||
|
||||
// TEMPORARY (Slice 0.3): there is no equipment system yet. These constants
|
||||
// stand in for the starting weapon/armor until Slice 0.5 introduces real
|
||||
// equipment. Replacing them there must not change this method's signature
|
||||
// or the combat API it feeds (spec §10).
|
||||
const TEMPORARY_WEAPON_DAMAGE = 8;
|
||||
const TEMPORARY_ARMOR = 6;
|
||||
|
||||
@Injectable()
|
||||
export class CharacterCombatStatsService {
|
||||
getStats(character: Character): CharacterCombatStats {
|
||||
return {
|
||||
maxHp: character.baseHp,
|
||||
attack: character.baseAttack,
|
||||
weaponDamage: TEMPORARY_WEAPON_DAMAGE,
|
||||
armor: TEMPORARY_ARMOR,
|
||||
};
|
||||
}
|
||||
}
|
||||
115
apps/api/src/characters/character-stats.service.spec.ts
Normal file
115
apps/api/src/characters/character-stats.service.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// apps/api/src/characters/character-stats.service.spec.ts
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CharacterStatsService } from './character-stats.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||
|
||||
type EquippedFixture = {
|
||||
slot: EquipmentSlot;
|
||||
item: Partial<ItemDefinition>;
|
||||
};
|
||||
|
||||
function fakeScope(equipped: EquippedFixture[]): Pick<DataSource, 'getRepository'> {
|
||||
const rows = equipped.map((entry) => ({
|
||||
slot: entry.slot,
|
||||
characterItem: {
|
||||
itemDefinition: {
|
||||
weaponDamage: 0,
|
||||
bonusHp: 0,
|
||||
bonusAttack: 0,
|
||||
bonusArmor: 0,
|
||||
...entry.item,
|
||||
},
|
||||
},
|
||||
}));
|
||||
return {
|
||||
getRepository: () => ({ find: async () => rows }) as never,
|
||||
} as unknown as Pick<DataSource, 'getRepository'>;
|
||||
}
|
||||
|
||||
function character(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 'character-1',
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 90,
|
||||
...overrides,
|
||||
} as Character;
|
||||
}
|
||||
|
||||
describe('CharacterStatsService', () => {
|
||||
const service = new CharacterStatsService({} as DataSource);
|
||||
|
||||
it('derives stats from the starting weapon alone', async () => {
|
||||
const scope = fakeScope([{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } }]);
|
||||
|
||||
const stats = await service.calculate(character(), scope);
|
||||
|
||||
expect(stats.attack).toBe(6);
|
||||
expect(stats.weaponDamage).toBe(8);
|
||||
expect(stats.maxHp).toBe(100);
|
||||
expect(stats.armor).toBe(0);
|
||||
});
|
||||
|
||||
it('applies Räuberklinge\'s weapon damage and bonus attack', async () => {
|
||||
const scope = fakeScope([
|
||||
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
|
||||
]);
|
||||
|
||||
const stats = await service.calculate(character(), scope);
|
||||
|
||||
expect(stats.attack).toBe(7);
|
||||
expect(stats.weaponDamage).toBe(11);
|
||||
});
|
||||
|
||||
it('sums bonusArmor across multiple equipped armor pieces', async () => {
|
||||
const scope = fakeScope([
|
||||
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } },
|
||||
{ slot: EquipmentSlot.CHEST, item: { bonusArmor: 7 } },
|
||||
]);
|
||||
|
||||
const stats = await service.calculate(character(), scope);
|
||||
|
||||
expect(stats.armor).toBe(10);
|
||||
});
|
||||
|
||||
it('sums bonusHp across equipped items on top of base HP', async () => {
|
||||
const scope = fakeScope([
|
||||
{ slot: EquipmentSlot.HEAD, item: { bonusHp: 5 } },
|
||||
{ slot: EquipmentSlot.CHEST, item: { bonusHp: 10 } },
|
||||
]);
|
||||
|
||||
const stats = await service.calculate(character(), scope);
|
||||
|
||||
expect(stats.maxHp).toBe(115);
|
||||
});
|
||||
|
||||
it('reports weaponDamage as 0 when no weapon is equipped', async () => {
|
||||
const scope = fakeScope([{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } }]);
|
||||
|
||||
const stats = await service.calculate(character(), scope);
|
||||
|
||||
expect(stats.weaponDamage).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates Combat Power as HP/10 + attack*2 + weaponDamage*2 + armor*1.5', async () => {
|
||||
const scope = fakeScope([
|
||||
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
|
||||
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3, bonusHp: 5 } },
|
||||
]);
|
||||
|
||||
const stats = await service.calculate(character(), scope);
|
||||
|
||||
// maxHp=105, attack=7, weaponDamage=11, armor=3
|
||||
expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5);
|
||||
});
|
||||
|
||||
it('passes currentHp through unchanged from the character', async () => {
|
||||
const scope = fakeScope([]);
|
||||
|
||||
const stats = await service.calculate(character({ currentHp: 42 }), scope);
|
||||
|
||||
expect(stats.currentHp).toBe(42);
|
||||
});
|
||||
});
|
||||
64
apps/api/src/characters/character-stats.service.ts
Normal file
64
apps/api/src/characters/character-stats.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
export interface EffectiveCharacterStats {
|
||||
maxHp: number;
|
||||
currentHp: number;
|
||||
attack: number;
|
||||
weaponDamage: number;
|
||||
armor: number;
|
||||
combatPower: number;
|
||||
}
|
||||
|
||||
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||
|
||||
/**
|
||||
* Single authoritative source of effective character stats (spec §18).
|
||||
* Replaces the Slice 0.3 `CharacterCombatStatsService` shortcut.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CharacterStatsService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async calculate(
|
||||
character: Character,
|
||||
scope?: RepositoryScope,
|
||||
): Promise<EffectiveCharacterStats> {
|
||||
const db = scope ?? this.dataSource;
|
||||
const equipped = await db.getRepository(CharacterEquipment).find({
|
||||
where: { characterId: character.id },
|
||||
relations: { characterItem: { itemDefinition: true } },
|
||||
});
|
||||
|
||||
let weaponDamage = 0;
|
||||
let bonusHp = 0;
|
||||
let bonusAttack = 0;
|
||||
let bonusArmor = 0;
|
||||
|
||||
for (const slot of equipped) {
|
||||
const definition = slot.characterItem.itemDefinition;
|
||||
if (slot.slot === EquipmentSlot.WEAPON) {
|
||||
weaponDamage = definition.weaponDamage;
|
||||
}
|
||||
bonusHp += definition.bonusHp;
|
||||
bonusAttack += definition.bonusAttack;
|
||||
bonusArmor += definition.bonusArmor;
|
||||
}
|
||||
|
||||
const maxHp = character.baseHp + bonusHp;
|
||||
const attack = character.baseAttack + bonusAttack;
|
||||
const armor = bonusArmor;
|
||||
|
||||
return {
|
||||
maxHp,
|
||||
currentHp: character.currentHp,
|
||||
attack,
|
||||
weaponDamage,
|
||||
armor,
|
||||
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CharacterCombatStatsService } from './character-combat-stats.service';
|
||||
import { CharacterStatsService } from './character-stats.service';
|
||||
import { CharactersController } from './characters.controller';
|
||||
import { CharactersService } from './characters.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
@@ -8,7 +8,7 @@ import { Character } from './entities/character.entity';
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Character])],
|
||||
controllers: [CharactersController],
|
||||
providers: [CharactersService, CharacterCombatStatsService],
|
||||
exports: [CharacterCombatStatsService],
|
||||
providers: [CharactersService, CharacterStatsService],
|
||||
exports: [CharacterStatsService],
|
||||
})
|
||||
export class CharactersModule {}
|
||||
|
||||
@@ -2,11 +2,27 @@ import { NotFoundException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
|
||||
import { CharacterStatsService } from './character-stats.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
import { CharactersService } from './characters.service';
|
||||
|
||||
function fakeCharacterStats(
|
||||
overrides: Partial<{ maxHp: number; attack: number }> = {},
|
||||
): CharacterStatsService {
|
||||
return {
|
||||
calculate: jest.fn().mockResolvedValue({
|
||||
maxHp: overrides.maxHp ?? 100,
|
||||
currentHp: 100,
|
||||
attack: overrides.attack ?? 6,
|
||||
weaponDamage: 8,
|
||||
armor: 0,
|
||||
combatPower: 0,
|
||||
}),
|
||||
} as unknown as CharacterStatsService;
|
||||
}
|
||||
|
||||
describe('CharactersService', () => {
|
||||
it('returns the demo character with its current location summary', async () => {
|
||||
it('returns the demo character with effective attack/HP and its location summary', async () => {
|
||||
const repository = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: DEMO_CHARACTER_ID,
|
||||
@@ -20,11 +36,12 @@ describe('CharactersService', () => {
|
||||
currentLocation: {
|
||||
id: SOUTH_GATE_ID,
|
||||
key: 'south-gate',
|
||||
name: 'S\u00fcdtor von Graufurt',
|
||||
name: 'Südtor von Graufurt',
|
||||
},
|
||||
}),
|
||||
} as unknown as Repository<Character>;
|
||||
const service = new CharactersService(repository);
|
||||
const characterStats = fakeCharacterStats({ maxHp: 115, attack: 7 });
|
||||
const service = new CharactersService(repository, characterStats);
|
||||
|
||||
await expect(service.getDemoCharacter()).resolves.toEqual({
|
||||
id: DEMO_CHARACTER_ID,
|
||||
@@ -33,18 +50,17 @@ describe('CharactersService', () => {
|
||||
experience: 0,
|
||||
silver: 0,
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
attack: 6,
|
||||
maxHp: 115,
|
||||
attack: 7,
|
||||
currentLocation: {
|
||||
id: SOUTH_GATE_ID,
|
||||
key: 'south-gate',
|
||||
name: 'S\u00fcdtor von Graufurt',
|
||||
name: 'Südtor von Graufurt',
|
||||
},
|
||||
});
|
||||
expect(repository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: DEMO_CHARACTER_ID },
|
||||
relations: { currentLocation: true },
|
||||
});
|
||||
expect(characterStats.calculate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: DEMO_CHARACTER_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes the persisted silver so the HUD never has to guess', async () => {
|
||||
@@ -65,7 +81,7 @@ describe('CharactersService', () => {
|
||||
},
|
||||
}),
|
||||
} as unknown as Repository<Character>;
|
||||
const service = new CharactersService(repository);
|
||||
const service = new CharactersService(repository, fakeCharacterStats());
|
||||
|
||||
await expect(service.getDemoCharacter()).resolves.toEqual(
|
||||
expect.objectContaining({ experience: 24, silver: 18 }),
|
||||
@@ -76,7 +92,7 @@ describe('CharactersService', () => {
|
||||
const repository = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as Repository<Character>;
|
||||
const service = new CharactersService(repository);
|
||||
const service = new CharactersService(repository, fakeCharacterStats());
|
||||
|
||||
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { CharacterStatsService } from './character-stats.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -9,6 +10,7 @@ export class CharactersService {
|
||||
constructor(
|
||||
@InjectRepository(Character)
|
||||
private readonly characters: Repository<Character>,
|
||||
private readonly characterStats: CharacterStatsService,
|
||||
) {}
|
||||
|
||||
async getDemoCharacter() {
|
||||
@@ -21,6 +23,8 @@ export class CharactersService {
|
||||
throw new NotFoundException('Demo character has not been seeded');
|
||||
}
|
||||
|
||||
const stats = await this.characterStats.calculate(character);
|
||||
|
||||
return {
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
@@ -28,8 +32,8 @@ export class CharactersService {
|
||||
experience: character.experience,
|
||||
silver: character.silver,
|
||||
currentHp: character.currentHp,
|
||||
maxHp: character.baseHp,
|
||||
attack: character.baseAttack,
|
||||
maxHp: stats.maxHp,
|
||||
attack: stats.attack,
|
||||
currentLocation: {
|
||||
id: character.currentLocation.id,
|
||||
key: character.currentLocation.key,
|
||||
|
||||
378
apps/api/src/combat/combat-equipment-integration.spec.ts
Normal file
378
apps/api/src/combat/combat-equipment-integration.spec.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||
import { EquipmentService } from '../equipment/equipment.service';
|
||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
|
||||
import { HuntStatus } from '../hunting/hunt-status.enum';
|
||||
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 { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||
import { CombatRewardService } from '../rewards/combat-reward.service';
|
||||
import { TravelService } from '../travel/travel.service';
|
||||
import { CombatAction } from './combat-action.enum';
|
||||
import { CombatEngineService } from './combat-engine.service';
|
||||
import { CombatService } from './combat.service';
|
||||
import { CombatEvent } from './entities/combat-event.entity';
|
||||
import { Combat } from './entities/combat.entity';
|
||||
|
||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
|
||||
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
|
||||
const WORN_SWORD_DEFINITION_ID = '50000000-0000-4000-8000-000000000001';
|
||||
const BANDIT_BLADE_DEFINITION_ID = '50000000-0000-4000-8000-000000000002';
|
||||
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
|
||||
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
|
||||
|
||||
interface FakeState {
|
||||
characters: Character[];
|
||||
hunts: Hunt[];
|
||||
huntEncounters: HuntEncounter[];
|
||||
monsters: MonsterDefinition[];
|
||||
combats: Combat[];
|
||||
combatEvents: CombatEvent[];
|
||||
itemDefinitions: ItemDefinition[];
|
||||
characterItems: CharacterItem[];
|
||||
characterEquipment: CharacterEquipment[];
|
||||
}
|
||||
|
||||
class FakeRepository<T extends { id: string }> {
|
||||
constructor(
|
||||
private readonly state: FakeState,
|
||||
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>;
|
||||
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
|
||||
}): Promise<T[]> {
|
||||
const matched = this.rows().filter((row) => this.matches(row, options.where));
|
||||
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
|
||||
}
|
||||
|
||||
count(options: { where: Partial<T> }): Promise<number> {
|
||||
return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length);
|
||||
}
|
||||
|
||||
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 === Hunt) return this.state.hunts as T[];
|
||||
if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
|
||||
if (this.target === MonsterDefinition) return this.state.monsters as T[];
|
||||
if (this.target === Combat) return this.state.combats as T[];
|
||||
if (this.target === CombatEvent) return this.state.combatEvents 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[];
|
||||
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: FakeState) {}
|
||||
|
||||
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 character(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: CHARACTER_ID,
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
silver: 0,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
currentLocationId: 'location-1',
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
...overrides,
|
||||
} as Character;
|
||||
}
|
||||
|
||||
function monster(overrides: Partial<MonsterDefinition> = {}): MonsterDefinition {
|
||||
return {
|
||||
id: MONSTER_ID,
|
||||
key: 'road-bandit',
|
||||
name: 'Straßenräuber',
|
||||
level: 2,
|
||||
maxHp: 75,
|
||||
attack: 9,
|
||||
armor: 5,
|
||||
experienceReward: 16,
|
||||
silverMin: 9,
|
||||
silverMax: 15,
|
||||
artworkPath: '/images/monsters/road-bandit.png',
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
...overrides,
|
||||
} as MonsterDefinition;
|
||||
}
|
||||
|
||||
function hunt(overrides: Partial<Hunt> = {}): Hunt {
|
||||
return {
|
||||
id: HUNT_ID,
|
||||
characterId: CHARACTER_ID,
|
||||
locationId: 'location-1',
|
||||
status: HuntStatus.ACTIVE,
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
...overrides,
|
||||
} as Hunt;
|
||||
}
|
||||
|
||||
function encounter(id: string, overrides: Partial<HuntEncounter> = {}): HuntEncounter {
|
||||
return {
|
||||
id,
|
||||
huntId: HUNT_ID,
|
||||
monsterDefinitionId: MONSTER_ID,
|
||||
position: 0,
|
||||
status: HuntEncounterStatus.AVAILABLE,
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
...overrides,
|
||||
} as HuntEncounter;
|
||||
}
|
||||
|
||||
function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
|
||||
return {
|
||||
id: WORN_SWORD_DEFINITION_ID,
|
||||
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('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
...overrides,
|
||||
} as ItemDefinition;
|
||||
}
|
||||
|
||||
function fakeTravelService(): TravelService {
|
||||
return { completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }) } as unknown as TravelService;
|
||||
}
|
||||
|
||||
function fakeRewardService(): CombatRewardService {
|
||||
return {
|
||||
grantVictoryRewards: jest.fn().mockResolvedValue({ experience: 0, silver: 0, items: [] }),
|
||||
loadRewards: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as CombatRewardService;
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const state: FakeState = {
|
||||
characters: [character()],
|
||||
hunts: [hunt()],
|
||||
huntEncounters: [],
|
||||
monsters: [monster()],
|
||||
combats: [],
|
||||
combatEvents: [],
|
||||
itemDefinitions: [
|
||||
itemDefinition(),
|
||||
itemDefinition({
|
||||
id: BANDIT_BLADE_DEFINITION_ID,
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
weaponDamage: 11,
|
||||
bonusAttack: 1,
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
}),
|
||||
],
|
||||
characterItems: [
|
||||
{
|
||||
id: WORN_SWORD_ITEM_ID,
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: WORN_SWORD_DEFINITION_ID,
|
||||
quantity: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as CharacterItem,
|
||||
{
|
||||
id: BANDIT_BLADE_ITEM_ID,
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: BANDIT_BLADE_DEFINITION_ID,
|
||||
quantity: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as CharacterItem,
|
||||
],
|
||||
characterEquipment: [
|
||||
{
|
||||
id: 'equip-1',
|
||||
characterId: CHARACTER_ID,
|
||||
slot: EquipmentSlot.WEAPON,
|
||||
characterItemId: WORN_SWORD_ITEM_ID,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as CharacterEquipment,
|
||||
],
|
||||
};
|
||||
const dataSource = new FakeDataSource(state);
|
||||
const characterStats = new CharacterStatsService(dataSource as unknown as DataSource);
|
||||
const equipmentService = new EquipmentService(dataSource as unknown as DataSource, characterStats);
|
||||
const combatService = new CombatService(
|
||||
dataSource as unknown as DataSource,
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
characterStats,
|
||||
fakeRewardService(),
|
||||
);
|
||||
return { state, equipmentService, combatService };
|
||||
}
|
||||
|
||||
describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () => {
|
||||
it('deals more damage against the same monster after the upgrade than before it', async () => {
|
||||
const { state, combatService, equipmentService } = createHarness();
|
||||
|
||||
state.huntEncounters.push(encounter('encounter-1'));
|
||||
const before = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
|
||||
let beforeDamage = 0;
|
||||
let beforeResult = before;
|
||||
for (let round = 0; round < 10 && beforeResult.status === 'ACTIVE'; round += 1) {
|
||||
beforeResult = await combatService.performAction(CHARACTER_ID, before.id, CombatAction.ATTACK);
|
||||
if (round === 0) {
|
||||
beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp;
|
||||
}
|
||||
}
|
||||
// Equipment cannot change during an active combat (spec §46), so this
|
||||
// first fight must be resolved to completion before equipping.
|
||||
expect(beforeResult.status).not.toBe('ACTIVE');
|
||||
|
||||
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
|
||||
|
||||
state.huntEncounters.push(encounter('encounter-2'));
|
||||
const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2');
|
||||
const afterResult = await combatService.performAction(CHARACTER_ID, after.id, CombatAction.ATTACK);
|
||||
const afterDamage = after.monster.maxHp - afterResult.monster.currentHp;
|
||||
|
||||
// (6+8) vs 5 armor -> round(14 * 60/65) = 13
|
||||
expect(beforeDamage).toBe(13);
|
||||
// (7+11) vs 5 armor -> round(18 * 60/65) = 17
|
||||
expect(afterDamage).toBe(17);
|
||||
expect(afterDamage).toBeGreaterThan(beforeDamage);
|
||||
});
|
||||
|
||||
it('rejects equipping during an active combat, and never retroactively rewrites a finished combat\'s snapshot', async () => {
|
||||
const { state, combatService, equipmentService } = createHarness();
|
||||
state.huntEncounters.push(encounter('encounter-1'));
|
||||
|
||||
const combat = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
|
||||
|
||||
// Equipment cannot change while this combat is ACTIVE (spec §46).
|
||||
await expect(
|
||||
equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID),
|
||||
).rejects.toMatchObject({ code: 'CHARACTER_IN_COMBAT' });
|
||||
|
||||
// Resolve the fight, then equip — the already-finished combat's snapshot
|
||||
// (status/round) must stay exactly what it was when the fight ended.
|
||||
let result = combat;
|
||||
for (let round = 0; round < 10 && result.status === 'ACTIVE'; round += 1) {
|
||||
result = await combatService.performAction(CHARACTER_ID, combat.id, CombatAction.ATTACK);
|
||||
}
|
||||
expect(result.status).not.toBe('ACTIVE');
|
||||
|
||||
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
|
||||
|
||||
// The finished combat's playerState snapshot (written once at startCombat)
|
||||
// must not be retroactively rewritten by equipping after the fight ends.
|
||||
expect(state.combats[0].playerState).toEqual({ attack: 6, weaponDamage: 8, armor: 0 });
|
||||
|
||||
const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id);
|
||||
expect(reloaded.status).toBe(result.status);
|
||||
expect(reloaded.round).toBe(result.round);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||
@@ -242,6 +242,19 @@ function createState(overrides: Partial<FakeState> = {}): FakeState {
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCharacterStats(): CharacterStatsService {
|
||||
return {
|
||||
calculate: jest.fn(async (character: Character) => ({
|
||||
maxHp: character.baseHp,
|
||||
currentHp: character.currentHp,
|
||||
attack: character.baseAttack,
|
||||
weaponDamage: 8,
|
||||
armor: 6,
|
||||
combatPower: 0,
|
||||
})),
|
||||
} as unknown as CharacterStatsService;
|
||||
}
|
||||
|
||||
function fakeTravelService(
|
||||
status: 'IDLE' | 'TRAVELLING' = 'IDLE',
|
||||
): TravelService {
|
||||
@@ -271,7 +284,7 @@ function createService(
|
||||
const dataSource = new FakeDataSource(state);
|
||||
const travelService = options.travelService ?? fakeTravelService();
|
||||
const combatEngine = new CombatEngineService();
|
||||
const characterCombatStats = new CharacterCombatStatsService();
|
||||
const characterCombatStats = fakeCharacterStats();
|
||||
const service = new CombatService(
|
||||
dataSource as unknown as DataSource,
|
||||
travelService,
|
||||
@@ -785,7 +798,7 @@ describe('CombatService', () => {
|
||||
dataSource as unknown as DataSource,
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
new CharacterCombatStatsService(),
|
||||
fakeCharacterStats(),
|
||||
rewards,
|
||||
);
|
||||
|
||||
@@ -833,7 +846,7 @@ describe('CombatService', () => {
|
||||
dataSource as unknown as DataSource,
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
new CharacterCombatStatsService(),
|
||||
fakeCharacterStats(),
|
||||
rewards,
|
||||
);
|
||||
|
||||
@@ -890,7 +903,7 @@ describe('CombatService', () => {
|
||||
dataSource as unknown as DataSource,
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
new CharacterCombatStatsService(),
|
||||
fakeCharacterStats(),
|
||||
rewards,
|
||||
);
|
||||
|
||||
@@ -927,7 +940,7 @@ describe('CombatService', () => {
|
||||
dataSource as unknown as DataSource,
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
new CharacterCombatStatsService(),
|
||||
fakeCharacterStats(),
|
||||
fakeRewardService({
|
||||
// Genuinely write XP/silver through the transaction's manager
|
||||
// before failing, so the assertions below prove the rollback
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||
@@ -69,7 +69,7 @@ export class CombatService {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly travelService: TravelService,
|
||||
private readonly combatEngine: CombatEngineService,
|
||||
private readonly characterCombatStats: CharacterCombatStatsService,
|
||||
private readonly characterStats: CharacterStatsService,
|
||||
private readonly combatRewards: CombatRewardService,
|
||||
) {}
|
||||
|
||||
@@ -126,7 +126,7 @@ export class CombatService {
|
||||
throw invalidHuntEncounter();
|
||||
}
|
||||
|
||||
const playerStats = this.characterCombatStats.getStats(character);
|
||||
const playerStats = await this.characterStats.calculate(character, manager);
|
||||
|
||||
const combat = combats.create({
|
||||
characterId,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateEquipment1789000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Reuses the "equipment_slot_enum" type created by CreateLootAndRewards.
|
||||
await queryRunner.query(`CREATE TABLE "character_equipment" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"character_id" uuid NOT NULL,
|
||||
"slot" "equipment_slot_enum" NOT NULL,
|
||||
"character_item_id" uuid NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_character_equipment" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_character_equipment_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_character_equipment_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_character_equipment_character_slot" ON "character_equipment" ("character_id", "slot")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_character_equipment_character_item" ON "character_equipment" ("character_item_id")',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'DROP INDEX "IDX_character_equipment_character_item"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'DROP INDEX "IDX_character_equipment_character_slot"',
|
||||
);
|
||||
await queryRunner.query('DROP TABLE "character_equipment"');
|
||||
}
|
||||
}
|
||||
52
apps/api/src/database/migrations/equipment.migration.spec.ts
Normal file
52
apps/api/src/database/migrations/equipment.migration.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
|
||||
|
||||
describe('character_equipment schema', () => {
|
||||
it('stores slot as a non-nullable equipment_slot_enum column', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
const column = metadata.columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === CharacterEquipment && candidate.propertyName === 'slot',
|
||||
);
|
||||
|
||||
expect(column).toBeDefined();
|
||||
expect(column?.options.type).toBe('enum');
|
||||
expect(column?.options.enumName).toBe('equipment_slot_enum');
|
||||
expect(column?.options.nullable).toBeFalsy();
|
||||
});
|
||||
|
||||
it('enforces one equipped item per character per slot', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
const index = metadata.indices.find(
|
||||
(candidate) =>
|
||||
candidate.target === CharacterEquipment &&
|
||||
candidate.columns?.includes('characterId') &&
|
||||
candidate.columns?.includes('slot'),
|
||||
);
|
||||
|
||||
expect(index).toBeDefined();
|
||||
const indexMetadata = index as typeof index & {
|
||||
options?: { unique?: boolean };
|
||||
unique?: boolean;
|
||||
};
|
||||
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
|
||||
});
|
||||
|
||||
it('forbids one CharacterItem from occupying more than one equipment slot', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
const index = metadata.indices.find(
|
||||
(candidate) =>
|
||||
candidate.target === CharacterEquipment &&
|
||||
candidate.columns?.length === 1 &&
|
||||
candidate.columns?.includes('characterItemId'),
|
||||
);
|
||||
|
||||
expect(index).toBeDefined();
|
||||
const indexMetadata = index as typeof index & {
|
||||
options?: { unique?: boolean };
|
||||
unique?: boolean;
|
||||
};
|
||||
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
|
||||
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||
@@ -7,6 +9,7 @@ import { LocationMonster } from '../../monsters/entities/location-monster.entity
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
|
||||
import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants';
|
||||
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||
|
||||
@@ -76,6 +79,8 @@ function createDataSource(
|
||||
itemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
): DataSource {
|
||||
return {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
@@ -87,6 +92,8 @@ function createDataSource(
|
||||
if (entity === ItemDefinition) return itemRepository;
|
||||
if (entity === LootTable) return lootTableRepository;
|
||||
if (entity === LootTableEntry) return lootEntryRepository;
|
||||
if (entity === CharacterItem) return characterItemRepository;
|
||||
if (entity === CharacterEquipment) return characterEquipmentRepository;
|
||||
|
||||
throw new Error('Unexpected repository');
|
||||
}),
|
||||
@@ -449,4 +456,125 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const connectionRepository = new InMemoryRepository();
|
||||
const characterRepository = new InMemoryRepository();
|
||||
const monsterRepository = new InMemoryRepository();
|
||||
const locationMonsterRepository = new InMemoryRepository();
|
||||
const characterItemRepository = new InMemoryRepository();
|
||||
const characterEquipmentRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
connectionRepository,
|
||||
characterRepository,
|
||||
monsterRepository,
|
||||
locationMonsterRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
characterItemRepository,
|
||||
characterEquipmentRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(characterItemRepository.rows).toHaveLength(1);
|
||||
expect(characterItemRepository.rows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||
quantity: 1,
|
||||
}),
|
||||
);
|
||||
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
||||
expect(characterEquipmentRepository.rows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
slot: 'WEAPON',
|
||||
characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('never re-equips the starting sword once the player has equipped different gear', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const connectionRepository = new InMemoryRepository();
|
||||
const characterRepository = new InMemoryRepository();
|
||||
const monsterRepository = new InMemoryRepository();
|
||||
const locationMonsterRepository = new InMemoryRepository();
|
||||
const characterItemRepository = new InMemoryRepository();
|
||||
const characterEquipmentRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
connectionRepository,
|
||||
characterRepository,
|
||||
monsterRepository,
|
||||
locationMonsterRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
characterItemRepository,
|
||||
characterEquipmentRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
// Simulate the player having equipped earned loot instead.
|
||||
characterEquipmentRepository.rows[0]['characterItemId'] = 'earned-bandit-blade-item-id';
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
||||
expect(characterEquipmentRepository.rows[0]['characterItemId']).toBe(
|
||||
'earned-bandit-blade-item-id',
|
||||
);
|
||||
expect(characterItemRepository.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reuses a naturally-looted starting sword instead of inserting a duplicate CharacterItem', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const connectionRepository = new InMemoryRepository();
|
||||
const characterRepository = new InMemoryRepository();
|
||||
const monsterRepository = new InMemoryRepository();
|
||||
const locationMonsterRepository = new InMemoryRepository();
|
||||
const characterItemRepository = new InMemoryRepository();
|
||||
const characterEquipmentRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
connectionRepository,
|
||||
characterRepository,
|
||||
monsterRepository,
|
||||
locationMonsterRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
characterItemRepository,
|
||||
characterEquipmentRepository,
|
||||
);
|
||||
|
||||
// Simulate the demo character having already looted a worn-short-sword
|
||||
// naturally, under a DB-generated id that differs from the seed's
|
||||
// stable literal constant.
|
||||
const naturallyLootedItemId = 'naturally-looted-sword-item-id';
|
||||
characterItemRepository.rows.push({
|
||||
id: naturallyLootedItemId,
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(characterItemRepository.rows).toHaveLength(1);
|
||||
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
||||
expect(characterEquipmentRepository.rows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
slot: 'WEAPON',
|
||||
characterItemId: naturallyLootedItemId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
|
||||
import {
|
||||
DEMO_CHARACTER_ID,
|
||||
DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
|
||||
DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
||||
} from '../../demo/demo-character.constants';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
|
||||
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||
import { EquipmentSlot } from '../../items/equipment-slot.enum';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||
@@ -12,6 +19,7 @@ import { LocationDefinition } from '../../world/entities/location-definition.ent
|
||||
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
ITEM_IDS,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
import {
|
||||
@@ -238,4 +246,42 @@ export async function seedVisibleVerticalSlice(
|
||||
currentLocationId: southGateId,
|
||||
});
|
||||
}
|
||||
|
||||
const characterItemRepository = dataSource.getRepository(CharacterItem);
|
||||
const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment);
|
||||
|
||||
// Starting loadout is weapon-only -- no starter armor piece exists in
|
||||
// content yet -- so the demo character's effective armor (sum of equipped
|
||||
// bonusArmor) is 0 until the player loots and equips bandit-hood (+3
|
||||
// armor). This is a deliberate tradeoff, not a bug: Slice 0.5 spec §19
|
||||
// says to preserve the existing demo balance "as closely as the
|
||||
// implemented content allows" and explicitly forbids fabricating a full
|
||||
// starter gear set just to hit the old hardcoded TEMPORARY_ARMOR = 6.
|
||||
const existingStartingSword = await characterItemRepository.findOneBy({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||
});
|
||||
const startingSwordItemId =
|
||||
existingStartingSword?.id ?? DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID;
|
||||
if (!existingStartingSword) {
|
||||
await characterItemRepository.insert({
|
||||
id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const existingWeaponEquipment = await characterEquipmentRepository.findOneBy({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
slot: EquipmentSlot.WEAPON,
|
||||
});
|
||||
if (!existingWeaponEquipment) {
|
||||
await characterEquipmentRepository.insert({
|
||||
id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
slot: EquipmentSlot.WEAPON,
|
||||
characterItemId: startingSwordItemId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||
export const DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID =
|
||||
'10000000-0000-4000-8000-000000000002';
|
||||
export const DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID =
|
||||
'10000000-0000-4000-8000-000000000003';
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||
import { EquipmentSlot } from '../../items/equipment-slot.enum';
|
||||
|
||||
/**
|
||||
* One equipped item in one slot for one character (spec §12).
|
||||
*
|
||||
* `characterItemId` must belong to `characterId` — enforced by
|
||||
* `EquipmentService`, never by the client (spec §13).
|
||||
*/
|
||||
@Entity({ name: 'character_equipment' })
|
||||
@Index('IDX_character_equipment_character_slot', ['characterId', 'slot'], {
|
||||
unique: true,
|
||||
})
|
||||
@Index('IDX_character_equipment_character_item', ['characterItemId'], {
|
||||
unique: true,
|
||||
})
|
||||
export class CharacterEquipment {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'character_id', type: 'uuid' })
|
||||
characterId!: string;
|
||||
|
||||
@Column({
|
||||
name: 'slot',
|
||||
type: 'enum',
|
||||
enum: EquipmentSlot,
|
||||
enumName: 'equipment_slot_enum',
|
||||
})
|
||||
slot!: EquipmentSlot;
|
||||
|
||||
@Column({ name: 'character_item_id', type: 'uuid' })
|
||||
characterItemId!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'character_id' })
|
||||
character!: Character;
|
||||
|
||||
@ManyToOne(() => CharacterItem, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'character_item_id' })
|
||||
characterItem!: CharacterItem;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
71
apps/api/src/equipment/equipment.errors.ts
Normal file
71
apps/api/src/equipment/equipment.errors.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export type EquipmentErrorCode =
|
||||
| 'CHARACTER_ITEM_NOT_FOUND'
|
||||
| 'ITEM_NOT_OWNED'
|
||||
| 'ITEM_NOT_EQUIPPABLE'
|
||||
| 'ITEM_LEVEL_REQUIREMENT_NOT_MET'
|
||||
| 'INVALID_EQUIPMENT_SLOT'
|
||||
| 'CHARACTER_IN_COMBAT';
|
||||
|
||||
export class EquipmentDomainError extends HttpException {
|
||||
constructor(
|
||||
public readonly code: EquipmentErrorCode,
|
||||
status: HttpStatus,
|
||||
message: string,
|
||||
) {
|
||||
super({ statusCode: status, code, message }, status);
|
||||
}
|
||||
}
|
||||
|
||||
export function characterItemNotFound(): EquipmentDomainError {
|
||||
return new EquipmentDomainError(
|
||||
'CHARACTER_ITEM_NOT_FOUND',
|
||||
HttpStatus.NOT_FOUND,
|
||||
'This item could not be found.',
|
||||
);
|
||||
}
|
||||
|
||||
export function itemNotOwned(): EquipmentDomainError {
|
||||
return new EquipmentDomainError(
|
||||
'ITEM_NOT_OWNED',
|
||||
HttpStatus.FORBIDDEN,
|
||||
'This item does not belong to the character.',
|
||||
);
|
||||
}
|
||||
|
||||
export function itemNotEquippable(): EquipmentDomainError {
|
||||
return new EquipmentDomainError(
|
||||
'ITEM_NOT_EQUIPPABLE',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'This item cannot be equipped.',
|
||||
);
|
||||
}
|
||||
|
||||
export function itemLevelRequirementNotMet(): EquipmentDomainError {
|
||||
return new EquipmentDomainError(
|
||||
'ITEM_LEVEL_REQUIREMENT_NOT_MET',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"The character does not meet this item's level requirement.",
|
||||
);
|
||||
}
|
||||
|
||||
// Defensive: slot is always derived from the item definition server-side, so
|
||||
// this is unreachable in practice (spec §27 still names it explicitly).
|
||||
export function invalidEquipmentSlot(): EquipmentDomainError {
|
||||
return new EquipmentDomainError(
|
||||
'INVALID_EQUIPMENT_SLOT',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'This item does not target a valid equipment slot.',
|
||||
);
|
||||
}
|
||||
|
||||
export function characterInCombat(): EquipmentDomainError {
|
||||
return new EquipmentDomainError(
|
||||
'CHARACTER_IN_COMBAT',
|
||||
HttpStatus.CONFLICT,
|
||||
'Equipment cannot be changed during an active combat.',
|
||||
);
|
||||
}
|
||||
|
||||
export { characterNotFound } from '../travel/travel.errors';
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
13
apps/api/src/inventory/inventory.controller.ts
Normal file
13
apps/api/src/inventory/inventory.controller.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { InventoryResponseDto, InventoryService } from './inventory.service';
|
||||
|
||||
@Controller('inventory')
|
||||
export class InventoryController {
|
||||
constructor(private readonly inventoryService: InventoryService) {}
|
||||
|
||||
@Get()
|
||||
getInventory(): Promise<InventoryResponseDto> {
|
||||
return this.inventoryService.getInventory(DEMO_CHARACTER_ID);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/inventory/inventory.module.ts
Normal file
13
apps/api/src/inventory/inventory.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { InventoryController } from './inventory.controller';
|
||||
import { InventoryService } from './inventory.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([CharacterItem, CharacterEquipment])],
|
||||
controllers: [InventoryController],
|
||||
providers: [InventoryService],
|
||||
})
|
||||
export class InventoryModule {}
|
||||
97
apps/api/src/inventory/inventory.service.spec.ts
Normal file
97
apps/api/src/inventory/inventory.service.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||
import { ItemRarity } from '../items/item-rarity.enum';
|
||||
import { ItemType } from '../items/item-type.enum';
|
||||
import { InventoryService } from './inventory.service';
|
||||
|
||||
const CHARACTER_ID = 'character-1';
|
||||
|
||||
function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'def-1',
|
||||
quantity: 1,
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
itemDefinition: {
|
||||
key: 'worn-short-sword',
|
||||
name: 'Abgenutztes Kurzschwert',
|
||||
rarity: ItemRarity.COMMON,
|
||||
type: ItemType.WEAPON,
|
||||
equipmentSlot: EquipmentSlot.WEAPON,
|
||||
requiredLevel: 1,
|
||||
weaponDamage: 8,
|
||||
bonusAttack: 0,
|
||||
bonusHp: 0,
|
||||
bonusArmor: 0,
|
||||
iconPath: '/images/items/worn-short-sword.png',
|
||||
},
|
||||
...overrides,
|
||||
} as CharacterItem;
|
||||
}
|
||||
|
||||
describe('InventoryService', () => {
|
||||
it('returns only the current character\'s items with definition data, quantity, and equipped state', async () => {
|
||||
const items = [
|
||||
characterItem({ id: 'item-1', quantity: 1 }),
|
||||
characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }),
|
||||
];
|
||||
const characterItems = {
|
||||
find: jest.fn().mockResolvedValue(items),
|
||||
} as unknown as Repository<CharacterItem>;
|
||||
const equipment = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ characterItemId: 'item-1', slot: EquipmentSlot.WEAPON } as CharacterEquipment,
|
||||
]),
|
||||
} as unknown as Repository<CharacterEquipment>;
|
||||
const service = new InventoryService(characterItems, equipment);
|
||||
|
||||
const result = await service.getInventory(CHARACTER_ID);
|
||||
|
||||
expect(characterItems.find).toHaveBeenCalledWith({
|
||||
where: { characterId: CHARACTER_ID },
|
||||
relations: { itemDefinition: true },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
expect(result.items).toEqual([
|
||||
{
|
||||
id: 'item-1',
|
||||
quantity: 1,
|
||||
equipped: true,
|
||||
item: {
|
||||
key: 'worn-short-sword',
|
||||
name: 'Abgenutztes Kurzschwert',
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
requiredLevel: 1,
|
||||
weaponDamage: 8,
|
||||
bonusAttack: 0,
|
||||
bonusHp: 0,
|
||||
bonusArmor: 0,
|
||||
iconPath: '/images/items/worn-short-sword.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
quantity: 3,
|
||||
equipped: false,
|
||||
item: expect.objectContaining({ key: 'worn-short-sword' }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when the character owns nothing', async () => {
|
||||
const characterItems = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as Repository<CharacterItem>;
|
||||
const equipment = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as Repository<CharacterEquipment>;
|
||||
const service = new InventoryService(characterItems, equipment);
|
||||
|
||||
await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ items: [] });
|
||||
});
|
||||
});
|
||||
71
apps/api/src/inventory/inventory.service.ts
Normal file
71
apps/api/src/inventory/inventory.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||
import { ItemRarity } from '../items/item-rarity.enum';
|
||||
|
||||
export interface InventoryItemDto {
|
||||
id: string;
|
||||
quantity: number;
|
||||
equipped: boolean;
|
||||
item: {
|
||||
key: string;
|
||||
name: string;
|
||||
rarity: ItemRarity;
|
||||
equipmentSlot: EquipmentSlot | null;
|
||||
requiredLevel: number;
|
||||
weaponDamage: number;
|
||||
bonusAttack: number;
|
||||
bonusHp: number;
|
||||
bonusArmor: number;
|
||||
iconPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InventoryResponseDto {
|
||||
items: InventoryItemDto[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
constructor(
|
||||
@InjectRepository(CharacterItem)
|
||||
private readonly characterItems: Repository<CharacterItem>,
|
||||
@InjectRepository(CharacterEquipment)
|
||||
private readonly equipment: Repository<CharacterEquipment>,
|
||||
) {}
|
||||
|
||||
async getInventory(characterId: string): Promise<InventoryResponseDto> {
|
||||
const [items, equipped] = await Promise.all([
|
||||
this.characterItems.find({
|
||||
where: { characterId },
|
||||
relations: { itemDefinition: true },
|
||||
order: { createdAt: 'ASC' },
|
||||
}),
|
||||
this.equipment.find({ where: { characterId } }),
|
||||
]);
|
||||
const equippedIds = new Set(equipped.map((row) => row.characterItemId));
|
||||
|
||||
return {
|
||||
items: items.map((characterItem) => ({
|
||||
id: characterItem.id,
|
||||
quantity: characterItem.quantity,
|
||||
equipped: equippedIds.has(characterItem.id),
|
||||
item: {
|
||||
key: characterItem.itemDefinition.key,
|
||||
name: characterItem.itemDefinition.name,
|
||||
rarity: characterItem.itemDefinition.rarity,
|
||||
equipmentSlot: characterItem.itemDefinition.equipmentSlot,
|
||||
requiredLevel: characterItem.itemDefinition.requiredLevel,
|
||||
weaponDamage: characterItem.itemDefinition.weaponDamage,
|
||||
bonusAttack: characterItem.itemDefinition.bonusAttack,
|
||||
bonusHp: characterItem.itemDefinition.bonusHp,
|
||||
bonusArmor: characterItem.itemDefinition.bonusArmor,
|
||||
iconPath: characterItem.itemDefinition.iconPath,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user