forked from phenom/healer-man
Complete RoM pair elite mechanics and creature family rules
This commit is contained in:
1 parent
c8b3a3d95a
commit
0206fda9e2
21 files changed
+1082
-134
No files matched your search
@@ -182,6 +182,8 @@ export type AbilityEffect =
|
||||
readonly recipient?: "ability-target" | "caster";
|
||||
/** Party/raid area auras are applied to the player and every active party member. */
|
||||
readonly scope?: "target" | "party";
|
||||
/** Some party effects benefit allies but exclude the caster. */
|
||||
readonly excludeCaster?: boolean;
|
||||
readonly control?: AbilityAuraControl;
|
||||
readonly sourceEffectType: number;
|
||||
readonly sourceAuraType: number;
|
||||
|
||||
+83
-19
@@ -156,8 +156,9 @@ import {
|
||||
type HeirloomContext,
|
||||
} from "./heirloomEquipment";
|
||||
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount } from "./romCombatMechanics";
|
||||
import { romMageWarlockCriticalEffects } from "./romMageWarlockOverrides";
|
||||
import { romScoutRogueProcActionSpec } from "./romScoutRogueOverrides";
|
||||
import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, romFireLightningBurstReset, romMageWarlockCriticalEffects } from "./romMageWarlockOverrides";
|
||||
import { romScoutRogueProcActionSpec, romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
|
||||
import { romCreatureFamilyForServerEntry, romCreatureFamilyTargetTags, type CombatCreatureFamily } from "./romCreatureFamilies";
|
||||
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
|
||||
import { specializationById } from "./specializationCatalog";
|
||||
import {
|
||||
@@ -393,6 +394,7 @@ export interface MobCombatState {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly serverEntry: number | null;
|
||||
readonly creatureFamily: CombatCreatureFamily;
|
||||
readonly summonedBy?: string;
|
||||
readonly spawnGroupId?: string;
|
||||
readonly boss: boolean;
|
||||
@@ -592,6 +594,7 @@ export interface CharacterCombatSeed {
|
||||
export interface MobRegistration {
|
||||
readonly name: string;
|
||||
readonly serverEntry?: number;
|
||||
readonly creatureFamily?: CombatCreatureFamily;
|
||||
readonly boss?: boolean;
|
||||
readonly level?: number;
|
||||
readonly resistances?: Partial<MobMagicResistances>;
|
||||
@@ -1220,7 +1223,9 @@ function addHealingThreatInWork(
|
||||
const engagedIds = Object.values(work.mobs)
|
||||
.filter((mob) => mob.engaged && !mob.dead)
|
||||
.map((mob) => mob.id);
|
||||
const perMob = threatFromHealing(effectiveHealing, engagedIds.length) * (source.threatScale ?? 1);
|
||||
const perMob = threatFromHealing(effectiveHealing, engagedIds.length)
|
||||
* (source.threatScale ?? 1)
|
||||
* resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat");
|
||||
if (perMob <= 0) return;
|
||||
for (const id of engagedIds) {
|
||||
const mob = work.mobs[id];
|
||||
@@ -1390,21 +1395,24 @@ function playerCharacterStats(
|
||||
});
|
||||
}
|
||||
|
||||
function npcCombatDefender(mob: MobCombatState, attackerLevel: number): CombatDefenderStats {
|
||||
function npcCombatDefender(mob: MobCombatState, attackerLevel: number, auras: readonly ActiveAura[]): CombatDefenderStats {
|
||||
const levelAdvantage = Math.max(0, mob.level - attackerLevel);
|
||||
const resistance = (value: number, school: DamageSchool): number => Math.max(0,
|
||||
resolveAuraStatValue(resolveAuraStatValue(value, auras, `resistance:${school}`), auras, "magical-defense"),
|
||||
);
|
||||
return {
|
||||
level: mob.level,
|
||||
armor: mob.armor,
|
||||
armor: Math.max(0, resolveAuraStatValue(mob.armor, auras, "armor")),
|
||||
dodgePercent: 5 + levelAdvantage * 0.5,
|
||||
parryPercent: 5 + levelAdvantage * 0.5,
|
||||
blockPercent: 0,
|
||||
blockValue: 0,
|
||||
resistances: {
|
||||
arcane: mob.resistances.arcane,
|
||||
fire: mob.resistances.fire,
|
||||
frost: mob.resistances.frost,
|
||||
nature: mob.resistances.nature,
|
||||
shadow: mob.resistances.shadow,
|
||||
arcane: resistance(mob.resistances.arcane, "arcane"),
|
||||
fire: resistance(mob.resistances.fire, "fire"),
|
||||
frost: resistance(mob.resistances.frost, "frost"),
|
||||
nature: resistance(mob.resistances.nature, "nature"),
|
||||
shadow: resistance(mob.resistances.shadow, "shadow"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1471,7 +1479,7 @@ function resolvePlayerDamageResult(
|
||||
attackKind,
|
||||
damageSchool: school,
|
||||
attacker,
|
||||
defender: mob.characterDefender ?? npcCombatDefender(mob, stats.level),
|
||||
defender: mob.characterDefender ?? npcCombatDefender(mob, stats.level, aurasForEntity(work.auras, mob.id)),
|
||||
rules: immune
|
||||
? {
|
||||
...rules,
|
||||
@@ -2070,6 +2078,7 @@ function summonedMobCombatState(
|
||||
id,
|
||||
name: template?.name ?? `Summoned Creature ${creatureEntry}`,
|
||||
serverEntry: creatureEntry,
|
||||
creatureFamily: romCreatureFamilyForServerEntry(creatureEntry),
|
||||
summonedBy: summoner.id,
|
||||
...(spawnGroupId ? { spawnGroupId } : {}),
|
||||
boss: false,
|
||||
@@ -2842,7 +2851,8 @@ function damageMobInWork(
|
||||
: addActorThreat(
|
||||
engagedMob.threatByActor,
|
||||
source.actorId,
|
||||
threatFromDamage(applied, source.role, source.threatScale),
|
||||
threatFromDamage(applied, source.role, source.threatScale)
|
||||
* resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat"),
|
||||
);
|
||||
work.mobs[id] = {
|
||||
...engagedMob,
|
||||
@@ -4139,6 +4149,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const existing = state.mobs[id];
|
||||
const boss = registration.boss ?? existing?.boss ?? false;
|
||||
const serverEntry = registration.serverEntry ?? existing?.serverEntry ?? null;
|
||||
const creatureFamily = registration.creatureFamily
|
||||
?? (registration.serverEntry === undefined && existing?.creatureFamily
|
||||
? existing.creatureFamily : romCreatureFamilyForServerEntry(serverEntry));
|
||||
const lootSourceId = registration.lootSourceId ?? existing?.lootSourceId ?? null;
|
||||
const level = Math.max(1, Math.trunc(registration.level ?? existing?.level ?? state.level));
|
||||
const resistances: MobMagicResistances = {
|
||||
@@ -4208,6 +4221,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
...existing,
|
||||
name: registration.name || existing.name,
|
||||
serverEntry,
|
||||
creatureFamily,
|
||||
boss,
|
||||
lootSourceId,
|
||||
level,
|
||||
@@ -4238,6 +4252,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
id,
|
||||
name: registration.name || "Unknown Creature",
|
||||
serverEntry,
|
||||
creatureFamily,
|
||||
boss,
|
||||
lootSourceId,
|
||||
level,
|
||||
@@ -4931,9 +4946,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
else if (ability.id === "rom-mage-primary-55" && !hasRuneWakerState("rom-charged")) failure = "condition-not-met";
|
||||
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
|
||||
else if (ability.dbcSpellId === 493017 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met";
|
||||
else if (ability.dbcSpellId === 492920 && !state.effects.some((effect) =>
|
||||
effect.kind === "dot" && effect.targetId === targetId && effect.remainingTicks > 0
|
||||
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")) failure = "condition-not-met";
|
||||
else if (abilityRequiresOutOfCombat(ability)
|
||||
&& !(ability.source === "runewaker" && state.classId === "rom-rogue" && hasRuneWakerState("rom-vanish"))
|
||||
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
|
||||
@@ -5013,6 +5025,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
if (!failure && ability?.name === "Power Word: Shield"
|
||||
&& hasWeakenedSoul(state.auras, targetId ?? PLAYER_AURA_ENTITY_ID, now)) failure = "condition-not-met";
|
||||
if (!failure && ability?.source === "runewaker") {
|
||||
const family = targetId ? state.mobs[targetId]?.creatureFamily : undefined;
|
||||
if (ability.dbcSpellId === 491613 && family === "living"
|
||||
&& !hasRuneWakerState("rom-evil-shackles")) failure = "condition-not-met";
|
||||
if (ability.dbcSpellId === 490347 && family === "undead") failure = "condition-not-met";
|
||||
if (ability.dbcSpellId === 492920 && !state.effects.some((effect) =>
|
||||
effect.kind === "dot" && effect.targetId === targetId && effect.remainingTicks > 0
|
||||
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")) failure = "condition-not-met";
|
||||
if (ability.dbcSpellId === 490151
|
||||
&& (!targetId || state.comboTargetId !== targetId || state.comboPoints <= 0)) failure = "condition-not-met";
|
||||
if (ability.dbcSpellId === 490347 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met";
|
||||
@@ -5352,6 +5371,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []),
|
||||
targetTags: work.auras.filter((aura) => aura.targetId === targetId)
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
|
||||
romCreatureFamilyTargetTags(targetId && work.mobs[targetId]
|
||||
? work.mobs[targetId].creatureFamily : "unknown"),
|
||||
work.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0)
|
||||
.flatMap((effect) => [
|
||||
...(effect.tag ? [effect.tag] : []),
|
||||
@@ -5360,6 +5381,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
]),
|
||||
),
|
||||
});
|
||||
if (ability.dbcSpellId === 492920 && targetId
|
||||
&& castEffects.some((effect) => effect.kind === "dot" && effect.tag === "rom-622992")) {
|
||||
work.effects = work.effects.filter((effect) => !(effect.kind === "dot"
|
||||
&& effect.targetId === targetId
|
||||
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35"));
|
||||
}
|
||||
const effectsToResolve = completingCast?.mode === "channel"
|
||||
? castEffects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind))
|
||||
: castEffects;
|
||||
@@ -5415,6 +5442,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
ability.name === "Shiv" ? { canCrit: false } : {},
|
||||
);
|
||||
grantDamageResourceInWork(work, effect, result.amount, castThreatSource.actorId, ability.id, now);
|
||||
if (ability.dbcSpellId === 490133) {
|
||||
const bonus = romScoutRogueWhirlwindBonusEffect(
|
||||
ability,
|
||||
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID).flatMap((aura) => aura.definition.utilityTags ?? []),
|
||||
work.effects.filter((active) => active.targetId === id && active.remainingTicks > 0)
|
||||
.flatMap((active) => active.tag ? [active.tag] : []),
|
||||
);
|
||||
if (bonus) {
|
||||
const bonusAmount = talentAdjustedAmount(
|
||||
equipmentAdjustedCombatAmount(bonus, work.progression.level, work.gearStats, state.classId, ability),
|
||||
modifiers, "damage-percent", ability,
|
||||
);
|
||||
resolveAndDamageMobInWork(
|
||||
state.classId, work, id, bonusAmount, now, castThreatSource,
|
||||
"melee", "physical", `${castThreatSource.actorId}:${ability.id}:${id}:${now}:poison-bonus`, ability,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (effect.kind === "finisher-damage") {
|
||||
const comboPoints = work.comboPoints;
|
||||
@@ -5980,7 +6025,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: effectiveTarget === "hostile"
|
||||
? affectedMobIds
|
||||
: [targetId ?? PLAYER_AURA_ENTITY_ID];
|
||||
for (const recipientId of recipients) {
|
||||
for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AURA_ENTITY_ID)) {
|
||||
const previousAuras = work.auras;
|
||||
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
|
||||
const auraResult = applyCombatAura(previousAuras, {
|
||||
@@ -6430,9 +6475,26 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ability.dbcSpellId === 498759 && targetId && work.mobs[targetId]
|
||||
&& !work.mobs[targetId].dead && work.mobs[targetId].health > 0) {
|
||||
const burstReset = romFireLightningBurstReset(
|
||||
ability,
|
||||
Boolean(targetId && work.mobs[targetId] && !work.mobs[targetId].dead && work.mobs[targetId].health > 0),
|
||||
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID).some((aura) =>
|
||||
aura.definition.utilityTags?.includes(ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG)),
|
||||
);
|
||||
if (burstReset.reset) {
|
||||
cooldowns[ability.id] = 0;
|
||||
work.auras = [...applyCombatAura(work.auras, {
|
||||
definition: {
|
||||
id: "rom-fire-lightning-burst-reset-guard",
|
||||
name: "Fire Lightning Burst reset guard",
|
||||
disposition: "buff",
|
||||
durationMs: burstReset.guardDurationMs,
|
||||
utilityTags: [ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now,
|
||||
}).auras];
|
||||
}
|
||||
|
||||
const primaryCombatResult = work.combatResults.length === 1 ? work.combatResults[0] : null;
|
||||
@@ -6875,7 +6937,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const engagedIds = Object.values(state.mobs)
|
||||
.filter((mob) => mob.engaged && !mob.dead)
|
||||
.map((mob) => mob.id);
|
||||
const perMob = threatFromHealing(effectiveHealing, engagedIds.length) * (source.threatScale ?? 1);
|
||||
const perMob = threatFromHealing(effectiveHealing, engagedIds.length)
|
||||
* (source.threatScale ?? 1)
|
||||
* resolveAuraStatValue(1, aurasForEntity(state.auras, source.actorId), "threat");
|
||||
if (perMob <= 0) return state;
|
||||
const mobs = { ...state.mobs };
|
||||
for (const id of engagedIds) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"sourceSha256":"b627b6eb269c30576a6558000a31abe2db472689ef9a60cc1c1e20207abd69f1","raceOffset":176,"knownIds":[100001,100002,100003,100004,100005,100006,100007,100008,100009,100010,100011,100012,100013,100014,100015,100016,100017,100018,100019,100020,100021,100022,100023,100024,100025,100026,100027,100028,100029,100030,100031,100032,100033,100034,100035,100036,100037,100038,100039,100040,100041,100042,100043,100044,100045,100046,100047,100048,100049,100050,100051,100052,100053,100054,100055,100056,100057,100058,100059,100060,100061,100062,100063,100064,100065,100066,100067,100068,100069,100070,100071,100072,100073,100074,100075,100076,100077,100078,100079,100080,100081,100082,100083,100084,100085,100086,100087,100088,100089,100090,100091,100092,100093,100094,100095,100096,100097,100098,100099,100100,100101,100102,100103,100104,100105,100106,100107,100108,100109,100110,100111,100112,100113,100114,100115,100116,100117,100118,100119,100120,100121,100122,100123,100124,100125,100126,100127,100128,100129,100130,100131,100132,100133,100134,100135,100136,100137,100138,100139,100140,100141,100142,100143,100144,100145,100146,100147,100148,100149,100150,100151,100152,100153,100154,100155,100156,100157,100158,100159,100160,100161,100162,100163,100164,100165,100166,100167,100168,100169,100170,100171,100172,100173,100174,100175,100176,100177,100178,100179,100180,100181,100182,100183,100184,100185,100186,100187,100188,100189,100190,100191,100192,100193,100194,100195,100196,100197,100198,100199,100200,100201,100202,100203,100204,100205,100206,100207,100208,100209,100210,100211,100212,100213,100214,100215,100216,100217,100218,100219,100220,100221,100222,100223,100224,100225,100226,100227,100228,100229,100230,100231,100232,100233,100234,100235,100236,100237,100238,100239,100240,100241,100242,100243,100244,100245,100246,100247,100248,100249,100250,100251,100252,100253,100254,100255,100256,100257,100258,100259,100260,100261,100262,100263,100264,100265,100266,100267,100268,100269,100270,100271,100272,100273,100274,100275,100276,100277,100278,100279,100280,100281,100282,100283,100284,100285,100286,100287,100288,100289,100290,100291,100292,100293,100294,100295,100296,100297,100298,100299,100300,100301,100302,100303,100304,100305,100306,100307,100308,100309,100310,100311,100312,100313,100314,100315,100316,100317,100318,100319,100320,100321,100322,100323,100324,100325,100326,100327,100328,100329,100330,100331,100332,100333,100334,100335,100336,100337,100338,100339,100340,100341,100342,100343,100344,100345,100346,100347,100348,100349,100350,100351,100352,100353,100354,100355,100356,100357,100358,100359,100360,100361,100362,100363,100364,100365,100366,100367,100368,100369,100370,100371,100372,100373,100374,100375,100376,100377,100378,100379,100380,100381,100382,100383,100384,100385,100386,100387,100388,100389,100390,100391,100392,100393,100394,100395,100396,100397,100398,100399,100400,100401,100402,100403,100404,100405,100406,100407,100408,100409,100410,100411,100412,100413,100414,100415,100416,100417,100418,100419,100420,100421,100422,100423,100424,100425,100426,100427,100428,100429,100430,100431,100432,100433,100434,100435,100436,100437,100438,100439,100440,100441,100442,100443,100444,100445,100446,100447,100448,100449,100450,100451,100452,100453,100454,100455,100456,100457,100458,100459,100460,100461,100462,100463,100464,100465,100466,100467,100468,100469,100470,100471,100472,100473,100474,100475,100476,100477,100478,100479,100480,100481,100482,100483,100484,100485,100486,100487,100488,100489,100490,100491,100492,100493,100494,100495,100496,100497,100498,100499,100500,100501,100502,100503,100504,100505,100506,100507,100508,100509,100510,100511,100512,100513,100514,100515,100516,100517,100518,100519,100520,100521,100522,100523,100524,100525,100526,100527,100528,100529,100530,100531,100532,100533,100534,100535,100536,100537,100538,100539,100540,100541,100542,100543,100544,100545,100546,100547,100548,100549,100550,100551,100552,100553,100554,100555,100556,100557,100558,100559,100560,100561,100562,100563,100564,100565,100566,100567,100568,100569,100570,100571,100572,100573,100574,100575,100576,100577,100578,100579,100580,100581,100582,100583,100584,100585,100586,100587,100588,100589,100590,100591,100592,100593,100594,100595,100596,100597,100598,100599,100600,100601,100602,100603,100604,100605,100606,100607,100608,100609,100610,100611,100612,100613,100614,100615,100616,100617,100618,100619,100620,100621,100622,100623,100624,100625,100626,100627,100628,100629,100630,100631,100632,100633,100634,100635,100636,100637,100638,100639,100640,100641,100642,100643,100644,100645,100646,100647,100648,100649,100650,100651,100652,100653,100654,100655,100656,100657,100658,100659,100660,100661,100662,100663,100664,100665,100666,100667,100668,100669,100670,100671,100672,100673,100674,100675,100676,100677,100678,100679,100680,100681,100682,100683,100684,100685,100686,100687,100688,100689,100690,100691,100692,100693,100694,100695,1006Line truncated
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
type RotationFriendlyTarget,
|
||||
} from "./combatRotation";
|
||||
import { spendDeathKnightRunes } from "./combatResources";
|
||||
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
|
||||
import { resolveResourceValue } from "./combatAuras";
|
||||
import { equipmentAdjustedCombatAmount, useCombatStore, type MobCombatState } from "./combatStore";
|
||||
import type { PartyMember } from "./partyStore";
|
||||
import { baseResourcesFor } from "./wotlkStats";
|
||||
import { companionResourceMaximum } from "./combatActorAuthority";
|
||||
@@ -51,12 +52,20 @@ function currentResource(member: PartyMember, type: ResourceType): number {
|
||||
}
|
||||
|
||||
function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly { resource: ResourceType; amount: number }[] {
|
||||
return (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ({
|
||||
resource: cost.resource,
|
||||
amount: cost.percentage ? cost.amount / 100 * (cost.percentageBasis === "base-mana"
|
||||
const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id);
|
||||
return (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => {
|
||||
const maximum = cost.percentageBasis === "base-mana"
|
||||
? baseResourcesFor(member.classId, member.level).mana
|
||||
: cost.resource === "health" ? member.maxHealth : companionResourceMaximum(member, cost.resource)) : cost.amount,
|
||||
}));
|
||||
: cost.resource === "health" ? member.maxHealth : companionResourceMaximum(member, cost.resource);
|
||||
const baseAmount = cost.percentage ? cost.amount / 100 * maximum : cost.amount;
|
||||
const adjusted = resolveResourceValue(
|
||||
resolveResourceValue(baseAmount, auras, cost.resource, "cost"),
|
||||
auras,
|
||||
"power",
|
||||
"cost",
|
||||
);
|
||||
return { resource: cost.resource, amount: Math.max(0, Math.round(adjusted)) };
|
||||
});
|
||||
}
|
||||
|
||||
export function partyAbilityIsReady(
|
||||
|
||||
@@ -82,6 +82,9 @@ import {
|
||||
ROGUE_ENVENOM_BUFF_TAG,
|
||||
} from "./roguePoison";
|
||||
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics";
|
||||
import { romCreatureFamilyTargetTags } from "./romCreatureFamilies";
|
||||
import { romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
|
||||
import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, romFireLightningBurstReset } from "./romMageWarlockOverrides";
|
||||
|
||||
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
|
||||
const ATTACK_ACQUISITION_RANGE = 30;
|
||||
@@ -768,9 +771,17 @@ function partyAbilityTargetIsValid(
|
||||
&& aura.definition.disposition === "buff" && aura.definition.dispelCategory === "enrage"
|
||||
&& !aura.definition.name.startsWith("Enraged Regeneration"))) return false;
|
||||
if (ability.source === "runewaker") {
|
||||
if (ability.dbcSpellId === 491613 && mob?.creatureFamily === "living"
|
||||
&& !(member.classId === "rom-knight" && member.secondaryClassId === "rom-scout" && member.level >= 60)) return false;
|
||||
if (ability.dbcSpellId === 490347 && mob?.creatureFamily === "undead") return false;
|
||||
if (ability.dbcSpellId === 490151 && (member.comboTargetId !== targetId || member.comboPoints <= 0)) return false;
|
||||
if (ability.dbcSpellId === 490347 && !combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("rom-poisonous"))) return false;
|
||||
if (ability.dbcSpellId === 493017 && !combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("rom-poisonous"))) return false;
|
||||
if (ability.dbcSpellId === 492920 && !combat.effects.some((effect) => effect.kind === "dot"
|
||||
&& effect.targetId === targetId && effect.remainingTicks > 0
|
||||
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")) return false;
|
||||
if (ability.dbcSpellId === 491171 && !combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("rom-charged"))) return false;
|
||||
if (ability.dbcSpellId === 491172 && !combat.auras.some((aura) => aura.targetId === member.id
|
||||
@@ -1225,10 +1236,19 @@ function executePartyAbility(
|
||||
),
|
||||
targetTags: combat.auras.filter((aura) => aura.targetId === targetId)
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
|
||||
romCreatureFamilyTargetTags(combat.mobs[targetId]?.creatureFamily ?? "unknown"),
|
||||
combat.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0)
|
||||
.flatMap((effect) => effect.tag ? [effect.tag] : []),
|
||||
),
|
||||
});
|
||||
if (ability.dbcSpellId === 492920 && castEffects.some((effect) => effect.kind === "dot"
|
||||
&& effect.tag === "rom-622992")) {
|
||||
useCombatStore.setState((state) => ({
|
||||
effects: state.effects.filter((effect) => !(effect.kind === "dot"
|
||||
&& effect.targetId === targetId
|
||||
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")),
|
||||
}));
|
||||
}
|
||||
for (const effect of castEffects) {
|
||||
if (ability.name === "Penance" && combat.mobs[targetId]
|
||||
&& (effect.kind === "heal" || effect.kind === "hot")) continue;
|
||||
@@ -1272,6 +1292,20 @@ function executePartyAbility(
|
||||
} : undefined,
|
||||
);
|
||||
effectiveAmount += result.amount;
|
||||
if (effect.kind === "damage" && ability.dbcSpellId === 490133) {
|
||||
const currentCombat = useCombatStore.getState();
|
||||
const bonus = romScoutRogueWhirlwindBonusEffect(
|
||||
ability,
|
||||
currentCombat.auras.filter((aura) => aura.targetId === member.id)
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []),
|
||||
currentCombat.effects.filter((active) => active.targetId === hostileTargetId && active.remainingTicks > 0)
|
||||
.flatMap((active) => active.tag ? [active.tag] : []),
|
||||
);
|
||||
if (bonus) effectiveAmount += currentCombat.damageMob(
|
||||
hostileTargetId, partyAbilityAmount(runtimeMember, ability, bonus), now,
|
||||
{ actorId: member.id, role: member.role, abilityId: ability.id }, "physical",
|
||||
).amount;
|
||||
}
|
||||
if (
|
||||
result.amount > 0
|
||||
&& effect.kind === "damage"
|
||||
@@ -1383,7 +1417,7 @@ function executePartyAbility(
|
||||
: effect.recipient === "caster"
|
||||
? [member.id]
|
||||
: combat.mobs[targetId] ? hostileTargets : friendlyTargets;
|
||||
for (const recipientId of recipients) {
|
||||
for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== member.id)) {
|
||||
const appliedEffect = ability.name === "Rupture"
|
||||
? { ...effect, aura: { ...effect.aura, durationMs: Math.max(4, Math.min(8, 3 + runtimeMember.comboPoints)) * 2_000 } }
|
||||
: ability.name === "Envenom"
|
||||
@@ -1563,6 +1597,34 @@ function executePartyAbility(
|
||||
if (consumesColdBlood) useCombatStore.getState().consumeActorAuraTag(member.id, "next-offensive-critical");
|
||||
if (ability.dbcSpellId === 491171) useCombatStore.getState().consumeActorAuraTag(member.id, "rom-charged");
|
||||
if (ability.dbcSpellId === 491172) useCombatStore.getState().consumeActorAuraTag(member.id, "rom-static-field-charge");
|
||||
if (ability.dbcSpellId === 498759) {
|
||||
const currentCombat = useCombatStore.getState();
|
||||
const reset = romFireLightningBurstReset(
|
||||
ability,
|
||||
Boolean(currentCombat.mobs[targetId] && !currentCombat.mobs[targetId].dead
|
||||
&& currentCombat.mobs[targetId].health > 0),
|
||||
currentCombat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes(ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG)),
|
||||
);
|
||||
if (reset.reset) {
|
||||
useCombatStore.getState().applyActorAura(member.id, member.id, ability, {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 621514,
|
||||
aura: {
|
||||
id: "rom-fire-lightning-burst-reset-guard",
|
||||
name: "Fire Lightning Burst reset guard",
|
||||
disposition: "buff",
|
||||
durationMs: reset.guardDurationMs,
|
||||
utilityTags: [ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG],
|
||||
},
|
||||
}, now);
|
||||
runtimeMember = { ...runtimeMember, cooldowns: { ...runtimeMember.cooldowns, [ability.id]: 0 } };
|
||||
}
|
||||
}
|
||||
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
|
||||
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
|
||||
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type RomGeneratedAbilityLike,
|
||||
type RomSourceComponent,
|
||||
} from "./romAbilitySemantics";
|
||||
import { overrideRomPriestKnightEffects } from "./romPriestKnightOverrides";
|
||||
import { overrideRomPriestKnightEffects, romPriestKnightAdditionalAbilities } from "./romPriestKnightOverrides";
|
||||
import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides";
|
||||
import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
|
||||
|
||||
@@ -744,7 +744,11 @@ function ability(row: GeneratedSkill): AbilityDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
export const ROM_ALL_ABILITIES = Object.freeze(generated.map(ability));
|
||||
const generatedAbilities = generated.map(ability);
|
||||
export const ROM_ALL_ABILITIES = Object.freeze([
|
||||
...generatedAbilities,
|
||||
...romPriestKnightAdditionalAbilities(generatedAbilities),
|
||||
]);
|
||||
|
||||
const romAbilityByGeneratedId = new Map(ROM_ALL_ABILITIES.map((entry) => [entry.id, entry]));
|
||||
const triggeredComponentOwners = new Map<number, { readonly parent: GeneratedSkill; readonly component: RomSourceComponent }>();
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("executable RuneWaker ability mappings", () => {
|
||||
})),
|
||||
]);
|
||||
|
||||
expect(versions).toHaveLength(1_863);
|
||||
expect(versions).toHaveLength(1_864);
|
||||
const scriptOnly = ROM_ALL_ABILITIES
|
||||
.filter((ability) => !ability.passive && ability.effects.every((effect) => effect.kind === "scripted"))
|
||||
.map((ability) => ability.name)
|
||||
|
||||
@@ -67,7 +67,7 @@ function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect {
|
||||
export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMechanics): readonly AbilityEffect[] {
|
||||
if (ability.source !== "runewaker") return ability.effects;
|
||||
const priestKnightEffects = romPriestKnightCastEffects(
|
||||
ability, ability.effects, context.passiveTags ?? [], context.casterMaxHealth,
|
||||
ability, ability.effects, context.passiveTags ?? [], context.casterMaxHealth, context.targetTags ?? [],
|
||||
);
|
||||
const mageWarlockEffects = romMageWarlockCastEffects(ability, priestKnightEffects, new Set(context.passiveTags ?? []));
|
||||
const effects = romScoutRogueCastEffects(ability, mageWarlockEffects, context.passiveTags ?? [], context.targetTags ?? []);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { romCreatureFamilyForServerEntry } from "./romCreatureFamilies";
|
||||
|
||||
describe("RuneWaker creature-family combat rules", () => {
|
||||
beforeEach(() => clearMobRuntimeRegistry());
|
||||
|
||||
it("uses NPCObjectDB race records for living, Undead, and Demon targets", () => {
|
||||
expect(romCreatureFamilyForServerEntry(103946)).toBe("living");
|
||||
expect(romCreatureFamilyForServerEntry(106432)).toBe("undead");
|
||||
expect(romCreatureFamilyForServerEntry(102698)).toBe("demon");
|
||||
expect(romCreatureFamilyForServerEntry(null)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("restricts base Holy Chains and unlocks living targets with Evil Shackles", () => {
|
||||
for (const level of [30, 60]) {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level,
|
||||
});
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
useCombatStore.getState().registerMob("living", {
|
||||
name: "Shadoj", serverEntry: 103946, maxHealth: 1_000_000,
|
||||
});
|
||||
setMobPosition("living", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("living");
|
||||
expect(useCombatStore.getState().mobs.living?.creatureFamily).toBe("living");
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-25", undefined, 1_000))
|
||||
.toMatchObject(level < 60 ? { ok: false, reason: "condition-not-met" } : { ok: true });
|
||||
}
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 30,
|
||||
});
|
||||
useCombatStore.getState().registerMob("undead", {
|
||||
name: "Self-Loathing Ghost", serverEntry: 106432, maxHealth: 1_000_000,
|
||||
});
|
||||
setMobPosition("undead", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("undead");
|
||||
expect(useCombatStore.getState().mobs.undead?.creatureFamily).toBe("undead");
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-25", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("accepts the Poisonous state without a bag item and rejects Numbing Dagger on Undead", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 50,
|
||||
});
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
for (const [id, entry] of [["undead", 106432], ["living", 103946]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, serverEntry: entry, maxHealth: 1_000_000 });
|
||||
setMobPosition(id, [3, 0, 0]);
|
||||
}
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 2_100);
|
||||
useCombatStore.getState().selectMob("undead");
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 2_600))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
useCombatStore.getState().selectMob("living");
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 2_600))
|
||||
.toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import source from "./generated/romCreatureFamilies.json";
|
||||
|
||||
export type CombatCreatureFamily = "living" | "undead" | "demon" | "unknown";
|
||||
|
||||
const known = new Set<number>(source.knownIds);
|
||||
const undead = new Set<number>(source.undeadIds);
|
||||
const demon = new Set<number>(source.demonIds);
|
||||
|
||||
/** RuneWaker NPCObjectDB race field, exported by export-rom-creature-families.mjs. */
|
||||
export function romCreatureFamilyForServerEntry(serverEntry: number | null | undefined): CombatCreatureFamily {
|
||||
if (serverEntry == null || !known.has(serverEntry)) return "unknown";
|
||||
if (undead.has(serverEntry)) return "undead";
|
||||
if (demon.has(serverEntry)) return "demon";
|
||||
return "living";
|
||||
}
|
||||
|
||||
export function romCreatureFamilyTargetTags(family: CombatCreatureFamily): readonly string[] {
|
||||
if (family === "undead" || family === "demon") return ["rom-target-undead-demon"];
|
||||
if (family === "living") return ["rom-target-living"];
|
||||
return [];
|
||||
}
|
||||
@@ -13,10 +13,16 @@ export const ROM_MAGE_WARLOCK_PASSIVE_TAGS = Object.freeze({
|
||||
} as const);
|
||||
|
||||
export const ROM_SOUL_STEPPING_CAST_TAG = "rom-mage-warlock-soul-stepping-cast";
|
||||
export const ROM_BREATH_ERASE_THREAT_TAG = "rom-mage-warlock-breath-erase-threat";
|
||||
export const ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG = "rom-fire-lightning-burst-reset-guard";
|
||||
export const ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_MS = 6_000;
|
||||
|
||||
// Industry Fire's player tooltip gives no magnitude for its extra fire hit.
|
||||
// One Soul Pain tick is a conservative source-derived starting value.
|
||||
const INDUSTRY_FIRE_DOT_MULTIPLIER = 1.1;
|
||||
// Hidden replacement skill 498746: 621504 is the initial Fire hit, and
|
||||
// 621503 is its 24-second Fire DoT. These values come from MagicObject.db.
|
||||
const INDUSTRY_FIRE_HIT_BASE = 140;
|
||||
const INDUSTRY_FIRE_HIT_PER_LEVEL = 40;
|
||||
const INDUSTRY_FIRE_DOT_BASE = 23.4;
|
||||
const INDUSTRY_FIRE_DOT_PER_LEVEL = 10.4;
|
||||
|
||||
function stateAura(id: string, tag: string, durationMs: number | null): AbilityEffect {
|
||||
return {
|
||||
@@ -60,6 +66,16 @@ export function overrideRomMageWarlockEffects(
|
||||
});
|
||||
}
|
||||
|
||||
if (row.sourceSkillId === 498750) {
|
||||
return translated.map((effect) => effect.kind === "apply-aura" ? {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_BREATH_ERASE_THREAT_TAG],
|
||||
},
|
||||
} : effect);
|
||||
}
|
||||
|
||||
if (row.sourceSkillId === 498745) return [stateAura("industry-fire", ROM_MAGE_WARLOCK_PASSIVE_TAGS.industryFire, null)];
|
||||
if (row.sourceSkillId === 498748) return [stateAura("lightning-display", ROM_MAGE_WARLOCK_PASSIVE_TAGS.lightningDisplay, null)];
|
||||
if (row.sourceSkillId === 498751) return [stateAura("elements-of-pride", ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementsOfPride, null)];
|
||||
@@ -72,18 +88,6 @@ export function overrideRomMageWarlockEffects(
|
||||
return null;
|
||||
}
|
||||
|
||||
function scaleDamage(effect: AbilityEffect, factor: number): AbilityEffect {
|
||||
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
|
||||
return {
|
||||
...effect,
|
||||
coefficient: effect.coefficient * factor,
|
||||
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
|
||||
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
|
||||
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
|
||||
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
|
||||
};
|
||||
}
|
||||
|
||||
function elementsOfPrideAura(school: "fire" | "nature", value: number): AbilityEffect {
|
||||
return {
|
||||
kind: "apply-aura",
|
||||
@@ -113,20 +117,25 @@ export function romMageWarlockCastEffects(
|
||||
if (ability.source !== "runewaker") return effects;
|
||||
|
||||
if (ability.dbcSpellId === 497964 && passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.industryFire)) {
|
||||
const transformed = effects.map((effect) => effect.kind === "dot"
|
||||
? { ...scaleDamage(effect, INDUSTRY_FIRE_DOT_MULTIPLIER), school: "fire" as const }
|
||||
: effect);
|
||||
const dot = transformed.find((effect) => effect.kind === "dot");
|
||||
if (dot?.kind !== "dot") return transformed;
|
||||
return [
|
||||
...transformed,
|
||||
{
|
||||
kind: "damage",
|
||||
school: "fire",
|
||||
coefficient: dot.coefficient,
|
||||
basePoints: dot.basePoints ?? 0,
|
||||
pointsPerLevel: dot.pointsPerLevel ?? 0,
|
||||
sourceLevel: dot.sourceLevel ?? 1,
|
||||
coefficient: 0,
|
||||
basePoints: INDUSTRY_FIRE_HIT_BASE,
|
||||
pointsPerLevel: INDUSTRY_FIRE_HIT_PER_LEVEL,
|
||||
sourceLevel: 1,
|
||||
},
|
||||
{
|
||||
kind: "dot",
|
||||
school: "fire",
|
||||
coefficient: 0,
|
||||
basePoints: INDUSTRY_FIRE_DOT_BASE,
|
||||
pointsPerLevel: INDUSTRY_FIRE_DOT_PER_LEVEL,
|
||||
sourceLevel: 1,
|
||||
ticks: 24,
|
||||
intervalMs: 1_000,
|
||||
tag: "rom-industry-fire",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -134,19 +143,52 @@ export function romMageWarlockCastEffects(
|
||||
let result = effects;
|
||||
if (passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.lightningDisplay)) {
|
||||
result = result.map((effect) => {
|
||||
if (effect.kind === "apply-aura" && effect.aura.utilityTags?.includes("rom-charged")
|
||||
&& ability.dbcSpellId === 491169) {
|
||||
return { ...effect, aura: { ...effect.aura, durationMs: 18_000 } };
|
||||
if (effect.kind === "apply-aura" && [491169, 498749].includes(ability.dbcSpellId)) {
|
||||
if (effect.aura.utilityTags?.includes("rom-charged")) {
|
||||
return { ...effect, aura: { ...effect.aura, durationMs: 18_000 } };
|
||||
}
|
||||
if (effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "magical-critical-strike")) {
|
||||
return {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
durationMs: 18_000,
|
||||
modifiers: [
|
||||
...effect.aura.modifiers,
|
||||
{ kind: "damage" as const, direction: "dealt" as const, school: "nature" as const,
|
||||
operation: "percent" as const, value: 0.01 },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return (effect.kind === "damage" || effect.kind === "dot") && effect.school === "nature"
|
||||
? scaleDamage(effect, 1.01)
|
||||
: effect;
|
||||
return effect;
|
||||
});
|
||||
}
|
||||
|
||||
if (passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementsOfPride)) {
|
||||
if (ability.dbcSpellId === 491150) result = [...result, elementsOfPrideAura("nature", 0.02)];
|
||||
if (ability.dbcSpellId === 491170) result = [...result, elementsOfPrideAura("fire", 0.01)];
|
||||
if ([491150, 498755, 499812].includes(ability.dbcSpellId)) {
|
||||
result = [...result, elementsOfPrideAura("nature", 0.02)];
|
||||
}
|
||||
if ([491170, 498756, 499851].includes(ability.dbcSpellId)) {
|
||||
result = [...result, elementsOfPrideAura("fire", 0.01)];
|
||||
}
|
||||
}
|
||||
|
||||
if (ability.dbcSpellId === 491170 && passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction)) {
|
||||
// Hidden level-70 Electric Explosion 499851 replaces the normal branch's
|
||||
// 620232 hit with 623668; its Charged 623669 hit keeps the same values.
|
||||
const damage = result.filter((effect) => effect.kind === "damage");
|
||||
const normal = damage.at(-1);
|
||||
if (damage.length >= 2 && normal) result = result.map((effect) => effect === normal ? {
|
||||
kind: "damage" as const,
|
||||
school: "nature" as const,
|
||||
coefficient: 0,
|
||||
basePoints: 65,
|
||||
pointsPerLevel: 15,
|
||||
sourceLevel: 1,
|
||||
} : effect);
|
||||
}
|
||||
|
||||
if (ability.dbcSpellId === 498757 && !passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.soulSpeed)) {
|
||||
@@ -162,7 +204,7 @@ export function romMageWarlockCriticalEffects(
|
||||
passiveTags: ReadonlySet<string>,
|
||||
): readonly AbilityEffect[] {
|
||||
if (!passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction)
|
||||
|| (ability.dbcSpellId !== 491170 && ability.dbcSpellId !== 499812)) return [];
|
||||
|| ![491150, 491170, 498755, 498756, 499812, 499851].includes(ability.dbcSpellId)) return [];
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
@@ -184,3 +226,18 @@ export function romMageWarlockCriticalEffects(
|
||||
triggerSpellId: 499814,
|
||||
}];
|
||||
}
|
||||
|
||||
/** Lua script 02557: reset Burst on a surviving target once per six seconds. */
|
||||
export function romFireLightningBurstReset(
|
||||
ability: AbilityDefinition,
|
||||
targetAlive: boolean,
|
||||
guardActive: boolean,
|
||||
): { readonly reset: boolean; readonly guardDurationMs: number } {
|
||||
const reset = ability.dbcSpellId === 498759 && targetAlive && !guardActive;
|
||||
return { reset, guardDurationMs: reset ? ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_MS : 0 };
|
||||
}
|
||||
|
||||
/** Breath Erase source 621506: ten percent less generated threat for five seconds. */
|
||||
export function romMageWarlockThreatScale(activeTags: ReadonlySet<string>): number {
|
||||
return activeTags.has(ROM_BREATH_ERASE_THREAT_TAG) ? 0.9 : 1;
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, abilityById, learnedAbilityForBuild } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura as applyCombatAura } from "./combatAuras";
|
||||
import { abilityDamageSchool, useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
||||
import {
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS,
|
||||
ROM_BREATH_ERASE_THREAT_TAG,
|
||||
ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_MS,
|
||||
romMageWarlockCastEffects,
|
||||
romMageWarlockCriticalEffects,
|
||||
romFireLightningBurstReset,
|
||||
romMageWarlockThreatScale,
|
||||
} from "./romMageWarlockOverrides";
|
||||
|
||||
const ELITES = [
|
||||
@@ -78,6 +84,8 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
expect(elite(30).effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.utilityTags?.includes(ROM_BREATH_ERASE_THREAT_TAG))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses magic schools for Mage and Warlock magic projectiles and dark spells", () => {
|
||||
@@ -150,6 +158,23 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
expect(useCombatStore.getState().mobs["weave-far"]!.health).toBe(100_000);
|
||||
});
|
||||
|
||||
it("caps Surge of Malice at three targets even when four enemies are nearby", () => {
|
||||
initialize(30);
|
||||
for (const [id, x] of [
|
||||
["surge-a", 2], ["surge-b", 3], ["surge-c", 4], ["surge-d", 5], ["surge-far", 20],
|
||||
] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
|
||||
setMobPosition(id, [x, 0, 0]);
|
||||
}
|
||||
useCombatStore.getState().selectMob("surge-a");
|
||||
expect(useCombatStore.getState().castAbility("rom-warlock-general-5", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
const damaged = ["surge-a", "surge-b", "surge-c", "surge-d"]
|
||||
.filter((id) => useCombatStore.getState().mobs[id]!.health < 100_000);
|
||||
expect(damaged).toHaveLength(3);
|
||||
expect(useCombatStore.getState().mobs["surge-far"]!.health).toBe(100_000);
|
||||
});
|
||||
|
||||
it("Static Resonance resets only Electrostatic Charge", () => {
|
||||
initialize(60);
|
||||
useCombatStore.setState({ cooldowns: {
|
||||
@@ -196,6 +221,19 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
expect(useCombatStore.getState().mobs["moving-cast-dummy"]!.health).toBeLessThan(100_000);
|
||||
});
|
||||
|
||||
it("Soul Speed adds its movement and spell speed buffs when Soul Stepping is cast", () => {
|
||||
initialize(60);
|
||||
expect(useCombatStore.getState().castAbility(elite(40).id, undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
const modifiers = useCombatStore.getState().auras
|
||||
.filter((aura) => aura.targetId === PLAYER_AGGRO_ID)
|
||||
.flatMap((aura) => aura.definition.modifiers ?? []);
|
||||
expect(modifiers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "stat", stat: "movement-speed", value: 0.2 }),
|
||||
expect.objectContaining({ kind: "stat", stat: "spell-haste", value: 20 }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("Fire Lightning Burst hits twice and resets its own cooldown only if the target survives", () => {
|
||||
initialize(60);
|
||||
registerTarget();
|
||||
@@ -206,6 +244,10 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
expect(useCombatStore.getState().mobs["mage-warlock-dummy"]!.health).toBeLessThan(100_000);
|
||||
expect(useCombatStore.getState().cooldowns[elite(50).id] ?? 0).toBeLessThanOrEqual(3_100);
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(90_000);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(elite(50).id, undefined, 4_600)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 6_700);
|
||||
expect(useCombatStore.getState().cooldowns[elite(50).id]).toBeGreaterThan(6_700);
|
||||
});
|
||||
|
||||
it("does not apply conditional passive buffs or enemy defense penalties to the caster on login", () => {
|
||||
@@ -217,16 +259,52 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
expect(elite(20).effects.some((effect) => effect.kind === "scripted")).toBe(false);
|
||||
});
|
||||
|
||||
it("converts Soul Pain to fire with an extra hit only while Industry Fire is learned", () => {
|
||||
it("uses Industry Fire's hidden Soul Pain payload during a live cast", () => {
|
||||
initialize(60);
|
||||
registerTarget("industry-fire-dummy");
|
||||
expect(useCombatStore.getState().castAbility("rom-warlock-general-4", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 3_100);
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.mobs["industry-fire-dummy"]!.health).toBeLessThan(100_000);
|
||||
expect(state.effects.some((effect) => effect.tag === "rom-industry-fire"
|
||||
&& effect.targetId === "industry-fire-dummy")).toBe(true);
|
||||
});
|
||||
|
||||
it("triggers Lightning Display and Elements of Pride only after their source spells land", () => {
|
||||
initialize(60);
|
||||
registerTarget("elite-trigger-dummy");
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-52", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 3_100);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "magical-critical-strike" && modifier.value === 20))).toBe(true);
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 4_600))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 7_700);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id === "rom-mage-warlock-elements-nature" && aura.stacks === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses hidden skill 498746's exact fire hit and DoT after Industry Fire", () => {
|
||||
const soulPain = abilityById("rom-warlock-general-4")!;
|
||||
const ordinary = romMageWarlockCastEffects(soulPain, soulPain.effects, new Set());
|
||||
const upgraded = romMageWarlockCastEffects(soulPain, soulPain.effects,
|
||||
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.industryFire]));
|
||||
expect(ordinary.some((effect) => effect.kind === "damage")).toBe(false);
|
||||
expect(upgraded).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "dot", school: "fire" }),
|
||||
expect.objectContaining({ kind: "damage", school: "fire" }),
|
||||
]));
|
||||
expect(upgraded).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "damage", school: "fire", coefficient: 0,
|
||||
basePoints: 140, pointsPerLevel: 40, sourceLevel: 1,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "dot", school: "fire", coefficient: 0,
|
||||
basePoints: 23.4, pointsPerLevel: 10.4, sourceLevel: 1,
|
||||
ticks: 24, intervalMs: 1_000,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("extends Charged, creates conditional Elements stacks, and enhances Soul Stepping", () => {
|
||||
@@ -240,6 +318,20 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
effect.kind === "apply-aura" && effect.aura.utilityTags?.includes("rom-charged")
|
||||
));
|
||||
expect(charged).toMatchObject({ aura: { durationMs: 18_000 } });
|
||||
const lightningBuff = romMageWarlockCastEffects(plasma, plasma.effects, tags).find((effect) => (
|
||||
effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => (
|
||||
modifier.kind === "stat" && modifier.stat === "magical-critical-strike"
|
||||
))
|
||||
));
|
||||
expect(lightningBuff).toMatchObject({
|
||||
aura: {
|
||||
durationMs: 18_000,
|
||||
modifiers: expect.arrayContaining([
|
||||
expect.objectContaining({ stat: "magical-critical-strike", value: 20 }),
|
||||
expect.objectContaining({ kind: "damage", school: "nature", value: 0.01 }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const flame = abilityById("rom-mage-primary-1")!;
|
||||
expect(romMageWarlockCastEffects(flame, flame.effects, tags)).toEqual(expect.arrayContaining([
|
||||
@@ -258,20 +350,138 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
});
|
||||
|
||||
it("keeps Elemental Extraction's defense debuff on a critical-hit target", () => {
|
||||
const explosion = abilityById("rom-mage-primary-54")!;
|
||||
expect(romMageWarlockCriticalEffects(explosion, new Set())).toEqual([]);
|
||||
expect(romMageWarlockCriticalEffects(explosion,
|
||||
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura", recipient: "ability-target",
|
||||
aura: expect.objectContaining({
|
||||
disposition: "debuff", durationMs: 10_000,
|
||||
modifiers: expect.arrayContaining([
|
||||
expect.objectContaining({ stat: "armor", value: -0.3 }),
|
||||
expect.objectContaining({ stat: "magical-defense", value: -0.3 }),
|
||||
]),
|
||||
for (const id of ["rom-mage-primary-1", "rom-mage-primary-54"]) {
|
||||
const attack = abilityById(id)!;
|
||||
expect(romMageWarlockCriticalEffects(attack, new Set())).toEqual([]);
|
||||
expect(romMageWarlockCriticalEffects(attack,
|
||||
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura", recipient: "ability-target",
|
||||
aura: expect.objectContaining({
|
||||
disposition: "debuff", durationMs: 10_000,
|
||||
modifiers: expect.arrayContaining([
|
||||
expect.objectContaining({ stat: "armor", value: -0.3 }),
|
||||
expect.objectContaining({ stat: "magical-defense", value: -0.3 }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("triggers Elemental Extraction's target debuff on a live critical Flame hit", () => {
|
||||
initialize(79);
|
||||
registerTarget("critical-extraction-dummy");
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: applyCombatAura(state.auras, {
|
||||
definition: {
|
||||
id: "mage-warlock-test-guaranteed-crit",
|
||||
name: "Guaranteed critical test trigger",
|
||||
disposition: "buff",
|
||||
durationMs: 10_000,
|
||||
utilityTags: ["next-offensive-critical"],
|
||||
},
|
||||
sourceId: PLAYER_AGGRO_ID,
|
||||
targetId: PLAYER_AGGRO_ID,
|
||||
now: 1_000,
|
||||
}).auras,
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 4_100);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "critical-extraction-dummy"
|
||||
&& aura.definition.id === "rom-mage-warlock-elemental-extraction-debuff")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses hidden 499851's stronger normal Electric Explosion branch at level 70", () => {
|
||||
const explosion = abilityById("rom-mage-primary-54")!;
|
||||
const original = explosion.effects.filter((effect) => effect.kind === "damage");
|
||||
const upgraded = romMageWarlockCastEffects(explosion, explosion.effects,
|
||||
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))
|
||||
.filter((effect) => effect.kind === "damage");
|
||||
expect(upgraded).toHaveLength(2);
|
||||
expect(upgraded[0]).toEqual(original[0]);
|
||||
expect(upgraded[1]).toMatchObject({
|
||||
school: "nature", coefficient: 0, basePoints: 65, pointsPerLevel: 15, sourceLevel: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors Fire Lightning Burst's source six-second reset guard", () => {
|
||||
const burst = elite(50);
|
||||
expect(romFireLightningBurstReset(burst, true, false)).toEqual({
|
||||
reset: true, guardDurationMs: ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_MS,
|
||||
});
|
||||
expect(romFireLightningBurstReset(burst, true, true)).toEqual({ reset: false, guardDurationMs: 0 });
|
||||
expect(romFireLightningBurstReset(burst, false, false)).toEqual({ reset: false, guardDurationMs: 0 });
|
||||
expect(romFireLightningBurstReset(elite(45), true, false)).toEqual({ reset: false, guardDurationMs: 0 });
|
||||
});
|
||||
|
||||
it("exposes Breath Erase's five-second threat reduction to the runtime", () => {
|
||||
const breath = elite(30).effects.find((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.utilityTags?.includes(ROM_BREATH_ERASE_THREAT_TAG));
|
||||
expect(breath).toMatchObject({ aura: { durationMs: 5_000 } });
|
||||
expect(romMageWarlockThreatScale(new Set())).toBe(1);
|
||||
expect(romMageWarlockThreatScale(new Set([ROM_BREATH_ERASE_THREAT_TAG]))).toBe(0.9);
|
||||
});
|
||||
|
||||
it("reduces live spell threat while Breath Erase is active", () => {
|
||||
const castFlame = (useBreath: boolean) => {
|
||||
initialize(60);
|
||||
registerTarget("breath-threat-dummy");
|
||||
if (useBreath) {
|
||||
expect(useCombatStore.getState().castAbility(elite(30).id, undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
}
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 2_600))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 5_700);
|
||||
const target = useCombatStore.getState().mobs["breath-threat-dummy"]!;
|
||||
return {
|
||||
damage: target.maxHealth - target.health,
|
||||
threat: target.threatByActor[PLAYER_AGGRO_ID] ?? 0,
|
||||
};
|
||||
};
|
||||
const baseline = castFlame(false);
|
||||
const reduced = castFlame(true);
|
||||
expect(reduced.damage).toBeGreaterThan(0);
|
||||
expect(reduced.damage).toBe(baseline.damage);
|
||||
expect((reduced.threat - 1) / (baseline.threat - 1)).toBeCloseTo(0.9, 5);
|
||||
});
|
||||
|
||||
it("applies Elemental Extraction's defense reduction to the target's live magic mitigation", () => {
|
||||
const targetId = "extraction-defense-dummy";
|
||||
const castFlame = (useDebuff: boolean) => {
|
||||
initialize(60);
|
||||
useCombatStore.getState().registerMob(targetId, {
|
||||
name: targetId,
|
||||
maxHealth: 100_000,
|
||||
resistances: { fire: 1_000 },
|
||||
});
|
||||
setMobPosition(targetId, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
if (useDebuff) {
|
||||
const rider = romMageWarlockCriticalEffects(abilityById("rom-mage-primary-1")!,
|
||||
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))[0];
|
||||
expect(rider.kind).toBe("apply-aura");
|
||||
if (rider.kind !== "apply-aura") throw new Error("Elemental Extraction debuff missing");
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: applyCombatAura(state.auras, {
|
||||
definition: rider.aura,
|
||||
sourceId: PLAYER_AGGRO_ID,
|
||||
targetId,
|
||||
now: 1_000,
|
||||
}).auras,
|
||||
}));
|
||||
}
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 4_100);
|
||||
return useCombatStore.getState().mobs[targetId]!.maxHealth
|
||||
- useCombatStore.getState().mobs[targetId]!.health;
|
||||
};
|
||||
const baselineDamage = castFlame(false);
|
||||
const reducedDefenseDamage = castFlame(true);
|
||||
expect(baselineDamage).toBeGreaterThan(0);
|
||||
expect(reducedDefenseDamage).toBeGreaterThan(baselineDamage);
|
||||
});
|
||||
});
|
||||
@@ -40,23 +40,26 @@ describe("generated RuneWaker player catalog", () => {
|
||||
expect(audit.hashes.interfaceFdb).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("resolves every ordered pair to all ten executable elite milestones", () => {
|
||||
expect(ROM_ELITE_ABILITIES).toHaveLength(660);
|
||||
it("resolves every ordered pair to all ten elite milestones and the source-backed Devotion Halo", () => {
|
||||
expect(ROM_ELITE_ABILITIES).toHaveLength(661);
|
||||
const pairGroups = new Map<string, typeof ROM_ELITE_ABILITIES[number][]>();
|
||||
for (const ability of ROM_ELITE_ABILITIES) {
|
||||
const key = `${ability.classId}:${ability.secondaryClassId}`;
|
||||
pairGroups.set(key, [...(pairGroups.get(key) ?? []), ability]);
|
||||
}
|
||||
expect(pairGroups.size).toBe(66);
|
||||
for (const elites of pairGroups.values()) {
|
||||
expect(elites.map((ability) => ability.unlockLevel)).toEqual(ELITE_LEVELS);
|
||||
for (const [pair, elites] of pairGroups) {
|
||||
expect(elites.map((ability) => ability.unlockLevel)).toEqual(pair === "rom-priest:rom-knight"
|
||||
? [...ELITE_LEVELS, 70] : ELITE_LEVELS);
|
||||
expect(elites.every((ability) => ability.source === "runewaker" && ability.effects.length > 0)).toBe(true);
|
||||
}
|
||||
expect(ROM_ALL_ABILITIES).toHaveLength(929);
|
||||
expect(ROM_ALL_ABILITIES.every((ability) => (
|
||||
expect(ROM_ALL_ABILITIES).toHaveLength(930);
|
||||
expect(ROM_ALL_ABILITIES.filter((ability) => ability.id !== "rom-priest-rom-knight-elite-70-devotion-halo").every((ability) => (
|
||||
/^\/assets\/ui\/rom-spells\/\d+\.png$/.test(ability.icon)
|
||||
&& ability.icon === generated.skills.find((skill) => skill.id === ability.id)?.icon
|
||||
))).toBe(true);
|
||||
expect(ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-priest-rom-knight-elite-70-devotion-halo")?.icon)
|
||||
.toBe(generated.skills.find((skill) => skill.sourceSkillId === 499957)?.icon);
|
||||
expect(generated.skills.every((ability) => (
|
||||
ability.iconResolution === "native-interface-fdb"
|
||||
&& /^\/assets\/ui\/rom-spells\/\d+\.png$/.test(ability.icon)
|
||||
|
||||
@@ -150,6 +150,7 @@ export function romPriestKnightCastEffects(
|
||||
effects: readonly AbilityEffect[],
|
||||
passiveTags: readonly string[] | ReadonlySet<string>,
|
||||
casterMaxHealth: number,
|
||||
targetTags: readonly string[] | ReadonlySet<string> = [],
|
||||
): readonly AbilityEffect[] {
|
||||
if (ability.source !== "runewaker" || casterMaxHealth <= 0) return effects;
|
||||
switch (ability.dbcSpellId) {
|
||||
@@ -202,12 +203,127 @@ export function romPriestKnightCastEffects(
|
||||
triggerSpellId: 490066,
|
||||
}];
|
||||
case 491613: { // Evil Shackles halves duration on non-undead/demon targets.
|
||||
if (!hasTag(passiveTags, "rom-evil-shackles") || hasTag(passiveTags, "rom-target-undead-demon")) return effects;
|
||||
if (!hasTag(passiveTags, "rom-evil-shackles") || hasTag(targetTags, "rom-target-undead-demon")) return effects;
|
||||
return effects.map((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root"
|
||||
? { ...effect, aura: { ...effect.aura, durationMs: Math.max(1_000, Math.round((effect.aura.durationMs ?? 10_000) / 2)) } }
|
||||
: effect);
|
||||
}
|
||||
case 490273: // Source 499967 keeps Wave Armor's shield and adds heal component 623009.
|
||||
if (!hasTag(passiveTags, "rom-splendor-ripple")) return effects;
|
||||
return [...effects, {
|
||||
kind: "heal",
|
||||
recipient: "ability-target",
|
||||
coefficient: 0,
|
||||
basePoints: 106.25,
|
||||
pointsPerLevel: 21.25,
|
||||
sourceLevel: 1,
|
||||
}];
|
||||
case 499966: // Devotion Halo refreshes Light Guidance while the halo is active.
|
||||
if (!hasTag(passiveTags, "rom-light-guidance")) return effects;
|
||||
return [...effects, {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-pair-light-guidance-healing",
|
||||
name: "Light Guidance",
|
||||
disposition: "buff",
|
||||
durationMs: 900_000,
|
||||
modifiers: [{ kind: "healing", direction: "done", operation: "percent", value: 0.05 }],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 149,
|
||||
miscValue: 5,
|
||||
triggerSpellId: 623015,
|
||||
}];
|
||||
case 490434: // Source 499924 adds component 623167 to the base Blood Arrow.
|
||||
if (!hasTag(passiveTags, "rom-blood-punishment")) return effects;
|
||||
return [...effects, {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-pair-blood-arrow-healing-received",
|
||||
name: "Blood Punishment",
|
||||
disposition: "buff",
|
||||
durationMs: 30_000,
|
||||
modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: 0.5 }],
|
||||
utilityTags: ["rom-blood-arrow-upgrade"],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 144,
|
||||
miscValue: 50,
|
||||
triggerSpellId: 623167,
|
||||
}];
|
||||
default:
|
||||
return effects;
|
||||
}
|
||||
}
|
||||
|
||||
/** Source skill 499966 is granted by the level-70 Priest/Knight elite pair. */
|
||||
export function romPriestKnightAdditionalAbilities(
|
||||
catalog: readonly AbilityDefinition[],
|
||||
): readonly AbilityDefinition[] {
|
||||
const lightGuidance = catalog.find((ability) => ability.dbcSpellId === 499957
|
||||
&& ability.classId === "rom-priest" && ability.secondaryClassId === "rom-knight");
|
||||
if (!lightGuidance) return [];
|
||||
const effects: readonly AbilityEffect[] = [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
scope: "party",
|
||||
aura: {
|
||||
id: "rom-pair-devotion-halo",
|
||||
name: "Devotion Halo",
|
||||
disposition: "buff",
|
||||
durationMs: 900_000,
|
||||
modifiers: [{ kind: "stat", stat: "wisdom", operation: "percent", value: 0.05 }],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 164,
|
||||
miscValue: 5,
|
||||
triggerSpellId: 623016,
|
||||
}, {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
scope: "party",
|
||||
excludeCaster: true,
|
||||
aura: {
|
||||
id: "rom-pair-devotion-halo-mana",
|
||||
name: "Devotion Halo Mana",
|
||||
disposition: "buff",
|
||||
durationMs: 900_000,
|
||||
modifiers: [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: -0.06 }],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 164,
|
||||
miscValue: -6,
|
||||
triggerSpellId: 623016,
|
||||
}];
|
||||
return [{
|
||||
id: "rom-priest-rom-knight-elite-70-devotion-halo",
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: "rom-knight",
|
||||
dbcSpellId: 499966,
|
||||
name: "Devotion Halo",
|
||||
unlockLevel: 70,
|
||||
icon: lightGuidance.icon,
|
||||
sigil: "DH",
|
||||
description: "Raises party Wisdom by 5% and lowers party mana costs by 6% for 15 minutes.",
|
||||
target: "self",
|
||||
range: { min: 0, max: 5 },
|
||||
radius: 20,
|
||||
castMode: "instant",
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 0,
|
||||
gcdMs: 1_500,
|
||||
cost: { resource: "mana", amount: 0 },
|
||||
costs: [],
|
||||
effects,
|
||||
passive: false,
|
||||
source: "runewaker",
|
||||
sourceUnlockLevel: 70,
|
||||
romSkillGroup: "elite",
|
||||
}];
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { commitPartyAbilityUse } from "./partyAbilityAi";
|
||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
||||
import { romPriestKnightCastEffects } from "./romPriestKnightOverrides";
|
||||
import { romPriestKnightAdditionalAbilities, romPriestKnightCastEffects } from "./romPriestKnightOverrides";
|
||||
|
||||
const PAIRED_SKILLS = [
|
||||
["rom-priest", "rom-knight", 15, 492355, "Enhanced Grace of Life", "passive"],
|
||||
@@ -58,7 +59,7 @@ describe("accepted RoM Priest/Knight and Knight/Scout pairs", () => {
|
||||
for (const [primary, secondary] of [["rom-priest", "rom-knight"], ["rom-knight", "rom-scout"]] as const) {
|
||||
const pair = ROM_ELITE_ABILITIES.filter((ability) => ability.classId === primary
|
||||
&& ability.secondaryClassId === secondary);
|
||||
expect(pair, `${primary}/${secondary}`).toHaveLength(10);
|
||||
expect(pair, `${primary}/${secondary}`).toHaveLength(primary === "rom-priest" ? 11 : 10);
|
||||
const kit = abilitiesForCharacter(primary, secondary);
|
||||
for (const skill of pair) expect(kit).toContain(skill);
|
||||
}
|
||||
@@ -197,11 +198,58 @@ describe("accepted RoM Priest/Knight and Knight/Scout pairs", () => {
|
||||
.find((effect) => effect.control?.kind === "root")?.aura.durationMs;
|
||||
const ordinary = romPriestKnightCastEffects(chains, chains.effects, ["rom-evil-shackles"], 1_000);
|
||||
const undead = romPriestKnightCastEffects(chains, chains.effects,
|
||||
["rom-evil-shackles", "rom-target-undead-demon"], 1_000);
|
||||
["rom-evil-shackles"], 1_000, ["rom-target-undead-demon"]);
|
||||
expect(rootDuration(ordinary)).toBe(rootDuration(chains.effects)! / 2);
|
||||
expect(rootDuration(undead)).toBe(rootDuration(chains.effects));
|
||||
});
|
||||
|
||||
it("adds source 499967's extra Wave Armor heal only with Splendor Ripple", () => {
|
||||
const waveArmor = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490273)!;
|
||||
const ordinary = romPriestKnightCastEffects(waveArmor, waveArmor.effects, [], 1_000);
|
||||
const upgraded = romPriestKnightCastEffects(waveArmor, waveArmor.effects, ["rom-splendor-ripple"], 1_000);
|
||||
expect(ordinary.filter((effect) => effect.kind === "heal")).toHaveLength(0);
|
||||
expect(upgraded).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "heal", basePoints: 106.25, pointsPerLevel: 21.25 }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("adds source 499924's Blood Arrow healing bonus only with Blood Punishment", () => {
|
||||
const bloodArrow = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490434)!;
|
||||
const ordinary = romPriestKnightCastEffects(bloodArrow, bloodArrow.effects, [], 1_000);
|
||||
const upgraded = romPriestKnightCastEffects(bloodArrow, bloodArrow.effects, ["rom-blood-punishment"], 1_000);
|
||||
expect(ordinary.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.utilityTags?.includes("rom-blood-arrow-upgrade"))).toBe(false);
|
||||
expect(upgraded.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.durationMs === 30_000
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"
|
||||
&& modifier.direction === "taken" && modifier.value === 0.5))).toBe(true);
|
||||
});
|
||||
|
||||
it("builds source 499966 Devotion Halo and grants conditional Light Guidance healing", () => {
|
||||
const [halo] = romPriestKnightAdditionalAbilities(ROM_ALL_ABILITIES);
|
||||
expect(halo).toMatchObject({
|
||||
id: "rom-priest-rom-knight-elite-70-devotion-halo",
|
||||
dbcSpellId: 499966,
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: "rom-knight",
|
||||
unlockLevel: 70,
|
||||
radius: 20,
|
||||
passive: false,
|
||||
});
|
||||
expect(abilitiesForCharacter("rom-priest", "rom-knight").some((ability) => ability.dbcSpellId === 499966)).toBe(true);
|
||||
expect(halo!.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.scope === "party" && effect.aura.durationMs === 900_000
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "wisdom" && modifier.value === 0.05))).toBe(true);
|
||||
const ordinary = romPriestKnightCastEffects(halo!, halo!.effects, [], 1_000);
|
||||
const guided = romPriestKnightCastEffects(halo!, halo!.effects, ["rom-light-guidance"], 1_000);
|
||||
expect(ordinary.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"))).toBe(false);
|
||||
expect(guided.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"
|
||||
&& modifier.direction === "done" && modifier.value === 0.05))).toBe(true);
|
||||
});
|
||||
|
||||
it("heals multiple nearby allies with Priest Group Heal", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30, role: "healer",
|
||||
@@ -251,6 +299,32 @@ describe("accepted RoM Priest/Knight and Knight/Scout pairs", () => {
|
||||
expect(useCombatStore.getState().mobs["arrow-b"]!.forcedTarget?.actorId).toBe(PLAYER_AGGRO_ID);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[30, "living", false, 0],
|
||||
[30, "undead", true, 10_000],
|
||||
[60, "living", true, 5_000],
|
||||
[60, "demon", true, 10_000],
|
||||
] as const)("uses Holy Chains against %s-level %s for the correct duration", (
|
||||
level, creatureFamily, allowed, durationMs,
|
||||
) => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level, role: "tank",
|
||||
});
|
||||
useCombatStore.getState().registerMob("chains-target", {
|
||||
name: "Chains Target", maxHealth: 10_000, creatureFamily,
|
||||
});
|
||||
setMobPosition("chains-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("chains-target");
|
||||
const cast = useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-25", undefined, 1_000);
|
||||
if (!allowed) {
|
||||
expect(cast).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
return;
|
||||
}
|
||||
expect(cast).toMatchObject({ ok: true });
|
||||
const root = useCombatStore.getState().mobs["chains-target"]!.statuses.find((status) => status.kind === "root");
|
||||
expect(root?.endsAt).toBe(1_000 + durationMs);
|
||||
});
|
||||
|
||||
it("damages multiple nearby enemies with Knight Whirlwind Shield", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 30, role: "tank",
|
||||
@@ -322,6 +396,82 @@ describe("accepted RoM Priest/Knight and Knight/Scout pairs", () => {
|
||||
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(1_000);
|
||||
});
|
||||
|
||||
it("Splendor Ripple adds healing when the Priest casts Wave Armor at level 60", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 60, role: "healer",
|
||||
});
|
||||
usePartyStore.getState().initializeParty("priest-ripple", 60, useCombatStore.getState().health, "healer", 2, "rom");
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
usePartyStore.getState().damageMember(member.id, Math.floor(member.maxHealth * 0.5));
|
||||
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
const injured = usePartyStore.getState().members[0]!.health;
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-primary-1", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injured);
|
||||
});
|
||||
|
||||
it("casts Devotion Halo on the party and gains Light Guidance at level 70", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 70, role: "healer",
|
||||
});
|
||||
usePartyStore.getState().initializeParty("priest-halo", 70, useCombatStore.getState().health, "healer", 2, "rom");
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-70-devotion-halo", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.id === "rom-pair-devotion-halo")).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.id === "rom-pair-devotion-halo-mana")).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id === "rom-pair-devotion-halo-mana")).toBe(false);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id === "rom-pair-light-guidance-healing")).toBe(true);
|
||||
});
|
||||
|
||||
it("reduces a companion's actual Mana spending while Devotion Halo is active", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 70, role: "healer",
|
||||
});
|
||||
usePartyStore.getState().initializeParty("priest-halo-cost", 70, useCombatStore.getState().health, "healer", 2, "rom");
|
||||
const partyMember = usePartyStore.getState().members[0]!;
|
||||
registerPartyRuntimePosition(partyMember.id, [3, 0, 0]);
|
||||
const member = {
|
||||
...partyMember,
|
||||
classId: "rom-priest" as const,
|
||||
secondaryClassId: "rom-knight" as const,
|
||||
resourceType: "mana" as const,
|
||||
resource: 100_000,
|
||||
maxResource: 100_000,
|
||||
resourcePools: { mana: 100_000 },
|
||||
cooldowns: {},
|
||||
globalCooldownEndsAt: 0,
|
||||
};
|
||||
const spell = abilitiesForCharacter("rom-priest", "rom-knight")
|
||||
.find((ability) => !ability.passive && ability.unlockLevel <= 70
|
||||
&& ability.cost.resource === "mana" && ability.cost.amount >= 10)!;
|
||||
const ordinary = commitPartyAbilityUse(member, spell, 2_000)!;
|
||||
expect(ordinary).not.toBeNull();
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-70-devotion-halo", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
const discounted = commitPartyAbilityUse(member, spell, 2_000)!;
|
||||
expect(discounted).not.toBeNull();
|
||||
expect(discounted.resourcePools.mana).toBeGreaterThan(ordinary.resourcePools.mana!);
|
||||
});
|
||||
|
||||
it("adds Blood Punishment's healing bonus during Knight/Scout Blood Arrow", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 79, role: "tank",
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-general-4", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id === "rom-pair-blood-arrow-healing-received"
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "healing"
|
||||
&& modifier.direction === "taken" && modifier.value === 0.5))).toBe(true);
|
||||
});
|
||||
|
||||
it("summons the Priest/Knight Light Fairy companion", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 60, role: "healer",
|
||||
|
||||
@@ -161,10 +161,10 @@ export function overrideRomScoutRogueEffects(
|
||||
customProc(493257, "hit", "rom-reclaim-energy", { chance: 0.25 }));
|
||||
case 499476:
|
||||
return taggedPassive(499476, "Toxic Splash", "rom-toxic-splash",
|
||||
customProc(499476, "hit", "rom-toxic-splash"));
|
||||
// 493296 replaces Infection and can generate 20 rage on a successful hit.
|
||||
customProc(499476, "hit", "rom-infection-rage", { chance: 0.25 }));
|
||||
case 499480:
|
||||
return taggedPassive(499480, "Poison Battle Technique", "rom-poison-battle-technique",
|
||||
customProc(499480, "hit", "rom-poison-battle-technique"));
|
||||
return taggedPassive(499480, "Poison Battle Technique", "rom-poison-battle-technique");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -180,7 +180,8 @@ export function romScoutRogueCastEffects(
|
||||
const passives = new Set(passiveTags);
|
||||
const target = new Set(targetTags);
|
||||
if (ability.dbcSpellId === 490457) {
|
||||
const masteryFactor = passives.has("rom-master-reflected-shot") ? 1.2 : 1;
|
||||
// Replacement 499535 / component 623051 raises ranged DPS from 0.75 to 0.85.
|
||||
const masteryFactor = passives.has("rom-master-reflected-shot") ? 0.85 / 0.75 : 1;
|
||||
return effects.map((effect) => effect.kind === "damage" ? {
|
||||
...effect,
|
||||
coefficient: effect.coefficient * masteryFactor,
|
||||
@@ -192,6 +193,40 @@ export function romScoutRogueCastEffects(
|
||||
falloffPerJump: passives.has("rom-poisonous-spit") ? 1 : effect.falloffPerJump,
|
||||
} : effect);
|
||||
}
|
||||
if (ability.dbcSpellId === 490451 && passives.has("rom-meridian-attack")) {
|
||||
// 499537/623061 replaces Wrist Attack: -30 physical and magical damage for 10s.
|
||||
return effects.map((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster"
|
||||
? {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
durationMs: 10_000,
|
||||
modifiers: [
|
||||
{ kind: "damage" as const, direction: "dealt" as const, school: "physical" as const, operation: "percent" as const, value: -0.30 },
|
||||
{ kind: "damage" as const, direction: "dealt" as const, attackKind: "spell" as const, operation: "percent" as const, value: -0.30 },
|
||||
],
|
||||
},
|
||||
} : effect);
|
||||
}
|
||||
if (ability.dbcSpellId === 492920 && passives.has("rom-toxic-splash")) {
|
||||
// 499477 replaces Poison Spray. 493296 is its additional infection hit;
|
||||
// 622992 is the six-second poison it spreads to nearby targets.
|
||||
return [
|
||||
...effects.filter((effect) => effect.kind !== "dot"),
|
||||
// 620287: 0.85 main-hand DPS + 0.20 Dexterity, then +7.5% skill-level DPS.
|
||||
{ kind: "damage", coefficient: 1.11375, coefficientPerLevel: 0.06375, sourceLevel: 1 },
|
||||
{ kind: "dot", coefficient: 0, basePoints: 16.2, pointsPerLevel: 4.2,
|
||||
sourceLevel: 1, ticks: 3, intervalMs: 2_000, tag: "rom-622992" },
|
||||
];
|
||||
}
|
||||
if (ability.dbcSpellId === 490133 && passives.has("rom-poison-battle-technique")) {
|
||||
// 499481/620286 raises Whirlwind's main-hand DPS coefficient 0.70 -> 0.75.
|
||||
return effects.map((effect) => effect.kind === "damage" ? {
|
||||
...effect,
|
||||
coefficient: effect.coefficient * 0.75 / 0.70,
|
||||
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * 0.75 / 0.70 }),
|
||||
} : effect);
|
||||
}
|
||||
if (ability.dbcSpellId === 490454 && passives.has("rom-lasso-master")) {
|
||||
return [...effects, {
|
||||
kind: "status",
|
||||
@@ -216,6 +251,17 @@ export function romScoutRogueCastEffects(
|
||||
return effects;
|
||||
}
|
||||
|
||||
/** 499481/622993 adds a hit only to targets already carrying 622992's poison. */
|
||||
export function romScoutRogueWhirlwindBonusEffect(
|
||||
ability: AbilityDefinition,
|
||||
passiveTags: readonly string[],
|
||||
targetTags: readonly string[],
|
||||
): Extract<AbilityEffect, { kind: "damage" }> | null {
|
||||
if (ability.dbcSpellId !== 490133 || !passiveTags.includes("rom-poison-battle-technique")
|
||||
|| !targetTags.includes("rom-622992")) return null;
|
||||
return { kind: "damage", coefficient: 0.50, coefficientPerLevel: 0.03, sourceLevel: 1 };
|
||||
}
|
||||
|
||||
export type RomScoutRogueProcActionSpec =
|
||||
| {
|
||||
readonly kind: "aura";
|
||||
@@ -246,13 +292,13 @@ export type RomScoutRogueProcActionSpec =
|
||||
/**
|
||||
* A pure result for combat-store proc execution. `triggeringSourceSkillId` is
|
||||
* the RuneWaker skill ID of the attack that generated the hit/crit event.
|
||||
* The level-60/70 replacement skills 499477 and 499481 are absent from the
|
||||
* playable source catalog; those unresolved actions deliberately return null.
|
||||
* The level-60/70 replacement skills 499477 and 499481 are represented by
|
||||
* their learned passive tags and base-ID cast upgrades, not custom procs.
|
||||
*/
|
||||
export function romScoutRogueProcActionSpec(
|
||||
actionId: string,
|
||||
triggeringSourceSkillId: number,
|
||||
casterPoisonous: boolean,
|
||||
_casterPoisonous: boolean,
|
||||
): RomScoutRogueProcActionSpec | null {
|
||||
switch (actionId) {
|
||||
case "rom-exploiting-shot":
|
||||
@@ -286,21 +332,21 @@ export function romScoutRogueProcActionSpec(
|
||||
provenance: "hybrid-estimate",
|
||||
};
|
||||
case "rom-poisonous-spit":
|
||||
if (!casterPoisonous || ![490457, 490424].includes(triggeringSourceSkillId)) return null;
|
||||
if (![490457, 490424].includes(triggeringSourceSkillId)) return null;
|
||||
return {
|
||||
kind: "dot",
|
||||
recipient: "target",
|
||||
effect: {
|
||||
kind: "dot",
|
||||
coefficient: 0,
|
||||
basePoints: 16,
|
||||
pointsPerLevel: 8,
|
||||
basePoints: 7.1,
|
||||
pointsPerLevel: 2.1,
|
||||
sourceLevel: 1,
|
||||
ticks: 5,
|
||||
ticks: 4,
|
||||
intervalMs: 2_000,
|
||||
tag: "rom-poisonous-spit",
|
||||
},
|
||||
provenance: "hybrid-estimate", // 501690 poison scale is a proxy; Poison Powder payload is not in this catalog.
|
||||
provenance: "source", // 499535/503522. Poison Powder is supplied without inventory gating.
|
||||
};
|
||||
case "rom-reclaim-energy":
|
||||
if (triggeringSourceSkillId === 490306) return {
|
||||
@@ -310,8 +356,12 @@ export function romScoutRogueProcActionSpec(
|
||||
kind: "resource", recipient: "caster", resource: "rage", amount: 10, provenance: "hybrid-estimate",
|
||||
};
|
||||
return null;
|
||||
case "rom-toxic-splash": // Requires absent replacement skill 499477 and triggered payload 493296.
|
||||
case "rom-poison-battle-technique": // Requires absent replacement skill 499481.
|
||||
case "rom-infection-rage":
|
||||
return triggeringSourceSkillId === 492625
|
||||
? { kind: "resource", recipient: "caster", resource: "rage", amount: 20, provenance: "hybrid-estimate" }
|
||||
: null;
|
||||
case "rom-toxic-splash": // 499477 is applied as an upgrade of base 492920.
|
||||
case "rom-poison-battle-technique": // 499481 is applied as an upgrade of base 490133.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, learnedAbilityForBuild, type AbilityDefinition } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { romScoutRogueCastEffects, romScoutRogueProcActionSpec } from "./romScoutRogueOverrides";
|
||||
import { romScoutRogueCastEffects, romScoutRogueProcActionSpec, romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
|
||||
|
||||
const scoutRogue = abilitiesForCharacter("rom-scout", "rom-rogue");
|
||||
const rogueWarrior = abilitiesForCharacter("rom-rogue", "rom-warrior");
|
||||
@@ -122,7 +121,7 @@ describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
|
||||
expect(new Set(damage).size).toBe(1);
|
||||
});
|
||||
|
||||
it("Master Reflected Shot applies a labeled 20% hybrid damage bonus", () => {
|
||||
it("Master Reflected Shot uses replacement 499535's 0.85 / 0.75 DPS ratio", () => {
|
||||
const ability = skill(scoutRogue, "rom-scout-primary-11");
|
||||
const base = ability.effects.find((effect) => effect.kind === "damage");
|
||||
const mastered = romScoutRogueCastEffects(ability, ability.effects,
|
||||
@@ -131,7 +130,7 @@ describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
|
||||
expect(base?.kind).toBe("damage");
|
||||
expect(mastered?.kind).toBe("damage");
|
||||
if (base?.kind !== "damage" || mastered?.kind !== "damage") return;
|
||||
expect(mastered.coefficient).toBeCloseTo(base.coefficient * 1.2);
|
||||
expect(mastered.coefficient).toBeCloseTo(base.coefficient * 0.85 / 0.75);
|
||||
expect(mastered.falloffPerJump).toBe(1);
|
||||
});
|
||||
|
||||
@@ -160,12 +159,11 @@ describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
|
||||
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000);
|
||||
});
|
||||
|
||||
it("proc resolver poisons only Reflected or Combo Shot while carrying Poison Powder", () => {
|
||||
it("proc resolver poisons Reflected or Combo Shot with unlimited Poison Powder", () => {
|
||||
for (const sourceId of [490457, 490424]) {
|
||||
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, false)).toBeNull();
|
||||
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, true)).toMatchObject({
|
||||
kind: "dot", recipient: "target", provenance: "hybrid-estimate",
|
||||
effect: { kind: "dot", ticks: 5, intervalMs: 2_000 },
|
||||
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, false)).toMatchObject({
|
||||
kind: "dot", recipient: "target", provenance: "source",
|
||||
effect: { kind: "dot", ticks: 4, intervalMs: 2_000, basePoints: 7.1, pointsPerLevel: 2.1 },
|
||||
});
|
||||
}
|
||||
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", 490423, true)).toBeNull();
|
||||
@@ -175,15 +173,6 @@ describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
|
||||
|
||||
it("executes the Scout/Rogue poison rider on a successful reflected hit", () => {
|
||||
prime("rom-scout", 60);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const poison = skill(rogueWarrior, "rom-rogue-primary-6");
|
||||
const state = poison.effects
|
||||
.filter((effect): effect is Extract<AbilityDefinition["effects"][number], { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
||||
.find((effect) => effect.aura.utilityTags?.includes("rom-poisonous") === true);
|
||||
expect(state).toBeDefined();
|
||||
expect(useCombatStore.getState().applyActorAura(
|
||||
PLAYER_AGGRO_ID, PLAYER_AGGRO_ID, poison, state!, 1_000,
|
||||
)).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 2_600))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-poisonous-spit"
|
||||
@@ -202,11 +191,112 @@ describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
|
||||
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.chance : null).toBe(0.25);
|
||||
});
|
||||
|
||||
it("keeps absent 499477/499481 replacement-skill procs explicitly unresolved", () => {
|
||||
it("Toxic Splash's 493296 Infection upgrade can return 20 rage on its hit", () => {
|
||||
expect(romScoutRogueProcActionSpec("rom-infection-rage", 490338, false)).toBeNull();
|
||||
expect(romScoutRogueProcActionSpec("rom-infection-rage", 492625, false)).toMatchObject({
|
||||
kind: "resource", recipient: "caster", resource: "rage", amount: 20,
|
||||
provenance: "hybrid-estimate",
|
||||
});
|
||||
const proc = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-60").effects.find((effect) => effect.kind === "apply-aura");
|
||||
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.chance : null).toBe(0.25);
|
||||
});
|
||||
|
||||
it("maps 499477/499481 through cast upgrades rather than redundant procs", () => {
|
||||
expect(romScoutRogueProcActionSpec("rom-toxic-splash", 499477, true)).toBeNull();
|
||||
expect(romScoutRogueProcActionSpec("rom-poison-battle-technique", 499481, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("Meridian Attack upgrades Wrist Attack's physical and spell damage penalty", () => {
|
||||
const ability = skill(scoutRogue, "rom-scout-general-6");
|
||||
expect(ability.dbcSpellId).toBe(490451);
|
||||
const upgraded = romScoutRogueCastEffects(ability, ability.effects, ["rom-meridian-attack"], []);
|
||||
const debuff = upgraded.find((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster");
|
||||
expect(debuff?.kind === "apply-aura" ? debuff.aura : null).toMatchObject({
|
||||
durationMs: 10_000,
|
||||
modifiers: expect.arrayContaining([
|
||||
{ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: -0.3 },
|
||||
{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "percent", value: -0.3 },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("applies Meridian Attack's ten-second Wrist Attack debuff at level 79", () => {
|
||||
prime("rom-scout", 79);
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-general-6", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
const debuff = useCombatStore.getState().auras.find((aura) => aura.targetId === "target"
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage"
|
||||
&& modifier.direction === "dealt" && modifier.attackKind === "spell"));
|
||||
expect(debuff?.definition.durationMs).toBe(10_000);
|
||||
});
|
||||
|
||||
it("Toxic Splash upgrades Poison Spray to 493296's hit and 622992's six-second poison", () => {
|
||||
const ability = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-40");
|
||||
const upgraded = romScoutRogueCastEffects(ability, ability.effects, ["rom-toxic-splash"], []);
|
||||
expect(upgraded.filter((effect) => effect.kind === "damage")).toHaveLength(2);
|
||||
expect(upgraded.filter((effect) => effect.kind === "damage")[1]).toMatchObject({
|
||||
kind: "damage", coefficient: 1.11375, coefficientPerLevel: 0.06375,
|
||||
});
|
||||
expect(upgraded.find((effect) => effect.kind === "dot")).toMatchObject({
|
||||
kind: "dot", ticks: 3, intervalMs: 2_000, tag: "rom-622992",
|
||||
basePoints: 16.2, pointsPerLevel: 4.2,
|
||||
});
|
||||
});
|
||||
|
||||
it("spreads Toxic Splash's replacement poison to nearby enemies at level 79", () => {
|
||||
prime("rom-rogue", 79);
|
||||
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 100 } }));
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(1.1, 2_100);
|
||||
const infectionCast = useCombatStore.getState().castAbility("rom-rogue-rom-warrior-elite-35", undefined, 2_600);
|
||||
expect(infectionCast, infectionCast.reason).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warrior-elite-40", undefined, 4_200))
|
||||
.toMatchObject({ ok: true, affectedMobIds: expect.arrayContaining(["target", "nearby"]) });
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-622992"
|
||||
&& effect.targetId === "nearby")).toBe(true);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot"
|
||||
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35" && effect.targetId === "target")).toBe(false);
|
||||
});
|
||||
|
||||
it("Poison Battle Technique upgrades Whirlwind and adds a per-target poisoned hit", () => {
|
||||
const ability = skill(rogueWarrior, "rom-warrior-general-2");
|
||||
const base = ability.effects.find((effect) => effect.kind === "damage");
|
||||
const upgraded = romScoutRogueCastEffects(ability, ability.effects, ["rom-poison-battle-technique"], [])
|
||||
.find((effect) => effect.kind === "damage");
|
||||
expect(base?.kind).toBe("damage");
|
||||
expect(upgraded?.kind).toBe("damage");
|
||||
if (base?.kind !== "damage" || upgraded?.kind !== "damage") return;
|
||||
expect(upgraded.coefficient).toBeCloseTo(base.coefficient * 0.75 / 0.70);
|
||||
expect(romScoutRogueWhirlwindBonusEffect(ability, ["rom-poison-battle-technique"], []))
|
||||
.toBeNull();
|
||||
expect(romScoutRogueWhirlwindBonusEffect(ability, ["rom-poison-battle-technique"], ["rom-622992"]))
|
||||
.toMatchObject({ kind: "damage", coefficient: 0.5, coefficientPerLevel: 0.03 });
|
||||
});
|
||||
|
||||
it("Whirlwind grants its Poison Battle Technique rider only to sprayed targets at level 79", () => {
|
||||
prime("rom-rogue", 79);
|
||||
useCombatStore.getState().registerMob("clean", { name: "clean", maxHealth: 1_000_000 });
|
||||
setMobPosition("clean", [-4, 0, 0]); // Whirlwind range, outside Spray's target-centered radius.
|
||||
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 100 } }));
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(1.1, 2_100);
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warrior-elite-35", undefined, 2_600))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warrior-elite-40", undefined, 4_200))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-622992" && effect.targetId === "target"))
|
||||
.toBe(true);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-622992" && effect.targetId === "clean"))
|
||||
.toBe(false);
|
||||
expect(useCombatStore.getState().castAbility("rom-warrior-general-2", undefined, 5_800))
|
||||
.toMatchObject({ ok: true, affectedMobIds: expect.arrayContaining(["target", "clean"]) });
|
||||
const hits = useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "rom-warrior-general-2"
|
||||
&& event.kind === "damage");
|
||||
expect(hits.filter((event) => event.targetActorId === "target")).toHaveLength(2);
|
||||
expect(hits.filter((event) => event.targetActorId === "clean")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("Rogue Combo Throw has two hits and Poison grants a proc state (490338, 490329)", () => {
|
||||
expect(skill(rogueWarrior, "rom-rogue-primary-15").effects.filter((effect) => effect.kind === "damage")).toHaveLength(2);
|
||||
prime("rom-rogue", 60);
|
||||
|
||||
Reference in new issue
Block a user