Files
healer-man/src/game/combatResults.test.ts
T
2026-08-14 15:56:39 -04:00

175 lines
5.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { EMPTY_ITEM_STATS } from "./itemStats";
import {
baseMissChancePercent,
combatAttackTable,
combatAttackerFromCharacterStats,
combatDefenderFromCharacterStats,
resolveCombatResult,
type CombatRollSource,
type ResolveCombatResultInput,
} from "./combatResults";
import { deriveCharacterStats } from "./wotlkStats";
function fixedRoll(outcome: number, resistance = 0.5): CombatRollSource {
return (stage) => stage === "outcome" ? outcome : resistance;
}
function physicalAttack(overrides: Partial<ResolveCombatResultInput> = {}): ResolveCombatResultInput {
return {
amount: 100,
attackKind: "melee",
damageSchool: "physical",
attacker: { level: 80, criticalChancePercent: 20 },
defender: {
level: 80,
dodgePercent: 10,
parryPercent: 10,
blockPercent: 10,
blockValue: 20,
},
...overrides,
};
}
describe("discrete combat results", () => {
it("builds WotLK-shaped special, spell, and dual-wield miss chances", () => {
expect(baseMissChancePercent("melee", 80, 80)).toBe(5);
expect(baseMissChancePercent("melee", 80, 83)).toBe(8);
expect(baseMissChancePercent("spell", 80, 83)).toBe(17);
expect(baseMissChancePercent("melee", 80, 80, true)).toBe(24);
});
it.each([
[0.01, "miss"],
[0.10, "dodge"],
[0.20, "parry"],
[0.30, "block"],
[0.40, "crit"],
[0.80, "hit"],
] as const)("resolves roll %s as %s on a single attack table", (roll, outcome) => {
const result = resolveCombatResult({ ...physicalAttack(), roll: fixedRoll(roll) });
expect(result.outcome).toBe(outcome);
expect(result.landed).toBe(["block", "crit", "hit"].includes(outcome));
expect(result.avoided).toBe(["miss", "dodge", "parry"].includes(outcome));
if (outcome === "block") expect(result.blockedAmount).toBe(20);
if (outcome === "crit") expect(result.damageBeforeMitigation).toBe(200);
});
it("applies hit and expertise before rolling and exposes the resulting table", () => {
const input = physicalAttack({
attacker: {
level: 80,
hitPercent: 5,
expertiseReductionPercent: 10,
criticalChancePercent: 20,
},
});
expect(combatAttackTable(input)).toEqual({
missPercent: 0,
dodgePercent: 0,
parryPercent: 0,
blockPercent: 10,
criticalPercent: 20,
hitPercent: 70,
});
});
it("reduces physical damage with effective armor after armor penetration", () => {
const result = resolveCombatResult({
...physicalAttack({
attacker: { level: 80, hitPercent: 100, armorPenetrationPercent: 50 },
defender: { level: 80, armor: 2_000 },
}),
roll: fixedRoll(0.5),
});
expect(result.outcome).toBe("hit");
expect(result.effectiveArmor).toBe(1_000);
expect(result.armorReductionPercent).toBeGreaterThan(0);
expect(result.armorMitigatedAmount).toBeGreaterThan(0);
expect(result.finalDamage).toBe(100 - result.armorMitigatedAmount);
});
it("fully resists binary spells using a separate deterministic resistance roll", () => {
const result = resolveCombatResult({
amount: 100,
attackKind: "spell",
damageSchool: "fire",
attacker: { level: 80, hitPercent: 4 },
defender: { level: 80, resistances: { fire: 400 } },
rules: { resistanceMode: "binary" },
roll: fixedRoll(0.5, 0.2),
});
expect(result.outcome).toBe("resist");
expect(result.fullyResisted).toBe(true);
expect(result.avoided).toBe(true);
expect(result.resistedAmount).toBe(100);
expect(result.damageBeforeMitigation).toBe(100);
expect(result.finalDamage).toBe(0);
});
it("rolls partial resistance in 10% buckets and honors spell penetration", () => {
const resisted = resolveCombatResult({
amount: 100,
attackKind: "spell",
damageSchool: "frost",
attacker: { level: 80, hitPercent: 4, spellPenetration: 100 },
defender: { level: 80, resistances: { frost: 500 } },
roll: fixedRoll(0.5, 0.5),
});
expect(resisted.outcome).toBe("hit");
expect(resisted.resistanceFraction).toBeCloseTo(0.4);
expect(resisted.resistedAmount).toBe(40);
expect(resisted.finalDamage).toBe(60);
expect(resisted.partiallyResisted).toBe(true);
});
it("reports critical, block, resistance, and flat damage-reduction metadata exactly", () => {
const result = resolveCombatResult({
amount: 100,
attackKind: "melee",
damageSchool: "physical",
attacker: { level: 80, hitPercent: 100, criticalChancePercent: 100 },
defender: { level: 80, damageReductionPercent: 25 },
roll: fixedRoll(0.5),
});
expect(result).toMatchObject({
outcome: "crit",
landed: true,
critical: true,
avoided: false,
rawDamage: 100,
damageBeforeMitigation: 200,
armorMitigatedAmount: 0,
blockedAmount: 0,
resistedAmount: 0,
damageReductionAmount: 50,
finalDamage: 150,
});
});
it("adapts derived character stats for outgoing and incoming attacks", () => {
const stats = deriveCharacterStats("warrior", 80, {
...EMPTY_ITEM_STATS,
armor: 1_000,
hitRating: 100,
criticalStrikeRating: 100,
expertiseRating: 100,
armorPenetrationRating: 100,
blockValue: 50,
fireResistance: 40,
});
const attacker = combatAttackerFromCharacterStats(stats, "melee");
const defender = combatDefenderFromCharacterStats(stats);
expect(attacker.hitPercent).toBe(stats.meleeHitPercent);
expect(attacker.criticalChancePercent).toBe(stats.meleeCritPercent);
expect(attacker.expertiseReductionPercent).toBe(stats.expertiseReductionPercent);
expect(defender).toMatchObject({
level: 80,
armor: stats.armor,
blockValue: stats.blockValue,
resistances: { fire: 40 },
});
});
});