Files
healer-man/src/game/romNativePeriodicCombat.integration.test.ts
T
2026-10-04 13:13:35 -04:00

358 lines
15 KiB
TypeScript

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<AbilityEffect, { kind: "dot" }> => (
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<AbilityEffect, { kind: "dot" }> => (
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);
});
});