forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
35dfdaed61
commit
4fb7a0b9c8
161 files changed
+24618
-529
No files matched your search
@@ -3,6 +3,9 @@ import { PLAYER_AURA_ENTITY_ID, useCombatStore, resolvedAbilityCosts } from "./c
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
|
||||
function shamanAbility(name: string) {
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
||||
@@ -11,9 +14,16 @@ function shamanAbility(name: string) {
|
||||
}
|
||||
|
||||
function setTalentRank(talentId: string, rank: number): void {
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, [talentId]: rank },
|
||||
}));
|
||||
useCombatStore.setState((state) => {
|
||||
const talentRanks = { ...state.talentRanks, [talentId]: rank };
|
||||
const passiveModifiers = state.talentModifiers.filter((modifier) => (
|
||||
modifier.sourceTalentId?.startsWith("class-passive:")
|
||||
));
|
||||
return {
|
||||
talentRanks,
|
||||
talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function castAndFinish(abilityId: string, targetActorId: string | null | undefined, now: number): void {
|
||||
@@ -26,9 +36,10 @@ function castAndFinish(abilityId: string, targetActorId: string | null | undefin
|
||||
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
||||
}
|
||||
|
||||
function setupShaman(): void {
|
||||
async function setupShaman(): Promise<void> {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
await preloadAbilityCatalogsForClasses(["shaman", "mage"]);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
|
||||
useCombatStore.getState().resetSettings();
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
@@ -43,9 +54,200 @@ function setupShaman(): void {
|
||||
useCombatStore.getState().selectMob("shaman-conditional-target");
|
||||
}
|
||||
|
||||
function setMagePartyCaster(): string {
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((candidate) => candidate.id === member.id ? {
|
||||
...candidate,
|
||||
classId: "mage",
|
||||
talentRanks: { "talent-2049": 3 },
|
||||
resourceType: "mana",
|
||||
resourceName: "Mana",
|
||||
} : candidate),
|
||||
}));
|
||||
useCombatStore.setState((state) => {
|
||||
const actors = { ...state.actors };
|
||||
delete actors[member.id];
|
||||
return { actors };
|
||||
});
|
||||
return member.id;
|
||||
}
|
||||
|
||||
function damageWithShamanSpellCrit(sourceActorId?: string, attackKind: "spell" | "melee" | "ranged" = "spell") {
|
||||
const isParty = Boolean(sourceActorId);
|
||||
const sourceMember = sourceActorId
|
||||
? usePartyStore.getState().members.find((member) => member.id === sourceActorId)
|
||||
: undefined;
|
||||
const ability = isParty
|
||||
? abilitiesForClass("mage").find((candidate) => candidate.name === "Fireball")!
|
||||
: shamanAbility("Lightning Bolt");
|
||||
return useCombatStore.getState().damageMob(
|
||||
"shaman-conditional-target",
|
||||
100,
|
||||
10_000 + useCombatStore.getState().combatEvents.length,
|
||||
sourceActorId && sourceMember
|
||||
? { actorId: sourceActorId, role: sourceMember.role, abilityId: ability.id }
|
||||
: undefined,
|
||||
attackKind === "spell" ? "nature" : "physical",
|
||||
{
|
||||
attackKind,
|
||||
seed: `shaman-elemental-oath-${sourceActorId ?? "player"}-${attackKind}-${useCombatStore.getState().combatEvents.length}`,
|
||||
abilityId: ability.id,
|
||||
rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function shamanHealRoll(abilityId: string, targetId: string, impactAt: number): number {
|
||||
let hash = 2166136261;
|
||||
for (const character of `${PLAYER_AURA_ENTITY_ID}:${abilityId}:${targetId}:${impactAt}:heal`) {
|
||||
hash ^= character.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
function findShamanHealImpactAt(
|
||||
abilityId: string,
|
||||
targetId: string,
|
||||
minimumChancePercent: number,
|
||||
maximumChancePercent: number,
|
||||
startAt: number,
|
||||
): number {
|
||||
for (let impactAt = startAt; impactAt < startAt + 100_000; impactAt += 1) {
|
||||
const roll = shamanHealRoll(abilityId, targetId, impactAt);
|
||||
if (roll >= minimumChancePercent / 100 && roll < maximumChancePercent / 100) return impactAt;
|
||||
}
|
||||
throw new Error("Could not find a seeded Shaman heal crit roll in the requested interval.");
|
||||
}
|
||||
|
||||
describe("WoW Shaman conditional talent mechanics", () => {
|
||||
beforeEach(setupShaman);
|
||||
|
||||
it("applies each Convection rank to its complete generated spell list", () => {
|
||||
const eligibleNames = [
|
||||
"Earth Shock",
|
||||
"Flame Shock",
|
||||
"Frost Shock",
|
||||
"Lightning Bolt",
|
||||
"Chain Lightning",
|
||||
"Lava Burst",
|
||||
"Wind Shear",
|
||||
];
|
||||
|
||||
for (let rank = 1; rank <= 5; rank += 1) {
|
||||
setTalentRank("talent-564", rank);
|
||||
const state = useCombatStore.getState();
|
||||
for (const name of eligibleNames) {
|
||||
const ability = shamanAbility(name);
|
||||
const baseCost = resolvedAbilityCosts(ability, [], { ...state, auras: [] })
|
||||
.find((cost) => cost.resource === "mana")?.amount;
|
||||
const reducedCost = resolvedAbilityCosts(ability, state.talentModifiers, { ...state, auras: [] })
|
||||
.find((cost) => cost.resource === "mana")?.amount;
|
||||
expect(baseCost, `${name} base mana cost`).toBeGreaterThan(0);
|
||||
expect(reducedCost, `${name} at Convection rank ${rank}`)
|
||||
.toBe(Math.round(baseCost! * (1 - rank * 0.03)));
|
||||
}
|
||||
|
||||
const healingWave = shamanAbility("Healing Wave");
|
||||
const baseHealingWaveCost = resolvedAbilityCosts(healingWave, [], { ...state, auras: [] })
|
||||
.find((cost) => cost.resource === "mana")?.amount;
|
||||
const unchangedHealingWaveCost = resolvedAbilityCosts(healingWave, state.talentModifiers, { ...state, auras: [] })
|
||||
.find((cost) => cost.resource === "mana")?.amount;
|
||||
expect(unchangedHealingWaveCost).toBe(baseHealingWaveCost);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds Elemental Oath once to Shaman and party spell crit chance only", () => {
|
||||
const partyCasterId = setMagePartyCaster();
|
||||
const generatedCritModifiers = (rank: number) => talentModifiersForRanks({ "talent-2049": rank }, "shaman")
|
||||
.filter((modifier) => modifier.kind === "critical-chance-percent");
|
||||
const playerSpellChance: number[] = [];
|
||||
const partySpellChance: number[] = [];
|
||||
const playerMeleeChance: number[] = [];
|
||||
const playerRangedChance: number[] = [];
|
||||
let playerSpellBase = 0;
|
||||
let partySpellBase = 0;
|
||||
let playerMeleeBase = 0;
|
||||
let playerRangedBase = 0;
|
||||
|
||||
for (let rank = 0; rank <= 3; rank += 1) {
|
||||
setTalentRank("talent-2049", rank);
|
||||
expect(generatedCritModifiers(rank)).toEqual([]);
|
||||
const playerSpell = damageWithShamanSpellCrit();
|
||||
const partySpell = damageWithShamanSpellCrit(partyCasterId);
|
||||
const playerMelee = damageWithShamanSpellCrit(undefined, "melee");
|
||||
const playerRanged = damageWithShamanSpellCrit(undefined, "ranged");
|
||||
const playerSpellCrit = playerSpell.combatResult!.attackTable.criticalPercent;
|
||||
const partySpellCrit = partySpell.combatResult!.attackTable.criticalPercent;
|
||||
const playerMeleeCrit = playerMelee.combatResult!.attackTable.criticalPercent;
|
||||
const playerRangedCrit = playerRanged.combatResult!.attackTable.criticalPercent;
|
||||
if (rank === 0) {
|
||||
playerSpellBase = playerSpellCrit;
|
||||
partySpellBase = partySpellCrit;
|
||||
playerMeleeBase = playerMeleeCrit;
|
||||
playerRangedBase = playerRangedCrit;
|
||||
}
|
||||
playerSpellChance.push(playerSpellCrit);
|
||||
partySpellChance.push(partySpellCrit);
|
||||
playerMeleeChance.push(playerMeleeCrit);
|
||||
playerRangedChance.push(playerRangedCrit);
|
||||
}
|
||||
|
||||
expect(playerSpellChance).toEqual([0, 1, 2, 3].map((rank) => playerSpellBase + rank));
|
||||
expect(partySpellChance).toEqual([0, 1, 2, 3].map((rank) => partySpellBase + rank));
|
||||
expect(playerMeleeChance).toEqual([playerMeleeBase, playerMeleeBase, playerMeleeBase, playerMeleeBase]);
|
||||
expect(playerRangedChance).toEqual([playerRangedBase, playerRangedBase, playerRangedBase, playerRangedBase]);
|
||||
});
|
||||
|
||||
it("applies the Elemental Oath rank once to Shaman healing spell crits", async () => {
|
||||
const probeHealingWave = shamanAbility("Healing Wave");
|
||||
const probe = useCombatStore.getState().castAbility(probeHealingWave.id, { targetActorId: null, now: 1_000 });
|
||||
expect(probe.ok, JSON.stringify(probe)).toBe(true);
|
||||
const castDurationMs = useCombatStore.getState().activeCast!.completesAt - 1_000;
|
||||
await setupShaman();
|
||||
const healingWave = shamanAbility("Healing Wave");
|
||||
const state = useCombatStore.getState();
|
||||
useCombatStore.setState({ selectedFriendlyActorId: null });
|
||||
usePartyStore.setState({ selectedMemberId: null });
|
||||
const baseSpellCrit = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId).spellCritPercent
|
||||
+ applyTalentModifierValue(0, state.talentModifiers, "critical-chance-percent", {
|
||||
abilityName: healingWave.name,
|
||||
tags: ["cast-time", "healing", "nature"],
|
||||
});
|
||||
const targetId = PLAYER_AURA_ENTITY_ID;
|
||||
const firstImpactAt = findShamanHealImpactAt(
|
||||
healingWave.id,
|
||||
targetId,
|
||||
baseSpellCrit,
|
||||
baseSpellCrit + 1,
|
||||
10_000,
|
||||
);
|
||||
const secondImpactAt = findShamanHealImpactAt(
|
||||
healingWave.id,
|
||||
targetId,
|
||||
baseSpellCrit + 1,
|
||||
baseSpellCrit + 2,
|
||||
firstImpactAt + 20_000,
|
||||
);
|
||||
setTalentRank("talent-2049", 1);
|
||||
|
||||
const castForImpact = (impactAt: number) => {
|
||||
useCombatStore.setState((current) => ({ health: Math.max(1, current.maxHealth - 1_000) }));
|
||||
castAndFinish(healingWave.id, null, impactAt - castDurationMs);
|
||||
const events = useCombatStore.getState().combatEvents;
|
||||
const event = [...events].reverse().find((candidate) => (
|
||||
candidate.kind === "healing" && candidate.abilityId === healingWave.id
|
||||
));
|
||||
expect(event, JSON.stringify(events)).toBeDefined();
|
||||
expect(event?.occurredAt).toBe(impactAt);
|
||||
return event;
|
||||
};
|
||||
|
||||
expect(castForImpact(firstImpactAt)?.critical).toBe(true);
|
||||
expect(castForImpact(secondImpactAt)?.critical).toBe(false);
|
||||
});
|
||||
|
||||
it("grants two 30% mana-cost reductions after a critical elemental spell", () => {
|
||||
setTalentRank("talent-574", 2);
|
||||
useCombatStore.setState((state) => ({
|
||||
@@ -54,14 +256,16 @@ describe("WoW Shaman conditional talent mechanics", () => {
|
||||
const lightningBolt = shamanAbility("Lightning Bolt");
|
||||
castAndFinish(lightningBolt.id, undefined, 1_000);
|
||||
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
const criticalBolt = useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "damage" && event.abilityId === lightningBolt.id && event.critical
|
||||
))).toBe(true);
|
||||
));
|
||||
expect(criticalBolt).toBeDefined();
|
||||
const clearcasting = () => useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-shaman-elemental-focus-clearcasting")
|
||||
));
|
||||
expect(clearcasting()?.stacks).toBe(2);
|
||||
expect(clearcasting()?.expiresAt).toBe(criticalBolt!.occurredAt + 15_000);
|
||||
|
||||
const healingWave = shamanAbility("Healing Wave");
|
||||
const before = useCombatStore.getState();
|
||||
@@ -79,9 +283,12 @@ describe("WoW Shaman conditional talent mechanics", () => {
|
||||
.find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost);
|
||||
castAndFinish(healingWave.id, null, 20_000);
|
||||
expect(clearcasting()).toBeUndefined();
|
||||
const expiredState = useCombatStore.getState();
|
||||
expect(resolvedAbilityCosts(healingWave, expiredState.talentModifiers, expiredState)
|
||||
.find((cost) => cost.resource === "mana")?.amount).toBe(fullCost);
|
||||
});
|
||||
|
||||
it("uses Reverberation's rank chance and 20% bonus on the next different Shock", () => {
|
||||
it("uses Reverberation's rank chance and 20% bonus on the next different Shock", async () => {
|
||||
setTalentRank("talent-575", 3);
|
||||
const earthShock = shamanAbility("Earth Shock");
|
||||
const frostShock = shamanAbility("Frost Shock");
|
||||
@@ -105,7 +312,7 @@ describe("WoW Shaman conditional talent mechanics", () => {
|
||||
.toContain(`wow335-shaman-reverberation:from:${frostShock.id}`);
|
||||
|
||||
const buffedRawDamage = frostShockHit?.rawAmount;
|
||||
setupShaman();
|
||||
await setupShaman();
|
||||
const baselineBolt = shamanAbility("Lightning Bolt");
|
||||
const baselineFrostShock = shamanAbility("Frost Shock");
|
||||
castAndFinish(baselineBolt.id, undefined, 1_000);
|
||||
|
||||
Reference in new issue
Block a user