Files
healer-man/src/game/combatSpellBlocking.test.ts
T
2026-10-06 22:25:34 -04:00

144 lines
6.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { resolveCombatResult, type CombatRollStage, type ResolveCombatResultInput } from "./combatResults";
function directSpell(overrides: Partial<ResolveCombatResultInput> = {}): ResolveCombatResultInput {
return {
amount: 100, attackKind: "melee", damageSchool: "physical",
attacker: { level: 30, criticalChancePercent: 20 },
defender: { level: 30, blockPercent: 80, blockValue: 15 },
rules: { blockMode: "post-hit", canMiss: false },
roll: (stage) => stage === "block" ? 0.1 : 0.8,
...overrides,
};
}
describe("source direct spell post-hit block", () => {
it("keeps block outside the attack table and independently mitigates a critical hit", () => {
const result = resolveCombatResult(directSpell({ roll: () => 0.1 }));
expect(result.attackTable.blockPercent).toBe(0);
expect(result.attackTable.criticalPercent).toBe(20);
expect(result).toMatchObject({ outcome: "crit", critical: true, blocked: true,
damageBeforeMitigation: 200, blockedAmount: 15, finalDamage: 185,
postHitBlockRoll: 0.1, postHitBlockChancePercent: 80 });
});
it("records a normal blocked hit and caps block value at remaining damage", () => {
expect(resolveCombatResult(directSpell({ defender: { level: 30, blockPercent: 80, blockValue: 500 } })))
.toMatchObject({ outcome: "block", critical: false, blocked: true, blockedAmount: 100, finalDamage: 0 });
});
it("does not reinterpret a failed independent block roll as an attack-table block", () => {
expect(resolveCombatResult(directSpell({ roll: () => 0.9 })))
.toMatchObject({ outcome: "hit", blocked: false, finalDamage: 100 });
});
it.each(["miss", "dodge", "parry", "immune"] as const)("does not roll a block after %s", (avoidance) => {
const stages: CombatRollStage[] = [];
const result = resolveCombatResult(directSpell({
attacker: { level: 30, criticalChancePercent: 0 },
defender: { level: 30, dodgePercent: avoidance === "dodge" ? 100 : 0,
parryPercent: avoidance === "parry" ? 100 : 0, blockPercent: 100, blockValue: 15 },
rules: { blockMode: "post-hit", canMiss: avoidance === "miss", immune: avoidance === "immune" },
roll: (stage) => { stages.push(stage); return 0; },
}));
expect(result.landed).toBe(false);
expect(stages).toEqual(["outcome"]);
expect(result.blocked).toBe(false);
});
it.each([
{ damageSchool: "arcane" as const },
{ attackKind: "spell" as const },
{ rules: { blockMode: "post-hit" as const, canBlock: false, canMiss: false } },
])("requires a physical weapon packet and an enabled block rule: %j", (overrides) => {
const stages: CombatRollStage[] = [];
const result = resolveCombatResult(directSpell({ ...overrides,
roll: (stage) => { stages.push(stage); return 0.8; } }));
expect(result.blocked).toBe(false);
expect(stages).toEqual(["outcome"]);
});
it("preserves the single-roll autoattack table", () => {
const stages: CombatRollStage[] = [];
const result = resolveCombatResult(directSpell({ rules: { isAutoAttack: true, canMiss: false },
roll: (stage) => { stages.push(stage); return 0.1; } }));
expect(result).toMatchObject({ outcome: "block", critical: false, blocked: true, finalDamage: 85 });
expect(stages).toEqual(["outcome"]);
expect(result.postHitBlockRoll).toBeUndefined();
});
});
describe("source critical block roll", () => {
it("doubles an attack-table block after an independent integer roll", () => {
const stages: CombatRollStage[] = [];
const result = resolveCombatResult(directSpell({
attacker: { level: 30, criticalChancePercent: 0 },
defender: { level: 30, blockPercent: 80, blockValue: 15, criticalBlockChancePercent: 20 },
rules: { canMiss: false },
roll: (stage) => {
stages.push(stage);
return stage === "outcome" ? 0.1 : 0.199;
},
}));
expect(result).toMatchObject({ outcome: "block", blocked: true,
blockedAmount: 30, finalDamage: 70, criticalBlockChancePercent: 20,
criticalBlockRoll: 19, criticalBlocked: true });
expect(stages).toEqual(["outcome", "critical-block"]);
});
it("doubles a successful post-hit block and samples after the ordinary block", () => {
const stages: CombatRollStage[] = [];
const result = resolveCombatResult(directSpell({
defender: { level: 30, blockPercent: 80, blockValue: 15, criticalBlockChancePercent: 20 },
roll: (stage) => {
stages.push(stage);
return stage === "outcome" ? 0.8 : stage === "block" ? 0.1 : 0.199;
},
}));
expect(result).toMatchObject({ outcome: "block", blocked: true, blockedAmount: 30,
finalDamage: 70, postHitBlockRoll: 0.1, criticalBlockRoll: 19, criticalBlocked: true });
expect(stages).toEqual(["outcome", "block", "critical-block"]);
});
it("keeps ordinary block value when the independent integer roll fails", () => {
const result = resolveCombatResult(directSpell({
defender: { level: 30, blockPercent: 80, blockValue: 15, criticalBlockChancePercent: 20 },
roll: (stage) => stage === "outcome" ? 0.8 : stage === "block" ? 0.1 : 0.2,
}));
expect(result).toMatchObject({ blocked: true, blockedAmount: 15, finalDamage: 85,
criticalBlockRoll: 20, criticalBlocked: false });
});
it("does not roll critical block when the attack did not block", () => {
const stages: CombatRollStage[] = [];
const result = resolveCombatResult(directSpell({
defender: { level: 30, blockPercent: 80, blockValue: 15, criticalBlockChancePercent: 100 },
roll: (stage) => { stages.push(stage); return 0.99; },
}));
expect(result).toMatchObject({ outcome: "hit", blocked: false, blockedAmount: 0 });
expect(result.criticalBlocked).toBeUndefined();
expect(stages).toEqual(["outcome", "block"]);
});
it.each([
{ chance: 0, expected: false, blockedAmount: 15 },
{ chance: 100, expected: true, blockedAmount: 30 },
])("matches roll_chance_i at $chance percent while consuming its 0..99 sample", ({ chance, expected, blockedAmount }) => {
const stages: CombatRollStage[] = [];
const result = resolveCombatResult(directSpell({
attacker: { level: 30, criticalChancePercent: 0 },
defender: { level: 30, blockPercent: 80, blockValue: 15, criticalBlockChancePercent: chance },
rules: { canMiss: false },
roll: (stage) => { stages.push(stage); return stage === "outcome" ? 0.1 : 0.5; },
}));
expect(result).toMatchObject({ blocked: true, blockedAmount, criticalBlockChancePercent: chance,
criticalBlockRoll: 50, criticalBlocked: expected });
expect(stages).toEqual(["outcome", "critical-block"]);
});
});