diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c8be7e0..79d5f80 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { CharactersModule } from './characters/characters.module'; import { CombatModule } from './combat/combat.module'; import { DatabaseModule } from './database/database.module'; +import { EquipmentModule } from './equipment/equipment.module'; import { HealthModule } from './health/health.module'; import { HuntingModule } from './hunting/hunting.module'; +import { InventoryModule } from './inventory/inventory.module'; import { TravelModule } from './travel/travel.module'; import { WorldModule } from './world/world.module'; @@ -16,6 +18,8 @@ import { WorldModule } from './world/world.module'; WorldModule, HuntingModule, CombatModule, + EquipmentModule, + InventoryModule, ], }) export class AppModule {} diff --git a/apps/api/src/characters/character-combat-stats.service.spec.ts b/apps/api/src/characters/character-combat-stats.service.spec.ts deleted file mode 100644 index 2cb2279..0000000 --- a/apps/api/src/characters/character-combat-stats.service.spec.ts +++ /dev/null @@ -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, - }); - }); -}); diff --git a/apps/api/src/characters/character-combat-stats.service.ts b/apps/api/src/characters/character-combat-stats.service.ts deleted file mode 100644 index 665d893..0000000 --- a/apps/api/src/characters/character-combat-stats.service.ts +++ /dev/null @@ -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, - }; - } -} diff --git a/apps/api/src/characters/character-stats.service.spec.ts b/apps/api/src/characters/character-stats.service.spec.ts new file mode 100644 index 0000000..45f8417 --- /dev/null +++ b/apps/api/src/characters/character-stats.service.spec.ts @@ -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; +}; + +function fakeScope(equipped: EquippedFixture[]): Pick { + 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; +} + +function character(overrides: Partial = {}): 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); + }); +}); diff --git a/apps/api/src/characters/character-stats.service.ts b/apps/api/src/characters/character-stats.service.ts new file mode 100644 index 0000000..5d0e0a1 --- /dev/null +++ b/apps/api/src/characters/character-stats.service.ts @@ -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; + +/** + * 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 { + 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, + }; + } +} diff --git a/apps/api/src/characters/characters.module.ts b/apps/api/src/characters/characters.module.ts index f8a46c1..af53989 100644 --- a/apps/api/src/characters/characters.module.ts +++ b/apps/api/src/characters/characters.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { CharacterCombatStatsService } from './character-combat-stats.service'; +import { CharacterStatsService } from './character-stats.service'; import { CharactersController } from './characters.controller'; import { CharactersService } from './characters.service'; import { Character } from './entities/character.entity'; @@ -8,7 +8,7 @@ import { Character } from './entities/character.entity'; @Module({ imports: [TypeOrmModule.forFeature([Character])], controllers: [CharactersController], - providers: [CharactersService, CharacterCombatStatsService], - exports: [CharacterCombatStatsService], + providers: [CharactersService, CharacterStatsService], + exports: [CharacterStatsService], }) export class CharactersModule {} diff --git a/apps/api/src/characters/characters.service.spec.ts b/apps/api/src/characters/characters.service.spec.ts index 63940ed..0cddc32 100644 --- a/apps/api/src/characters/characters.service.spec.ts +++ b/apps/api/src/characters/characters.service.spec.ts @@ -2,11 +2,27 @@ import { NotFoundException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants'; +import { CharacterStatsService } from './character-stats.service'; import { Character } from './entities/character.entity'; import { CharactersService } from './characters.service'; +function fakeCharacterStats( + overrides: Partial<{ maxHp: number; attack: number }> = {}, +): CharacterStatsService { + return { + calculate: jest.fn().mockResolvedValue({ + maxHp: overrides.maxHp ?? 100, + currentHp: 100, + attack: overrides.attack ?? 6, + weaponDamage: 8, + armor: 0, + combatPower: 0, + }), + } as unknown as CharacterStatsService; +} + describe('CharactersService', () => { - it('returns the demo character with its current location summary', async () => { + it('returns the demo character with effective attack/HP and its location summary', async () => { const repository = { findOne: jest.fn().mockResolvedValue({ id: DEMO_CHARACTER_ID, @@ -20,11 +36,12 @@ describe('CharactersService', () => { currentLocation: { id: SOUTH_GATE_ID, key: 'south-gate', - name: 'S\u00fcdtor von Graufurt', + name: 'Südtor von Graufurt', }, }), } as unknown as Repository; - const service = new CharactersService(repository); + const characterStats = fakeCharacterStats({ maxHp: 115, attack: 7 }); + const service = new CharactersService(repository, characterStats); await expect(service.getDemoCharacter()).resolves.toEqual({ id: DEMO_CHARACTER_ID, @@ -33,18 +50,17 @@ describe('CharactersService', () => { experience: 0, silver: 0, currentHp: 100, - maxHp: 100, - attack: 6, + maxHp: 115, + attack: 7, currentLocation: { id: SOUTH_GATE_ID, key: 'south-gate', - name: 'S\u00fcdtor von Graufurt', + name: 'Südtor von Graufurt', }, }); - expect(repository.findOne).toHaveBeenCalledWith({ - where: { id: DEMO_CHARACTER_ID }, - relations: { currentLocation: true }, - }); + expect(characterStats.calculate).toHaveBeenCalledWith( + expect.objectContaining({ id: DEMO_CHARACTER_ID }), + ); }); it('exposes the persisted silver so the HUD never has to guess', async () => { @@ -65,7 +81,7 @@ describe('CharactersService', () => { }, }), } as unknown as Repository; - const service = new CharactersService(repository); + const service = new CharactersService(repository, fakeCharacterStats()); await expect(service.getDemoCharacter()).resolves.toEqual( expect.objectContaining({ experience: 24, silver: 18 }), @@ -76,7 +92,7 @@ describe('CharactersService', () => { const repository = { findOne: jest.fn().mockResolvedValue(null), } as unknown as Repository; - const service = new CharactersService(repository); + const service = new CharactersService(repository, fakeCharacterStats()); await expect(service.getDemoCharacter()).rejects.toBeInstanceOf( NotFoundException, diff --git a/apps/api/src/characters/characters.service.ts b/apps/api/src/characters/characters.service.ts index ce5a099..10ed4dc 100644 --- a/apps/api/src/characters/characters.service.ts +++ b/apps/api/src/characters/characters.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { CharacterStatsService } from './character-stats.service'; import { Character } from './entities/character.entity'; @Injectable() @@ -9,6 +10,7 @@ export class CharactersService { constructor( @InjectRepository(Character) private readonly characters: Repository, + private readonly characterStats: CharacterStatsService, ) {} async getDemoCharacter() { @@ -21,6 +23,8 @@ export class CharactersService { throw new NotFoundException('Demo character has not been seeded'); } + const stats = await this.characterStats.calculate(character); + return { id: character.id, name: character.name, @@ -28,8 +32,8 @@ export class CharactersService { experience: character.experience, silver: character.silver, currentHp: character.currentHp, - maxHp: character.baseHp, - attack: character.baseAttack, + maxHp: stats.maxHp, + attack: stats.attack, currentLocation: { id: character.currentLocation.id, key: character.currentLocation.key, diff --git a/apps/api/src/combat/combat-equipment-integration.spec.ts b/apps/api/src/combat/combat-equipment-integration.spec.ts new file mode 100644 index 0000000..d74d9db --- /dev/null +++ b/apps/api/src/combat/combat-equipment-integration.spec.ts @@ -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 { + constructor( + private readonly state: FakeState, + private readonly target: EntityTarget, + private readonly dataSource: FakeDataSource, + ) {} + + findOne(options: { + where: Partial; + relations?: Record; + lock?: { mode: string }; + }): Promise { + 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): Promise { + return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null); + } + + find(options: { + where: Partial; + relations?: Record; + order?: Partial>; + }): Promise { + 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 }): Promise { + return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length); + } + + create(values: Partial): T { + return { ...values } as T; + } + + save(entity: T): Promise { + 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): T { + if (!relations) { + return row; + } + const copy = { ...row } as T & Record; + 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): 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(); + constructor(public state: FakeState) {} + + getRepository(target: EntityTarget) { + return new FakeRepository(this.state, target, this); + } + + async transaction(work: (manager: EntityManager) => Promise): Promise { + return work({ + getRepository: (target: EntityTarget) => + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + }); +}); diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index 5fed426..ffef68b 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -1,5 +1,5 @@ import { DataSource, EntityManager, EntityTarget } from 'typeorm'; -import { CharacterCombatStatsService } from '../characters/character-combat-stats.service'; +import { CharacterStatsService } from '../characters/character-stats.service'; import { Character } from '../characters/entities/character.entity'; import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; @@ -242,6 +242,19 @@ function createState(overrides: Partial = {}): FakeState { }; } +function fakeCharacterStats(): CharacterStatsService { + return { + calculate: jest.fn(async (character: Character) => ({ + maxHp: character.baseHp, + currentHp: character.currentHp, + attack: character.baseAttack, + weaponDamage: 8, + armor: 6, + combatPower: 0, + })), + } as unknown as CharacterStatsService; +} + function fakeTravelService( status: 'IDLE' | 'TRAVELLING' = 'IDLE', ): TravelService { @@ -271,7 +284,7 @@ function createService( const dataSource = new FakeDataSource(state); const travelService = options.travelService ?? fakeTravelService(); const combatEngine = new CombatEngineService(); - const characterCombatStats = new CharacterCombatStatsService(); + const characterCombatStats = fakeCharacterStats(); const service = new CombatService( dataSource as unknown as DataSource, travelService, @@ -785,7 +798,7 @@ describe('CombatService', () => { dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), - new CharacterCombatStatsService(), + fakeCharacterStats(), rewards, ); @@ -833,7 +846,7 @@ describe('CombatService', () => { dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), - new CharacterCombatStatsService(), + fakeCharacterStats(), rewards, ); @@ -890,7 +903,7 @@ describe('CombatService', () => { dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), - new CharacterCombatStatsService(), + fakeCharacterStats(), rewards, ); @@ -927,7 +940,7 @@ describe('CombatService', () => { dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), - new CharacterCombatStatsService(), + fakeCharacterStats(), fakeRewardService({ // Genuinely write XP/silver through the transaction's manager // before failing, so the assertions below prove the rollback diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index ae16bc5..425f804 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DataSource, Repository } from 'typeorm'; -import { CharacterCombatStatsService } from '../characters/character-combat-stats.service'; +import { CharacterStatsService } from '../characters/character-stats.service'; import { Character } from '../characters/entities/character.entity'; import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; @@ -69,7 +69,7 @@ export class CombatService { private readonly dataSource: DataSource, private readonly travelService: TravelService, private readonly combatEngine: CombatEngineService, - private readonly characterCombatStats: CharacterCombatStatsService, + private readonly characterStats: CharacterStatsService, private readonly combatRewards: CombatRewardService, ) {} @@ -126,7 +126,7 @@ export class CombatService { throw invalidHuntEncounter(); } - const playerStats = this.characterCombatStats.getStats(character); + const playerStats = await this.characterStats.calculate(character, manager); const combat = combats.create({ characterId, diff --git a/apps/api/src/database/migrations/1789000000000-CreateEquipment.ts b/apps/api/src/database/migrations/1789000000000-CreateEquipment.ts new file mode 100644 index 0000000..424bea7 --- /dev/null +++ b/apps/api/src/database/migrations/1789000000000-CreateEquipment.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateEquipment1789000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 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 { + 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"'); + } +} diff --git a/apps/api/src/database/migrations/equipment.migration.spec.ts b/apps/api/src/database/migrations/equipment.migration.spec.ts new file mode 100644 index 0000000..fdd59c7 --- /dev/null +++ b/apps/api/src/database/migrations/equipment.migration.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index 4628510..347ee58 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -1,5 +1,7 @@ import { DataSource } from 'typeorm'; import { Character } from '../../characters/entities/character.entity'; +import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity'; +import { CharacterItem } from '../../items/entities/character-item.entity'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { LootTable } from '../../loot/entities/loot-table.entity'; @@ -7,6 +9,7 @@ import { LocationMonster } from '../../monsters/entities/location-monster.entity import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity'; +import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants'; import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; @@ -76,6 +79,8 @@ function createDataSource( itemRepository: InMemoryRepository = new InMemoryRepository(), lootTableRepository: InMemoryRepository = new InMemoryRepository(), lootEntryRepository: InMemoryRepository = new InMemoryRepository(), + characterItemRepository: InMemoryRepository = new InMemoryRepository(), + characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(), ): DataSource { return { getRepository: jest.fn((entity: unknown) => { @@ -87,6 +92,8 @@ function createDataSource( if (entity === ItemDefinition) return itemRepository; if (entity === LootTable) return lootTableRepository; if (entity === LootTableEntry) return lootEntryRepository; + if (entity === CharacterItem) return characterItemRepository; + if (entity === CharacterEquipment) return characterEquipmentRepository; throw new Error('Unexpected repository'); }), @@ -449,4 +456,125 @@ describe('seedVisibleVerticalSlice', () => { ]), ); }); + + it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const monsterRepository = new InMemoryRepository(); + const locationMonsterRepository = new InMemoryRepository(); + const characterItemRepository = new InMemoryRepository(); + const characterEquipmentRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, + monsterRepository, + locationMonsterRepository, + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + characterItemRepository, + characterEquipmentRepository, + ); + + await seedVisibleVerticalSlice(dataSource); + await seedVisibleVerticalSlice(dataSource); + + expect(characterItemRepository.rows).toHaveLength(1); + expect(characterItemRepository.rows[0]).toEqual( + expect.objectContaining({ + characterId: DEMO_CHARACTER_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], + quantity: 1, + }), + ); + expect(characterEquipmentRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows[0]).toEqual( + expect.objectContaining({ + characterId: DEMO_CHARACTER_ID, + slot: 'WEAPON', + characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, + }), + ); + }); + + it('never re-equips the starting sword once the player has equipped different gear', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const monsterRepository = new InMemoryRepository(); + const locationMonsterRepository = new InMemoryRepository(); + const characterItemRepository = new InMemoryRepository(); + const characterEquipmentRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, + monsterRepository, + locationMonsterRepository, + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + characterItemRepository, + characterEquipmentRepository, + ); + + await seedVisibleVerticalSlice(dataSource); + // Simulate the player having equipped earned loot instead. + characterEquipmentRepository.rows[0]['characterItemId'] = 'earned-bandit-blade-item-id'; + + await seedVisibleVerticalSlice(dataSource); + + expect(characterEquipmentRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows[0]['characterItemId']).toBe( + 'earned-bandit-blade-item-id', + ); + expect(characterItemRepository.rows).toHaveLength(1); + }); + + it('reuses a naturally-looted starting sword instead of inserting a duplicate CharacterItem', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const monsterRepository = new InMemoryRepository(); + const locationMonsterRepository = new InMemoryRepository(); + const characterItemRepository = new InMemoryRepository(); + const characterEquipmentRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, + monsterRepository, + locationMonsterRepository, + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + characterItemRepository, + characterEquipmentRepository, + ); + + // Simulate the demo character having already looted a worn-short-sword + // naturally, under a DB-generated id that differs from the seed's + // stable literal constant. + const naturallyLootedItemId = 'naturally-looted-sword-item-id'; + characterItemRepository.rows.push({ + id: naturallyLootedItemId, + characterId: DEMO_CHARACTER_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], + quantity: 1, + }); + + await seedVisibleVerticalSlice(dataSource); + + expect(characterItemRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows[0]).toEqual( + expect.objectContaining({ + characterId: DEMO_CHARACTER_ID, + slot: 'WEAPON', + characterItemId: naturallyLootedItemId, + }), + ); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index 36176c4..3228315 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -1,6 +1,13 @@ import { DataSource } from 'typeorm'; -import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants'; +import { + DEMO_CHARACTER_ID, + DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID, + DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, +} from '../../demo/demo-character.constants'; import { Character } from '../../characters/entities/character.entity'; +import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity'; +import { CharacterItem } from '../../items/entities/character-item.entity'; +import { EquipmentSlot } from '../../items/equipment-slot.enum'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { LootTable } from '../../loot/entities/loot-table.entity'; @@ -12,6 +19,7 @@ import { LocationDefinition } from '../../world/entities/location-definition.ent import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content'; import { ASH_RAT_LOOT_TABLE_ID, + ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID, } from './item.constants'; import { @@ -238,4 +246,42 @@ export async function seedVisibleVerticalSlice( currentLocationId: southGateId, }); } + + const characterItemRepository = dataSource.getRepository(CharacterItem); + const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment); + + // Starting loadout is weapon-only -- no starter armor piece exists in + // content yet -- so the demo character's effective armor (sum of equipped + // bonusArmor) is 0 until the player loots and equips bandit-hood (+3 + // armor). This is a deliberate tradeoff, not a bug: Slice 0.5 spec §19 + // says to preserve the existing demo balance "as closely as the + // implemented content allows" and explicitly forbids fabricating a full + // starter gear set just to hit the old hardcoded TEMPORARY_ARMOR = 6. + const existingStartingSword = await characterItemRepository.findOneBy({ + characterId: DEMO_CHARACTER_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], + }); + const startingSwordItemId = + existingStartingSword?.id ?? DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID; + if (!existingStartingSword) { + await characterItemRepository.insert({ + id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, + characterId: DEMO_CHARACTER_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], + quantity: 1, + }); + } + + const existingWeaponEquipment = await characterEquipmentRepository.findOneBy({ + characterId: DEMO_CHARACTER_ID, + slot: EquipmentSlot.WEAPON, + }); + if (!existingWeaponEquipment) { + await characterEquipmentRepository.insert({ + id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID, + characterId: DEMO_CHARACTER_ID, + slot: EquipmentSlot.WEAPON, + characterItemId: startingSwordItemId, + }); + } } diff --git a/apps/api/src/demo/demo-character.constants.ts b/apps/api/src/demo/demo-character.constants.ts index e79635b..f09bf6e 100644 --- a/apps/api/src/demo/demo-character.constants.ts +++ b/apps/api/src/demo/demo-character.constants.ts @@ -1 +1,5 @@ export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +export const DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID = + '10000000-0000-4000-8000-000000000002'; +export const DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID = + '10000000-0000-4000-8000-000000000003'; diff --git a/apps/api/src/equipment/dto/equip-item.dto.ts b/apps/api/src/equipment/dto/equip-item.dto.ts new file mode 100644 index 0000000..69e2530 --- /dev/null +++ b/apps/api/src/equipment/dto/equip-item.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class EquipItemDto { + @IsUUID() + characterItemId!: string; +} diff --git a/apps/api/src/equipment/entities/character-equipment.entity.ts b/apps/api/src/equipment/entities/character-equipment.entity.ts new file mode 100644 index 0000000..ef0ae07 --- /dev/null +++ b/apps/api/src/equipment/entities/character-equipment.entity.ts @@ -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; +} diff --git a/apps/api/src/equipment/equipment.controller.ts b/apps/api/src/equipment/equipment.controller.ts new file mode 100644 index 0000000..1a42a90 --- /dev/null +++ b/apps/api/src/equipment/equipment.controller.ts @@ -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 { + return this.equipmentService.getEquipment(DEMO_CHARACTER_ID); + } + + @Post() + equip(@Body() request: EquipItemDto): Promise { + return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId); + } +} diff --git a/apps/api/src/equipment/equipment.errors.ts b/apps/api/src/equipment/equipment.errors.ts new file mode 100644 index 0000000..2165172 --- /dev/null +++ b/apps/api/src/equipment/equipment.errors.ts @@ -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'; diff --git a/apps/api/src/equipment/equipment.module.ts b/apps/api/src/equipment/equipment.module.ts new file mode 100644 index 0000000..48aafe5 --- /dev/null +++ b/apps/api/src/equipment/equipment.module.ts @@ -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 {} diff --git a/apps/api/src/equipment/equipment.service.spec.ts b/apps/api/src/equipment/equipment.service.spec.ts new file mode 100644 index 0000000..5536a9d --- /dev/null +++ b/apps/api/src/equipment/equipment.service.spec.ts @@ -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 { + constructor( + private readonly state: State, + private readonly target: EntityTarget, + private readonly dataSource: FakeDataSource, + ) {} + + findOne(options: { + where: Partial; + relations?: Record; + lock?: { mode: string }; + }): Promise { + 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): Promise { + return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null); + } + + find(options: { where: Partial; relations?: Record }): Promise { + 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 { + return { ...values } as T; + } + + save(entity: T): Promise { + 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): T { + if (!relations) { + return row; + } + const copy = { ...row } as T & Record; + 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): 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(); + constructor(public state: State) {} + + getRepository(target: EntityTarget) { + return new FakeRepository(this.state, target, this); + } + + async transaction(work: (manager: EntityManager) => Promise): Promise { + return work({ + getRepository: (target: EntityTarget) => + 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 { + 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 { + return { + id: CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + baseHp: 100, + baseAttack: 6, + currentHp: 100, + ...overrides, + } as Character; +} + +function createHarness(state: Partial = {}) { + 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, code: string): Promise { + 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 }); + }); + }); +}); diff --git a/apps/api/src/equipment/equipment.service.ts b/apps/api/src/equipment/equipment.service.ts new file mode 100644 index 0000000..5fd094a --- /dev/null +++ b/apps/api/src/equipment/equipment.service.ts @@ -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; + +export interface EquipmentStatsDto { + maxHp: number; + attack: number; + weaponDamage: number; + armor: number; +} + +export interface EquipmentResponseDto { + slots: EquipmentSlotsDto; + stats: EquipmentStatsDto; +} + +type RepositoryScope = Pick; + +@Injectable() +export class EquipmentService { + constructor( + private readonly dataSource: DataSource, + private readonly characterStats: CharacterStatsService, + ) {} + + async getEquipment(characterId: string): Promise { + 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 { + 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 { + 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, + }, + }; + } +} diff --git a/apps/api/src/inventory/inventory.controller.ts b/apps/api/src/inventory/inventory.controller.ts new file mode 100644 index 0000000..781e91c --- /dev/null +++ b/apps/api/src/inventory/inventory.controller.ts @@ -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 { + return this.inventoryService.getInventory(DEMO_CHARACTER_ID); + } +} diff --git a/apps/api/src/inventory/inventory.module.ts b/apps/api/src/inventory/inventory.module.ts new file mode 100644 index 0000000..5092c22 --- /dev/null +++ b/apps/api/src/inventory/inventory.module.ts @@ -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 {} diff --git a/apps/api/src/inventory/inventory.service.spec.ts b/apps/api/src/inventory/inventory.service.spec.ts new file mode 100644 index 0000000..2d39c00 --- /dev/null +++ b/apps/api/src/inventory/inventory.service.spec.ts @@ -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 { + 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; + const equipment = { + find: jest.fn().mockResolvedValue([ + { characterItemId: 'item-1', slot: EquipmentSlot.WEAPON } as CharacterEquipment, + ]), + } as unknown as Repository; + 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; + const equipment = { + find: jest.fn().mockResolvedValue([]), + } as unknown as Repository; + const service = new InventoryService(characterItems, equipment); + + await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ items: [] }); + }); +}); diff --git a/apps/api/src/inventory/inventory.service.ts b/apps/api/src/inventory/inventory.service.ts new file mode 100644 index 0000000..3eb9a3f --- /dev/null +++ b/apps/api/src/inventory/inventory.service.ts @@ -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, + @InjectRepository(CharacterEquipment) + private readonly equipment: Repository, + ) {} + + async getInventory(characterId: string): Promise { + 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, + }, + })), + }; + } +} diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 648992c..7eb45b1 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -35,6 +35,13 @@ export const routes: Routes = [ (module) => module.CombatPageComponent, ), }, + { + path: 'inventory', + loadComponent: () => + import('./features/inventory/inventory-page.component').then( + (module) => module.InventoryPageComponent, + ), + }, ], }, { path: '**', redirectTo: 'location' }, diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index f33cc2a..901f009 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -57,7 +57,14 @@ describe('App', () => { expect(huntButton?.getAttribute('aria-current')).toBeNull(); expect(huntButton?.getAttribute('aria-label')).toBe('Jagd'); - for (const destination of ['quests', 'inventory', 'character']) { + const inventoryButton = element.querySelector( + '[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( element.querySelector(`[data-navigation="${destination}"]`)?.disabled, ).toBe(true); diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index a33118f..1fdd763 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -207,3 +207,58 @@ export interface CombatReward { silver: number; 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; + +export interface EquipmentStats { + maxHp: number; + attack: number; + weaponDamage: number; + armor: number; +} + +export interface EquipmentResponse { + slots: EquipmentSlots; + stats: EquipmentStats; +} diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts index a5686d1..2149104 100644 --- a/apps/web/src/app/core/api/game-api.service.ts +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -7,7 +7,9 @@ import { CombatAction, CurrentLocationResponse, CurrentTravel, + EquipmentResponse, HuntResult, + InventoryResponse, LocationInteractionResult, } from './game-api.models'; @@ -65,4 +67,16 @@ export class GameApiService { performCombatAction(combatId: string, action: CombatAction): Observable { return this.http.post(`/api/combats/${combatId}/actions`, { action }); } + + getInventory(): Observable { + return this.http.get('/api/inventory'); + } + + getEquipment(): Observable { + return this.http.get('/api/equipment'); + } + + equipItem(characterItemId: string): Observable { + return this.http.post('/api/equipment', { characterItemId }); + } } diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html index 7bd7c3f..8b05fa4 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -129,6 +129,14 @@ > Zum Ort + } @else if (combat.status === 'LOST') { diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss index e170302..2492327 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -492,6 +492,7 @@ .outcome__buttons { display: flex; + flex-wrap: wrap; gap: var(--ar-space-3); justify-content: center; } diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts index 5e4bbd3..976b049 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -294,6 +294,15 @@ describe('CombatPageComponent', () => { 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('[data-combat-to-inventory]')?.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/inventory']); + }); + it('shows an error and retries loading the combat', async () => { const fixture = await setup(null); combatStore.error.set('Dieser Kampf wurde nicht gefunden.'); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index ff63cbd..8a5a8da 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -168,6 +168,10 @@ export class CombatPageComponent implements OnInit { void this.router.navigate(['/location']); } + protected goToInventory(): void { + void this.router.navigate(['/inventory']); + } + protected monsterSprite(monsterKey: string, artworkPath: string): string { return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; } diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.html b/apps/web/src/app/features/inventory/inventory-detail-panel.component.html new file mode 100644 index 0000000..e981664 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.html @@ -0,0 +1,60 @@ +@if (item(); as item) { +
+
+ +
+

{{ item.item.name }}

+

{{ rarityLabel() }}

+ @if (slotLabel(); as slot) { +

{{ slot }} · Stufe {{ item.item.requiredLevel }}

+ } +
+
+ + @if (statRows().length) { +
+ @for (row of statRows(); track row.label) { +
+
{{ row.label }}
+
+ {{ row.value }} + @if (row.diff !== null && row.diff !== 0) { + + ({{ row.diff > 0 ? '+' : '' }}{{ row.diff }}) + + } +
+
+ } +
+ } + +
+ @if (item.equipped) { + Ausgerüstet + } @else if (!isEquippable()) { + Nicht ausrüstbar + } @else if (!meetsLevelRequirement()) { + + } @else { + + } +
+
+} @else { +

Wähle einen Gegenstand aus deinem Inventar.

+} diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss b/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss new file mode 100644 index 0000000..49fb865 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss @@ -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; +} diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts b/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts new file mode 100644 index 0000000..109e9e6 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts @@ -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('[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('[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('[data-detail-equip]'); + + expect(button?.disabled).toBe(true); + }); +}); diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts b/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts new file mode 100644 index 0000000..291f1e0 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts @@ -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> = { + 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(null); + readonly equippedItemInSlot = input(null); + readonly characterLevel = input(1); + readonly busy = input(false); + readonly equip = output(); + + 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(() => { + 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); + } + } +} diff --git a/apps/web/src/app/features/inventory/inventory-page.component.html b/apps/web/src/app/features/inventory/inventory-page.component.html new file mode 100644 index 0000000..1d16a21 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.html @@ -0,0 +1,65 @@ + +
+ @if (inventoryStore.loading() && !inventoryStore.inventory()) { +

Inventar wird geladen…

+ } @else if (inventoryStore.inventory(); as inventory) { +
+ @for (entry of inventory.items; track entry.id) { + + } @empty { +

Noch keine Gegenstände gefunden.

+ } +
+ + + } + + @if (inventoryStore.error(); as error) { + + } +
diff --git a/apps/web/src/app/features/inventory/inventory-page.component.scss b/apps/web/src/app/features/inventory/inventory-page.component.scss new file mode 100644 index 0000000..bc83851 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.scss @@ -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; + } +} diff --git a/apps/web/src/app/features/inventory/inventory-page.component.spec.ts b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts new file mode 100644 index 0000000..4fc928f --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts @@ -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(options.selectedItemId ?? null), + loading: signal(false), + equipping: signal(false), + error: signal(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('.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'); + }); +}); diff --git a/apps/web/src/app/features/inventory/inventory-page.component.ts b/apps/web/src/app/features/inventory/inventory-page.component.ts new file mode 100644 index 0000000..270c754 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.ts @@ -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> = { + 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 { + await this.inventoryStore.equip(characterItemId); + } + + protected retry(): void { + void this.inventoryStore.load(); + } +} diff --git a/apps/web/src/app/features/inventory/inventory.store.spec.ts b/apps/web/src/app/features/inventory/inventory.store.spec.ts new file mode 100644 index 0000000..2deb123 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory.store.spec.ts @@ -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; + getEquipment: ReturnType; + equipItem: ReturnType; + }; + let worldStore: { refreshCharacter: ReturnType }; + 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.'); + }); +}); diff --git a/apps/web/src/app/features/inventory/inventory.store.ts b/apps/web/src/app/features/inventory/inventory.store.ts new file mode 100644 index 0000000..143569e --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory.store.ts @@ -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> = { + 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(null); + private readonly equipmentState = signal(null); + private readonly selectedItemIdState = signal(null); + private readonly loadingState = signal(false); + private readonly equippingState = signal(false); + private readonly errorState = signal(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 { + 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 { + 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; + } +} diff --git a/apps/web/src/app/layout/side-navigation/side-navigation.component.html b/apps/web/src/app/layout/side-navigation/side-navigation.component.html index e4d443f..12510f2 100644 --- a/apps/web/src/app/layout/side-navigation/side-navigation.component.html +++ b/apps/web/src/app/layout/side-navigation/side-navigation.component.html @@ -55,9 +55,12 @@