feat(combat): generalize the web combat action and expose potions/monster intent
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user