205 lines
7.4 KiB
TypeScript
205 lines
7.4 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
|
import { deriveCharacterStats } from "./wotlkStats";
|
|
import {
|
|
WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID,
|
|
wow335RogueUnfairAdvantageDamageMultiplierForRank,
|
|
} from "./wow335RogueTalentRuntime";
|
|
|
|
function stableUnit(seed: string): number {
|
|
let hash = 2_166_136_261;
|
|
for (let index = 0; index < seed.length; index += 1) {
|
|
hash ^= seed.charCodeAt(index);
|
|
hash = Math.imul(hash, 16_777_619);
|
|
}
|
|
return (hash >>> 0) / 0x1_0000_0000;
|
|
}
|
|
|
|
function timeForDodge(
|
|
mobId: string,
|
|
attackId: string,
|
|
attackRevision: number,
|
|
earliest: number,
|
|
actorId = "__player__",
|
|
): number {
|
|
for (let now = earliest; now < earliest + 30_000; now += 1) {
|
|
const roll = stableUnit(`${mobId}:${attackId}:${actorId}:${attackRevision}:${now}:outcome`);
|
|
if (roll >= 0.1 && roll < 0.99) return now;
|
|
}
|
|
throw new Error(`Could not find a deterministic dodge roll for ${mobId}:${attackRevision}`);
|
|
}
|
|
|
|
describe("WoW Rogue Unfair Advantage", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
});
|
|
|
|
const setUpDodgeTarget = (id: string, rank: number, repeatCooldownMs = 3_000) => {
|
|
const attackId = `${id}-swing`;
|
|
useCombatStore.setState((state) => ({
|
|
gearStats: {
|
|
...state.gearStats,
|
|
dodgeRating: 1_000_000,
|
|
meleeWeapon: {
|
|
damageMin: 100,
|
|
damageMax: 100,
|
|
damageSchool: "physical",
|
|
speedMs: 2_000,
|
|
},
|
|
},
|
|
talentRanks: { ...state.talentRanks, "talent-2073": rank },
|
|
}));
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: id,
|
|
level: 80,
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
attacks: [{
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
id: attackId,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [repeatCooldownMs, repeatCooldownMs],
|
|
}],
|
|
});
|
|
setMobPosition(id, [2, 0, 0]);
|
|
useCombatStore.getState().damageMob(id, 1, 1_100);
|
|
return attackId;
|
|
};
|
|
|
|
it.each([[1, 0.5], [2, 1]] as const)(
|
|
"returns the generated rank %i main-hand damage fraction after a dodge",
|
|
(rank, multiplier) => {
|
|
const targetId = `unfair-advantage-rank-${rank}`;
|
|
const attackId = setUpDodgeTarget(targetId, rank);
|
|
const state = useCombatStore.getState();
|
|
const weapon = state.gearStats.meleeWeapon!;
|
|
const stats = deriveCharacterStats(
|
|
state.classId,
|
|
state.level,
|
|
state.gearStats,
|
|
state.raceId,
|
|
{},
|
|
state.secondaryClassId,
|
|
);
|
|
const expectedRawDamage = Math.round((
|
|
(weapon.damageMin + weapon.damageMax) / 2
|
|
+ stats.attackPower / 14 * weapon.speedMs / 1_000
|
|
) * multiplier);
|
|
|
|
const dodgeAt = timeForDodge(targetId, attackId, 0, 1_450);
|
|
useCombatStore.getState().advanceMobCombat(dodgeAt);
|
|
|
|
const retaliation = useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage"
|
|
&& event.sourceActorId === PLAYER_AGGRO_ID
|
|
&& event.targetActorId === targetId
|
|
&& event.abilityId === WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID
|
|
));
|
|
expect(useCombatStore.getState().combatEvents.some((event) => (
|
|
event.kind === "damage"
|
|
&& event.sourceActorId === targetId
|
|
&& event.targetActorId === PLAYER_AGGRO_ID
|
|
&& event.outcome === "dodge"
|
|
))).toBe(true);
|
|
expect(retaliation?.rawAmount).toBe(expectedRawDamage);
|
|
expect(wow335RogueUnfairAdvantageDamageMultiplierForRank(rank)).toBe(multiplier);
|
|
},
|
|
);
|
|
|
|
it("shares one two-second cooldown across repeated dodges", () => {
|
|
const targetId = "unfair-advantage-cooldown-target";
|
|
const attackId = setUpDodgeTarget(targetId, 2, 500);
|
|
const procTimes: number[] = [];
|
|
let attackAt = timeForDodge(targetId, attackId, 0, 1_450);
|
|
|
|
for (let attackRevision = 0; attackRevision < 12; attackRevision += 1) {
|
|
useCombatStore.getState().advanceMobCombat(attackAt);
|
|
const procs = useCombatStore.getState().combatEvents.filter((event) => (
|
|
event.kind === "damage" && event.abilityId === WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID
|
|
));
|
|
if (procs.length > procTimes.length) procTimes.push(attackAt);
|
|
attackAt = timeForDodge(targetId, attackId, attackRevision + 1, attackAt + 500);
|
|
if (procTimes.length === 2) break;
|
|
}
|
|
|
|
expect(procTimes).toHaveLength(2);
|
|
expect(procTimes[1]! - procTimes[0]!).toBeGreaterThanOrEqual(2_000);
|
|
});
|
|
|
|
it("uses a companion Rogue's rank, melee weapon, and individual cooldown on dodge", () => {
|
|
const playerHealth = useCombatStore.getState().health;
|
|
usePartyStore.getState().initializeParty("unfair-advantage-companion", 80, playerHealth, "damage", 2);
|
|
const initialMember = usePartyStore.getState().members[0]!;
|
|
const memberId = initialMember.id;
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((member) => member.id === memberId
|
|
? {
|
|
...member,
|
|
classId: "rogue",
|
|
talentRanks: { ...member.talentRanks, "talent-2073": 2 },
|
|
gearStats: {
|
|
...member.gearStats,
|
|
dodgeRating: 1_000_000,
|
|
meleeWeapon: {
|
|
damageMin: 100,
|
|
damageMax: 100,
|
|
damageSchool: "physical" as const,
|
|
speedMs: 2_000,
|
|
},
|
|
},
|
|
}
|
|
: member),
|
|
}));
|
|
registerPartyRuntimePosition(memberId, [1, 0, 0]);
|
|
|
|
const targetId = "unfair-advantage-companion-target";
|
|
const attackId = `${targetId}-swing`;
|
|
useCombatStore.getState().registerMob(targetId, {
|
|
name: targetId,
|
|
level: 80,
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
attacks: [{
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
id: attackId,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [3_000, 3_000],
|
|
}],
|
|
});
|
|
setMobPosition(targetId, [2, 0, 0]);
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === memberId)!;
|
|
useCombatStore.getState().damageMob(targetId, 1, 1_100, {
|
|
actorId: memberId,
|
|
role: member.role,
|
|
abilityId: "test-companion-engage",
|
|
});
|
|
const dodgeAt = timeForDodge(targetId, attackId, 0, 1_450, memberId);
|
|
useCombatStore.getState().advanceMobCombat(dodgeAt);
|
|
|
|
const retaliation = useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage"
|
|
&& event.sourceActorId === memberId
|
|
&& event.targetActorId === targetId
|
|
&& event.abilityId === WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID
|
|
));
|
|
expect(useCombatStore.getState().combatEvents.some((event) => (
|
|
event.kind === "damage"
|
|
&& event.sourceActorId === targetId
|
|
&& event.targetActorId === memberId
|
|
&& event.outcome === "dodge"
|
|
))).toBe(true);
|
|
expect(retaliation?.rawAmount).toBeGreaterThan(0);
|
|
expect(usePartyStore.getState().members.find((candidate) => candidate.id === memberId)?.cooldowns[
|
|
WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID
|
|
]).toBe(dodgeAt + 2_000);
|
|
});
|
|
});
|