feat(combat): start real combats from the hunt page and navigate to /combat/:combatId

This commit is contained in:
Bastian Wagner
2026-08-19 17:03:35 +02:00
parent 1fd62cddde
commit aa8c374db0
3 changed files with 95 additions and 28 deletions

View File

@@ -64,4 +64,11 @@
<button type="button" data-hunt-retry (click)="retry()">Erneut versuchen</button> <button type="button" data-hunt-retry (click)="retry()">Erneut versuchen</button>
</section> </section>
} }
@if (combatStore.error(); as combatError) {
<section class="hunt-page__error" role="alert">
<p>{{ combatError }}</p>
<button type="button" data-hunt-combat-dismiss (click)="dismissCombatError()">Schließen</button>
</section>
}
</section> </section>

View File

@@ -1,8 +1,10 @@
// apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router'; import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest'; import { vi } from 'vitest';
import type { CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models'; import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store'; import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store'; import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component'; import { HuntPageComponent } from './hunt-page.component';
@@ -42,12 +44,7 @@ const threeEncounterHunt: HuntResult = {
encounters: [ encounters: [
{ {
id: 'encounter-1', id: 'encounter-1',
monster: { monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/enemies/AshRat.png',
},
dangerRating: 'WEAK', dangerRating: 'WEAK',
}, },
{ {
@@ -62,17 +59,28 @@ const threeEncounterHunt: HuntResult = {
}, },
{ {
id: 'encounter-3', id: 'encounter-3',
monster: { monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/enemies/AshRat.png',
},
dangerRating: 'WEAK', dangerRating: 'WEAK',
}, },
], ],
}; };
const startedCombat: Combat = {
id: 'combat-2',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
maxHp: 75,
currentHp: 75,
artworkPath: '/images/enemies/RoadBandit.png',
},
events: [],
};
describe('HuntPageComponent', () => { describe('HuntPageComponent', () => {
let worldStore: { let worldStore: {
currentLocation: ReturnType<typeof signal<CurrentLocationResponse | null>>; currentLocation: ReturnType<typeof signal<CurrentLocationResponse | null>>;
@@ -87,6 +95,12 @@ describe('HuntPageComponent', () => {
refreshHunt: ReturnType<typeof vi.fn>; refreshHunt: ReturnType<typeof vi.fn>;
selectEncounter: ReturnType<typeof vi.fn>; selectEncounter: ReturnType<typeof vi.fn>;
}; };
let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>;
error: ReturnType<typeof signal<string | null>>;
startCombat: ReturnType<typeof vi.fn>;
clearError: ReturnType<typeof vi.fn>;
};
let router: Router; let router: Router;
async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) { async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) {
@@ -101,6 +115,12 @@ describe('HuntPageComponent', () => {
refreshHunt: vi.fn(() => Promise.resolve()), refreshHunt: vi.fn(() => Promise.resolve()),
selectEncounter: vi.fn(), selectEncounter: vi.fn(),
}; };
combatStore = {
combat: signal<Combat | null>(null),
error: signal<string | null>(null),
startCombat: vi.fn(() => Promise.resolve()),
clearError: vi.fn(),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [HuntPageComponent], imports: [HuntPageComponent],
@@ -108,6 +128,7 @@ describe('HuntPageComponent', () => {
provideRouter([]), provideRouter([]),
{ provide: WorldStore, useValue: worldStore }, { provide: WorldStore, useValue: worldStore },
{ provide: HuntingStore, useValue: huntingStore }, { provide: HuntingStore, useValue: huntingStore },
{ provide: CombatStore, useValue: combatStore },
], ],
}).compileComponents(); }).compileComponents();
@@ -124,9 +145,7 @@ describe('HuntPageComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Keine Jagd verfügbar'); expect(element.textContent).toContain('Keine Jagd verfügbar');
expect(element.textContent).toContain( expect(element.textContent).toContain('Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.');
'Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.',
);
expect( expect(
Array.from(element.querySelectorAll('button')).some( Array.from(element.querySelectorAll('button')).some(
(button) => button.textContent?.trim() === 'Jagd beginnen', (button) => button.textContent?.trim() === 'Jagd beginnen',
@@ -171,8 +190,11 @@ describe('HuntPageComponent', () => {
expect(huntingStore.refreshHunt).toHaveBeenCalledOnce(); expect(huntingStore.refreshHunt).toHaveBeenCalledOnce();
}); });
it('navigates to /combat/new with the encounter id (not the monster key) when Angreifen is clicked', async () => { it('starts a real combat from the encounter id (not the monster key) and navigates to /combat/:combatId', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt); const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.combat.set(startedCombat);
});
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter( const attackButtons = Array.from(element.querySelectorAll('button')).filter(
@@ -181,14 +203,43 @@ describe('HuntPageComponent', () => {
expect(attackButtons.length).toBe(3); expect(attackButtons.length).toBe(3);
attackButtons[1].click(); attackButtons[1].click();
await Promise.resolve();
await Promise.resolve();
expect(huntingStore.selectEncounter).toHaveBeenCalledWith('encounter-2'); expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-2');
expect(router.navigate).toHaveBeenCalledWith(['/combat/new'], { expect(combatStore.startCombat).not.toHaveBeenCalledWith('road-bandit');
queryParams: { encounterId: 'encounter-2' }, expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-2']);
}); });
expect(router.navigate).not.toHaveBeenCalledWith(['/combat/new'], {
queryParams: { encounterId: 'road-bandit' }, it('does not navigate when starting the combat fails', 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',
);
attackButtons[0].click();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-1');
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
});
it('shows a combat-start error and dismisses it', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const alerts = Array.from(element.querySelectorAll('[role="alert"]'));
expect(alerts.some((alert) => alert.textContent?.includes('Du befindest dich bereits in einem Kampf.'))).toBe(
true,
);
element.querySelector<HTMLButtonElement>('[data-hunt-combat-dismiss]')?.click();
expect(combatStore.clearError).toHaveBeenCalledOnce();
}); });
it('does not trigger a hunt automatically on page entry', async () => { it('does not trigger a hunt automatically on page entry', async () => {

View File

@@ -1,8 +1,9 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store';
import { EncounterCardComponent } from '../encounter-card/encounter-card.component'; import { EncounterCardComponent } from '../encounter-card/encounter-card.component';
import { HuntingStore } from '../hunting.store'; import { HuntingStore } from '../hunting.store';
import { WorldStore } from '../../world/world.store';
@Component({ @Component({
selector: 'app-hunt-page', selector: 'app-hunt-page',
@@ -13,6 +14,7 @@ import { WorldStore } from '../../world/world.store';
export class HuntPageComponent implements OnInit { export class HuntPageComponent implements OnInit {
protected readonly worldStore = inject(WorldStore); protected readonly worldStore = inject(WorldStore);
protected readonly huntingStore = inject(HuntingStore); protected readonly huntingStore = inject(HuntingStore);
protected readonly combatStore = inject(CombatStore);
private readonly router = inject(Router); private readonly router = inject(Router);
ngOnInit(): void { ngOnInit(): void {
@@ -41,8 +43,15 @@ export class HuntPageComponent implements OnInit {
void this.router.navigate(['/world']); void this.router.navigate(['/world']);
} }
protected onAttack(encounterId: string): void { protected async onAttack(encounterId: string): Promise<void> {
this.huntingStore.selectEncounter(encounterId); await this.combatStore.startCombat(encounterId);
void this.router.navigate(['/combat/new'], { queryParams: { encounterId } }); const combat = this.combatStore.combat();
if (combat) {
void this.router.navigate(['/combat', combat.id]);
}
}
protected dismissCombatError(): void {
this.combatStore.clearError();
} }
} }