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); }); });