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..1cc1205 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory.store.spec.ts @@ -0,0 +1,131 @@ +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 }, +}; + +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 () => { + await store.load(); + + await store.equip('item-blade'); + + expect(api.equipItem).toHaveBeenCalledWith('item-blade'); + expect(store.equipment()).toEqual(equippedAfter); + 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; + } +}