Merge branch 'worktree-playable-slice-0.5-first-upgrade'

# Conflicts:
#	apps/web/src/app/core/api/game-api.service.ts
#	apps/web/src/app/features/combat/combat-page/combat-page.component.html
#	apps/web/src/app/features/combat/combat-page/combat-page.component.scss
#	apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
#	apps/web/src/app/features/combat/combat-page/combat-page.component.ts
This commit is contained in:
Bastian Wagner
2026-08-20 19:42:10 +02:00
46 changed files with 3051 additions and 74 deletions

View File

@@ -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' },

View File

@@ -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<HTMLButtonElement>(
'[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<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
).toBe(true);

View File

@@ -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<EquipmentSlot, EquipmentSlotItem | null>;
export interface EquipmentStats {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
export interface EquipmentResponse {
slots: EquipmentSlots;
stats: EquipmentStats;
}

View File

@@ -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<Combat> {
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
}
getInventory(): Observable<InventoryResponse> {
return this.http.get<InventoryResponse>('/api/inventory');
}
getEquipment(): Observable<EquipmentResponse> {
return this.http.get<EquipmentResponse>('/api/equipment');
}
equipItem(characterItemId: string): Observable<EquipmentResponse> {
return this.http.post<EquipmentResponse>('/api/equipment', { characterItemId });
}
}

View File

@@ -129,6 +129,14 @@
>
Zum Ort
</button>
<button
type="button"
class="outcome__button outcome__button--secondary"
data-combat-to-inventory
(click)="goToInventory()"
>
Inventar öffnen
</button>
</div>
</div>
} @else if (combat.status === 'LOST') {

View File

@@ -492,6 +492,7 @@
.outcome__buttons {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-3);
justify-content: center;
}

View File

@@ -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<HTMLButtonElement>('[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.');

View File

@@ -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;
}

View File

@@ -0,0 +1,60 @@
@if (item(); as item) {
<article class="inventory-detail" aria-label="Gegenstandsdetails">
<div class="inventory-detail__header">
<img class="inventory-detail__icon" [src]="item.item.iconPath" [alt]="item.item.name" />
<div>
<h2 class="inventory-detail__name" data-detail-name>{{ item.item.name }}</h2>
<p class="inventory-detail__rarity" data-detail-rarity>{{ rarityLabel() }}</p>
@if (slotLabel(); as slot) {
<p class="inventory-detail__meta">{{ slot }} · Stufe {{ item.item.requiredLevel }}</p>
}
</div>
</div>
@if (statRows().length) {
<dl class="inventory-detail__stats" data-detail-stats>
@for (row of statRows(); track row.label) {
<div class="inventory-detail__stat">
<dt>{{ row.label }}</dt>
<dd>
{{ row.value }}
@if (row.diff !== null && row.diff !== 0) {
<span
class="inventory-detail__diff"
[class.inventory-detail__diff--positive]="row.diff > 0"
[class.inventory-detail__diff--negative]="row.diff < 0"
>
({{ row.diff > 0 ? '+' : '' }}{{ row.diff }})
</span>
}
</dd>
</div>
}
</dl>
}
<div class="inventory-detail__actions">
@if (item.equipped) {
<span class="inventory-detail__equipped" data-detail-equipped>Ausgerüstet</span>
} @else if (!isEquippable()) {
<span class="inventory-detail__note">Nicht ausrüstbar</span>
} @else if (!meetsLevelRequirement()) {
<button type="button" class="inventory-detail__equip" data-detail-equip disabled>
Benötigt Stufe {{ item.item.requiredLevel }}
</button>
} @else {
<button
type="button"
class="inventory-detail__equip"
data-detail-equip
[disabled]="busy()"
(click)="onEquip()"
>
Ausrüsten
</button>
}
</div>
</article>
} @else {
<p class="inventory-detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
}

View File

@@ -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;
}

View File

@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-detail-equip]');
expect(button?.disabled).toBe(true);
});
});

View File

