import { describe, expect, it } from "vitest"; import { resolveCombatResult, type CombatRollStage, type ResolveCombatResultInput } from "./combatResults"; function directSpell(overrides: Partial = {}): 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"]); }); });