feat(combat): generalize the web combat action and expose potions/monster intent

This commit is contained in:
Bastian Wagner
2026-08-20 22:01:28 +02:00
parent c7b9601eb4
commit b8d00278b8
5 changed files with 29 additions and 13 deletions

View File

@@ -149,9 +149,10 @@ export interface HuntResult {
}
export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST';
export type CombatEventType = 'DAMAGE' | 'COMBAT_WON' | 'COMBAT_LOST';
export type CombatEventType = 'DAMAGE' | 'HEAL' | 'DEFEND' | 'TELEGRAPH' | 'INTERRUPT' | 'COMBAT_WON' | 'COMBAT_LOST';
export type CombatSide = 'PLAYER' | 'MONSTER';
export type CombatAction = 'ATTACK';
export type CombatAction = 'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION';
export type CombatMonsterIntent = 'HEAVY_ATTACK';
export interface CombatEvent {
round: number;
@@ -166,6 +167,8 @@ export interface CombatPlayer {
name: string;
maxHp: number;
currentHp: number;
potionsRemaining: number;
potionsMax: number;
}
export interface CombatMonster {
@@ -175,6 +178,7 @@ export interface CombatMonster {
maxHp: number;
currentHp: number;
artworkPath: string;
pendingIntent: CombatMonsterIntent | null;
}
export interface Combat {

View File

@@ -9,7 +9,7 @@ const runningCombat: Combat = {
id: 'combat-running',
status: 'ACTIVE',
round: 4,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62 },
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62, potionsRemaining: 2, potionsMax: 2 },
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
@@ -17,6 +17,7 @@ const runningCombat: Combat = {
maxHp: 75,
currentHp: 30,
artworkPath: '/images/enemies/RoadBandit.png',
pendingIntent: null,
},
events: [],
rewards: null,

View File

@@ -10,7 +10,7 @@ const startedCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
monster: {
key: 'ash-rat',
name: 'Aschenratte',
@@ -18,6 +18,7 @@ const startedCombat: Combat = {
maxHp: 45,
currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
},
events: [],
rewards: null,
@@ -139,14 +140,22 @@ describe('CombatStore', () => {
it('sends only the ATTACK action and replaces combat with the server response', async () => {
await store.startCombat('encounter-1');
await store.attack();
await store.performAction('ATTACK');
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK');
expect(store.combat()).toEqual(afterAttack);
});
it('sends whichever action is requested', async () => {
await store.startCombat('encounter-1');
await store.performAction('HEAVY_STRIKE');
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'HEAVY_STRIKE');
});
it('does nothing when attacking without a loaded combat', async () => {
await store.attack();
await store.performAction('ATTACK');
expect(api.performCombatAction).not.toHaveBeenCalled();
});
@@ -162,9 +171,9 @@ describe('CombatStore', () => {
),
);
const first = store.attack();
const first = store.performAction('ATTACK');
expect(store.actionPending()).toBe(true);
const second = store.attack();
const second = store.performAction('ATTACK');
resolveAttack(afterAttack);
await Promise.all([first, second]);
@@ -176,7 +185,7 @@ describe('CombatStore', () => {
await store.startCombat('encounter-1');
api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
await store.attack();
await store.performAction('ATTACK');
expect(store.actionPending()).toBe(false);
expect(store.combat()).toEqual(startedCombat);

View File

@@ -1,7 +1,7 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { Combat } from '../../core/api/game-api.models';
import { Combat, CombatAction } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.';
@@ -16,6 +16,7 @@ const COMBAT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
COMBAT_NO_POTIONS_REMAINING: 'Du hast keine Tränke mehr.',
};
@Injectable({ providedIn: 'root' })
@@ -83,7 +84,7 @@ export class CombatStore {
}
}
async attack(): Promise<void> {
async performAction(action: CombatAction): Promise<void> {
const combat = this.combatState();
if (!combat || this.actionPendingState()) {
return;
@@ -93,7 +94,7 @@ export class CombatStore {
this.clearError();
try {
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, action));
this.combatState.set(updated);
} catch (error) {
this.setError(error);

View File

@@ -51,7 +51,7 @@ const startedCombat: Combat = {
id: 'combat-2',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
@@ -59,6 +59,7 @@ const startedCombat: Combat = {
maxHp: 75,
currentHp: 75,
artworkPath: '/images/enemies/RoadBandit.png',
pendingIntent: null,
},
events: [],
rewards: null,