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