Update Healer Man server
This commit is contained in:
1 parent
9d7e0e2f9e
commit
35dfdaed61
94 files changed
+17034
-418
No files matched your search
@@ -0,0 +1,307 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { clearMobRuntimeRegistry, getMobRuntimeTransform, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { LOOT_CATALOG } from "./lootCatalog";
|
||||
import { abilityRequiresOutOfCombat } from "./abilityRules";
|
||||
import { romEffectsForCast } from "./romCombatMechanics";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
|
||||
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||
import {
|
||||
ROM_ROGUE_DAGGER_MASTERY_TAG,
|
||||
ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID,
|
||||
ROM_ROGUE_TREASURE_HUNTER_TAG,
|
||||
romRogueDaggerDamageMultiplier,
|
||||
romRogueShadowWalkerAdjustment,
|
||||
romRogueTreasureHunterLootChance,
|
||||
} from "./romRogueMechanics";
|
||||
|
||||
function effectAuras(abilityId: string) {
|
||||
return abilityById(abilityId)!.effects.filter((effect) => effect.kind === "apply-aura");
|
||||
}
|
||||
|
||||
function testWeapon(weaponType: string, instanceId: string, stats = { ...useCombatStore.getState().gearStats }) {
|
||||
return {
|
||||
instanceId,
|
||||
itemId: 1,
|
||||
itemKey: instanceId,
|
||||
name: `Test ${weaponType}`,
|
||||
quality: "uncommon" as const,
|
||||
inventorySlot: weaponType === "crossbow" ? "two-hand" : "one-hand",
|
||||
armorType: null,
|
||||
weaponType,
|
||||
itemLevel: 1,
|
||||
sourceItemLevel: 1,
|
||||
stats,
|
||||
quantity: 1,
|
||||
sourceBossKey: "test",
|
||||
sourceBossName: "Test",
|
||||
acquiredAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RoM Rogue source mechanics", () => {
|
||||
afterEach(() => resetCombatSpatialProvider());
|
||||
|
||||
beforeEach(() => {
|
||||
resetCombatSpatialProvider();
|
||||
clearMobRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 });
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
registerCombatSpatialProvider({
|
||||
actor: (id) => {
|
||||
if (id === PLAYER_AGGRO_ID) return { id, position: useCombatStore.getState().playerPosition, yaw: 0 };
|
||||
const transform = getMobRuntimeTransform(id);
|
||||
return transform ? { id, ...transform } : null;
|
||||
},
|
||||
hasLineOfSight: () => true,
|
||||
projectGroundPoint: (point) => point,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Dagger Mastery, Treasure Hunter, and Off Hand Mastery as scoped passive tags", () => {
|
||||
const daggerMastery = effectAuras("rom-rogue-primary-3")[0];
|
||||
const treasureHunter = effectAuras("rom-rogue-primary-10")[0];
|
||||
expect(daggerMastery?.kind === "apply-aura" && daggerMastery.aura.utilityTags)
|
||||
.toContain(ROM_ROGUE_DAGGER_MASTERY_TAG);
|
||||
expect(treasureHunter?.kind === "apply-aura" && treasureHunter.aura.utilityTags)
|
||||
.toContain(ROM_ROGUE_TREASURE_HUNTER_TAG);
|
||||
expect(abilityById("rom-rogue-primary-12")!.effects).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies Dagger Mastery only to the Rogue player's physical melee hits with a main-hand dagger", () => {
|
||||
const context = {
|
||||
classId: "rom-rogue",
|
||||
sourceActorId: "player",
|
||||
playerActorId: "player",
|
||||
attackKind: "melee",
|
||||
school: "physical",
|
||||
mainHandDagger: true,
|
||||
passiveTags: [ROM_ROGUE_DAGGER_MASTERY_TAG],
|
||||
};
|
||||
expect(romRogueDaggerDamageMultiplier(context)).toBe(1.1);
|
||||
expect(romRogueDaggerDamageMultiplier({ ...context, mainHandDagger: false })).toBe(1);
|
||||
expect(romRogueDaggerDamageMultiplier({ ...context, classId: "rom-scout" })).toBe(1);
|
||||
expect(romRogueDaggerDamageMultiplier({ ...context, attackKind: "spell" })).toBe(1);
|
||||
expect(romRogueDaggerDamageMultiplier({ ...context, passiveTags: [] })).toBe(1);
|
||||
});
|
||||
|
||||
it("adds Treasure Hunter only to the supported bonus loot chance and clamps at one", () => {
|
||||
expect(romRogueTreasureHunterLootChance(0.2, false)).toBe(0.2);
|
||||
expect(romRogueTreasureHunterLootChance(0.2, true)).toBeCloseTo(0.25);
|
||||
expect(romRogueTreasureHunterLootChance(undefined, true)).toBeCloseTo(0.05);
|
||||
expect(romRogueTreasureHunterLootChance(0.98, true)).toBe(1);
|
||||
});
|
||||
|
||||
it("limits Shadow Walker's in-combat, cost, and cooldown rider to Rogue/Priest Sneak Attack", () => {
|
||||
const sneakAttack = abilityById("rom-rogue-primary-9")!;
|
||||
expect(romRogueShadowWalkerAdjustment(
|
||||
"rom-rogue", "rom-priest", sneakAttack.source, sneakAttack.dbcSpellId,
|
||||
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
|
||||
)).toEqual({ allowsCombatUse: true, energyCostReduction: 10, cooldownIncreaseMs: 10_000 });
|
||||
expect(romRogueShadowWalkerAdjustment(
|
||||
"rom-rogue", "rom-scout", sneakAttack.source, sneakAttack.dbcSpellId,
|
||||
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
|
||||
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
|
||||
expect(romRogueShadowWalkerAdjustment(
|
||||
"rom-rogue", "rom-priest", sneakAttack.source, sneakAttack.dbcSpellId, [],
|
||||
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
|
||||
expect(romRogueShadowWalkerAdjustment(
|
||||
"rom-rogue", "rom-priest", sneakAttack.source, 490306,
|
||||
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
|
||||
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
|
||||
});
|
||||
|
||||
it("adds one bonus boss loot roll only while Treasure Hunter is active", () => {
|
||||
const bossKey = LOOT_CATALOG.bosses[0]!.bossKey;
|
||||
const lootCount = (passiveActive: boolean) => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 });
|
||||
if (!passiveActive) {
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_TREASURE_HUNTER_TAG)),
|
||||
}));
|
||||
}
|
||||
useCombatStore.getState().registerMob("rogue-treasure-target", {
|
||||
name: "Treasure Target", boss: true, maxHealth: 10, lootSourceId: bossKey,
|
||||
});
|
||||
const random = vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
try {
|
||||
useCombatStore.getState().damageMob("rogue-treasure-target", 10, 1_000);
|
||||
} finally {
|
||||
random.mockRestore();
|
||||
}
|
||||
return useCombatStore.getState().mobs["rogue-treasure-target"]!.pendingLoot.length;
|
||||
};
|
||||
const ordinaryDrops = lootCount(false);
|
||||
const treasureHunterDrops = lootCount(true);
|
||||
expect(treasureHunterDrops).toBe(ordinaryDrops + 1);
|
||||
});
|
||||
|
||||
it("keeps Soul Stab's unconditional first hit and gates only the second hit on Weakened", () => {
|
||||
const ability = abilityById("rom-rogue-rom-warlock-elite-25")!;
|
||||
const context = {
|
||||
sealStacks: 0,
|
||||
bleedCount: 0,
|
||||
charged: false,
|
||||
mainHandDagger: false,
|
||||
casterMaxHealth: 2_000,
|
||||
targetHealthPercent: 1,
|
||||
};
|
||||
const ordinary = romEffectsForCast(ability, context).filter((effect) => effect.kind === "damage");
|
||||
const weakened = romEffectsForCast(ability, { ...context, targetTags: ["rom-weakened"] })
|
||||
.filter((effect) => effect.kind === "damage");
|
||||
expect(ordinary).toHaveLength(1);
|
||||
expect(weakened).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("marks Blind Spot and Sneak Attack as rear attacks and preserves Sneak Attack's combat gate", () => {
|
||||
const blindSpot = abilityById("rom-rogue-primary-4")!;
|
||||
const sneakAttack = abilityById("rom-rogue-primary-9")!;
|
||||
expect(blindSpot.targeting?.requiresBehindTarget).toBe(true);
|
||||
expect(sneakAttack.targeting?.requiresBehindTarget).toBe(true);
|
||||
expect(abilityRequiresOutOfCombat(sneakAttack)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects Blind Spot from the front and allows Sneak Attack from behind out of combat", () => {
|
||||
const dagger = testWeapon("dagger", "rogue-position-dagger");
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", raceId: "rom-human", level: 80,
|
||||
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
|
||||
});
|
||||
useCombatStore.getState().registerMob("rogue-rear-target", { name: "Target", maxHealth: 100_000 });
|
||||
setMobPosition("rogue-rear-target", [0, 0, 0]);
|
||||
useCombatStore.getState().selectMob("rogue-rear-target");
|
||||
useCombatStore.setState((state) => ({
|
||||
playerPosition: [0, 0, 2],
|
||||
resource: 100,
|
||||
resourcePools: { ...state.resourcePools, energy: 100 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-4", undefined, 1_000))
|
||||
.toMatchObject({ ok: false, reason: "must-be-behind" });
|
||||
|
||||
useCombatStore.setState((state) => ({ playerPosition: [0, 0, -2], resource: 100,
|
||||
resourcePools: { ...state.resourcePools, energy: 100 } }));
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 2_000).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects Sneak Attack while an enemy is already engaged", () => {
|
||||
const dagger = testWeapon("dagger", "rogue-combat-dagger");
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", raceId: "rom-human", level: 80,
|
||||
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
|
||||
});
|
||||
useCombatStore.getState().registerMob("rogue-engaged-target", { name: "Target", maxHealth: 100_000 });
|
||||
setMobPosition("rogue-engaged-target", [0, 0, 0]);
|
||||
useCombatStore.getState().selectMob("rogue-engaged-target");
|
||||
useCombatStore.setState((state) => ({
|
||||
playerPosition: [0, 0, -2],
|
||||
resource: 100,
|
||||
resourcePools: { ...state.resourcePools, energy: 100 },
|
||||
mobs: { ...state.mobs, "rogue-engaged-target": { ...state.mobs["rogue-engaged-target"]!, engaged: true } },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 1_000))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
});
|
||||
|
||||
it("applies Shadow Walker to combat Sneak Attack with the exact Energy and cooldown rider", () => {
|
||||
const dagger = testWeapon("dagger", "rogue-shadow-walker-dagger");
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
|
||||
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
|
||||
});
|
||||
expect(useCombatStore.getState().abilities.some((candidate) => (
|
||||
candidate.dbcSpellId === ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID && candidate.passive
|
||||
))).toBe(true);
|
||||
useCombatStore.getState().registerMob("rogue-shadow-walker-target", { name: "Target", maxHealth: 100_000 });
|
||||
setMobPosition("rogue-shadow-walker-target", [0, 0, 0]);
|
||||
useCombatStore.getState().selectMob("rogue-shadow-walker-target");
|
||||
useCombatStore.setState((state) => ({
|
||||
playerPosition: [0, 0, -2],
|
||||
resource: 100,
|
||||
resourcePools: { ...state.resourcePools, energy: 100 },
|
||||
mobs: { ...state.mobs, "rogue-shadow-walker-target": { ...state.mobs["rogue-shadow-walker-target"]!, engaged: true } },
|
||||
}));
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().resourcePools.energy).toBe(80);
|
||||
expect(useCombatStore.getState().cooldowns["rom-rogue-primary-9"]).toBe(11_000);
|
||||
});
|
||||
|
||||
it("enforces Energy Conservation's strict below-75% health threshold", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-warlock", raceId: "rom-human", level: 80,
|
||||
});
|
||||
const maximum = useCombatStore.getState().maxHealth;
|
||||
const thresholdHealth = Math.ceil(maximum * 0.75);
|
||||
useCombatStore.setState((state) => ({
|
||||
health: thresholdHealth,
|
||||
resource: 100,
|
||||
resourcePools: { ...state.resourcePools, energy: 100 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warlock-elite-20", undefined, 1_000))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
useCombatStore.setState((state) => ({
|
||||
health: thresholdHealth - 1,
|
||||
resource: 100,
|
||||
resourcePools: { ...state.resourcePools, energy: 100 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warlock-elite-20", undefined, 1_000).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("requires the Rogue/Knight Lion's Protection aura for Power of the Lion", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-knight", raceId: "rom-human", level: 80,
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 100,
|
||||
resourcePools: { ...state.resourcePools, energy: 100, mana: 100 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-40", undefined, 1_000))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-25", undefined, 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-491552")))
|
||||
.toBe(true);
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-40", undefined, 2_600).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("increases Shadowstab damage only with Dagger Mastery and a main-hand dagger", () => {
|
||||
const baselineStats = { ...useCombatStore.getState().gearStats };
|
||||
const hitWith = (weaponType: string, passiveActive: boolean, instanceId: string) => {
|
||||
const weapon = testWeapon(weaponType, instanceId, baselineStats);
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", raceId: "rom-human", level: 80,
|
||||
inventory: [weapon], equipment: { player: { "main-hand": weapon.instanceId } },
|
||||
});
|
||||
const equipped = resolvedEquipmentLoadout(useCombatStore.getState().equipment, useCombatStore.getState().inventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: "rom-rogue",
|
||||
specializationId: null, role: "damage", level: 80,
|
||||
});
|
||||
expect(equipped["main-hand"]?.weaponType).toBe(weaponType);
|
||||
if (!passiveActive) {
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_DAGGER_MASTERY_TAG)),
|
||||
}));
|
||||
}
|
||||
useCombatStore.getState().registerMob("rogue-dagger-mastery-target", { name: "Target", maxHealth: 100_000 });
|
||||
setMobPosition("rogue-dagger-mastery-target", [0, 0, 3]);
|
||||
useCombatStore.getState().selectMob("rogue-dagger-mastery-target");
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 100,
|
||||
resourcePools: { ...state.resourcePools, energy: 100 },
|
||||
}));
|
||||
const before = useCombatStore.getState().mobs["rogue-dagger-mastery-target"]!.health;
|
||||
const cast = useCombatStore.getState().castAbility("rom-rogue-general-0", undefined, 1_000);
|
||||
if (!cast.ok) throw new Error(JSON.stringify(cast));
|
||||
return before - useCombatStore.getState().mobs["rogue-dagger-mastery-target"]!.health;
|
||||
};
|
||||
const baseDaggerDamage = hitWith("dagger", false, "rogue-mastery-dagger-without-passive");
|
||||
const daggerDamage = hitWith("dagger", true, "rogue-mastery-dagger-with-passive");
|
||||
const baseNonDaggerDamage = hitWith("crossbow", false, "rogue-mastery-crossbow-without-passive");
|
||||
const nonDaggerDamage = hitWith("crossbow", true, "rogue-mastery-crossbow-with-passive");
|
||||
expect(daggerDamage).toBeGreaterThan(baseDaggerDamage);
|
||||
expect(daggerDamage / baseDaggerDamage).toBeCloseTo(1.1, 1);
|
||||
expect(nonDaggerDamage).toBe(baseNonDaggerDamage);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user