729 lines
21 KiB
TypeScript
729 lines
21 KiB
TypeScript
import { CombatAction } from './combat-action.enum';
|
|
import {
|
|
CombatEngineService,
|
|
UnsupportedCombatActionError,
|
|
} from './combat-engine.service';
|
|
import {
|
|
CombatEngineCombatant,
|
|
CombatEngineState,
|
|
} from './combat-engine.types';
|
|
import { CombatEventType } from './combat-event-type.enum';
|
|
import { CombatStatus } from './combat-status.enum';
|
|
import { Combatant } from './combatant.enum';
|
|
import { StatusEffectType } from './status-effect.enum';
|
|
import type { MonsterAbilities } from '../monsters/monster-abilities';
|
|
|
|
function baseState(
|
|
overrides: Partial<CombatEngineState> = {},
|
|
): CombatEngineState {
|
|
return {
|
|
status: CombatStatus.ACTIVE,
|
|
round: 1,
|
|
player: {
|
|
currentHp: 100,
|
|
maxHp: 100,
|
|
stats: { attack: 6, weaponDamage: 8, armor: 6 },
|
|
},
|
|
// The Ash Rat baseline: no telegraph, no status effect (spec §4).
|
|
monster: monster(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function monster(
|
|
overrides: Partial<CombatEngineCombatant> = {},
|
|
): CombatEngineCombatant {
|
|
return {
|
|
currentHp: 45,
|
|
maxHp: 45,
|
|
stats: { attack: 5, armor: 0 },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A monster whose content grants it an ability. Every special behaviour is
|
|
* configuration now (spec §4, §8), so a test that wants a telegraph or a
|
|
* bleed has to say so -- there is no rule every enemy shares any more.
|
|
*/
|
|
function withAbilities(
|
|
abilities: MonsterAbilities,
|
|
overrides: Partial<CombatEngineCombatant> = {},
|
|
): CombatEngineCombatant {
|
|
const base = monster(overrides);
|
|
return { ...base, stats: { ...base.stats, abilities } };
|
|
}
|
|
|
|
const ROAD_BANDIT_ABILITIES: MonsterAbilities = {
|
|
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
|
|
};
|
|
|
|
const ROAD_HOUND_ABILITIES: MonsterAbilities = {
|
|
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
|
|
};
|
|
|
|
describe('CombatEngineService', () => {
|
|
let engine: CombatEngineService;
|
|
|
|
beforeEach(() => {
|
|
engine = new CombatEngineService();
|
|
});
|
|
|
|
it('reduces monster HP by the calculated damage and emits a DAMAGE event', () => {
|
|
const result = engine.resolveAction(baseState(), {
|
|
action: CombatAction.ATTACK,
|
|
});
|
|
|
|
// raw = 6 + 8 = 14; armor 0 -> 14 mitigated
|
|
expect(result.state.monster.currentHp).toBe(45 - 14);
|
|
expect(result.events[0]).toEqual({
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 14,
|
|
});
|
|
});
|
|
|
|
it('lets the monster retaliate when it survives the player attack, and advances the round', () => {
|
|
const result = engine.resolveAction(baseState(), {
|
|
action: CombatAction.ATTACK,
|
|
});
|
|
|
|
// raw = 5; armor 6 -> 5*60/66 = 4.545 -> rounds to 5
|
|
expect(result.state.player.currentHp).toBe(100 - 5);
|
|
expect(result.events[1]).toEqual({
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 5,
|
|
});
|
|
expect(result.state.status).toBe(CombatStatus.ACTIVE);
|
|
expect(result.state.round).toBe(2);
|
|
});
|
|
|
|
it('does not let the monster attack once it is reduced to 0 HP, and ends the combat as WON', () => {
|
|
const state = baseState({
|
|
monster: { currentHp: 10, maxHp: 45, stats: { attack: 5, armor: 0 } },
|
|
});
|
|
|
|
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
|
|
|
expect(result.state.monster.currentHp).toBe(0);
|
|
expect(result.state.status).toBe(CombatStatus.WON);
|
|
expect(result.state.round).toBe(1);
|
|
expect(result.events).toEqual([
|
|
{
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 14,
|
|
},
|
|
{
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.COMBAT_WON,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('ends the combat as LOST when the monster attack reduces the player to 0 HP', () => {
|
|
const state = baseState({
|
|
player: {
|
|
currentHp: 3,
|
|
maxHp: 100,
|
|
stats: { attack: 6, weaponDamage: 8, armor: 6 },
|
|
},
|
|
});
|
|
|
|
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
|
|
|
expect(result.state.player.currentHp).toBe(0);
|
|
expect(result.state.status).toBe(CombatStatus.LOST);
|
|
expect(result.events).toEqual([
|
|
{
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 14,
|
|
},
|
|
{
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 5,
|
|
},
|
|
{
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.COMBAT_LOST,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('produces the exact same result for the same state and action (determinism)', () => {
|
|
const state = baseState();
|
|
|
|
const first = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
|
const second = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
|
|
|
expect(first).toEqual(second);
|
|
});
|
|
|
|
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
|
|
expect(() =>
|
|
engine.resolveAction(baseState(), { action: 'FLEE' as CombatAction }),
|
|
).toThrow(UnsupportedCombatActionError);
|
|
});
|
|
|
|
it('HEAVY_STRIKE deals 160% damage to the monster', () => {
|
|
const result = engine.resolveAction(baseState(), {
|
|
action: CombatAction.HEAVY_STRIKE,
|
|
});
|
|
|
|
// raw = 14; 14 * 1.6 = 22.4 -> rounds to 22
|
|
expect(result.events[0]).toEqual({
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 22,
|
|
});
|
|
expect(result.state.monster.currentHp).toBe(45 - 22);
|
|
});
|
|
|
|
it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => {
|
|
const result = engine.resolveAction(baseState(), {
|
|
action: CombatAction.SHIELD_BASH,
|
|
});
|
|
|
|
// raw = 14; 14 * 0.7 = 9.8 -> rounds to 10
|
|
expect(result.events[0]).toEqual({
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 10,
|
|
});
|
|
expect(
|
|
result.events.some((event) => event.type === CombatEventType.INTERRUPT),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => {
|
|
const state = baseState({
|
|
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
|
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
|
}),
|
|
});
|
|
|
|
const result = engine.resolveAction(state, {
|
|
action: CombatAction.SHIELD_BASH,
|
|
});
|
|
|
|
expect(result.events).toEqual([
|
|
{
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 10,
|
|
},
|
|
{
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.INTERRUPT,
|
|
},
|
|
]);
|
|
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
|
expect(result.state.player.currentHp).toBe(100);
|
|
expect(result.state.round).toBe(2);
|
|
});
|
|
|
|
it('DEFEND deals no damage and halves the monster normal attack this round', () => {
|
|
const result = engine.resolveAction(baseState(), {
|
|
action: CombatAction.DEFEND,
|
|
});
|
|
|
|
expect(result.state.monster.currentHp).toBe(45);
|
|
// raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2
|
|
expect(result.events).toEqual([
|
|
{
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.DEFEND,
|
|
},
|
|
{
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 2,
|
|
},
|
|
]);
|
|
expect(result.state.player.currentHp).toBe(98);
|
|
});
|
|
|
|
it('POTION heals 35% of max HP, decrements potionsRemaining, and the monster still acts', () => {
|
|
const state = baseState({
|
|
player: {
|
|
currentHp: 60,
|
|
maxHp: 100,
|
|
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 2 },
|
|
},
|
|
});
|
|
|
|
const result = engine.resolveAction(state, { action: CombatAction.POTION });
|
|
|
|
// 35% of 100 = 35
|
|
expect(result.events[0]).toEqual({
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.HEAL,
|
|
amount: 35,
|
|
});
|
|
expect(result.events[1]).toEqual({
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 5,
|
|
});
|
|
// 60 + 35 healed - 5 monster hit = 90
|
|
expect(result.state.player.currentHp).toBe(90);
|
|
expect(result.state.player.stats.potionsRemaining).toBe(1);
|
|
});
|
|
|
|
it('caps POTION healing at the maximum HP', () => {
|
|
const state = baseState({
|
|
player: {
|
|
currentHp: 90,
|
|
maxHp: 100,
|
|
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 1 },
|
|
},
|
|
});
|
|
|
|
const result = engine.resolveAction(state, { action: CombatAction.POTION });
|
|
|
|
// 35% of 100 = 35, but only 10 HP is missing
|
|
expect(result.events[0]).toEqual({
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.HEAL,
|
|
amount: 10,
|
|
});
|
|
});
|
|
|
|
it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => {
|
|
const round3State = baseState({
|
|
round: 3,
|
|
monster: withAbilities(ROAD_BANDIT_ABILITIES),
|
|
});
|
|
|
|
const telegraphResult = engine.resolveAction(round3State, {
|
|
action: CombatAction.ATTACK,
|
|
});
|
|
|
|
expect(telegraphResult.state.player.currentHp).toBe(100);
|
|
expect(telegraphResult.state.monster.stats.pendingAction).toBe(
|
|
'HEAVY_ATTACK',
|
|
);
|
|
expect(telegraphResult.events[1]).toEqual({
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.TELEGRAPH,
|
|
});
|
|
expect(telegraphResult.state.round).toBe(4);
|
|
|
|
const resolveResult = engine.resolveAction(telegraphResult.state, {
|
|
action: CombatAction.ATTACK,
|
|
});
|
|
|
|
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7
|
|
expect(resolveResult.events[1]).toEqual({
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 7,
|
|
});
|
|
expect(resolveResult.state.monster.stats.pendingAction).toBeUndefined();
|
|
expect(resolveResult.state.player.currentHp).toBe(100 - 7);
|
|
});
|
|
|
|
it('does not resolve a pending Heavy Attack when the monster is killed this round', () => {
|
|
const state = baseState({
|
|
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
|
|
currentHp: 10,
|
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
|
}),
|
|
});
|
|
|
|
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
|
|
|
expect(result.state.status).toBe(CombatStatus.WON);
|
|
expect(result.state.player.currentHp).toBe(100);
|
|
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
|
expect(result.events).toEqual([
|
|
{
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 14,
|
|
},
|
|
{
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.MONSTER,
|
|
type: CombatEventType.COMBAT_WON,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('is deterministic for HEAVY_STRIKE as well', () => {
|
|
const state = baseState();
|
|
|
|
const first = engine.resolveAction(state, {
|
|
action: CombatAction.HEAVY_STRIKE,
|
|
});
|
|
const second = engine.resolveAction(state, {
|
|
action: CombatAction.HEAVY_STRIKE,
|
|
});
|
|
|
|
expect(first).toEqual(second);
|
|
});
|
|
|
|
it('DEFEND mitigates a resolving Heavy Attack by half', () => {
|
|
const state = baseState({
|
|
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
|
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
|
}),
|
|
});
|
|
|
|
const result = engine.resolveAction(state, { action: CombatAction.DEFEND });
|
|
|
|
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27; * defend 0.5 = 3.636 -> rounds to 4
|
|
expect(result.events).toEqual([
|
|
{
|
|
source: Combatant.PLAYER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.DEFEND,
|
|
},
|
|
{
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 4,
|
|
},
|
|
]);
|
|
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
|
expect(result.state.player.currentHp).toBe(100 - 4);
|
|
});
|
|
|
|
describe('content-driven monster abilities (spec §4, §8)', () => {
|
|
it('never telegraphs for a monster whose content grants it no telegraph', () => {
|
|
// The Ash Rat is "pure baseline combat": round 3 must be an ordinary
|
|
// exchange for it, even though it is the Road Bandit's wind-up round.
|
|
const result = engine.resolveAction(baseState({ round: 3 }), {
|
|
action: CombatAction.ATTACK,
|
|
});
|
|
|
|
expect(
|
|
result.events.some((event) => event.type === CombatEventType.TELEGRAPH),
|
|
).toBe(false);
|
|
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
|
expect(result.state.player.currentHp).toBe(100 - 5);
|
|
});
|
|
|
|
it('telegraphs on the cadence its own content sets, not a shared one', () => {
|
|
// The Charred Raider winds up every second round rather than every
|
|
// third, which is the whole of its "noticeably stronger" mechanic.
|
|
const raider = withAbilities({
|
|
telegraph: { roundInterval: 2, damageMultiplier: 1.6 },
|
|
});
|
|
|
|
const round2 = engine.resolveAction(
|
|
baseState({ round: 2, monster: raider }),
|
|
{ action: CombatAction.ATTACK },
|
|
);
|
|
const round3 = engine.resolveAction(
|
|
baseState({ round: 3, monster: raider }),
|
|
{ action: CombatAction.ATTACK },
|
|
);
|
|
|
|
expect(round2.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
|
|
expect(round3.state.monster.stats.pendingAction).toBeUndefined();
|
|
});
|
|
|
|
it('uses the damage multiplier from the monster content when the Heavy Attack lands', () => {
|
|
const state = baseState({
|
|
monster: withAbilities(
|
|
{ telegraph: { roundInterval: 3, damageMultiplier: 3 } },
|
|
{ stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' } },
|
|
),
|
|
});
|
|
|
|
const result = engine.resolveAction(state, {
|
|
action: CombatAction.ATTACK,
|
|
});
|
|
|
|
// raw = 5; mitigated = 4.545...; * 3 = 13.6 -> rounds to 14
|
|
expect(result.events[1]).toMatchObject({
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 14,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Bleeding (spec §4)', () => {
|
|
it('applies Bleeding on top of a normal bite, then ticks it the same round', () => {
|
|
const state = baseState({
|
|
round: 3,
|
|
monster: withAbilities(ROAD_HOUND_ABILITIES),
|
|
});
|
|
|
|
const result = engine.resolveAction(state, {
|
|
action: CombatAction.ATTACK,
|
|
});
|
|
|
|
expect(result.events.slice(1)).toEqual([
|
|
{
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.DAMAGE,
|
|
amount: 5,
|
|
},
|
|
{
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.STATUS_APPLIED,
|
|
amount: 2,
|
|
statusEffect: StatusEffectType.BLEED,
|
|
},
|
|
{
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.STATUS_DAMAGE,
|
|
amount: 5,
|
|
statusEffect: StatusEffectType.BLEED,
|
|
},
|
|
]);
|
|
// The bite (5) and the first bleed tick (5) both land this round.
|
|
expect(result.state.player.currentHp).toBe(100 - 10);
|
|
expect(result.state.player.stats.statusEffects).toEqual([
|
|
{
|
|
type: StatusEffectType.BLEED,
|
|
remainingRounds: 1,
|
|
damagePerRound: 5,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('keeps ticking on later rounds and expires once its duration runs out', () => {
|
|
const state = baseState({
|
|
round: 4,
|
|
monster: withAbilities(ROAD_HOUND_ABILITIES),
|
|
player: {
|
|
currentHp: 90,
|
|
maxHp: 100,
|
|
stats: {
|
|
attack: 6,
|
|
weaponDamage: 8,
|
|
armor: 6,
|
|
statusEffects: [
|
|
{
|
|
type: StatusEffectType.BLEED,
|
|
remainingRounds: 1,
|
|
damagePerRound: 5,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = engine.resolveAction(state, {
|
|
action: CombatAction.ATTACK,
|
|
});
|
|
|
|
expect(result.events.map((event) => event.type)).toEqual([
|
|
CombatEventType.DAMAGE,
|
|
CombatEventType.DAMAGE,
|
|
CombatEventType.STATUS_DAMAGE,
|
|
CombatEventType.STATUS_EXPIRED,
|
|
]);
|
|
// Monster bite (5) plus the last bleed tick (5).
|
|
expect(result.state.player.currentHp).toBe(90 - 10);
|
|
expect(result.state.player.stats.statusEffects).toEqual([]);
|
|
});
|
|
|
|
it('ignores armor, because a bleed is an open wound and not a blow', () => {
|
|
const armoured = baseState({
|
|
round: 4,
|
|
player: {
|
|
currentHp: 100,
|
|
maxHp: 100,
|
|
stats: {
|
|
attack: 6,
|
|
weaponDamage: 8,
|
|
armor: 999,
|
|
statusEffects: [
|
|
{
|
|
type: StatusEffectType.BLEED,
|
|
remainingRounds: 2,
|
|
damagePerRound: 5,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = engine.resolveAction(armoured, {
|
|
action: CombatAction.ATTACK,
|
|
});
|
|
|
|
expect(
|
|
result.events.find(
|
|
(event) => event.type === CombatEventType.STATUS_DAMAGE,
|
|
),
|
|
).toMatchObject({ amount: 5 });
|
|
});
|
|
|
|
it('still ticks in a round where SHIELD_BASH interrupted the monster', () => {
|
|
// Bleeding sits on the player: interrupting its source stops the next
|
|
// blow, not the wound already open.
|
|
const state = baseState({
|
|
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
|
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
|
}),
|
|
player: {
|
|
currentHp: 100,
|
|
maxHp: 100,
|
|
stats: {
|
|
attack: 6,
|
|
weaponDamage: 8,
|
|
armor: 6,
|
|
statusEffects: [
|
|
{
|
|
type: StatusEffectType.BLEED,
|
|
remainingRounds: 2,
|
|
damagePerRound: 5,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = engine.resolveAction(state, {
|
|
action: CombatAction.SHIELD_BASH,
|
|
});
|
|
|
|
expect(result.events.map((event) => event.type)).toEqual([
|
|
CombatEventType.DAMAGE,
|
|
CombatEventType.INTERRUPT,
|
|
CombatEventType.STATUS_DAMAGE,
|
|
]);
|
|
expect(result.state.player.currentHp).toBe(95);
|
|
});
|
|
|
|
it('refreshes an existing Bleeding rather than stacking a second one', () => {
|
|
const state = baseState({
|
|
round: 3,
|
|
monster: withAbilities(ROAD_HOUND_ABILITIES),
|
|
player: {
|
|
currentHp: 100,
|
|
maxHp: 100,
|
|
stats: {
|
|
attack: 6,
|
|
weaponDamage: 8,
|
|
armor: 6,
|
|
statusEffects: [
|
|
{
|
|
type: StatusEffectType.BLEED,
|
|
remainingRounds: 1,
|
|
damagePerRound: 5,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = engine.resolveAction(state, {
|
|
action: CombatAction.ATTACK,
|
|
});
|
|
|
|
expect(result.state.player.stats.statusEffects).toHaveLength(1);
|
|
// Re-applied to 2 rounds, then aged by this round's own tick.
|
|
expect(result.state.player.stats.statusEffects?.[0].remainingRounds).toBe(
|
|
1,
|
|
);
|
|
});
|
|
|
|
it('ends the combat as LOST when a bleed tick empties the player', () => {
|
|
const state = baseState({
|
|
round: 4,
|
|
player: {
|
|
currentHp: 4,
|
|
maxHp: 100,
|
|
stats: {
|
|
attack: 6,
|
|
weaponDamage: 8,
|
|
armor: 999,
|
|
statusEffects: [
|
|
{
|
|
type: StatusEffectType.BLEED,
|
|
remainingRounds: 2,
|
|
damagePerRound: 5,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = engine.resolveAction(state, {
|
|
action: CombatAction.DEFEND,
|
|
});
|
|
|
|
expect(result.state.player.currentHp).toBe(0);
|
|
expect(result.state.status).toBe(CombatStatus.LOST);
|
|
expect(result.events.at(-1)).toEqual({
|
|
source: Combatant.MONSTER,
|
|
target: Combatant.PLAYER,
|
|
type: CombatEventType.COMBAT_LOST,
|
|
});
|
|
});
|
|
|
|
it('does not tick a bleed once the killing blow has already won the fight', () => {
|
|
const state = baseState({
|
|
monster: monster({ currentHp: 10 }),
|
|
player: {
|
|
currentHp: 3,
|
|
maxHp: 100,
|
|
stats: {
|
|
attack: 6,
|
|
weaponDamage: 8,
|
|
armor: 6,
|
|
statusEffects: [
|
|
{
|
|
type: StatusEffectType.BLEED,
|
|
remainingRounds: 2,
|
|
damagePerRound: 5,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = engine.resolveAction(state, {
|
|
action: CombatAction.ATTACK,
|
|
});
|
|
|
|
expect(result.state.status).toBe(CombatStatus.WON);
|
|
expect(result.state.player.currentHp).toBe(3);
|
|
});
|
|
|
|
it('does not mutate the state it was given', () => {
|
|
const state = baseState({
|
|
round: 3,
|
|
monster: withAbilities(ROAD_HOUND_ABILITIES),
|
|
});
|
|
const snapshot = structuredClone(state);
|
|
|
|
engine.resolveAction(state, { action: CombatAction.ATTACK });
|
|
|
|
expect(state).toEqual(snapshot);
|
|
});
|
|
});
|
|
});
|