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 { 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("lets a source-marked bleed tick bypass armor without skipping other target reductions", () => { const result = resolveCombatResult({ ...physicalAttack({ attacker: { level: 80, hitPercent: 100 }, defender: { level: 80, armor: 100_000, damageReductionPercent: 20 }, rules: { ignoreArmor: true, canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, }), roll: fixedRoll(0.5), }); expect(result).toMatchObject({ outcome: "hit", effectiveArmor: 0, armorReductionPercent: 0, armorMitigatedAmount: 0, damageReductionAmount: 20, finalDamage: 80, }); }); 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 }, }); }); });