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