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:
@@ -14,6 +14,20 @@ export const routes: Routes = [
|
|||||||
(module) => module.WorldPageComponent,
|
(module) => module.WorldPageComponent,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'hunt',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/hunting/hunt-page/hunt-page.component').then(
|
||||||
|
(module) => module.HuntPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'combat/new',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/combat/combat-placeholder-page.component').then(
|
||||||
|
(module) => module.CombatPlaceholderPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '**', redirectTo: 'world' },
|
{ path: '**', redirectTo: 'world' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { signal, WritableSignal } from '@angular/core';
|
import { signal, WritableSignal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { Router, provideRouter } from '@angular/router';
|
||||||
import { CharacterResponse } from './core/api/game-api.models';
|
import { CharacterResponse } from './core/api/game-api.models';
|
||||||
import { WorldStore } from './features/world/world.store';
|
import { WorldStore } from './features/world/world.store';
|
||||||
import { AppShellComponent } from './layout/app-shell/app-shell.component';
|
import { AppShellComponent } from './layout/app-shell/app-shell.component';
|
||||||
@@ -19,7 +19,10 @@ describe('App', () => {
|
|||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AppShellComponent],
|
imports: [AppShellComponent],
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([
|
||||||
|
{ path: 'world', children: [] },
|
||||||
|
{ path: 'hunt', children: [] },
|
||||||
|
]),
|
||||||
{
|
{
|
||||||
provide: WorldStore,
|
provide: WorldStore,
|
||||||
useValue: { character, currentLocation, selectedConnection },
|
useValue: { character, currentLocation, selectedConnection },
|
||||||
@@ -28,9 +31,12 @@ describe('App', () => {
|
|||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the reusable game shell with only Karte available', () => {
|
it('renders the reusable game shell with Karte and Jagd available', async () => {
|
||||||
const fixture = TestBed.createComponent(AppShellComponent);
|
const fixture = TestBed.createComponent(AppShellComponent);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
await router.navigateByUrl('/world');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
expect(element.querySelector('app-top-bar')).not.toBeNull();
|
expect(element.querySelector('app-top-bar')).not.toBeNull();
|
||||||
@@ -44,7 +50,13 @@ describe('App', () => {
|
|||||||
expect(mapButton?.getAttribute('aria-current')).toBe('page');
|
expect(mapButton?.getAttribute('aria-current')).toBe('page');
|
||||||
expect(mapButton?.getAttribute('aria-label')).toBe('Karte');
|
expect(mapButton?.getAttribute('aria-label')).toBe('Karte');
|
||||||
|
|
||||||
for (const destination of ['hunt', 'quests', 'inventory', 'character']) {
|
const huntButton = element.querySelector<HTMLButtonElement>('[data-navigation="hunt"]');
|
||||||
|
expect(huntButton).not.toBeNull();
|
||||||
|
expect(huntButton?.disabled).toBe(false);
|
||||||
|
expect(huntButton?.getAttribute('aria-current')).toBeNull();
|
||||||
|
expect(huntButton?.getAttribute('aria-label')).toBe('Jagd');
|
||||||
|
|
||||||
|
for (const destination of ['quests', 'inventory', 'character']) {
|
||||||
expect(
|
expect(
|
||||||
element.querySelector<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
|
element.querySelector<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -53,6 +65,21 @@ describe('App', () => {
|
|||||||
expect(element.textContent).not.toContain('Shop');
|
expect(element.textContent).not.toContain('Shop');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks Jagd as the active navigation entry while on /hunt', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AppShellComponent);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
await router.navigateByUrl('/hunt');
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const mapButton = element.querySelector<HTMLButtonElement>('[data-navigation="world"]');
|
||||||
|
const huntButton = element.querySelector<HTMLButtonElement>('[data-navigation="hunt"]');
|
||||||
|
|
||||||
|
expect(huntButton?.getAttribute('aria-current')).toBe('page');
|
||||||
|
expect(mapButton?.getAttribute('aria-current')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders loaded character values supplied by the WorldStore', () => {
|
it('renders loaded character values supplied by the WorldStore', () => {
|
||||||
character.set({
|
character.set({
|
||||||
id: 'character-id',
|
id: 'character-id',
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-combat-placeholder-page',
|
||||||
|
template: `
|
||||||
|
<section class="combat-placeholder" aria-label="Kampfvorbereitung">
|
||||||
|
<span class="combat-placeholder__eyebrow">KAMPF</span>
|
||||||
|
<h2>Vorbereitung auf den Kampf</h2>
|
||||||
|
<p>
|
||||||
|
Die Klinge ist gezogen, der Gegner steht bereit – doch das eigentliche Gefecht liegt noch
|
||||||
|
vor dir. Diese Ansicht ist ein Zwischenhalt auf dem Weg in den Kampf, der in einem
|
||||||
|
späteren Schritt folgt.
|
||||||
|
</p>
|
||||||
|
@if (encounterId) {
|
||||||
|
<p class="combat-placeholder__id" data-encounter-id>
|
||||||
|
Vorbereitung auf den Kampf gegen Begegnung {{ encounterId }}…
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-placeholder {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
max-inline-size: 40rem;
|
||||||
|
margin: var(--ar-space-6) auto;
|
||||||
|
padding: var(--ar-space-5);
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
background: var(--ar-panel);
|
||||||
|
box-shadow: var(--ar-shadow-raised);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-placeholder__eyebrow {
|
||||||
|
color: var(--ar-gold);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-placeholder h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-placeholder p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-placeholder__id {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class CombatPlaceholderPageComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
protected readonly encounterId = this.route.snapshot.queryParamMap.get('encounterId');
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,12 @@
|
|||||||
<dt>Jagd</dt>
|
<dt>Jagd</dt>
|
||||||
<dd>{{ location.huntingEnabled ? 'Jagd möglich' : 'Keine Jagd' }}</dd>
|
<dd>{{ location.huntingEnabled ? 'Jagd möglich' : 'Keine Jagd' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
@if (location.huntingEnabled && location.possibleMonsters.length) {
|
||||||
|
<div>
|
||||||
|
<dt>Mögliche Begegnungen</dt>
|
||||||
|
<dd>{{ location.possibleMonsters.join(', ') }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</dl>
|
</dl>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="context-panel__eyebrow">GEBIETSINFO</span>
|
<span class="context-panel__eyebrow">GEBIETSINFO</span>
|
||||||
|
|||||||
@@ -3,15 +3,7 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { WorldStore } from '../../features/world/world.store';
|
import { WorldStore } from '../../features/world/world.store';
|
||||||
import { ContextPanelComponent } from './context-panel.component';
|
import { ContextPanelComponent } from './context-panel.component';
|
||||||
|
|
||||||
describe('ContextPanelComponent', () => {
|
const southGate = {
|
||||||
it('uses a runtime location derivative while preserving the API artwork path as image fallback', async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [ContextPanelComponent],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: WorldStore,
|
|
||||||
useValue: {
|
|
||||||
currentLocation: signal({
|
|
||||||
id: 'south-gate-id',
|
id: 'south-gate-id',
|
||||||
key: 'south-gate',
|
key: 'south-gate',
|
||||||
name: 'Südtor von Graufurt',
|
name: 'Südtor von Graufurt',
|
||||||
@@ -24,7 +16,29 @@ describe('ContextPanelComponent', () => {
|
|||||||
huntingEnabled: false,
|
huntingEnabled: false,
|
||||||
artworkPath: '/images/backgrounds/Suedtor.png',
|
artworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
connections: [],
|
connections: [],
|
||||||
}),
|
possibleMonsters: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const burnedRoad = {
|
||||||
|
...southGate,
|
||||||
|
id: 'burned-road-id',
|
||||||
|
key: 'burned-road',
|
||||||
|
name: 'Verbrannte Straße',
|
||||||
|
isSafe: false,
|
||||||
|
huntingEnabled: true,
|
||||||
|
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ContextPanelComponent', () => {
|
||||||
|
it('uses a runtime location derivative while preserving the API artwork path as image fallback', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ContextPanelComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: WorldStore,
|
||||||
|
useValue: {
|
||||||
|
currentLocation: signal(southGate),
|
||||||
selectedConnection: signal(null),
|
selectedConnection: signal(null),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -42,4 +56,48 @@ describe('ContextPanelComponent', () => {
|
|||||||
'/images/backgrounds/Suedtor.png',
|
'/images/backgrounds/Suedtor.png',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides "Mögliche Begegnungen" when the location has no hunting', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ContextPanelComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: WorldStore,
|
||||||
|
useValue: {
|
||||||
|
currentLocation: signal(southGate),
|
||||||
|
selectedConnection: signal(null),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ContextPanelComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(element.textContent).not.toContain('Mögliche Begegnungen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists possible monsters as plain text when hunting is enabled', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ContextPanelComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: WorldStore,
|
||||||
|
useValue: {
|
||||||
|
currentLocation: signal(burnedRoad),
|
||||||
|
selectedConnection: signal(null),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ContextPanelComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(element.textContent).toContain('Mögliche Begegnungen');
|
||||||
|
expect(element.textContent).toContain('Aschenratte, Straßenräuber');
|
||||||
|
expect(element.textContent).not.toMatch(/\d+\s?%/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<nav class="side-navigation" aria-label="Spielnavigation">
|
<nav class="side-navigation" aria-label="Spielnavigation">
|
||||||
<button
|
<button
|
||||||
class="side-navigation__item side-navigation__item--active"
|
class="side-navigation__item"
|
||||||
type="button"
|
type="button"
|
||||||
routerLink="/world"
|
routerLink="/world"
|
||||||
|
routerLinkActive="side-navigation__item--active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
|
ariaCurrentWhenActive="page"
|
||||||
data-navigation="world"
|
data-navigation="world"
|
||||||
aria-current="page"
|
|
||||||
aria-label="Karte"
|
aria-label="Karte"
|
||||||
>
|
>
|
||||||
<img src="/images/hud/runtime/MapsIcon-128.png" alt="" />
|
<img src="/images/hud/runtime/MapsIcon-128.png" alt="" />
|
||||||
@@ -14,9 +16,12 @@
|
|||||||
<button
|
<button
|
||||||
class="side-navigation__item"
|
class="side-navigation__item"
|
||||||
type="button"
|
type="button"
|
||||||
|
routerLink="/hunt"
|
||||||
|
routerLinkActive="side-navigation__item--active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
|
ariaCurrentWhenActive="page"
|
||||||
data-navigation="hunt"
|
data-navigation="hunt"
|
||||||
disabled
|
aria-label="Jagd"
|
||||||
aria-label="Jagd ist noch nicht verfügbar"
|
|
||||||
>
|
>
|
||||||
<img src="/images/hud/runtime/HuntIcon-128.png" alt="" />
|
<img src="/images/hud/runtime/HuntIcon-128.png" alt="" />
|
||||||
<span>Jagd</span>
|
<span>Jagd</span>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-side-navigation',
|
selector: 'app-side-navigation',
|
||||||
imports: [RouterLink],
|
imports: [RouterLink, RouterLinkActive],
|
||||||
templateUrl: './side-navigation.component.html',
|
templateUrl: './side-navigation.component.html',
|
||||||
styleUrl: './side-navigation.component.scss',
|
styleUrl: './side-navigation.component.scss',
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user