feat(web): add InventoryStore

This commit is contained in:
Bastian Wagner
2026-08-20 17:32:52 +02:00
parent c994a4c46f
commit 10dd838465
2 changed files with 227 additions and 0 deletions

View File

@@ -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<typeof vi.fn>;
getEquipment: ReturnType<typeof vi.fn>;
equipItem: ReturnType<typeof vi.fn>;
};
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
let store: InventoryStore;
beforeEach(() => {
api = {
getInventory: vi.fn(() => of(inventory)),
getEquipment: vi.fn(() => of(equipment)),
equipItem: vi.fn(() => of(equippedAfter)),
};
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
TestBed.configureTestingModule({
providers: [
InventoryStore,
{ provide: GameApiService, useValue: api },
{ provide: WorldStore, useValue: worldStore },
],
});
store = TestBed.inject(InventoryStore);
});
it('loads inventory and equipment together', async () => {
await store.load();
expect(store.inventory()).toEqual(inventory);
expect(store.equipment()).toEqual(equipment);
});
it('selects an item by id', async () => {
await store.load();
store.selectItem('item-blade');
expect(store.selectedItemId()).toBe('item-blade');
expect(store.selectedItem()).toEqual(inventory.items[1]);
});
it('equips the selected item, refreshes inventory/equipment, and refreshes the character HUD', async () => {
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.');
});
});

View File

@@ -0,0 +1,96 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, signal } from '@angular/core';
import { firstValueFrom, forkJoin } from 'rxjs';
import { EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from '../world/world.store';
const GENERIC_ERROR_MESSAGE = 'Inventar konnte nicht geladen werden.';
// Mirrors `EquipmentErrorCode` in `apps/api/src/equipment/equipment.errors.ts`.
const EQUIPMENT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
CHARACTER_ITEM_NOT_FOUND: 'Dieser Gegenstand konnte nicht gefunden werden.',
ITEM_NOT_OWNED: 'Dieser Gegenstand gehört dir nicht.',
ITEM_NOT_EQUIPPABLE: 'Dieser Gegenstand kann nicht ausgerüstet werden.',
ITEM_LEVEL_REQUIREMENT_NOT_MET: 'Du erfüllst die Stufenanforderung nicht.',
INVALID_EQUIPMENT_SLOT: 'Dieser Ausrüstungsplatz ist ungültig.',
CHARACTER_IN_COMBAT: 'Ausrüstung kann während eines Kampfes nicht geändert werden.',
};
@Injectable({ providedIn: 'root' })
export class InventoryStore {
private readonly inventoryState = signal<InventoryResponse | null>(null);
private readonly equipmentState = signal<EquipmentResponse | null>(null);
private readonly selectedItemIdState = signal<string | null>(null);
private readonly loadingState = signal(false);
private readonly equippingState = signal(false);
private readonly errorState = signal<string | null>(null);
readonly inventory = this.inventoryState.asReadonly();
readonly equipment = this.equipmentState.asReadonly();
readonly selectedItemId = this.selectedItemIdState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly equipping = this.equippingState.asReadonly();
readonly error = this.errorState.asReadonly();
constructor(
private readonly api: GameApiService,
private readonly worldStore: WorldStore,
) {}
async load(): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
try {
const { inventory, equipment } = await firstValueFrom(
forkJoin({ inventory: this.api.getInventory(), equipment: this.api.getEquipment() }),
);
this.inventoryState.set(inventory);
this.equipmentState.set(equipment);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.loadingState.set(false);
}
}
selectItem(characterItemId: string | null): void {
this.selectedItemIdState.set(characterItemId);
}
selectedItem(): InventoryItem | null {
const id = this.selectedItemIdState();
if (!id) {
return null;
}
return this.inventoryState()?.items.find((item) => item.id === id) ?? null;
}
/** Equips an item, then refreshes inventory/equipment and the character HUD (spec §35, §40). */
async equip(characterItemId: string): Promise<void> {
if (this.equippingState()) {
return;
}
this.equippingState.set(true);
this.errorState.set(null);
try {
const equipment = await firstValueFrom(this.api.equipItem(characterItemId));
this.equipmentState.set(equipment);
const inventory = await firstValueFrom(this.api.getInventory());
this.inventoryState.set(inventory);
await this.worldStore.refreshCharacter();
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.equippingState.set(false);
}
}
private toErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code;
return (code && EQUIPMENT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
}
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
}
}