forked from phenom/healer-man
175 lines
5.8 KiB
TypeScript
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 },
|
|
});
|
|
});
|
|
});
|