Complete RoM pair elite mechanics and creature family rules

This commit is contained in:
phenom committed 2026-09-23 19:38:13 -04:00
1 parent c8b3a3d95a
commit 0206fda9e2
21 files changed
+1082 -134

No files matched your search

+2
View File
@@ -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
View File
@@ -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
+15 -6
View File
@@ -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(
+63 -1
View File
@@ -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))) {
+6 -2
View File
@@ -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 }>();
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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 ?? []);
+63
View File
@@ -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 });
});
});
+21
View File
@@ -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 [];
}
+91 -34
View File
@@ -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;
}
+229 -19
View File
@@ -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);
});
});
+9 -6
View File
@@ -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)
+117 -1
View File
@@ -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",
}];
}
+153 -3
View File
@@ -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",
+64 -14
View File
@@ -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;
+109 -19
View File
@@ -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);