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 { 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 { InventoryModule } from './inventory/inventory.module';
|
||||||
import { TravelModule } from './travel/travel.module';
|
import { TravelModule } from './travel/travel.module';
|
||||||
import { WorldModule } from './world/world.module';
|
import { WorldModule } from './world/world.module';
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ import { WorldModule } from './world/world.module';
|
|||||||
WorldModule,
|
WorldModule,
|
||||||
HuntingModule,
|
HuntingModule,
|
||||||
CombatModule,
|
CombatModule,
|
||||||
|
EquipmentModule,
|
||||||
|
InventoryModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { CharacterCombatStatsService } from './character-combat-stats.service';
|
import { CharacterStatsService } from './character-stats.service';
|
||||||
import { CharactersController } from './characters.controller';
|
import { CharactersController } from './characters.controller';
|
||||||
import { CharactersService } from './characters.service';
|
import { CharactersService } from './characters.service';
|
||||||
import { Character } from './entities/character.entity';
|
import { Character } from './entities/character.entity';
|
||||||
@@ -8,7 +8,7 @@ import { Character } from './entities/character.entity';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Character])],
|
imports: [TypeOrmModule.forFeature([Character])],
|
||||||
controllers: [CharactersController],
|
controllers: [CharactersController],
|
||||||
providers: [CharactersService, CharacterCombatStatsService],
|
providers: [CharactersService, CharacterStatsService],
|
||||||
exports: [CharacterCombatStatsService],
|
exports: [CharacterStatsService],
|
||||||
})
|
})
|
||||||
export class CharactersModule {}
|
export class CharactersModule {}
|
||||||
|
|||||||
@@ -2,11 +2,27 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
|
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
|
||||||
|
import { CharacterStatsService } from './character-stats.service';
|
||||||
import { Character } from './entities/character.entity';
|
import { Character } from './entities/character.entity';
|
||||||
import { CharactersService } from './characters.service';
|
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', () => {
|
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 = {
|
const repository = {
|
||||||
findOne: jest.fn().mockResolvedValue({
|
findOne: jest.fn().mockResolvedValue({
|
||||||
id: DEMO_CHARACTER_ID,
|
id: DEMO_CHARACTER_ID,
|
||||||
@@ -20,11 +36,12 @@ describe('CharactersService', () => {
|
|||||||
currentLocation: {
|
currentLocation: {
|
||||||
id: SOUTH_GATE_ID,
|
id: SOUTH_GATE_ID,
|
||||||
key: 'south-gate',
|
key: 'south-gate',
|
||||||
name: 'S\u00fcdtor von Graufurt',
|
name: 'Südtor von Graufurt',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
} as unknown as Repository<Character>;
|
} 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({
|
await expect(service.getDemoCharacter()).resolves.toEqual({
|
||||||
id: DEMO_CHARACTER_ID,
|
id: DEMO_CHARACTER_ID,
|
||||||
@@ -33,18 +50,17 @@ describe('CharactersService', () => {
|
|||||||
experience: 0,
|
experience: 0,
|
||||||
silver: 0,
|
silver: 0,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
maxHp: 100,
|
maxHp: 115,
|
||||||
attack: 6,
|
attack: 7,
|
||||||
currentLocation: {
|
currentLocation: {
|
||||||
id: SOUTH_GATE_ID,
|
id: SOUTH_GATE_ID,
|
||||||
key: 'south-gate',
|
key: 'south-gate',
|
||||||
name: 'S\u00fcdtor von Graufurt',
|
name: 'Südtor von Graufurt',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(repository.findOne).toHaveBeenCalledWith({
|
expect(characterStats.calculate).toHaveBeenCalledWith(
|
||||||
where: { id: DEMO_CHARACTER_ID },
|
expect.objectContaining({ id: DEMO_CHARACTER_ID }),
|
||||||
relations: { currentLocation: true },
|
);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes the persisted silver so the HUD never has to guess', async () => {
|
it('exposes the persisted silver so the HUD never has to guess', async () => {
|
||||||
@@ -65,7 +81,7 @@ describe('CharactersService', () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
} as unknown as Repository<Character>;
|
} as unknown as Repository<Character>;
|
||||||
const service = new CharactersService(repository);
|
const service = new CharactersService(repository, fakeCharacterStats());
|
||||||
|
|
||||||
await expect(service.getDemoCharacter()).resolves.toEqual(
|
await expect(service.getDemoCharacter()).resolves.toEqual(
|
||||||
expect.objectContaining({ experience: 24, silver: 18 }),
|
expect.objectContaining({ experience: 24, silver: 18 }),
|
||||||
@@ -76,7 +92,7 @@ describe('CharactersService', () => {
|
|||||||
const repository = {
|
const repository = {
|
||||||
findOne: jest.fn().mockResolvedValue(null),
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
} as unknown as Repository<Character>;
|
} as unknown as Repository<Character>;
|
||||||
const service = new CharactersService(repository);
|
const service = new CharactersService(repository, fakeCharacterStats());
|
||||||
|
|
||||||
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
|
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
|
import { CharacterStatsService } from './character-stats.service';
|
||||||
import { Character } from './entities/character.entity';
|
import { Character } from './entities/character.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -9,6 +10,7 @@ export class CharactersService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Character)
|
@InjectRepository(Character)
|
||||||
private readonly characters: Repository<Character>,
|
private readonly characters: Repository<Character>,
|
||||||
|
private readonly characterStats: CharacterStatsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getDemoCharacter() {
|
async getDemoCharacter() {
|
||||||
@@ -21,6 +23,8 @@ export class CharactersService {
|
|||||||
throw new NotFoundException('Demo character has not been seeded');
|
throw new NotFoundException('Demo character has not been seeded');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stats = await this.characterStats.calculate(character);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: character.id,
|
id: character.id,
|
||||||
name: character.name,
|
name: character.name,
|
||||||
@@ -28,8 +32,8 @@ export class CharactersService {
|
|||||||
experience: character.experience,
|
experience: character.experience,
|
||||||
silver: character.silver,
|
silver: character.silver,
|
||||||
currentHp: character.currentHp,
|
currentHp: character.currentHp,
|
||||||
maxHp: character.baseHp,
|
maxHp: stats.maxHp,
|
||||||
attack: character.baseAttack,
|
attack: stats.attack,
|
||||||
currentLocation: {
|
currentLocation: {
|
||||||
id: character.currentLocation.id,
|
id: character.currentLocation.id,
|
||||||
key: character.currentLocation.key,
|
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 { 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 { Character } from '../characters/entities/character.entity';
|
||||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.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(
|
function fakeTravelService(
|
||||||
status: 'IDLE' | 'TRAVELLING' = 'IDLE',
|
status: 'IDLE' | 'TRAVELLING' = 'IDLE',
|
||||||
): TravelService {
|
): TravelService {
|
||||||
@@ -271,7 +284,7 @@ function createService(
|
|||||||
const dataSource = new FakeDataSource(state);
|
const dataSource = new FakeDataSource(state);
|
||||||
const travelService = options.travelService ?? fakeTravelService();
|
const travelService = options.travelService ?? fakeTravelService();
|
||||||
const combatEngine = new CombatEngineService();
|
const combatEngine = new CombatEngineService();
|
||||||
const characterCombatStats = new CharacterCombatStatsService();
|
const characterCombatStats = fakeCharacterStats();
|
||||||
const service = new CombatService(
|
const service = new CombatService(
|
||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
travelService,
|
travelService,
|
||||||
@@ -785,7 +798,7 @@ describe('CombatService', () => {
|
|||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
fakeTravelService(),
|
fakeTravelService(),
|
||||||
new CombatEngineService(),
|
new CombatEngineService(),
|
||||||
new CharacterCombatStatsService(),
|
fakeCharacterStats(),
|
||||||
rewards,
|
rewards,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -833,7 +846,7 @@ describe('CombatService', () => {
|
|||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
fakeTravelService(),
|
fakeTravelService(),
|
||||||
new CombatEngineService(),
|
new CombatEngineService(),
|
||||||
new CharacterCombatStatsService(),
|
fakeCharacterStats(),
|
||||||
rewards,
|
rewards,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -890,7 +903,7 @@ describe('CombatService', () => {
|
|||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
fakeTravelService(),
|
fakeTravelService(),
|
||||||
new CombatEngineService(),
|
new CombatEngineService(),
|
||||||
new CharacterCombatStatsService(),
|
fakeCharacterStats(),
|
||||||
rewards,
|
rewards,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -927,7 +940,7 @@ describe('CombatService', () => {
|
|||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
fakeTravelService(),
|
fakeTravelService(),
|
||||||
new CombatEngineService(),
|
new CombatEngineService(),
|
||||||
new CharacterCombatStatsService(),
|
fakeCharacterStats(),
|
||||||
fakeRewardService({
|
fakeRewardService({
|
||||||
// Genuinely write XP/silver through the transaction's manager
|
// Genuinely write XP/silver through the transaction's manager
|
||||||
// before failing, so the assertions below prove the rollback
|
// before failing, so the assertions below prove the rollback
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
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 { Character } from '../characters/entities/character.entity';
|
||||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||||
@@ -69,7 +69,7 @@ export class CombatService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly travelService: TravelService,
|
private readonly travelService: TravelService,
|
||||||
private readonly combatEngine: CombatEngineService,
|
private readonly combatEngine: CombatEngineService,
|
||||||
private readonly characterCombatStats: CharacterCombatStatsService,
|
private readonly characterStats: CharacterStatsService,
|
||||||
private readonly combatRewards: CombatRewardService,
|
private readonly combatRewards: CombatRewardService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ export class CombatService {
|
|||||||
throw invalidHuntEncounter();
|
throw invalidHuntEncounter();
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerStats = this.characterCombatStats.getStats(character);
|
const playerStats = await this.characterStats.calculate(character, manager);
|
||||||
|
|
||||||
const combat = combats.create({
|
const combat = combats.create({
|
||||||
characterId,
|
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 { DataSource } from 'typeorm';
|
||||||
import { Character } from '../../characters/entities/character.entity';
|
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 { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||||
import { LootTable } from '../../loot/entities/loot-table.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 { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||||
import { LocationDefinition } from '../../world/entities/location-definition.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 { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants';
|
||||||
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||||
|
|
||||||
@@ -76,6 +79,8 @@ function createDataSource(
|
|||||||
itemRepository: InMemoryRepository = new InMemoryRepository(),
|
itemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
|
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
|
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
|
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
|
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
): DataSource {
|
): DataSource {
|
||||||
return {
|
return {
|
||||||
getRepository: jest.fn((entity: unknown) => {
|
getRepository: jest.fn((entity: unknown) => {
|
||||||
@@ -87,6 +92,8 @@ function createDataSource(
|
|||||||
if (entity === ItemDefinition) return itemRepository;
|
if (entity === ItemDefinition) return itemRepository;
|
||||||
if (entity === LootTable) return lootTableRepository;
|
if (entity === LootTable) return lootTableRepository;
|
||||||
if (entity === LootTableEntry) return lootEntryRepository;
|
if (entity === LootTableEntry) return lootEntryRepository;
|
||||||
|
if (entity === CharacterItem) return characterItemRepository;
|
||||||
|
if (entity === CharacterEquipment) return characterEquipmentRepository;
|
||||||
|
|
||||||
throw new Error('Unexpected repository');
|
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 { 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 { 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 { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||||
import { LootTable } from '../../loot/entities/loot-table.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 { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
|
||||||
import {
|
import {
|
||||||
ASH_RAT_LOOT_TABLE_ID,
|
ASH_RAT_LOOT_TABLE_ID,
|
||||||
|
ITEM_IDS,
|
||||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
} from './item.constants';
|
} from './item.constants';
|
||||||
import {
|
import {
|
||||||
@@ -238,4 +246,42 @@ export async function seedVisibleVerticalSlice(
|
|||||||
currentLocationId: southGateId,
|
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_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,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,13 @@ export const routes: Routes = [
|
|||||||
(module) => module.CombatPageComponent,
|
(module) => module.CombatPageComponent,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'inventory',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/inventory/inventory-page.component').then(
|
||||||
|
(module) => module.InventoryPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '**', redirectTo: 'location' },
|
{ path: '**', redirectTo: 'location' },
|
||||||
|
|||||||
@@ -57,7 +57,14 @@ describe('App', () => {
|
|||||||
expect(huntButton?.getAttribute('aria-current')).toBeNull();
|
expect(huntButton?.getAttribute('aria-current')).toBeNull();
|
||||||
expect(huntButton?.getAttribute('aria-label')).toBe('Jagd');
|
expect(huntButton?.getAttribute('aria-label')).toBe('Jagd');
|
||||||
|
|
||||||
for (const destination of ['quests', 'inventory', 'character']) {
|
const inventoryButton = element.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-navigation="inventory"]',
|
||||||
|
);
|
||||||
|
expect(inventoryButton).not.toBeNull();
|
||||||
|
expect(inventoryButton?.disabled).toBe(false);
|
||||||
|
expect(inventoryButton?.getAttribute('aria-label')).toBe('Inventar');
|
||||||
|
|
||||||
|
for (const destination of ['quests', 'character']) {
|
||||||
expect(
|
expect(
|
||||||
element.querySelector<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
|
element.querySelector<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|||||||
@@ -207,3 +207,58 @@ export interface CombatReward {
|
|||||||
silver: number;
|
silver: number;
|
||||||
items: CombatRewardItem[];
|
items: CombatRewardItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EquipmentSlot =
|
||||||
|
| 'WEAPON'
|
||||||
|
| 'HEAD'
|
||||||
|
| 'CHEST'
|
||||||
|
| 'HANDS'
|
||||||
|
| 'LEGS'
|
||||||
|
| 'FEET'
|
||||||
|
| 'AMULET';
|
||||||
|
|
||||||
|
export interface InventoryItem {
|
||||||
|
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 InventoryResponse {
|
||||||
|
items: InventoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EquipmentSlotItem {
|
||||||
|
characterItemId: string;
|
||||||
|
item: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
rarity: ItemRarity;
|
||||||
|
iconPath: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EquipmentSlots = Record<EquipmentSlot, EquipmentSlotItem | null>;
|
||||||
|
|
||||||
|
export interface EquipmentStats {
|
||||||
|
maxHp: number;
|
||||||
|
attack: number;
|
||||||
|
weaponDamage: number;
|
||||||
|
armor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EquipmentResponse {
|
||||||
|
slots: EquipmentSlots;
|
||||||
|
stats: EquipmentStats;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
CombatAction,
|
CombatAction,
|
||||||
CurrentLocationResponse,
|
CurrentLocationResponse,
|
||||||
CurrentTravel,
|
CurrentTravel,
|
||||||
|
EquipmentResponse,
|
||||||
HuntResult,
|
HuntResult,
|
||||||
|
InventoryResponse,
|
||||||
LocationInteractionResult,
|
LocationInteractionResult,
|
||||||
} from './game-api.models';
|
} from './game-api.models';
|
||||||
|
|
||||||
@@ -65,4 +67,16 @@ export class GameApiService {
|
|||||||
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
|
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
|
||||||
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
|
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInventory(): Observable<InventoryResponse> {
|
||||||
|
return this.http.get<InventoryResponse>('/api/inventory');
|
||||||
|
}
|
||||||
|
|
||||||
|
getEquipment(): Observable<EquipmentResponse> {
|
||||||
|
return this.http.get<EquipmentResponse>('/api/equipment');
|
||||||
|
}
|
||||||
|
|
||||||
|
equipItem(characterItemId: string): Observable<EquipmentResponse> {
|
||||||
|
return this.http.post<EquipmentResponse>('/api/equipment', { characterItemId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,14 @@
|
|||||||
>
|
>
|
||||||
Zum Ort
|
Zum Ort
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="outcome__button outcome__button--secondary"
|
||||||
|
data-combat-to-inventory
|
||||||
|
(click)="goToInventory()"
|
||||||
|
>
|
||||||
|
Inventar öffnen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else if (combat.status === 'LOST') {
|
} @else if (combat.status === 'LOST') {
|
||||||
|
|||||||
@@ -492,6 +492,7 @@
|
|||||||
|
|
||||||
.outcome__buttons {
|
.outcome__buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: var(--ar-space-3);
|
gap: var(--ar-space-3);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,6 +294,15 @@ describe('CombatPageComponent', () => {
|
|||||||
expect(router.navigate).toHaveBeenCalledWith(['/location']);
|
expect(router.navigate).toHaveBeenCalledWith(['/location']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('navigates to /inventory from the victory screen', async () => {
|
||||||
|
const fixture = await setup({ ...activeCombat, status: 'WON' });
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-to-inventory]')?.click();
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/inventory']);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows an error and retries loading the combat', async () => {
|
it('shows an error and retries loading the combat', async () => {
|
||||||
const fixture = await setup(null);
|
const fixture = await setup(null);
|
||||||
combatStore.error.set('Dieser Kampf wurde nicht gefunden.');
|
combatStore.error.set('Dieser Kampf wurde nicht gefunden.');
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ export class CombatPageComponent implements OnInit {
|
|||||||
void this.router.navigate(['/location']);
|
void this.router.navigate(['/location']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected goToInventory(): void {
|
||||||
|
void this.router.navigate(['/inventory']);
|
||||||
|
}
|
||||||
|
|
||||||
protected monsterSprite(monsterKey: string, artworkPath: string): string {
|
protected monsterSprite(monsterKey: string, artworkPath: string): string {
|
||||||
return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
|
return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@if (item(); as item) {
|
||||||
|
<article class="inventory-detail" aria-label="Gegenstandsdetails">
|
||||||
|
<div class="inventory-detail__header">
|
||||||
|
<img class="inventory-detail__icon" [src]="item.item.iconPath" [alt]="item.item.name" />
|
||||||
|
<div>
|
||||||
|
<h2 class="inventory-detail__name" data-detail-name>{{ item.item.name }}</h2>
|
||||||
|
<p class="inventory-detail__rarity" data-detail-rarity>{{ rarityLabel() }}</p>
|
||||||
|
@if (slotLabel(); as slot) {
|
||||||
|
<p class="inventory-detail__meta">{{ slot }} · Stufe {{ item.item.requiredLevel }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (statRows().length) {
|
||||||
|
<dl class="inventory-detail__stats" data-detail-stats>
|
||||||
|
@for (row of statRows(); track row.label) {
|
||||||
|
<div class="inventory-detail__stat">
|
||||||
|
<dt>{{ row.label }}</dt>
|
||||||
|
<dd>
|
||||||
|
{{ row.value }}
|
||||||
|
@if (row.diff !== null && row.diff !== 0) {
|
||||||
|
<span
|
||||||
|
class="inventory-detail__diff"
|
||||||
|
[class.inventory-detail__diff--positive]="row.diff > 0"
|
||||||
|
[class.inventory-detail__diff--negative]="row.diff < 0"
|
||||||
|
>
|
||||||
|
({{ row.diff > 0 ? '+' : '' }}{{ row.diff }})
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="inventory-detail__actions">
|
||||||
|
@if (item.equipped) {
|
||||||
|
<span class="inventory-detail__equipped" data-detail-equipped>Ausgerüstet</span>
|
||||||
|
} @else if (!isEquippable()) {
|
||||||
|
<span class="inventory-detail__note">Nicht ausrüstbar</span>
|
||||||
|
} @else if (!meetsLevelRequirement()) {
|
||||||
|
<button type="button" class="inventory-detail__equip" data-detail-equip disabled>
|
||||||
|
Benötigt Stufe {{ item.item.requiredLevel }}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inventory-detail__equip"
|
||||||
|
data-detail-equip
|
||||||
|
[disabled]="busy()"
|
||||||
|
(click)="onEquip()"
|
||||||
|
>
|
||||||
|
Ausrüsten
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
} @else {
|
||||||
|
<p class="inventory-detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail {
|
||||||
|
padding: var(--ar-space-4);
|
||||||
|
border: 1px solid var(--ar-border-highlight);
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
background:
|
||||||
|
linear-gradient(125deg, rgb(255 255 255 / 0.045), transparent 42%), rgb(12 15 17 / 0.96);
|
||||||
|
box-shadow: var(--ar-shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__header {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
align-items: center;
|
||||||
|
margin-block-end: var(--ar-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__icon {
|
||||||
|
inline-size: 4rem;
|
||||||
|
block-size: 4rem;
|
||||||
|
padding: var(--ar-space-1);
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
background: linear-gradient(180deg, #1b1f22, #0d1012);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__name {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-text);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__rarity {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__meta {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__stats {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-2);
|
||||||
|
margin: 0 0 var(--ar-space-3);
|
||||||
|
padding-block: var(--ar-space-2);
|
||||||
|
border-block: 1px solid rgb(155 122 66 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__stat dt {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__stat dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__diff--positive {
|
||||||
|
color: var(--ar-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__diff--negative {
|
||||||
|
color: var(--ar-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__equipped {
|
||||||
|
color: var(--ar-gold);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__note {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__equip {
|
||||||
|
inline-size: 100%;
|
||||||
|
padding: var(--ar-space-2) var(--ar-space-4);
|
||||||
|
border: 1px solid var(--ar-border-highlight);
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
color: var(--ar-text);
|
||||||
|
background: linear-gradient(180deg, #263b4b, #17232d);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__equip:hover:not(:disabled) {
|
||||||
|
border-color: #d6b26b;
|
||||||
|
background: linear-gradient(180deg, #315067, #1a2c3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__equip:disabled {
|
||||||
|
border-color: var(--ar-border);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
background: #1a1c1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-detail__empty {
|
||||||
|
padding: var(--ar-space-4);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { InventoryItem } from '../../core/api/game-api.models';
|
||||||
|
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
|
||||||
|
|
||||||
|
const wornSword: InventoryItem = {
|
||||||
|
id: 'item-sword',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const banditBlade: InventoryItem = {
|
||||||
|
id: 'item-blade',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: false,
|
||||||
|
item: {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup(overrides: {
|
||||||
|
item?: InventoryItem | null;
|
||||||
|
equippedItemInSlot?: InventoryItem | null;
|
||||||
|
characterLevel?: number;
|
||||||
|
busy?: boolean;
|
||||||
|
}) {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({ imports: [InventoryDetailPanelComponent] }).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(InventoryDetailPanelComponent);
|
||||||
|
fixture.componentRef.setInput('item', overrides.item ?? null);
|
||||||
|
fixture.componentRef.setInput('equippedItemInSlot', overrides.equippedItemInSlot ?? null);
|
||||||
|
fixture.componentRef.setInput('characterLevel', overrides.characterLevel ?? 1);
|
||||||
|
fixture.componentRef.setInput('busy', overrides.busy ?? false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InventoryDetailPanelComponent', () => {
|
||||||
|
it('shows a placeholder when nothing is selected', async () => {
|
||||||
|
const fixture = await setup({ item: null });
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('[data-detail-empty]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Ausgerüstet for the currently equipped item, with no equip button', async () => {
|
||||||
|
const fixture = await setup({ item: wornSword });
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-detail-equipped]')?.textContent).toContain('Ausgerüstet');
|
||||||
|
expect(element.querySelector('[data-detail-equip]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the stat comparison against the equipped item in the same slot', async () => {
|
||||||
|
const fixture = await setup({ item: banditBlade, equippedItemInSlot: wornSword });
|
||||||
|
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-stats]')?.textContent ?? '';
|
||||||
|
|
||||||
|
expect(text).toContain('11');
|
||||||
|
expect(text).toContain('+3');
|
||||||
|
expect(text).toContain('+1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a disabled Benötigt Stufe X button when the level requirement is not met', async () => {
|
||||||
|
const fixture = await setup({ item: { ...banditBlade, item: { ...banditBlade.item, requiredLevel: 5 } }, characterLevel: 1 });
|
||||||
|
const button = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]');
|
||||||
|
|
||||||
|
expect(button?.textContent).toContain('Benötigt Stufe 5');
|
||||||
|
expect(button?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits equip with the characterItemId when Ausrüsten is clicked', async () => {
|
||||||
|
const fixture = await setup({ item: banditBlade });
|
||||||
|
const emitted: string[] = [];
|
||||||
|
fixture.componentInstance.equip.subscribe((id: string) => emitted.push(id));
|
||||||
|
|
||||||
|
(fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]')?.click();
|
||||||
|
|
||||||
|
expect(emitted).toEqual(['item-blade']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the equip button while busy', async () => {
|
||||||
|
const fixture = await setup({ item: banditBlade, busy: true });
|
||||||
|
const button = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]');
|
||||||
|
|
||||||
|
expect(button?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models';
|
||||||
|
import { RARITY_LABELS } from '../../shared/item-card/item-card.component';
|
||||||
|
|
||||||
|
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
|
||||||
|
WEAPON: 'Waffe',
|
||||||
|
HEAD: 'Kopf',
|
||||||
|
CHEST: 'Brust',
|
||||||
|
HANDS: 'Handschuhe',
|
||||||
|
LEGS: 'Beine',
|
||||||
|
FEET: 'Stiefel',
|
||||||
|
AMULET: 'Amulett',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatRow {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
diff: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatKey = 'weaponDamage' | 'bonusAttack' | 'bonusHp' | 'bonusArmor';
|
||||||
|
const STAT_LABELS: ReadonlyArray<{ label: string; key: StatKey }> = [
|
||||||
|
{ label: 'Waffenschaden', key: 'weaponDamage' },
|
||||||
|
{ label: 'Angriff', key: 'bonusAttack' },
|
||||||
|
{ label: 'Leben', key: 'bonusHp' },
|
||||||
|
{ label: 'Rüstung', key: 'bonusArmor' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Selected-item details and equip comparison (spec §32–37). */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-inventory-detail-panel',
|
||||||
|
templateUrl: './inventory-detail-panel.component.html',
|
||||||
|
styleUrl: './inventory-detail-panel.component.scss',
|
||||||
|
})
|
||||||
|
export class InventoryDetailPanelComponent {
|
||||||
|
readonly item = input<InventoryItem | null>(null);
|
||||||
|
readonly equippedItemInSlot = input<InventoryItem | null>(null);
|
||||||
|
readonly characterLevel = input(1);
|
||||||
|
readonly busy = input(false);
|
||||||
|
readonly equip = output<string>();
|
||||||
|
|
||||||
|
protected readonly rarityLabel = computed(() => {
|
||||||
|
const item = this.item();
|
||||||
|
return item ? RARITY_LABELS[item.item.rarity] : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly slotLabel = computed(() => {
|
||||||
|
const slot = this.item()?.item.equipmentSlot;
|
||||||
|
return slot ? SLOT_LABELS[slot] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly statRows = computed<StatRow[]>(() => {
|
||||||
|
const item = this.item();
|
||||||
|
if (!item) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const compareTo = this.equippedItemInSlot();
|
||||||
|
const comparable = compareTo && compareTo.id !== item.id ? compareTo.item : null;
|
||||||
|
|
||||||
|
return STAT_LABELS.map(({ label, key }) => ({
|
||||||
|
label,
|
||||||
|
value: item.item[key],
|
||||||
|
diff: comparable ? item.item[key] - comparable[key] : null,
|
||||||
|
})).filter((row) => row.value > 0 || (row.diff ?? 0) !== 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly isEquippable = computed(() => !!this.item()?.item.equipmentSlot);
|
||||||
|
|
||||||
|
protected readonly meetsLevelRequirement = computed(() => {
|
||||||
|
const item = this.item();
|
||||||
|
return item ? item.item.requiredLevel <= this.characterLevel() : true;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected onEquip(): void {
|
||||||
|
const item = this.item();
|
||||||
|
if (item) {
|
||||||
|
this.equip.emit(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<!-- apps/web/src/app/features/inventory/inventory-page.component.html -->
|
||||||
|
<section class="inventory-page" aria-label="Inventar">
|
||||||
|
@if (inventoryStore.loading() && !inventoryStore.inventory()) {
|
||||||
|
<p class="inventory-page__notice" role="status">Inventar wird geladen…</p>
|
||||||
|
} @else if (inventoryStore.inventory(); as inventory) {
|
||||||
|
<section class="inventory-page__grid" aria-label="Gegenstände">
|
||||||
|
@for (entry of inventory.items; track entry.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inventory-page__slot"
|
||||||
|
[class.inventory-page__slot--selected]="inventoryStore.selectedItemId() === entry.id"
|
||||||
|
[attr.aria-pressed]="inventoryStore.selectedItemId() === entry.id"
|
||||||
|
(click)="selectItem(entry.id)"
|
||||||
|
>
|
||||||
|
<app-item-card [item]="entry.item" [quantity]="entry.quantity" />
|
||||||
|
@if (entry.equipped) {
|
||||||
|
<span class="inventory-page__equipped-badge" data-slot-equipped>Ausgerüstet</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
} @empty {
|
||||||
|
<p class="inventory-page__empty" data-inventory-empty>Noch keine Gegenstände gefunden.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="inventory-page__side" aria-label="Details und Ausrüstung">
|
||||||
|
<app-inventory-detail-panel
|
||||||
|
[item]="inventoryStore.selectedItem()"
|
||||||
|
[equippedItemInSlot]="equippedItemInSelectedSlot()"
|
||||||
|
[characterLevel]="characterLevel()"
|
||||||
|
[busy]="inventoryStore.equipping()"
|
||||||
|
(equip)="equipSelected($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (inventoryStore.equipment(); as equipment) {
|
||||||
|
<section class="inventory-page__equipment" aria-label="Ausrüstung">
|
||||||
|
<h3>Ausrüstung</h3>
|
||||||
|
<ul class="inventory-page__equipment-list">
|
||||||
|
@for (slot of slotOrder; track slot) {
|
||||||
|
<li>
|
||||||
|
<span class="inventory-page__equipment-slot-label">{{ slotLabels[slot] }}</span>
|
||||||
|
<span class="inventory-page__equipment-slot-value">
|
||||||
|
{{ equipment.slots[slot]?.item?.name ?? 'Leer' }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<dl class="inventory-page__stats" data-inventory-stats>
|
||||||
|
<div><dt>Leben</dt><dd>{{ equipment.stats.maxHp }}</dd></div>
|
||||||
|
<div><dt>Angriff</dt><dd>{{ equipment.stats.attack }}</dd></div>
|
||||||
|
<div><dt>Waffenschaden</dt><dd>{{ equipment.stats.weaponDamage }}</dd></div>
|
||||||
|
<div><dt>Rüstung</dt><dd>{{ equipment.stats.armor }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (inventoryStore.error(); as error) {
|
||||||
|
<section class="inventory-page__notice inventory-page__notice--error" role="alert">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button type="button" data-inventory-retry (click)="retry()">Erneut versuchen</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// apps/web/src/app/features/inventory/inventory-page.component.scss
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 20rem;
|
||||||
|
gap: var(--ar-space-5);
|
||||||
|
align-items: start;
|
||||||
|
padding: var(--ar-space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__slot {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--ar-space-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__slot--selected {
|
||||||
|
border-color: var(--ar-border-highlight);
|
||||||
|
background: rgb(155 122 66 / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__equipped-badge {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: 0.1rem;
|
||||||
|
inset-inline-start: 50%;
|
||||||
|
translate: -50% 0;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
border: 1px solid var(--ar-border-highlight);
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
color: var(--ar-gold);
|
||||||
|
background: rgb(9 11 13 / 0.9);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: var(--ar-space-4);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__side {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__equipment {
|
||||||
|
padding: var(--ar-space-4);
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
background: var(--ar-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__equipment h3 {
|
||||||
|
margin: 0 0 var(--ar-space-2);
|
||||||
|
color: var(--ar-gold);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__equipment-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-1);
|
||||||
|
margin: 0 0 var(--ar-space-3);
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__equipment-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-block: var(--ar-space-1);
|
||||||
|
border-block-end: 1px solid rgb(85 74 57 / 0.4);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__equipment-slot-label {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__stats {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-1);
|
||||||
|
margin: 0;
|
||||||
|
padding-block-start: var(--ar-space-2);
|
||||||
|
border-block-start: 1px solid rgb(155 122 66 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__stats div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__stats dt {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__stats dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__notice {
|
||||||
|
padding: var(--ar-space-4);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__notice--error {
|
||||||
|
color: var(--ar-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width < 960px) {
|
||||||
|
.inventory-page {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { CharacterResponse, EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models';
|
||||||
|
import { WorldStore } from '../world/world.store';
|
||||||
|
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
|
||||||
|
import { InventoryPageComponent } from './inventory-page.component';
|
||||||
|
import { InventoryStore } from './inventory.store';
|
||||||
|
|
||||||
|
const inventory: InventoryResponse = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item-sword',
|
||||||
|
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-blade',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: false,
|
||||||
|
item: {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const equipment: EquipmentResponse = {
|
||||||
|
slots: {
|
||||||
|
WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } },
|
||||||
|
HEAD: null,
|
||||||
|
CHEST: null,
|
||||||
|
HANDS: null,
|
||||||
|
LEGS: null,
|
||||||
|
FEET: null,
|
||||||
|
AMULET: null,
|
||||||
|
},
|
||||||
|
stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const character: CharacterResponse = {
|
||||||
|
id: 'character-1',
|
||||||
|
name: 'Aric Duskwalker',
|
||||||
|
level: 1,
|
||||||
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
|
currentHp: 100,
|
||||||
|
maxHp: 100,
|
||||||
|
attack: 6,
|
||||||
|
currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SetupOptions {
|
||||||
|
inventoryData?: InventoryResponse;
|
||||||
|
selectedItemId?: string | null;
|
||||||
|
selectedItem?: InventoryItem | null;
|
||||||
|
character?: CharacterResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(options: SetupOptions = {}) {
|
||||||
|
const inventoryStore = {
|
||||||
|
inventory: signal(options.inventoryData ?? inventory),
|
||||||
|
equipment: signal(equipment),
|
||||||
|
selectedItemId: signal<string | null>(options.selectedItemId ?? null),
|
||||||
|
loading: signal(false),
|
||||||
|
equipping: signal(false),
|
||||||
|
error: signal<string | null>(null),
|
||||||
|
load: vi.fn(() => Promise.resolve()),
|
||||||
|
selectItem: vi.fn(),
|
||||||
|
selectedItem: vi.fn(() => options.selectedItem ?? null),
|
||||||
|
equip: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
const worldStore = {
|
||||||
|
character: signal(options.character === undefined ? character : options.character),
|
||||||
|
load: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [InventoryPageComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: InventoryStore, useValue: inventoryStore },
|
||||||
|
{ provide: WorldStore, useValue: worldStore },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(InventoryPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return { fixture, inventoryStore, worldStore };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InventoryPageComponent', () => {
|
||||||
|
it('loads the inventory on init', async () => {
|
||||||
|
const { inventoryStore } = await setup();
|
||||||
|
expect(inventoryStore.load).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the world state on init when no character has been loaded yet (direct navigation/hard refresh)', async () => {
|
||||||
|
const { worldStore } = await setup({ character: null });
|
||||||
|
expect(worldStore.load).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call world load again when a character is already present', async () => {
|
||||||
|
const { worldStore } = await setup();
|
||||||
|
expect(worldStore.load).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders one tile per owned item', async () => {
|
||||||
|
const { fixture } = await setup();
|
||||||
|
const tiles = (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot');
|
||||||
|
expect(tiles.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the equipped item with a badge', async () => {
|
||||||
|
const { fixture } = await setup();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('[data-slot-equipped]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects an item when its tile is clicked', async () => {
|
||||||
|
const { fixture, inventoryStore } = await setup();
|
||||||
|
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>('.inventory-page__slot')[1].click();
|
||||||
|
|
||||||
|
expect(inventoryStore.selectItem).toHaveBeenCalledWith('item-blade');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the equipment overview with all seven slots and empty ones as Leer', async () => {
|
||||||
|
const { fixture } = await setup();
|
||||||
|
const text = (fixture.nativeElement as HTMLElement).querySelector('.inventory-page__equipment-list')?.textContent ?? '';
|
||||||
|
|
||||||
|
expect(text).toContain('Waffe');
|
||||||
|
expect(text).toContain('Abgenutztes Kurzschwert');
|
||||||
|
expect(text).toContain('Kopf');
|
||||||
|
expect(text).toContain('Leer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the effective stats summary from the equipment response', async () => {
|
||||||
|
const { fixture } = await setup();
|
||||||
|
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-inventory-stats]')?.textContent ?? '';
|
||||||
|
|
||||||
|
expect(text).toContain('100');
|
||||||
|
expect(text).toContain('6');
|
||||||
|
expect(text).toContain('8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the equipped item from the SAME slot as the selection — not just any equipped item — to the detail panel', async () => {
|
||||||
|
// item-helm (HEAD, equipped) is placed before item-sword (WEAPON, equipped) so that a
|
||||||
|
// regression which drops the equipmentSlot match (i.e. "find the first equipped item")
|
||||||
|
// would surface item-helm instead of item-sword, and this test would fail.
|
||||||
|
const threeItemInventory: InventoryResponse = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item-helm',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: true,
|
||||||
|
item: {
|
||||||
|
key: 'iron-helm',
|
||||||
|
name: 'Eiserner Helm',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'HEAD',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 0,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 5,
|
||||||
|
bonusArmor: 2,
|
||||||
|
iconPath: '/images/items/iron-helm.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inventory.items[0], // item-sword, WEAPON, equipped
|
||||||
|
inventory.items[1], // item-blade, WEAPON, not equipped — this is the selection
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { fixture } = await setup({
|
||||||
|
inventoryData: threeItemInventory,
|
||||||
|
selectedItemId: 'item-blade',
|
||||||
|
selectedItem: threeItemInventory.items[2],
|
||||||
|
});
|
||||||
|
|
||||||
|
const panel = fixture.debugElement.query(By.directive(InventoryDetailPanelComponent))
|
||||||
|
.componentInstance as InventoryDetailPanelComponent;
|
||||||
|
|
||||||
|
expect(panel.equippedItemInSlot()?.id).toBe('item-sword');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Component, OnInit, computed, inject } from '@angular/core';
|
||||||
|
import type { EquipmentSlot } from '../../core/api/game-api.models';
|
||||||
|
import { ItemCardComponent } from '../../shared/item-card/item-card.component';
|
||||||
|
import { WorldStore } from '../world/world.store';
|
||||||
|
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
|
||||||
|
import { InventoryStore } from './inventory.store';
|
||||||
|
|
||||||
|
const SLOT_ORDER: readonly EquipmentSlot[] = [
|
||||||
|
'WEAPON',
|
||||||
|
'HEAD',
|
||||||
|
'CHEST',
|
||||||
|
'HANDS',
|
||||||
|
'LEGS',
|
||||||
|
'FEET',
|
||||||
|
'AMULET',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
|
||||||
|
WEAPON: 'Waffe',
|
||||||
|
HEAD: 'Kopf',
|
||||||
|
CHEST: 'Brust',
|
||||||
|
HANDS: 'Handschuhe',
|
||||||
|
LEGS: 'Beine',
|
||||||
|
FEET: 'Stiefel',
|
||||||
|
AMULET: 'Amulett',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-inventory-page',
|
||||||
|
imports: [ItemCardComponent, InventoryDetailPanelComponent],
|
||||||
|
templateUrl: './inventory-page.component.html',
|
||||||
|
styleUrl: './inventory-page.component.scss',
|
||||||
|
})
|
||||||
|
export class InventoryPageComponent implements OnInit {
|
||||||
|
protected readonly inventoryStore = inject(InventoryStore);
|
||||||
|
private readonly worldStore = inject(WorldStore);
|
||||||
|
protected readonly slotOrder = SLOT_ORDER;
|
||||||
|
protected readonly slotLabels = SLOT_LABELS;
|
||||||
|
|
||||||
|
protected readonly characterLevel = computed(() => this.worldStore.character()?.level ?? 1);
|
||||||
|
|
||||||
|
protected readonly equippedItemInSelectedSlot = computed(() => {
|
||||||
|
const selected = this.inventoryStore.selectedItem();
|
||||||
|
if (!selected?.item.equipmentSlot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.inventoryStore.inventory()?.items.find(
|
||||||
|
(item) => item.equipped && item.item.equipmentSlot === selected.item.equipmentSlot,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (this.worldStore.character() === null) {
|
||||||
|
void this.worldStore.load();
|
||||||
|
}
|
||||||
|
void this.inventoryStore.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected selectItem(itemId: string): void {
|
||||||
|
this.inventoryStore.selectItem(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async equipSelected(characterItemId: string): Promise<void> {
|
||||||
|
await this.inventoryStore.equip(characterItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected retry(): void {
|
||||||
|
void this.inventoryStore.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
145
apps/web/src/app/features/inventory/inventory.store.spec.ts
Normal file
145
apps/web/src/app/features/inventory/inventory.store.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models';
|
||||||
|
import { GameApiService } from '../../core/api/game-api.service';
|
||||||
|
import { WorldStore } from '../world/world.store';
|
||||||
|
import { InventoryStore } from './inventory.store';
|
||||||
|
|
||||||
|
const inventory: InventoryResponse = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item-sword',
|
||||||
|
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-blade',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: false,
|
||||||
|
item: {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const equipment: EquipmentResponse = {
|
||||||
|
slots: {
|
||||||
|
WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } },
|
||||||
|
HEAD: null,
|
||||||
|
CHEST: null,
|
||||||
|
HANDS: null,
|
||||||
|
LEGS: null,
|
||||||
|
FEET: null,
|
||||||
|
AMULET: null,
|
||||||
|
},
|
||||||
|
stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const equippedAfter: EquipmentResponse = {
|
||||||
|
...equipment,
|
||||||
|
slots: { ...equipment.slots, WEAPON: { characterItemId: 'item-blade', item: { key: 'bandit-blade', name: 'Räuberklinge', rarity: 'COMMON', iconPath: '/images/items/bandit-blade.png' } } },
|
||||||
|
stats: { maxHp: 100, attack: 7, weaponDamage: 11, armor: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const inventoryAfterEquip: InventoryResponse = {
|
||||||
|
items: [
|
||||||
|
{ ...inventory.items[0], equipped: false },
|
||||||
|
{ ...inventory.items[1], equipped: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('InventoryStore', () => {
|
||||||
|
let api: {
|
||||||
|
getInventory: ReturnType<typeof vi.fn>;
|
||||||
|
getEquipment: ReturnType<typeof vi.fn>;
|
||||||
|
equipItem: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
|
||||||
|
let store: InventoryStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
api = {
|
||||||
|
getInventory: vi.fn(() => of(inventory)),
|
||||||
|
getEquipment: vi.fn(() => of(equipment)),
|
||||||
|
equipItem: vi.fn(() => of(equippedAfter)),
|
||||||
|
};
|
||||||
|
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
InventoryStore,
|
||||||
|
{ provide: GameApiService, useValue: api },
|
||||||
|
{ provide: WorldStore, useValue: worldStore },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
store = TestBed.inject(InventoryStore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads inventory and equipment together', async () => {
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
expect(store.inventory()).toEqual(inventory);
|
||||||
|
expect(store.equipment()).toEqual(equipment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects an item by id', async () => {
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
store.selectItem('item-blade');
|
||||||
|
|
||||||
|
expect(store.selectedItemId()).toBe('item-blade');
|
||||||
|
expect(store.selectedItem()).toEqual(inventory.items[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equips the selected item, refreshes inventory/equipment, and refreshes the character HUD', async () => {
|
||||||
|
api.getInventory
|
||||||
|
.mockReturnValueOnce(of(inventory)) // initial load()
|
||||||
|
.mockReturnValueOnce(of(inventoryAfterEquip)); // post-equip refetch
|
||||||
|
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
await store.equip('item-blade');
|
||||||
|
|
||||||
|
expect(api.equipItem).toHaveBeenCalledWith('item-blade');
|
||||||
|
expect(store.equipment()).toEqual(equippedAfter);
|
||||||
|
expect(store.inventory()).toEqual(inventoryAfterEquip);
|
||||||
|
expect(store.inventory()?.items.find((item) => item.id === 'item-sword')?.equipped).toBe(false);
|
||||||
|
expect(store.inventory()?.items.find((item) => item.id === 'item-blade')?.equipped).toBe(true);
|
||||||
|
expect(worldStore.refreshCharacter).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a German message for a known equip error', async () => {
|
||||||
|
await store.load();
|
||||||
|
api.equipItem.mockReturnValue(
|
||||||
|
throwError(() => new HttpErrorResponse({ error: { code: 'ITEM_LEVEL_REQUIREMENT_NOT_MET' }, status: 400 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.equip('item-blade');
|
||||||
|
|
||||||
|
expect(store.error()).toBe('Du erfüllst die Stufenanforderung nicht.');
|
||||||
|
});
|
||||||
|
});
|
||||||
96
apps/web/src/app/features/inventory/inventory.store.ts
Normal file
96
apps/web/src/app/features/inventory/inventory.store.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
import { firstValueFrom, forkJoin } from 'rxjs';
|
||||||
|
import { EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models';
|
||||||
|
import { GameApiService } from '../../core/api/game-api.service';
|
||||||
|
import { WorldStore } from '../world/world.store';
|
||||||
|
|
||||||
|
const GENERIC_ERROR_MESSAGE = 'Inventar konnte nicht geladen werden.';
|
||||||
|
|
||||||
|
// Mirrors `EquipmentErrorCode` in `apps/api/src/equipment/equipment.errors.ts`.
|
||||||
|
const EQUIPMENT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||||
|
CHARACTER_ITEM_NOT_FOUND: 'Dieser Gegenstand konnte nicht gefunden werden.',
|
||||||
|
ITEM_NOT_OWNED: 'Dieser Gegenstand gehört dir nicht.',
|
||||||
|
ITEM_NOT_EQUIPPABLE: 'Dieser Gegenstand kann nicht ausgerüstet werden.',
|
||||||
|
ITEM_LEVEL_REQUIREMENT_NOT_MET: 'Du erfüllst die Stufenanforderung nicht.',
|
||||||
|
INVALID_EQUIPMENT_SLOT: 'Dieser Ausrüstungsplatz ist ungültig.',
|
||||||
|
CHARACTER_IN_COMBAT: 'Ausrüstung kann während eines Kampfes nicht geändert werden.',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class InventoryStore {
|
||||||
|
private readonly inventoryState = signal<InventoryResponse | null>(null);
|
||||||
|
private readonly equipmentState = signal<EquipmentResponse | null>(null);
|
||||||
|
private readonly selectedItemIdState = signal<string | null>(null);
|
||||||
|
private readonly loadingState = signal(false);
|
||||||
|
private readonly equippingState = signal(false);
|
||||||
|
private readonly errorState = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly inventory = this.inventoryState.asReadonly();
|
||||||
|
readonly equipment = this.equipmentState.asReadonly();
|
||||||
|
readonly selectedItemId = this.selectedItemIdState.asReadonly();
|
||||||
|
readonly loading = this.loadingState.asReadonly();
|
||||||
|
readonly equipping = this.equippingState.asReadonly();
|
||||||
|
readonly error = this.errorState.asReadonly();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly api: GameApiService,
|
||||||
|
private readonly worldStore: WorldStore,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this.loadingState.set(true);
|
||||||
|
this.errorState.set(null);
|
||||||
|
try {
|
||||||
|
const { inventory, equipment } = await firstValueFrom(
|
||||||
|
forkJoin({ inventory: this.api.getInventory(), equipment: this.api.getEquipment() }),
|
||||||
|
);
|
||||||
|
this.inventoryState.set(inventory);
|
||||||
|
this.equipmentState.set(equipment);
|
||||||
|
} catch (error) {
|
||||||
|
this.errorState.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.loadingState.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectItem(characterItemId: string | null): void {
|
||||||
|
this.selectedItemIdState.set(characterItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedItem(): InventoryItem | null {
|
||||||
|
const id = this.selectedItemIdState();
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.inventoryState()?.items.find((item) => item.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Equips an item, then refreshes inventory/equipment and the character HUD (spec §35, §40). */
|
||||||
|
async equip(characterItemId: string): Promise<void> {
|
||||||
|
if (this.equippingState()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.equippingState.set(true);
|
||||||
|
this.errorState.set(null);
|
||||||
|
try {
|
||||||
|
const equipment = await firstValueFrom(this.api.equipItem(characterItemId));
|
||||||
|
this.equipmentState.set(equipment);
|
||||||
|
const inventory = await firstValueFrom(this.api.getInventory());
|
||||||
|
this.inventoryState.set(inventory);
|
||||||
|
await this.worldStore.refreshCharacter();
|
||||||
|
} catch (error) {
|
||||||
|
this.errorState.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.equippingState.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof HttpErrorResponse) {
|
||||||
|
const code = (error.error as { code?: string } | null)?.code;
|
||||||
|
return (code && EQUIPMENT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,9 +55,12 @@
|
|||||||
<button
|
<button
|
||||||
class="side-navigation__item"
|
class="side-navigation__item"
|
||||||
type="button"
|
type="button"
|
||||||
|
routerLink="/inventory"
|
||||||
|
routerLinkActive="side-navigation__item--active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
|
ariaCurrentWhenActive="page"
|
||||||
data-navigation="inventory"
|
data-navigation="inventory"
|
||||||
disabled
|
aria-label="Inventar"
|
||||||
aria-label="Inventar ist noch nicht verfügbar"
|
|
||||||
>
|
>
|
||||||
<img src="/images/hud/runtime/InventoryIcon-128.png" alt="" />
|
<img src="/images/hud/runtime/InventoryIcon-128.png" alt="" />
|
||||||
<span>Inventar</span>
|
<span>Inventar</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user