feat: add hunt page, combat placeholder, and Jagd navigation

Wires up Slice 0.2 end-to-end: HuntPageComponent renders the
hunting-unavailable/ready/loading/encounters-found states off
HuntingStore and WorldStore, Angreifen hands the HuntEncounter id to a
new inert CombatPlaceholderPageComponent via /combat/new, the Jagd nav
entry is enabled with router-driven active state (matching Karte's),
and the context panel now lists possible encounters for hunting-enabled
locations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-19 14:06:57 +02:00
parent 784bd3ce3a
commit 4e684dc45e
11 changed files with 664 additions and 24 deletions

View File

@@ -0,0 +1,67 @@
<section class="hunt-page" aria-label="Jagd">
@if (worldStore.currentLocation(); as location) {
@if (!location.huntingEnabled) {
<section class="hunt-page__unavailable">
<h2>Keine Jagd verfügbar</h2>
<p>
Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres
Gebiet, um nach Gegnern zu suchen.
</p>
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button>
</section>
} @else if (huntingStore.currentHunt(); as hunt) {
<section class="hunt-page__results" [attr.aria-label]="'Begegnungen bei ' + location.name">
<p class="hunt-page__results-heading">{{ location.name }} — Begegnungen</p>
<div class="hunt-page__encounters">
@for (encounter of huntingStore.encounters(); track encounter.id) {
<app-encounter-card [encounter]="encounter" (attack)="onAttack($event)" />
}
</div>
<div class="hunt-page__actions">
<button
type="button"
data-hunt-refresh
[disabled]="huntingStore.loading()"
(click)="refreshHunt()"
>
Neu suchen
</button>
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button>
</div>
</section>
} @else {
<section class="hunt-page__ready">
<h2>{{ location.name }}</h2>
<p>{{ location.description }}</p>
<p class="hunt-page__hint">
Durchsuche die Umgebung nach Spuren und Gegnern, bevor du dich in den Kampf wagst.
</p>
<button
type="button"
data-hunt-start
[disabled]="huntingStore.loading()"
(click)="startHunt()"
>
Jagd beginnen
</button>
</section>
}
@if (huntingStore.loading()) {
<p class="hunt-page__loading" role="status">Du suchst nach Spuren...</p>
}
} @else {
<section class="hunt-page__empty" aria-live="polite">
<p>Jagdgebiet wird vorbereitet.</p>
</section>
}
@if (huntingStore.error(); as error) {
<section class="hunt-page__error" role="alert">
<p>{{ error }}</p>
<button type="button" data-hunt-retry (click)="retry()">Erneut versuchen</button>
</section>
}
</section>

View File

@@ -0,0 +1,127 @@
:host {
display: block;
min-block-size: 100%;
}
.hunt-page {
position: relative;
min-block-size: 100%;
}
.hunt-page__unavailable,
.hunt-page__ready,
.hunt-page__results {
padding: var(--ar-space-5);
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
}
.hunt-page__unavailable h2,
.hunt-page__ready h2 {
margin: 0 0 var(--ar-space-3);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.6rem;
font-weight: 400;
}
.hunt-page__unavailable p,
.hunt-page__ready p {
margin: 0 0 var(--ar-space-4);
color: var(--ar-text-muted);
line-height: 1.55;
}
.hunt-page__hint {
font-style: italic;
}
.hunt-page__results-heading {
margin: 0 0 var(--ar-space-4);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.3rem;
}
.hunt-page__encounters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: var(--ar-space-4);
margin-block-end: var(--ar-space-5);
}
.hunt-page__actions {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-3);
}
.hunt-page__unavailable button,
.hunt-page__ready button,
.hunt-page__actions button {
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);
cursor: pointer;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
}
.hunt-page__unavailable button:hover,
.hunt-page__ready button:not(:disabled):hover,
.hunt-page__actions button:not(:disabled):hover {
border-color: #d6b26b;
background: linear-gradient(180deg, #315067, #1a2c3a);
}
.hunt-page__ready button:disabled,
.hunt-page__actions button:disabled {
cursor: not-allowed;
opacity: 0.52;
}
.hunt-page__loading,
.hunt-page__error,
.hunt-page__empty {
margin: var(--ar-space-4) 0 0;
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
}
.hunt-page__loading,
.hunt-page__empty {
padding: var(--ar-space-4);
color: var(--ar-text-muted);
}
.hunt-page__error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ar-space-4);
padding: var(--ar-space-3) var(--ar-space-4);
border-color: var(--ar-danger);
}
.hunt-page__error p {
margin: 0;
}
.hunt-page__error button {
flex: 0 0 auto;
padding: var(--ar-space-2) var(--ar-space-3);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: #1a2023;
cursor: pointer;
}
@media (width < 720px) {
.hunt-page__encounters {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,222 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component';
const southGate: CurrentLocationResponse = {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [],
possibleMonsters: [],
};
const burnedRoad: CurrentLocationResponse = {
...southGate,
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
connections: [],
};
const threeEncounterHunt: HuntResult = {
id: 'hunt-id',
location: { id: 'burned-road-id', key: 'burned-road', name: 'Verbrannte Straße' },
encounters: [
{
id: 'encounter-1',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/enemies/AshRat.png',
},
dangerRating: 'WEAK',
},
{
id: 'encounter-2',
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
artworkPath: '/images/enemies/RoadBandit.png',
},
dangerRating: 'MATCH',
},
{
id: 'encounter-3',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/enemies/AshRat.png',
},
dangerRating: 'WEAK',
},
],
};
describe('HuntPageComponent', () => {
let worldStore: {
currentLocation: ReturnType<typeof signal<CurrentLocationResponse | null>>;
};
let huntingStore: {
currentHunt: ReturnType<typeof signal<HuntResult | null>>;
loading: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
encounters: () => HuntResult['encounters'];
startHunt: ReturnType<typeof vi.fn>;
refreshHunt: ReturnType<typeof vi.fn>;
selectEncounter: ReturnType<typeof vi.fn>;
};
let router: Router;
async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) {
worldStore = { currentLocation: signal(location) };
const currentHunt = signal(hunt);
huntingStore = {
currentHunt,
loading: signal(false),
error: signal<string | null>(null),
encounters: () => currentHunt()?.encounters ?? [],
startHunt: vi.fn(() => Promise.resolve()),
refreshHunt: vi.fn(() => Promise.resolve()),
selectEncounter: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [HuntPageComponent],
providers: [
provideRouter([]),
{ provide: WorldStore, useValue: worldStore },
{ provide: HuntingStore, useValue: huntingStore },
],
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(HuntPageComponent);
fixture.detectChanges();
return fixture;
}
it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a working Zur Karte action', async () => {
const fixture = await setup(southGate);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Keine Jagd verfügbar');
expect(element.textContent).toContain(
'Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.',
);
expect(
Array.from(element.querySelectorAll('button')).some(
(button) => button.textContent?.trim() === 'Jagd beginnen',
),
).toBe(false);
const toWorldButton = element.querySelector<HTMLButtonElement>('[data-hunt-to-world]');
expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte');
toWorldButton?.click();
expect(router.navigate).toHaveBeenCalledWith(['/world']);
});
it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => {
const fixture = await setup(burnedRoad);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-hunt-start]')?.click();
expect(huntingStore.startHunt).toHaveBeenCalledOnce();
});
it('renders 3 encounter cards, duplicates included, with the correct data', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const cards = element.querySelectorAll('app-encounter-card');
expect(cards.length).toBe(3);
expect(element.textContent).toMatch(/Aschenratte[\s\S]*Straßenräuber[\s\S]*Aschenratte/);
expect(element.querySelectorAll('img[src="/images/enemies/AshRat.png"]').length).toBe(2);
expect(element.querySelectorAll('img[src="/images/enemies/RoadBandit.png"]').length).toBe(1);
expect(element.textContent).toContain('Stufe 1');
expect(element.textContent).toContain('Stufe 3');
});
it('calls refreshHunt when Neu suchen is clicked', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-hunt-refresh]')?.click();
expect(huntingStore.refreshHunt).toHaveBeenCalledOnce();
});
it('navigates to /combat/new with the encounter id (not the monster key) when Angreifen is clicked', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
expect(attackButtons.length).toBe(3);
attackButtons[1].click();
expect(huntingStore.selectEncounter).toHaveBeenCalledWith('encounter-2');
expect(router.navigate).toHaveBeenCalledWith(['/combat/new'], {
queryParams: { encounterId: 'encounter-2' },
});
expect(router.navigate).not.toHaveBeenCalledWith(['/combat/new'], {
queryParams: { encounterId: 'road-bandit' },
});
});
it('does not trigger a hunt automatically on page entry', async () => {
await setup(burnedRoad);
expect(huntingStore.startHunt).not.toHaveBeenCalled();
});
it('shows a loading state and disables the triggering action', async () => {
const fixture = await setup(burnedRoad);
huntingStore.loading.set(true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Du suchst nach Spuren...');
expect(element.querySelector<HTMLButtonElement>('[data-hunt-start]')?.disabled).toBe(true);
});
it('displays a hunting error and retries via startHunt when there is no current hunt', async () => {
const fixture = await setup(burnedRoad);
huntingStore.error.set('An diesem Ort gibt es keine Jagdgebiete.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'An diesem Ort gibt es keine Jagdgebiete.',
);
element.querySelector<HTMLButtonElement>('[data-hunt-retry]')?.click();
expect(huntingStore.startHunt).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,42 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { EncounterCardComponent } from '../encounter-card/encounter-card.component';
import { HuntingStore } from '../hunting.store';
import { WorldStore } from '../../world/world.store';
@Component({
selector: 'app-hunt-page',
imports: [EncounterCardComponent],
templateUrl: './hunt-page.component.html',
styleUrl: './hunt-page.component.scss',
})
export class HuntPageComponent {
protected readonly worldStore = inject(WorldStore);
protected readonly huntingStore = inject(HuntingStore);
private readonly router = inject(Router);
protected startHunt(): void {
void this.huntingStore.startHunt();
}
protected refreshHunt(): void {
void this.huntingStore.refreshHunt();
}
protected retry(): void {
if (this.huntingStore.currentHunt() === null) {
void this.huntingStore.startHunt();
} else {
void this.huntingStore.refreshHunt();
}
}
protected goToWorld(): void {
void this.router.navigate(['/world']);
}
protected onAttack(encounterId: string): void {
this.huntingStore.selectEncounter(encounterId);
void this.router.navigate(['/combat/new'], { queryParams: { encounterId } });
}
}