import { beforeEach, describe, expect, it } from "vitest"; import { abilityAtLevel, abilityById, type AbilityEffect } from "./abilityCatalog"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { resetPartyRuntimeClock } from "./partyRuntime"; import { createConfiguredPartyMember, type PartyMember, usePartyStore } from "./partyStore"; import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities"; import { nativePeriodicDamageTick, romNativePeriodicDamageBinding } from "./romNativePeriodicDamage"; import { romNativeNpcCombatant } from "./romNativeCombatResolution"; import type { EnemyAttackDefinition } from "./enemyAttacks"; const LEVEL = 80; const NOW = 1_000; const SOURCE_ABILITY_ID = "rom-scout-general-2"; const SOURCE_SKILL_ID = 491292; const SOURCE_COMPONENT_ID = 501690; const NPC_ID = 102041; const NPC_DOT_SPELL_ID = 493231; const NPC_DOT_COMPONENT_ID = 503485; function vampireArrows() { const catalog = abilityById(SOURCE_ABILITY_ID); if (!catalog) throw new Error("Missing active-five Vampire Arrows catalog row."); const ability = abilityAtLevel(catalog, LEVEL); const effect = ability.effects.find((candidate): candidate is Extract => ( candidate.kind === "dot" && candidate.romNativeSource?.kind === "dot" && candidate.romNativeSource.skillId === SOURCE_SKILL_ID && candidate.romNativeSource.componentId === SOURCE_COMPONENT_ID )); if (!effect) throw new Error("Vampire Arrows is missing its exact native DotMagic component binding."); return { ability, effect }; } function setupScoutPlayer() { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); usePartyStore.setState({ members: [] }); useCombatStore.getState().initializeCharacter({ classId: "rom-scout", secondaryClassId: "rom-rogue", raceId: "rom-human", role: "damage", level: LEVEL, statRuleset: "rom", }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); } function registerNativeTarget(id: string, maxHealth = 2_000_000) { useCombatStore.getState().registerMob(id, { name: id, runewakerNpcId: NPC_ID, level: 1, maxHealth, xpReward: 0, attacks: [], }); setMobPosition(id, [2, 0, 0]); } function createScoutPartyMember(statRuleset: "rom" | "wow", seed: string) { const identity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-scout")!; return { ...createConfiguredPartyMember({ ...identity, gender: "male", specialization: "Primary", statRuleset, }, LEVEL, statRuleset === "wow" ? 1 : 0, seed), status: "ready" as const, activeCast: null, activeCastTargetId: null, nextActionAt: 100_000, }; } function installPartyMembers(members: readonly PartyMember[]) { if (members.length < 1 || members.length > 5) throw new Error("The party fixture expects one to five members."); usePartyStore.getState().initializeParty("native-periodic-combat", LEVEL, useCombatStore.getState().health, "damage", members.length as 1 | 2 | 3 | 4 | 5, "rom"); usePartyStore.setState({ members }); for (const member of members) registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); } describe("native Runewaker periodic damage through combatStore", () => { beforeEach(() => setupScoutPlayer()); it("snapshots a real player Vampire Arrows cast and does not reroll crit or target mitigation on ticks", () => { const targetId = "native-periodic-player-target"; registerNativeTarget(targetId); const { ability } = vampireArrows(); const cast = useCombatStore.getState().castAbility(ability.id, { targetActorId: targetId, origin: [0, 0, 0], now: NOW, }); expect(cast.ok, JSON.stringify(cast)).toBe(true); const activeCast = useCombatStore.getState().activeCast; if (activeCast) useCombatStore.getState().advanceMobCombat(activeCast.completesAt, [0, 0, 0]); const applied = useCombatStore.getState().effects.find((entry) => ( entry.kind === "dot" && entry.sourceAbilityId === ability.id && entry.targetId === targetId )); expect(applied?.nativePeriodicDamageSnapshot).toMatchObject({ sourceSkillId: SOURCE_SKILL_ID, componentId: SOURCE_COMPONENT_ID, targetDefenseStage: "dot-application-rank-correction", sourceSemantics: "runewaker-caladddotmagiclv-buffproc-dot", }); expect(applied?.targetMitigationSnapshotted).toBe(true); expect(applied?.nativePeriodicDamageSnapshot?.isDotMagicNoKill).toBe(false); if (!applied?.nativePeriodicDamageSnapshot) throw new Error("Player DoT application did not keep its native snapshot."); const beforeTick = useCombatStore.getState(); const mob = beforeTick.mobs[targetId]!; useCombatStore.setState({ mobs: { ...beforeTick.mobs, [targetId]: { ...mob, armor: 9_999_999, resistances: { arcane: 9_999_999, fire: 9_999_999, frost: 9_999_999, nature: 9_999_999, shadow: 9_999_999 }, }, }, auras: beforeTick.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.name.startsWith("Ranged Weapon Mastery:") && aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "dealt") )) }); expect(useCombatStore.getState().auras.some((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.name.startsWith("Ranged Weapon Mastery:") && aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "dealt") ))).toBe(false); useCombatStore.getState().tick(0.1, NOW + applied.intervalMs); const tick = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === PLAYER_AURA_ENTITY_ID && event.targetActorId === targetId && event.abilityId === ability.id && event.occurredAt === NOW + applied.intervalMs )); const expectedTick = nativePeriodicDamageTick(applied.nativePeriodicDamageSnapshot); if (!expectedTick) throw new Error("Expected the player DoT snapshot to resolve a positive tick."); expect(tick).toMatchObject({ rawAmount: expectedTick.damage, critical: false, }); }); it("snapshots an active-five magical DotMagic with target resistance and explicit native MagicPower", () => { useCombatStore.getState().initializeCharacter({ classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", role: "healer", level: LEVEL, statRuleset: "rom", }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); const targetId = "native-periodic-magical-target"; registerNativeTarget(targetId); const catalog = abilityById("rom-priest-primary-4"); if (!catalog) throw new Error("Missing active-five Bone Chill catalog row."); const ability = abilityAtLevel(catalog, LEVEL); const effect = ability.effects.find((candidate): candidate is Extract => ( candidate.kind === "dot" && candidate.romNativeSource?.kind === "dot" && candidate.romNativeSource.skillId === 491165 && candidate.romNativeSource.componentId === 501548 )); expect(effect).toBeDefined(); const nativeTarget = romNativeNpcCombatant(NPC_ID, 1); expect(nativeTarget?.nativeCombatant?.magicalDefense).toBeGreaterThan(0); expect(nativeTarget?.nativeCombatant?.nativeMagicResistances[1]?.value).toBeGreaterThanOrEqual(0); const priestIdentity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-priest")!; const nativePriestBase = createConfiguredPartyMember({ ...priestIdentity, specialization: "Primary", statRuleset: "rom", }, LEVEL, 0, "native-periodic-magical-priest"); const nativePriest: PartyMember = { ...nativePriestBase, status: "ready", activeCast: null, activeCastTargetId: null, nextActionAt: 100_000, }; installPartyMembers([nativePriest]); expect(useCombatStore.getState().applyTimedActorEffect( nativePriest.id, targetId, ability, effect!, NOW, )).toBe(true); const applied = useCombatStore.getState().effects.find((entry) => ( entry.kind === "dot" && entry.sourceActorId === nativePriest.id && entry.sourceAbilityId === ability.id && entry.targetId === targetId )); expect(applied?.nativePeriodicDamageSnapshot).toMatchObject({ sourceSkillId: 491165, componentId: 501548, targetDefenseStage: "dot-application-rank-correction", sourceSemantics: "runewaker-caladddotmagiclv-buffproc-dot", }); expect(applied?.nativePeriodicDamageSnapshot?.sourceTickDamage).toBeGreaterThan(0); expect(applied?.nativePeriodicDamageSnapshot?.buffPower).toBeGreaterThan(0); if (!applied?.nativePeriodicDamageSnapshot) throw new Error("Magical DotMagic application did not keep its native snapshot."); const beforeTick = useCombatStore.getState(); const mob = beforeTick.mobs[targetId]!; useCombatStore.setState({ mobs: { ...beforeTick.mobs, [targetId]: { ...mob, resistances: { arcane: 9_999_999, fire: 9_999_999, frost: 9_999_999, nature: 9_999_999, shadow: 9_999_999 }, }, } }); useCombatStore.getState().tick(0.1, NOW + applied.intervalMs); const tick = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === nativePriest.id && event.targetActorId === targetId && event.abilityId === ability.id && event.occurredAt === NOW + applied.intervalMs )); const expectedTick = nativePeriodicDamageTick(applied.nativePeriodicDamageSnapshot); if (!expectedTick) throw new Error("Expected the magical DoT snapshot to resolve a positive tick."); expect(tick).toMatchObject({ rawAmount: expectedTick.damage, critical: false }); }); it("uses the companion's RoM snapshot while preserving the WoW companion fallback", () => { const romMember = createScoutPartyMember("rom", "native-periodic-rom-companion"); const wowMember = createScoutPartyMember("wow", "native-periodic-wow-companion"); installPartyMembers([romMember, wowMember]); const romTarget = "native-periodic-rom-companion-target"; const wowTarget = "native-periodic-wow-companion-target"; registerNativeTarget(romTarget); registerNativeTarget(wowTarget); const { ability, effect } = vampireArrows(); expect(useCombatStore.getState().applyTimedActorEffect( romMember.id, romTarget, ability, effect, NOW, 100, )).toBe(true); expect(useCombatStore.getState().applyTimedActorEffect( wowMember.id, wowTarget, ability, effect, NOW, 100, )).toBe(true); const romDot = useCombatStore.getState().effects.find((entry) => entry.targetId === romTarget)!; const wowDot = useCombatStore.getState().effects.find((entry) => entry.targetId === wowTarget)!; expect(romDot.nativePeriodicDamageSnapshot).toMatchObject({ sourceSkillId: SOURCE_SKILL_ID, componentId: SOURCE_COMPONENT_ID, }); expect(romDot.targetMitigationSnapshotted).toBe(true); expect(wowDot.nativePeriodicDamageSnapshot).toBeUndefined(); expect(wowDot.fixedAmount).toBeGreaterThan(0); useCombatStore.getState().tick(0.1, NOW + effect.intervalMs); const romTick = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === romMember.id && event.targetActorId === romTarget && event.abilityId === ability.id && event.occurredAt === NOW + effect.intervalMs )); const wowTick = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === wowMember.id && event.targetActorId === wowTarget && event.abilityId === ability.id && event.occurredAt === NOW + effect.intervalMs )); expect(romTick?.critical).toBe(false); expect(wowTick?.critical).toBe(true); }); it("applies an imported NPC HP_Per DoT and reads current target MaxHP on its later tick", () => { const npc = romNativeNpcCombatant(NPC_ID, 81); expect(npc?.combatStatus).toBe("supported"); const binding = romNativePeriodicDamageBinding({ npcId: NPC_ID, spellId: NPC_DOT_SPELL_ID, componentId: NPC_DOT_COMPONENT_ID, kind: "dot", }); expect(binding?.component).toMatchObject({ isDotMagic: true, dotType: 5, dotBase: -35, isDotMagicNoKill: false, }); const attack: EnemyAttackDefinition = { id: "npc-native-hp-percent-dot", name: "Source HP_Per DoT", spellId: NPC_DOT_SPELL_ID, delivery: "projectile", target: "primary", school: "physical", attackKind: "spell", runewakerEffectType: 1, damageOrigin: "runewaker", animation: "cast", range: 30, castTimeMs: 0, cooldownMs: 60_000, initialCooldownMs: [0, 0], damageMultiplier: 1, effect: { kind: "dot", multiplier: 1, ticks: 5, intervalMs: 2_000, sourceSkillId: NPC_DOT_SPELL_ID, componentId: NPC_DOT_COMPONENT_ID, }, }; const sourceMobId = "native-periodic-npc-source"; useCombatStore.getState().registerMob(sourceMobId, { name: "Imported Runewaker DoT caster", runewakerNpcId: NPC_ID, level: 81, maxHealth: 1_000_000, xpReward: 0, attacks: [attack], }); setMobPosition(sourceMobId, [2, 0, 0]); useCombatStore.getState().damageMob(sourceMobId, 1, NOW, { actorId: PLAYER_AURA_ENTITY_ID, role: "damage" }); useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [sourceMobId]: { ...state.mobs[sourceMobId]!, engaged: true, targetActorId: PLAYER_AURA_ENTITY_ID, abilityReadyAt: { [attack.id]: NOW }, }, }, })); useCombatStore.getState().advanceMobCombat(NOW, [0, 0, 0]); const applied = useCombatStore.getState().enemyPeriodicEffects.find((entry) => ( entry.sourceMob.id === sourceMobId && entry.attack.spellId === NPC_DOT_SPELL_ID )); expect(applied?.nativeSnapshot).toMatchObject({ sourceSkillId: NPC_DOT_SPELL_ID, componentId: NPC_DOT_COMPONENT_ID, percentHealthDot: true, isDotMagicNoKill: false, sourceTickDamage: 35, }); if (!applied?.nativeSnapshot) throw new Error("NPC DoT application did not keep its native snapshot."); const healthAtApplication = useCombatStore.getState().maxHealth; expect(applied.nativeSnapshot.targetMaxHealthAtApplication).toBe(healthAtApplication); useCombatStore.setState({ maxHealth: healthAtApplication * 2, health: healthAtApplication * 2 }); useCombatStore.getState().advanceMobCombat(NOW + applied.intervalMs, [0, 0, 0]); const expectedTick = nativePeriodicDamageTick(applied.nativeSnapshot, healthAtApplication * 2)!.damage; expect(useCombatStore.getState().health).toBe(healthAtApplication * 2 - expectedTick); }); });