forked from phenom/healer-man
204 lines
7.0 KiB
TypeScript
204 lines
7.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { applyAura } from "./combatAuras";
|
|
import { PLAYER_AURA_ENTITY_ID, stableCombatUnit, useCombatStore } from "./combatStore";
|
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
|
import { enemyBlockAuraContract, enemyBlockAuraInputDefinition } from "./enemyBlockAuras";
|
|
import { clearMobRuntimeRegistry, registerMobRuntime } from "./mobRuntimeRegistry";
|
|
import type { CombatPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
|
|
const MOB_ID = "enemy-block-aura-runtime";
|
|
const PARTY_ID = "enemy-block-aura-party";
|
|
const BLOCK_SEED = seedWithOutcomeBelow("npc-block-aura-block", 0.03);
|
|
const HIT_SEED = seedWithOutcomeBelow("npc-block-aura-hit", 0.9);
|
|
const positions = new Map<string, CombatPosition>();
|
|
|
|
function seedWithOutcomeBelow(prefix: string, maximum: number): string {
|
|
for (let index = 0; index < 10_000; index += 1) {
|
|
const seed = `${prefix}:${index}`;
|
|
if (stableCombatUnit(`${seed}:outcome`) < maximum) return seed;
|
|
}
|
|
throw new Error(`Could not find a deterministic combat seed below ${maximum}.`);
|
|
}
|
|
|
|
function setSpatialActors(entries: Readonly<Record<string, CombatPosition>>): void {
|
|
positions.clear();
|
|
for (const [id, position] of Object.entries(entries)) positions.set(id, position);
|
|
registerCombatSpatialProvider({
|
|
actor: (id) => {
|
|
const position = positions.get(id);
|
|
return position ? { id, position, yaw: 0 } : null;
|
|
},
|
|
hasLineOfSight: () => true,
|
|
projectGroundPoint: (position) => position,
|
|
});
|
|
}
|
|
|
|
function addInputAura(
|
|
targetId: string,
|
|
spellId: 3419 | 47296 | 667801,
|
|
now: number,
|
|
): void {
|
|
const contract = enemyBlockAuraContract(spellId, "ascension");
|
|
if (!contract) throw new Error(`Missing source block aura ${spellId}.`);
|
|
const definition = enemyBlockAuraInputDefinition(contract);
|
|
const current = useCombatStore.getState().auras;
|
|
useCombatStore.setState({
|
|
auras: applyAura(current, { definition, sourceId: targetId, targetId, now }).auras,
|
|
});
|
|
}
|
|
|
|
function registerEnemy(
|
|
id: string,
|
|
entry: number | null,
|
|
position: CombatPosition,
|
|
runewakerNpcId?: number,
|
|
): void {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: id,
|
|
...(entry === null ? {} : { serverEntry: entry }),
|
|
...(runewakerNpcId === undefined ? {} : { runewakerNpcId }),
|
|
level: 30,
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
attacks: [],
|
|
});
|
|
registerMobRuntime(id, position, 0);
|
|
}
|
|
|
|
function hit(
|
|
targetId: string,
|
|
actorId: string,
|
|
now: number,
|
|
seed: string,
|
|
options: { readonly canBlock?: boolean; readonly amount?: number } = {},
|
|
) {
|
|
return useCombatStore.getState().damageMob(
|
|
targetId,
|
|
options.amount ?? 39,
|
|
now,
|
|
{ actorId, role: "damage", abilityId: "enemy-block-aura-integration" },
|
|
"physical",
|
|
{
|
|
attackKind: "melee",
|
|
seed,
|
|
rules: {
|
|
isAutoAttack: true,
|
|
canMiss: false,
|
|
canCrit: false,
|
|
canDodge: false,
|
|
canParry: false,
|
|
ignoreArmor: true,
|
|
...(options.canBlock === undefined ? {} : { canBlock: options.canBlock }),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function addPartyAttacker(): string {
|
|
usePartyStore.getState().initializeParty(
|
|
PARTY_ID, 30, useCombatStore.getState().health, "damage", 2, "wow",
|
|
);
|
|
const member = createConfiguredPartyMember({
|
|
name: "Block aura test attacker",
|
|
classId: "warrior",
|
|
statRuleset: "wow",
|
|
role: "damage",
|
|
specialization: "Integration attacker",
|
|
talentTreeId: "",
|
|
talentRanks: {},
|
|
}, 30, 0, PARTY_ID);
|
|
usePartyStore.setState({ members: [member] });
|
|
useCombatStore.getState().synchronizeActors();
|
|
return member.id;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetCombatSpatialProvider();
|
|
positions.clear();
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 30, xp: 0 });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, -2]);
|
|
usePartyStore.getState().initializeParty(
|
|
PARTY_ID, 30, useCombatStore.getState().health, "damage", 2, "wow",
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetCombatSpatialProvider();
|
|
});
|
|
|
|
describe("ordinary NPC block aura runtime inputs", () => {
|
|
it("lets rear player and party auto-attacks block under aura 288 and doubles the source block value under aura 253", () => {
|
|
registerEnemy(MOB_ID, 1666, [0, 0, 0]);
|
|
setSpatialActors({ [PLAYER_AURA_ENTITY_ID]: [0, 0, -2] });
|
|
|
|
addInputAura(MOB_ID, 3419, 20_000);
|
|
addInputAura(MOB_ID, 47296, 20_000);
|
|
const rearWithoutDirectionAura = hit(MOB_ID, PLAYER_AGGRO_ID, 20_001, HIT_SEED);
|
|
expect(rearWithoutDirectionAura.combatResult).toMatchObject({
|
|
outcome: "hit",
|
|
attackTable: { blockPercent: 0 },
|
|
});
|
|
|
|
// The input-only fixture reads aura 288 from Deterrence without importing
|
|
// its unrelated DBC sibling effects as a complete spell definition.
|
|
addInputAura(MOB_ID, 667801, 20_002);
|
|
const playerBlock = hit(MOB_ID, PLAYER_AGGRO_ID, 20_003, BLOCK_SEED);
|
|
expect(playerBlock.combatResult).toMatchObject({
|
|
outcome: "block",
|
|
blocked: true,
|
|
attackTable: { blockPercent: 80 },
|
|
blockedAmount: 30,
|
|
finalDamage: 9,
|
|
criticalBlockChancePercent: 100,
|
|
criticalBlocked: true,
|
|
});
|
|
expect(playerBlock.combatResult?.criticalBlockRoll).toBeGreaterThanOrEqual(0);
|
|
expect(playerBlock.combatResult?.criticalBlockRoll).toBeLessThan(100);
|
|
|
|
const partyAttackerId = addPartyAttacker();
|
|
positions.set(partyAttackerId, [0, 0, -2]);
|
|
const partyBlock = hit(MOB_ID, partyAttackerId, 20_004, BLOCK_SEED);
|
|
expect(partyBlock.combatResult).toMatchObject({
|
|
outcome: "block",
|
|
attackTable: { blockPercent: 80 },
|
|
blockedAmount: 30,
|
|
criticalBlocked: true,
|
|
});
|
|
|
|
const blockingDisabled = hit(MOB_ID, PLAYER_AGGRO_ID, 20_005, BLOCK_SEED, { canBlock: false });
|
|
expect(blockingDisabled.combatResult).toMatchObject({
|
|
outcome: "hit",
|
|
attackTable: { blockPercent: 0 },
|
|
blocked: false,
|
|
});
|
|
|
|
const afterDirectionAuraExpiry = hit(MOB_ID, PLAYER_AGGRO_ID, 25_002, HIT_SEED);
|
|
expect(afterDirectionAuraExpiry.combatResult).toMatchObject({
|
|
outcome: "hit",
|
|
attackTable: { blockPercent: 0 },
|
|
blocked: false,
|
|
});
|
|
expect(afterDirectionAuraExpiry.combatResult?.criticalBlocked).toBeUndefined();
|
|
});
|
|
|
|
it("does not give native RoM defenders ordinary NPC aura block inputs", () => {
|
|
const nativeId = "native-block-aura-defender";
|
|
registerEnemy(nativeId, 1666, [4, 0, 0], 101_456);
|
|
setSpatialActors({ [PLAYER_AURA_ENTITY_ID]: [4, 0, 2] });
|
|
addInputAura(nativeId, 3419, 30_000);
|
|
addInputAura(nativeId, 47296, 30_000);
|
|
addInputAura(nativeId, 667801, 30_000);
|
|
|
|
const native = hit(nativeId, PLAYER_AGGRO_ID, 30_001, BLOCK_SEED);
|
|
expect(native.combatResult?.attackTable.blockPercent).toBe(0);
|
|
expect(native.combatResult?.blocked).toBe(false);
|
|
});
|
|
});
|