165 lines
6.6 KiB
TypeScript
165 lines
6.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { abilityAtLevel, abilityById } from "./abilityCatalog";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { HOTO_ENTITIES } from "./generated/hotoPopulation.generated";
|
|
import type { EnemyAttackDefinition } from "./enemyAttacks";
|
|
|
|
const LEVEL = 80;
|
|
const SOURCE_ABILITY_ID = "rom-mage-primary-53";
|
|
const SOURCE_SKILL_ID = 491_168;
|
|
const SOURCE_DAMAGE_COMPONENT_ID = 620_205;
|
|
const SOURCE_DOT_COMPONENT_ID = 501_550;
|
|
const NATIVE_NPC_ID = 102_041;
|
|
|
|
function setupMage() {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
usePartyStore.setState({ members: [] });
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-mage",
|
|
secondaryClassId: "rom-warlock",
|
|
raceId: "rom-human",
|
|
role: "damage",
|
|
level: LEVEL,
|
|
statRuleset: "rom",
|
|
});
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
}
|
|
|
|
function registerTarget(id: string) {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: "Native hit-gate target",
|
|
runewakerNpcId: NATIVE_NPC_ID,
|
|
level: 85,
|
|
maxHealth: 2_000_000,
|
|
xpReward: 0,
|
|
attacks: [],
|
|
});
|
|
setMobPosition(id, [2, 0, 0]);
|
|
}
|
|
|
|
function resolveImportedEnemyGate(attempt: number) {
|
|
setupMage();
|
|
const mobId = `native-shared-hit-enemy-${attempt}`;
|
|
const sourceEntity = HOTO_ENTITIES["rw-hoto-102041"];
|
|
const sourceAttacks: readonly EnemyAttackDefinition[] = sourceEntity.combat?.attacks ?? [];
|
|
const sourceAttack = sourceAttacks.find((attack) => attack.spellId === 493_231);
|
|
if (!sourceAttack) throw new Error("Missing imported Big Ripper source attack.");
|
|
useCombatStore.getState().registerMob(mobId, {
|
|
name: "Imported Teeth of the Reef",
|
|
runewakerNpcId: NATIVE_NPC_ID,
|
|
level: sourceEntity.combat?.level ?? 56,
|
|
maxHealth: 1_000_000,
|
|
xpReward: 0,
|
|
attacks: [{ ...sourceAttack, initialCooldownMs: [0, 0] }],
|
|
});
|
|
setMobPosition(mobId, [2, 0, 0]);
|
|
useCombatStore.getState().selectMob(mobId);
|
|
const now = 1_000 + attempt * 173;
|
|
useCombatStore.getState().damageMob(mobId, 1, now,
|
|
{ actorId: PLAYER_AGGRO_ID, role: "damage" });
|
|
useCombatStore.setState((state) => ({
|
|
mobs: {
|
|
...state.mobs,
|
|
[mobId]: {
|
|
...state.mobs[mobId]!,
|
|
engaged: true,
|
|
targetActorId: PLAYER_AGGRO_ID,
|
|
abilityReadyAt: { [sourceAttack.id]: now },
|
|
},
|
|
},
|
|
}));
|
|
useCombatStore.getState().advanceMobCombat(now, [0, 0, 0]);
|
|
const state = useCombatStore.getState();
|
|
const result = state.lastCombatResult;
|
|
const dot = state.enemyPeriodicEffects.find((entry) => (
|
|
entry.sourceMob.id === mobId && entry.attack.spellId === sourceAttack.spellId
|
|
));
|
|
return { result, dot, sourceAttack };
|
|
}
|
|
|
|
describe("native Runewaker shared hit gates", () => {
|
|
it("uses one target hit gate for a direct packet and its DotMagic in one cast", () => {
|
|
const catalog = abilityById(SOURCE_ABILITY_ID);
|
|
if (!catalog) throw new Error("Missing active-five Electric Bolt source ability.");
|
|
const ability = abilityAtLevel(catalog, LEVEL);
|
|
expect(ability.effects).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: "damage",
|
|
romNativeSource: { skillId: SOURCE_SKILL_ID, componentId: SOURCE_DAMAGE_COMPONENT_ID, kind: "damage" },
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "dot",
|
|
romNativeSource: { skillId: SOURCE_SKILL_ID, componentId: SOURCE_DOT_COMPONENT_ID, kind: "dot" },
|
|
}),
|
|
]));
|
|
|
|
const gateResults: Array<{ directGatePassed: boolean; dotApplied: boolean }> = [];
|
|
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
setupMage();
|
|
const targetId = `native-shared-hit-target-${attempt}`;
|
|
registerTarget(targetId);
|
|
const now = 1_000 + attempt * 137;
|
|
const cast = useCombatStore.getState().castAbility(ability.id, {
|
|
targetActorId: targetId,
|
|
origin: [0, 0, 0],
|
|
now,
|
|
});
|
|
expect(cast.ok, JSON.stringify(cast)).toBe(true);
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
if (activeCast) useCombatStore.getState().castAbility(ability.id, {
|
|
targetActorId: targetId,
|
|
origin: [0, 0, 0],
|
|
now: activeCast.completesAt,
|
|
completingCast: activeCast,
|
|
});
|
|
const state = useCombatStore.getState();
|
|
const direct = state.lastCombatResult;
|
|
expect(direct).toBeDefined();
|
|
const dot = state.effects.find((effect) => effect.kind === "dot"
|
|
&& effect.targetId === targetId && effect.sourceAbilityId === ability.id);
|
|
if (dot) expect(dot.nativePeriodicDamageSnapshot).toMatchObject({
|
|
sourceSkillId: SOURCE_SKILL_ID,
|
|
componentId: SOURCE_DOT_COMPONENT_ID,
|
|
});
|
|
gateResults.push({ directGatePassed: !direct!.missed && !direct!.dodged, dotApplied: Boolean(dot) });
|
|
}
|
|
|
|
// This source has a sub-100% native magical hit chance against the higher-level target,
|
|
// so both passed and failed gates occur in the fixed seed sweep.
|
|
expect(gateResults.some((result) => result.directGatePassed)).toBe(true);
|
|
expect(gateResults.some((result) => !result.directGatePassed)).toBe(true);
|
|
expect(gateResults.every((result) => result.directGatePassed === result.dotApplied)).toBe(true);
|
|
});
|
|
|
|
it("uses one target hit gate for an imported NPC direct packet and its DotMagic", () => {
|
|
const gateResults: Array<{ directGatePassed: boolean; dotApplied: boolean }> = [];
|
|
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
const { result, dot, sourceAttack } = resolveImportedEnemyGate(attempt);
|
|
expect(sourceAttack).toMatchObject({ spellId: 493_231, runewakerFormula: { componentId: 503_925 } });
|
|
expect(result).toBeDefined();
|
|
expect(dot?.nativeSnapshot).toMatchObject({
|
|
sourceSkillId: 493_231,
|
|
componentId: 503_485,
|
|
});
|
|
gateResults.push({
|
|
directGatePassed: !result!.missed && !result!.dodged,
|
|
dotApplied: Boolean(dot),
|
|
});
|
|
}
|
|
|
|
// MagicCollect uses the deterministic 100% hit function for this spell. A
|
|
// generic legacy miss reroll on its unsupported direct CalcType would break
|
|
// parity with the native DotMagic application gate.
|
|
expect(gateResults.every((result) => result.directGatePassed)).toBe(true);
|
|
expect(gateResults.every((result) => result.dotApplied)).toBe(true);
|
|
expect(gateResults.every((result) => result.directGatePassed === result.dotApplied)).toBe(true);
|
|
});
|
|
});
|