144 lines
6.6 KiB
TypeScript
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"]);
|
|
});
|
|
});
|