Files
i-want-to-heal-mmo/src/game/manaBalance.test.ts
T
2026-07-19 16:37:37 -04:00

64 lines
3.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "./healers";
import { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
import type { AbilitySlotId, HealerClassId } from "./types";
const LONG_DUAL_BOSS_SECONDS = 100;
const MINIMUM_ENDING_RESERVE = 20;
const MAXIMUM_ENDING_RESERVE = 50;
/**
* High-pressure reference rotations for the longest intended dual-boss fight.
* Counts include spot healing, maintenance effects, four cleanses, repeated
* group recovery, and both available one-minute cooldown casts.
*/
const REFERENCE_ROTATIONS = {
priest: { ability1: 8, ability2: 8, ability3: 5, ability4: 4, ability5: 5, ability6: 2 },
druid: { ability1: 7, ability2: 9, ability3: 9, ability4: 4, ability5: 5, ability6: 2 },
shaman: { ability1: 7, ability2: 9, ability3: 4, ability4: 4, ability5: 6, ability6: 2 },
paladin: { ability1: 10, ability2: 20, ability3: 4, ability4: 4, ability5: 6, ability6: 2 },
chronomancer: { ability1: 7, ability2: 8, ability3: 8, ability4: 4, ability5: 5, ability6: 2 },
} as const satisfies Record<HealerClassId, Record<AbilitySlotId, number>>;
function rotationManaCost(classId: HealerClassId): number {
const abilities = HEALER_CLASSES[classId].abilities;
return Object.entries(REFERENCE_ROTATIONS[classId]).reduce(
(total, [slotId, casts]) => total + abilities[slotId as AbilitySlotId].mana * casts,
0,
);
}
describe("dual-boss healer mana balance", () => {
const availableMana = BASE_MANA_POOL + MANA_REGEN_PER_SECOND * LONG_DUAL_BOSS_SECONDS;
it.each(HEALER_CLASS_ORDER)("funds %s's high-pressure 100-second rotation with a useful reserve", (classId) => {
const rotation = REFERENCE_ROTATIONS[classId];
const abilities = HEALER_CLASSES[classId].abilities;
const totalCasts = Object.values(rotation).reduce((total, casts) => total + casts, 0);
const remainingMana = availableMana - rotationManaCost(classId);
expect(totalCasts).toBeLessThanOrEqual(LONG_DUAL_BOSS_SECONDS / 0.5);
expect(Object.values(rotation).every((casts) => casts > 0)).toBe(true);
for (const [slotId, casts] of Object.entries(rotation)) {
const cooldown = abilities[slotId as AbilitySlotId].cooldown;
if (cooldown <= 0) continue;
const maximumCasts = Math.floor((LONG_DUAL_BOSS_SECONDS - Number.EPSILON) / cooldown) + 1;
expect(casts).toBeLessThanOrEqual(maximumCasts);
}
expect(remainingMana).toBeGreaterThanOrEqual(MINIMUM_ENDING_RESERVE);
expect(remainingMana).toBeLessThanOrEqual(MAXIMUM_ENDING_RESERVE);
});
it("keeps reference rotation costs close enough that no class gets a dominant mana advantage", () => {
const costs = HEALER_CLASS_ORDER.map(rotationManaCost);
expect(Math.max(...costs) - Math.min(...costs)).toBeLessThanOrEqual(25);
});
it("detects why the previous 100 mana pool was insufficient", () => {
const previousBudget = 100 + MANA_REGEN_PER_SECOND * LONG_DUAL_BOSS_SECONDS;
for (const classId of HEALER_CLASS_ORDER) {
expect(rotationManaCost(classId)).toBeGreaterThan(previousBudget);
}
});
});