feat(combat): rejoin the running combat instead of dead-ending

Attacking while a combat is already active returned COMBAT_ALREADY_ACTIVE
and left the hunt page showing an error the player could not act on, with
no way back into the fight they were already in.

Add GET /api/combats/active so the client can resolve that combat, and
have the hunt page navigate into it when an attack is rejected for this
reason. CombatStore now also exposes the error code so callers can tell
this case apart from a genuinely failed attack.
This commit is contained in:
Bastian Wagner
2026-08-19 22:45:53 +02:00
parent 92b9f1cd35
commit f33b0c0e4a
10 changed files with 244 additions and 7 deletions

View File

@@ -65,6 +65,14 @@ describe('GameApiService', () => {
request.flush({});
});
it('gets the running combat from the literal active route', () => {
service.getActiveCombat().subscribe();
const request = http.expectOne('/api/combats/active');
expect(request.request.method).toBe('GET');
request.flush({});
});
it('posts only the action enum when performing a combat action', () => {
service.performCombatAction('combat-uuid', 'ATTACK').subscribe();

View File

@@ -42,6 +42,10 @@ export class GameApiService {
return this.http.get<Combat>(`/api/combats/${combatId}`);
}
getActiveCombat(): Observable<Combat | null> {
return this.http.get<Combat | null>('/api/combats/active');
}
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
}

View File

@@ -37,6 +37,7 @@ describe('CombatStore', () => {
let api: {
startCombat: ReturnType<typeof vi.fn>;
getCombat: ReturnType<typeof vi.fn>;
getActiveCombat: ReturnType<typeof vi.fn>;
performCombatAction: ReturnType<typeof vi.fn>;
};
let store: CombatStore;
@@ -45,6 +46,7 @@ describe('CombatStore', () => {
api = {
startCombat: vi.fn(() => of(startedCombat)),
getCombat: vi.fn(() => of(startedCombat)),
getActiveCombat: vi.fn(() => of(startedCombat)),
performCombatAction: vi.fn(() => of(afterAttack)),
};
@@ -77,6 +79,37 @@ describe('CombatStore', () => {
expect(store.combat()).toBeNull();
expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.');
expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE');
});
it('loads the running combat and clears the error that sent us looking for it', async () => {
api.startCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' },
}),
),
);
await store.startCombat('encounter-1');
const active = await store.loadActiveCombat();
expect(api.getActiveCombat).toHaveBeenCalledOnce();
expect(active).toEqual(startedCombat);
expect(store.combat()).toEqual(startedCombat);
expect(store.error()).toBeNull();
expect(store.errorCode()).toBeNull();
});
it('resolves null and keeps the combat empty when no fight is running', async () => {
api.getActiveCombat.mockReturnValue(of(null));
const active = await store.loadActiveCombat();
expect(active).toBeNull();
expect(store.combat()).toBeNull();
});
it('loads a combat by id', async () => {

View File

@@ -24,24 +24,46 @@ export class CombatStore {
private readonly loadingState = signal(false);
private readonly actionPendingState = signal(false);
private readonly errorState = signal<string | null>(null);
private readonly errorCodeState = signal<string | null>(null);
readonly combat = this.combatState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly actionPending = this.actionPendingState.asReadonly();
readonly error = this.errorState.asReadonly();
readonly errorCode = this.errorCodeState.asReadonly();
constructor(private readonly api: GameApiService) {}
async startCombat(encounterId: string): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
this.clearError();
try {
const combat = await firstValueFrom(this.api.startCombat(encounterId));
this.combatState.set(combat);
} catch (error) {
this.combatState.set(null);
this.errorState.set(this.toErrorMessage(error));
this.setError(error);
} finally {
this.loadingState.set(false);
}
}
// Resolves the combat the character is already in, so an attack rejected with
// COMBAT_ALREADY_ACTIVE can rejoin that fight instead of dead-ending.
async loadActiveCombat(): Promise<Combat | null> {
this.loadingState.set(true);
try {
const combat = await firstValueFrom(this.api.getActiveCombat());
if (combat) {
this.combatState.set(combat);
this.clearError();
}
return combat;
} catch (error) {
this.setError(error);
return null;
} finally {
this.loadingState.set(false);
}
@@ -49,13 +71,13 @@ export class CombatStore {
async loadCombat(combatId: string): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
this.clearError();
try {
const combat = await firstValueFrom(this.api.getCombat(combatId));
this.combatState.set(combat);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
this.setError(error);
} finally {
this.loadingState.set(false);
}
@@ -68,13 +90,13 @@ export class CombatStore {
}
this.actionPendingState.set(true);
this.errorState.set(null);
this.clearError();
try {
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
this.combatState.set(updated);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
this.setError(error);
} finally {
this.actionPendingState.set(false);
}
@@ -82,6 +104,20 @@ export class CombatStore {
clearError(): void {
this.errorState.set(null);
this.errorCodeState.set(null);
}
private setError(error: unknown): void {
this.errorCodeState.set(this.toErrorCode(error));
this.errorState.set(this.toErrorMessage(error));
}
private toErrorCode(error: unknown): string | null {
if (error instanceof HttpErrorResponse) {
return (error.error as { code?: string } | null)?.code ?? null;
}
return null;
}
private toErrorMessage(error: unknown): string {

View File

@@ -98,7 +98,9 @@ describe('HuntPageComponent', () => {
let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>;
error: ReturnType<typeof signal<string | null>>;
errorCode: ReturnType<typeof signal<string | null>>;
startCombat: ReturnType<typeof vi.fn>;
loadActiveCombat: ReturnType<typeof vi.fn>;
clearError: ReturnType<typeof vi.fn>;
};
let router: Router;
@@ -118,7 +120,9 @@ describe('HuntPageComponent', () => {
combatStore = {
combat: signal<Combat | null>(null),
error: signal<string | null>(null),
errorCode: signal<string | null>(null),
startCombat: vi.fn(() => Promise.resolve()),
loadActiveCombat: vi.fn(() => Promise.resolve(null)),
clearError: vi.fn(),
};
@@ -226,6 +230,47 @@ describe('HuntPageComponent', () => {
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
});
it('rejoins the running combat when the attack is rejected with COMBAT_ALREADY_ACTIVE', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('COMBAT_ALREADY_ACTIVE');
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
});
combatStore.loadActiveCombat.mockResolvedValue({ ...startedCombat, id: 'combat-running' });
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();
await Promise.resolve();
expect(combatStore.loadActiveCombat).toHaveBeenCalledOnce();
expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-running']);
});
it('does not look for a running combat when the attack fails for another reason', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('HUNT_ENCOUNTER_ALREADY_CONSUMED');
combatStore.error.set('Diese Begegnung wurde bereits genutzt.');
});
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();
await Promise.resolve();
expect(combatStore.loadActiveCombat).not.toHaveBeenCalled();
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.');

View File

@@ -48,6 +48,16 @@ export class HuntPageComponent implements OnInit {
const combat = this.combatStore.combat();
if (combat) {
void this.router.navigate(['/combat', combat.id]);
return;
}
// A fight already running is not a dead end: rejoin it rather than
// leaving the player stuck behind an error they cannot act on.
if (this.combatStore.errorCode() === 'COMBAT_ALREADY_ACTIVE') {
const active = await this.combatStore.loadActiveCombat();
if (active) {
void this.router.navigate(['/combat', active.id]);
}
}
}