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 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 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 { export interface CombatEvent {
round: number; round: number;
@@ -166,6 +167,8 @@ export interface CombatPlayer {
name: string; name: string;
maxHp: number; maxHp: number;
currentHp: number; currentHp: number;
potionsRemaining: number;
potionsMax: number;
} }
export interface CombatMonster { export interface CombatMonster {
@@ -175,6 +178,7 @@ export interface CombatMonster {
maxHp: number; maxHp: number;
currentHp: number; currentHp: number;
artworkPath: string; artworkPath: string;
pendingIntent: CombatMonsterIntent | null;
} }
export interface Combat { export interface Combat {

View File

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

View File

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

View File

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

View File

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