358 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|