@@ -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<Record<EquipmentSlot, string>> = {
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 §3237). */
@Component({
selector: 'app-inventory-detail-panel',
templateUrl: './inventory-detail-panel.component.html',
styleUrl: './inventory-detail-panel.component.scss',
})
export class InventoryDetailPanelComponent {
readonly item = input<InventoryItem | null>(null);
readonly equippedItemInSlot = input<InventoryItem | null>(null);
readonly characterLevel = input(1);
readonly busy = input(false);
readonly equip = output<string>();
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<StatRow[]>(() => {
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);
}
}
}

View File

@@ -0,0 +1,65 @@
<!-- apps/web/src/app/features/inventory/inventory-page.component.html -->
<section class="inventory-page" aria-label="Inventar">
@if (inventoryStore.loading() && !inventoryStore.inventory()) {
<p class="inventory-page__notice" role="status">Inventar wird geladen…</p>
} @else if (inventoryStore.inventory(); as inventory) {
<section class="inventory-page__grid" aria-label="Gegenstände">
@for (entry of inventory.items; track entry.id) {
<button
type="button"
class="inventory-page__slot"
[class.inventory-page__slot--selected]="inventoryStore.selectedItemId() === entry.id"
[attr.aria-pressed]="inventoryStore.selectedItemId() === entry.id"
(click)="selectItem(entry.id)"
>
<app-item-card [item]="entry.item" [quantity]="entry.quantity" />
@if (entry.equipped) {
<span class="inventory-page__equipped-badge" data-slot-equipped>Ausgerüstet</span>
}
</button>
} @empty {
<p class="inventory-page__empty" data-inventory-empty>Noch keine Gegenstände gefunden.</p>
}
</section>
<aside class="inventory-page__side" aria-label="Details und Ausrüstung">
<app-inventory-detail-panel
[item]="inventoryStore.selectedItem()"
[equippedItemInSlot]="equippedItemInSelectedSlot()"
[characterLevel]="characterLevel()"
[busy]="inventoryStore.equipping()"
(equip)="equipSelected($event)"
/>
@if (inventoryStore.equipment(); as equipment) {
<section class="inventory-page__equipment" aria-label="Ausrüstung">
<h3>Ausrüstung</h3>
<ul class="inventory-page__equipment-list">
@for (slot of slotOrder; track slot) {
<li>
<span class="inventory-page__equipment-slot-label">{{ slotLabels[slot] }}</span>
<span class="inventory-page__equipment-slot-value">
{{ equipment.slots[slot]?.item?.name ?? 'Leer' }}
</span>
</li>
}
</ul>
<dl class="inventory-page__stats" data-inventory-stats>
<div><dt>Leben</dt><dd>{{ equipment.stats.maxHp }}</dd></div>
<div><dt>Angriff</dt><dd>{{ equipment.stats.attack }}</dd></div>
<div><dt>Waffenschaden</dt><dd>{{ equipment.stats.weaponDamage }}</dd></div>
<div><dt>Rüstung</dt><dd>{{ equipment.stats.armor }}</dd></div>
</dl>
</section>
}
</aside>
}
@if (inventoryStore.error(); as error) {
<section class="inventory-page__notice inventory-page__notice--error" role="alert">
<p>{{ error }}</p>
<button type="button" data-inventory-retry (click)="retry()">Erneut versuchen</button>
</section>
}
</section>

View File

@@ -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;
}
}

View File

@@ -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<string | null>(options.selectedItemId ?? null),
loading: signal(false),
equipping: signal(false),
error: signal<string | null>(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<HTMLButtonElement>('.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');
});
});

View File

@@ -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<Record<EquipmentSlot, string>> = {
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<void> {
await this.inventoryStore.equip(characterItemId);
}
protected retry(): void {
void this.inventoryStore.load();
}
}

View File

@@ -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<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 () => {
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.');
});
});

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;
}
}

View File

@@ -55,9 +55,12 @@
<button
class="side-navigation__item"
type="button"
routerLink="/inventory"
routerLinkActive="side-navigation__item--active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
data-navigation="inventory"
disabled
aria-label="Inventar ist noch nicht verfügbar"
aria-label="Inventar"
>
<img src="/images/hud/runtime/InventoryIcon-128.png" alt="" />
<span>Inventar</span>