Validate RoM primary five hybrid stats and pair mechanics
This commit is contained in:
1 parent
8e95480d4f
commit
c8b3a3d95a
18 files changed
+2061
-48
No files matched your search
@@ -29,7 +29,7 @@ export type ResourceType =
|
||||
| `custom:${string}`;
|
||||
export type AbilityTarget = "hostile" | "friendly" | "any" | "self";
|
||||
export type AbilityCastMode = "instant" | "cast" | "channel";
|
||||
export type AbilityTargetShape = "unit" | "circle" | "cone" | "chain" | "ground";
|
||||
export type AbilityTargetShape = "unit" | "circle" | "cone" | "chain" | "line" | "ground";
|
||||
|
||||
export interface AbilityTargetingRules {
|
||||
readonly shape: AbilityTargetShape;
|
||||
@@ -157,7 +157,7 @@ export interface AbilityDamageResource {
|
||||
}
|
||||
|
||||
export type AbilityEffect =
|
||||
| ({ readonly kind: "damage"; readonly coefficient: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| ({ readonly kind: "damage"; readonly coefficient: number; readonly school?: DamageSchool; readonly falloffPerJump?: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| {
|
||||
readonly kind: "finisher-damage";
|
||||
readonly baseCoefficient: number;
|
||||
@@ -167,7 +167,7 @@ export type AbilityEffect =
|
||||
}
|
||||
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
| ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
| ({ readonly kind: "dot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
|
||||
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
|
||||
|
||||
+154
-15
@@ -156,6 +156,8 @@ import {
|
||||
type HeirloomContext,
|
||||
} from "./heirloomEquipment";
|
||||
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount } from "./romCombatMechanics";
|
||||
import { romMageWarlockCriticalEffects } from "./romMageWarlockOverrides";
|
||||
import { romScoutRogueProcActionSpec } from "./romScoutRogueOverrides";
|
||||
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
|
||||
import { specializationById } from "./specializationCatalog";
|
||||
import {
|
||||
@@ -1269,6 +1271,12 @@ function talentAdjustedMaxHealth(
|
||||
}
|
||||
|
||||
function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind {
|
||||
if (ability.source === "runewaker") {
|
||||
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
|
||||
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school && sourceHit.school !== "physical") {
|
||||
return "spell";
|
||||
}
|
||||
}
|
||||
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
||||
const healing = ability.effects.some((effect) => (
|
||||
effect.kind === "heal"
|
||||
@@ -1285,6 +1293,10 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
|
||||
}
|
||||
|
||||
export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
|
||||
if (ability.source === "runewaker") {
|
||||
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
|
||||
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school) return sourceHit.school;
|
||||
}
|
||||
if (["Envenom", "Instant Poison", "Deadly Poison"].includes(ability.name)) return "nature";
|
||||
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
|
||||
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
||||
@@ -1306,7 +1318,8 @@ export function equipmentAdjustedCombatAmount(
|
||||
classId: ClassId,
|
||||
ability: AbilityDefinition,
|
||||
): number {
|
||||
const kind = abilityAttackKind(classId, ability);
|
||||
const kind = typeof scaling !== "number" && "school" in scaling && scaling.school && scaling.school !== "physical"
|
||||
? "spell" : abilityAttackKind(classId, ability);
|
||||
const attackPower = equipmentAttackPower(classId, gearStats, kind);
|
||||
const weapon = kind === "ranged" ? gearStats.rangedWeapon : kind === "melee" ? gearStats.meleeWeapon : null;
|
||||
const weaponSpeed = weapon?.speedMs ? weapon.speedMs / 1_000 : kind === "melee" ? 2.4 : 2.8;
|
||||
@@ -2202,7 +2215,7 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
|
||||
spellHastePercent: stat(base.spellHastePercent, "haste", "spell-haste"),
|
||||
dodgePercent: stat(base.dodgePercent, "dodge"),
|
||||
parryPercent: stat(base.parryPercent, "parry"),
|
||||
blockPercent: stat(base.blockPercent, "block"),
|
||||
blockPercent: stat(base.blockPercent, "block", "block-chance"),
|
||||
expertise: stat(base.expertise, "expertise"),
|
||||
arcaneResistance: resistance(base.arcaneResistance, "arcane"),
|
||||
fireResistance: resistance(base.fireResistance, "fire"),
|
||||
@@ -2309,6 +2322,52 @@ function executeTriggeredAuraProcsInWork(
|
||||
): void {
|
||||
for (const triggered of triggeredProcs) {
|
||||
const action = triggered.action;
|
||||
if (action.kind === "custom" && action.id.startsWith("rom-")) {
|
||||
const triggeringAbility = triggered.event.abilityId
|
||||
? work.abilities.find((ability) => ability.id === triggered.event.abilityId)
|
||||
?? abilityById(triggered.event.abilityId)
|
||||
: null;
|
||||
const poisonous = work.auras.some((aura) => aura.targetId === triggered.sourceId
|
||||
&& aura.definition.utilityTags?.includes("rom-poisonous"));
|
||||
const spec = romScoutRogueProcActionSpec(action.id, triggeringAbility?.dbcSpellId ?? 0, poisonous);
|
||||
if (spec) {
|
||||
const recipient = spec.recipient === "caster"
|
||||
? triggered.sourceId : triggered.event.otherId ?? triggered.targetId;
|
||||
if (spec.kind === "aura" && recipient) {
|
||||
work.auras = [...applyCombatAura(work.auras, {
|
||||
definition: spec.aura, sourceId: triggered.sourceId, targetId: recipient, now,
|
||||
}).auras];
|
||||
} else if (spec.kind === "resource" && recipient === PLAYER_AURA_ENTITY_ID) {
|
||||
const maximum = work.maxResourcePools[spec.resource] ?? defaultResourceMaximum(spec.resource);
|
||||
work.resourcePools[spec.resource] = clamp(
|
||||
(work.resourcePools[spec.resource] ?? 0) + spec.amount, 0, maximum,
|
||||
);
|
||||
} else if (spec.kind === "resource" && recipient && work.actors[recipient]) {
|
||||
const actor = work.actors[recipient];
|
||||
const pool = actor.resources[spec.resource];
|
||||
if (pool) {
|
||||
const next = { ...actor, resources: {
|
||||
...actor.resources,
|
||||
[spec.resource]: { ...pool, current: clamp(pool.current + spec.amount, 0, pool.maximum) },
|
||||
} };
|
||||
work.actors[recipient] = next;
|
||||
mirrorCompanionActor(next);
|
||||
}
|
||||
} else if (spec.kind === "dot" && recipient && work.mobs[recipient]) {
|
||||
work.effects = work.effects.filter((effect) => !(effect.kind === "dot"
|
||||
&& effect.sourceActorId === triggered.sourceId
|
||||
&& effect.targetId === recipient && effect.tag === spec.effect.tag));
|
||||
work.effects.push(timedEffect(
|
||||
"dot", triggeringAbility?.id ?? triggered.procId,
|
||||
triggered.sourceId, recipient, spec.effect,
|
||||
spec.effect.ticks, spec.effect.intervalMs, now,
|
||||
spec.effect.tag, spec.effect.school ?? "nature",
|
||||
work.gearStats.spellPenetration,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const actionTarget = action.kind === "custom"
|
||||
? null
|
||||
: action.target === "aura-source"
|
||||
@@ -2897,6 +2956,19 @@ function resolveAndDamageMobInWork(
|
||||
work.gearStats.spellPenetration,
|
||||
combatResult,
|
||||
);
|
||||
if (ability?.source === "runewaker" && combatResult.critical && result.amount > 0) {
|
||||
const passiveTags = new Set(aurasForEntity(work.auras, source.actorId)
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []));
|
||||
for (const rider of romMageWarlockCriticalEffects(ability, passiveTags)) {
|
||||
if (rider.kind !== "apply-aura") continue;
|
||||
work.auras = [...applyCombatAura(work.auras, {
|
||||
definition: auraDefinitionForAbility(rider.aura, ability),
|
||||
sourceId: source.actorId,
|
||||
targetId: id,
|
||||
now,
|
||||
}).auras];
|
||||
}
|
||||
}
|
||||
if (combustion && combatResult.landed && result.amount > 0) {
|
||||
const criticalHits = (combustion.combustionCriticalHits ?? 0) + Number(combatResult.critical);
|
||||
work.auras = work.auras.flatMap((aura) => aura.instanceId !== combustion.instanceId
|
||||
@@ -3063,6 +3135,7 @@ function executeTriggeredSpellInWork(
|
||||
ability,
|
||||
);
|
||||
for (const mobId of mobTargets) {
|
||||
const effectSchool = effect.school ?? school;
|
||||
const result = resolveAndDamageMobInWork(
|
||||
work.classId,
|
||||
work,
|
||||
@@ -3070,8 +3143,8 @@ function executeTriggeredSpellInWork(
|
||||
amount,
|
||||
now,
|
||||
source,
|
||||
attackKind,
|
||||
school,
|
||||
effectSchool === "physical" ? attackKind : "spell",
|
||||
effectSchool,
|
||||
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
|
||||
ability,
|
||||
);
|
||||
@@ -4773,10 +4846,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const catalogAbility = abilityById(abilityId);
|
||||
const talentNodeId = catalogAbility ? abilityTalentNodeId(catalogAbility) : null;
|
||||
const learnedTalentRank = catalogAbility ? learnedAbilityRank(catalogAbility, state.talentRanks) : undefined;
|
||||
const ability = catalogAbility
|
||||
let ability = catalogAbility
|
||||
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
|
||||
: null;
|
||||
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
|
||||
if (ability?.dbcSpellId === 490133 && activePlayerAuras.some((aura) =>
|
||||
aura.definition.utilityTags?.includes("rom-whirlwind-mastery"))) {
|
||||
const adjustedCosts = (ability.costs ?? [ability.cost]).map((cost) => cost.resource === "rage"
|
||||
? { ...cost, amount: Math.max(0, cost.amount - 20) } : cost);
|
||||
ability = {
|
||||
...ability,
|
||||
cooldownMs: Math.max(0, ability.cooldownMs - 20_000),
|
||||
cost: adjustedCosts[0] ?? ability.cost,
|
||||
costs: adjustedCosts,
|
||||
};
|
||||
}
|
||||
const innerFocusAura = activePlayerAuras.find((aura) => (
|
||||
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
||||
));
|
||||
@@ -4815,7 +4899,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const resourceCost = ability && !consumesInnerFocus ? talentAdjustedResourceCost(ability, modifiers) : 0;
|
||||
const costs = ability && !consumesInnerFocus ? resolvedAbilityCosts(ability, modifiers, state) : [];
|
||||
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
|
||||
const targeting = ability ? targetingRulesForAbility(ability) : null;
|
||||
const baseTargeting = ability ? targetingRulesForAbility(ability) : null;
|
||||
const targeting = ability?.dbcSpellId === 490457 && baseTargeting && hasRuneWakerState("rom-master-reflected-shot")
|
||||
? { ...baseTargeting, maximumTargets: 5 }
|
||||
: baseTargeting;
|
||||
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
|
||||
const runeCost = ability && baseClassFor(state.classId) === "death-knight"
|
||||
? runeCostForAbility(ability)
|
||||
@@ -4843,6 +4930,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked";
|
||||
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";
|
||||
@@ -5005,6 +5096,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
affectedMobIds.push(id);
|
||||
previous = position;
|
||||
}
|
||||
} else if (targeting?.shape === "line" && effectiveTarget === "hostile" && targetId && targetPosition) {
|
||||
const directionX = targetPosition[0] - playerOrigin[0];
|
||||
const directionZ = targetPosition[2] - playerOrigin[2];
|
||||
const targetDistance = Math.max(0.001, Math.hypot(directionX, directionZ));
|
||||
const reach = targetDistance + 4;
|
||||
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, reach).filter((id) => {
|
||||
const mob = state.mobs[id];
|
||||
const position = getMobPosition(id);
|
||||
if (!mob || mob.dead || !position || !combatHasLineOfSight(playerOrigin, position)) return false;
|
||||
const offsetX = position[0] - playerOrigin[0];
|
||||
const offsetZ = position[2] - playerOrigin[2];
|
||||
const forward = (offsetX * directionX + offsetZ * directionZ) / targetDistance;
|
||||
const sideways = Math.abs(offsetX * directionZ - offsetZ * directionX) / targetDistance;
|
||||
return forward >= 0 && forward <= reach && sideways <= 1.25;
|
||||
});
|
||||
} else if (targeting?.shape === "cone" && effectiveTarget === "hostile") {
|
||||
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
|
||||
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, maximumRange).filter((id) => {
|
||||
@@ -5024,14 +5130,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
} else if (effectiveTarget === "hostile" && targetId) {
|
||||
affectedMobIds = [targetId];
|
||||
}
|
||||
if (targeting?.maximumTargets && affectedMobIds.length > targeting.maximumTargets) {
|
||||
affectedMobIds = affectedMobIds.slice(0, targeting.maximumTargets);
|
||||
}
|
||||
|
||||
const castTimeMs = talentAdjustedCastTime(
|
||||
const lastPrayerReady = ability.source === "runewaker" && ability.dbcSpellId !== 491648
|
||||
&& ability.castTimeMs > 0 && hasRuneWakerState("rom-last-prayer");
|
||||
const ordinaryCastTimeMs = talentAdjustedCastTime(
|
||||
ability,
|
||||
modifiers,
|
||||
state.gearStats,
|
||||
state.level,
|
||||
state.classId,
|
||||
);
|
||||
const castTimeMs = lastPrayerReady ? Math.min(1_000, ordinaryCastTimeMs) : ordinaryCastTimeMs;
|
||||
const gcdMs = talentAdjustedGcd(
|
||||
ability,
|
||||
modifiers,
|
||||
@@ -5070,7 +5182,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
originalDurationMs: castTimeMs,
|
||||
pushbackHits: 0,
|
||||
school: spellSchool,
|
||||
canCastWhileMoving: canAbilityCastWhileMoving(ability),
|
||||
canCastWhileMoving: canAbilityCastWhileMoving(ability)
|
||||
|| (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")),
|
||||
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
|
||||
groundPosition,
|
||||
totalTicks: ability.castMode === "channel" ? (ability.name === "Divine Hymn" ? 4 : 5) : 0,
|
||||
@@ -5083,6 +5196,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
set({
|
||||
activeCast,
|
||||
...(lastPrayerReady ? { auras: state.auras.filter((aura) => !(
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("rom-last-prayer")
|
||||
)) } : {}),
|
||||
...autoAttackUpdate,
|
||||
playerAnimationEvent: nextAnimationEvent("ability-start", ability.id),
|
||||
health: activeCast.mode === "channel" ? healthAfterCosts : state.health,
|
||||
@@ -5231,10 +5348,22 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
charged: hasRuneWakerState("rom-charged"),
|
||||
mainHandDagger: romMainHand?.weaponType === "dagger",
|
||||
casterMaxHealth: state.maxHealth,
|
||||
passiveTags: work.auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID)
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []),
|
||||
targetTags: work.auras.filter((aura) => aura.targetId === targetId)
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
|
||||
work.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0)
|
||||
.flatMap((effect) => [
|
||||
...(effect.tag ? [effect.tag] : []),
|
||||
...(effect.tag?.includes("501690") ? ["rom-501690"] : []),
|
||||
...(effect.tag?.includes("rom-deadly-poison") ? ["rom-deadly-poison"] : []),
|
||||
]),
|
||||
),
|
||||
});
|
||||
const effectsToResolve = completingCast?.mode === "channel"
|
||||
? castEffects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind))
|
||||
: castEffects;
|
||||
let clearedSingleTargetControls = false;
|
||||
for (const effect of effectsToResolve) {
|
||||
if (ability.name === "Penance" && effectiveTarget === "friendly"
|
||||
&& (effect.kind === "damage" || effect.kind === "dot")) continue;
|
||||
@@ -5265,7 +5394,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
"damage-percent",
|
||||
ability,
|
||||
);
|
||||
for (const id of affectedMobIds) {
|
||||
for (const [targetIndex, id] of affectedMobIds.entries()) {
|
||||
const effectSchool = effect.school ?? damageSchool;
|
||||
const sunderStacks = effect.bonusPerSunderStack
|
||||
? work.auras.find((aura) => aura.targetId === id && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0
|
||||
: 0;
|
||||
@@ -5273,11 +5403,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
state.classId,
|
||||
work,
|
||||
id,
|
||||
amount + sunderStacks * (effect.bonusPerSunderStack ?? 0),
|
||||
Math.round((amount + sunderStacks * (effect.bonusPerSunderStack ?? 0))
|
||||
* Math.pow(effect.falloffPerJump ?? 1, targetIndex)),
|
||||
now,
|
||||
castThreatSource,
|
||||
attackKind,
|
||||
damageSchool,
|
||||
effectSchool === "physical" ? attackKind : "spell",
|
||||
effectSchool,
|
||||
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
|
||||
ability,
|
||||
false,
|
||||
@@ -5754,7 +5885,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
effect.intervalMs,
|
||||
now,
|
||||
effect.tag,
|
||||
damageSchool,
|
||||
effect.school ?? damageSchool,
|
||||
work.gearStats.spellPenetration,
|
||||
));
|
||||
}
|
||||
@@ -5808,12 +5939,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
} else if (effect.kind === "status") {
|
||||
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
|
||||
if (romSingleTargetControl(ability) && candidates.length) {
|
||||
if (romSingleTargetControl(ability) && candidates.length && !clearedSingleTargetControls) {
|
||||
for (const [mobId, mob] of Object.entries(work.mobs)) {
|
||||
work.mobs[mobId] = { ...mob, statuses: mob.statuses.filter((status) => !(
|
||||
status.sourceAbilityId === ability.id && status.sourceActorId === PLAYER_AGGRO_ID
|
||||
)) };
|
||||
}
|
||||
clearedSingleTargetControls = true;
|
||||
}
|
||||
for (const id of candidates) {
|
||||
const mob = work.mobs[id];
|
||||
@@ -6298,6 +6430,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ability.dbcSpellId === 498759 && targetId && work.mobs[targetId]
|
||||
&& !work.mobs[targetId].dead && work.mobs[targetId].health > 0) {
|
||||
cooldowns[ability.id] = 0;
|
||||
}
|
||||
|
||||
const primaryCombatResult = work.combatResults.length === 1 ? work.combatResults[0] : null;
|
||||
const levelMessage = work.progression.levelsGained > 0
|
||||
@@ -8716,6 +8852,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const redirected = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID
|
||||
? Math.round(namedAbsorb.remainingDamage * redirectFraction)
|
||||
: 0;
|
||||
const aurasAfterRedirect = redirected > 0 && redirectAura?.definition.utilityTags?.includes("rom-disregard-danger-one-hit")
|
||||
? namedAbsorb.auras.filter((aura) => aura.instanceId !== redirectAura.instanceId)
|
||||
: namedAbsorb.auras;
|
||||
const damaged = Math.min(
|
||||
authoritativeActor.health,
|
||||
Math.max(0, Math.round(namedAbsorb.remainingDamage - redirected)),
|
||||
@@ -8761,7 +8900,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
set({
|
||||
actors: { ...get().actors, [nextActor.id]: nextActor },
|
||||
auras: consumeInnerFireCharges(
|
||||
namedAbsorb.auras, member.id, combatResult.landed, enemyAttackKind(hit.attack),
|
||||
aurasAfterRedirect, member.id, combatResult.landed, enemyAttackKind(hit.attack),
|
||||
),
|
||||
combatEvents: recordCombatEvent(get().combatEvents, {
|
||||
occurredAt: now,
|
||||
|
||||
@@ -1130,7 +1130,21 @@ function executePartyAbility(
|
||||
depth = 0,
|
||||
castStartAlreadyEmitted = false,
|
||||
): PartyAbilityExecutionResult {
|
||||
const combat = useCombatStore.getState();
|
||||
let combat = useCombatStore.getState();
|
||||
if (ability.source === "runewaker") {
|
||||
for (const passive of abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.filter((candidate) => candidate.passive)
|
||||
.map((candidate) => learnedAbilityForBuild(candidate, member.level, member.talentRanks))
|
||||
.filter((candidate): candidate is AbilityDefinition => Boolean(candidate))) {
|
||||
for (const effect of passive.effects) {
|
||||
if (effect.kind !== "apply-aura" || effect.recipient !== "caster") continue;
|
||||
if (combat.auras.some((aura) => aura.sourceId === member.id
|
||||
&& aura.targetId === member.id && aura.definition.id === effect.aura.id)) continue;
|
||||
useCombatStore.getState().applyActorAura(member.id, member.id, passive, effect, now);
|
||||
combat = useCombatStore.getState();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ability.name === "Enraged Regeneration" && !combat.consumeActorEnrage(member.id, now)) {
|
||||
return { member, effectiveAmount: 0 };
|
||||
}
|
||||
@@ -1200,6 +1214,20 @@ function executePartyAbility(
|
||||
specializationId: member.specializationId, role: member.role, level: member.level,
|
||||
})["main-hand"]?.weaponType === "dagger",
|
||||
casterMaxHealth: member.maxHealth,
|
||||
passiveTags: combat.auras.filter((aura) => aura.targetId === member.id)
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
|
||||
abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.filter((candidate) => candidate.passive)
|
||||
.map((candidate) => learnedAbilityForBuild(candidate, member.level, member.talentRanks))
|
||||
.filter((candidate): candidate is AbilityDefinition => Boolean(candidate))
|
||||
.flatMap((candidate) => candidate.effects.flatMap((effect) => effect.kind === "apply-aura"
|
||||
? effect.aura.utilityTags ?? [] : [])),
|
||||
),
|
||||
targetTags: combat.auras.filter((aura) => aura.targetId === targetId)
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
|
||||
combat.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0)
|
||||
.flatMap((effect) => effect.tag ? [effect.tag] : []),
|
||||
),
|
||||
});
|
||||
for (const effect of castEffects) {
|
||||
if (ability.name === "Penance" && combat.mobs[targetId]
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
type RomGeneratedAbilityLike,
|
||||
type RomSourceComponent,
|
||||
} from "./romAbilitySemantics";
|
||||
import { overrideRomPriestKnightEffects } from "./romPriestKnightOverrides";
|
||||
import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides";
|
||||
import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
|
||||
|
||||
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
||||
|
||||
@@ -204,7 +207,7 @@ function auraModifiers(semantic: Extract<RomAbilitySemantic, { kind: "aura" }>):
|
||||
}
|
||||
if (type === 142) return magicDamageTaken(-percent(value));
|
||||
if (type === 143) return [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -percent(value) }];
|
||||
if (type === 144) return [{ kind: "healing", direction: "taken", operation: "percent", value: -percent(value) }];
|
||||
if (type === 144) return [{ kind: "healing", direction: "taken", operation: "percent", value: percent(value) }];
|
||||
if (type === 148) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "flat", value }];
|
||||
if (type === 149) return [{ kind: "healing", direction: "done", operation: "percent", value: percent(value) }];
|
||||
if (type === 150) return [{ kind: "healing", direction: "done", operation: "flat", value }];
|
||||
@@ -271,8 +274,7 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
||||
const sourcePoints = coefficientCalculation ? 0 : rawPower;
|
||||
const pointsPerLevel = sourcePoints * Math.max(0, semantic.powerPerSkillLevel) / 100;
|
||||
const basePoints = sourcePoints + pointsPerLevel;
|
||||
return {
|
||||
kind: semantic.kind,
|
||||
const amount = {
|
||||
coefficient,
|
||||
...(coefficientPerLevel > 0 ? { coefficientPerLevel, sourceLevel: 1 } : {}),
|
||||
...(basePoints > 0 ? {
|
||||
@@ -281,9 +283,16 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
||||
sourceLevel: 1,
|
||||
...(coefficient > 0 ? { bonusPowerCoefficient: coefficient } : {}),
|
||||
} : {}),
|
||||
...(semantic.kind === "heal" && semantic.percentMaxHealth !== undefined
|
||||
? { percentMaxHealth: semantic.percentMaxHealth }
|
||||
: {}),
|
||||
};
|
||||
if (semantic.kind === "damage") return {
|
||||
kind: "damage", ...amount,
|
||||
...(combatSchool(semantic.school) ? { school: combatSchool(semantic.school) } : {}),
|
||||
};
|
||||
return {
|
||||
kind: "heal", ...amount,
|
||||
...(semantic.percentMaxHealth !== undefined ? { percentMaxHealth: semantic.percentMaxHealth } : {}),
|
||||
...(/\b(?:all teammates|(?:all|every) party members?)\b/i.test(row.description)
|
||||
? { scope: "party" as const } : {}),
|
||||
};
|
||||
}
|
||||
if (semantic.kind === "dot" || semantic.kind === "hot") {
|
||||
@@ -292,6 +301,9 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
||||
return {
|
||||
kind: semantic.kind,
|
||||
coefficient: 0,
|
||||
...(semantic.kind === "dot" && combatSchool(semantic.school)
|
||||
? { school: combatSchool(semantic.school) }
|
||||
: {}),
|
||||
basePoints: Math.max(0, semantic.basePerTick) * (1 + Math.max(0, semantic.perSkillLevel) / 100),
|
||||
pointsPerLevel: Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100,
|
||||
sourceLevel: 1,
|
||||
@@ -327,7 +339,7 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
||||
if (semantic.kind === "aura") {
|
||||
const recipient = auraRecipient(row, semantic);
|
||||
const modifiers = auraModifiers(semantic);
|
||||
const partyScope = /\b(?:all )?party members?\b/i.test(row.description);
|
||||
const partyScope = /\b(?:(?:all|every) party members?|all teammates)\b/i.test(row.description);
|
||||
const aura: AuraDefinition = {
|
||||
id: `rom-aura-${effectId}-${slug(semantic.effect)}`,
|
||||
name: `${row.name}: ${semantic.effect}`,
|
||||
@@ -450,6 +462,21 @@ function stateAuraEffect(
|
||||
}
|
||||
|
||||
function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null {
|
||||
if (row.sourceSkillId === 498744) {
|
||||
return [{ kind: "cooldown-reset", mode: "reset", abilityName: "Electrostatic Charge" }];
|
||||
}
|
||||
if (row.sourceSkillId === 498758) {
|
||||
return [
|
||||
{ kind: "cooldown-reset", mode: "reset", abilityName: "Intensification" },
|
||||
{ kind: "cooldown-reset", mode: "reset", abilityName: "Energy Well" },
|
||||
];
|
||||
}
|
||||
if (row.sourceSkillId === 498759) {
|
||||
// Its own cooldown resets only after the two hits leave the target alive.
|
||||
return translateRomAbilitySemantics(row)
|
||||
.map((semantic, index) => semanticEffect(row, semantic, index))
|
||||
.filter((effect) => effect.kind === "damage");
|
||||
}
|
||||
if (row.sourceSkillId === 491163) {
|
||||
return [stateAuraEffect(row, "frost-arrow", "Frost Arrow", 600_000, {
|
||||
procs: [{
|
||||
@@ -497,6 +524,10 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
const specialized = specializedEffects(row);
|
||||
if (specialized) return specialized;
|
||||
const translated = translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
|
||||
const pairOverride = overrideRomPriestKnightEffects(row, translated)
|
||||
?? overrideRomScoutRogueEffects(row, translated)
|
||||
?? overrideRomMageWarlockEffects(row, translated);
|
||||
if (pairOverride) return pairOverride;
|
||||
const outputStrength = (effect: AbilityEffect): number => (
|
||||
"coefficient" in effect ? effect.coefficient : 0
|
||||
) + ("coefficientPerLevel" in effect ? effect.coefficientPerLevel ?? 0 : 0)
|
||||
@@ -640,9 +671,18 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
}
|
||||
|
||||
function areaRadius(row: Pick<GeneratedSkill, "description" | "range">): number | undefined {
|
||||
if (!/\b(?:multiple targets|all targets|surrounding (?:enemies|targets)|party members within|friendly targets within|targets within (?:a )?range)\b/i.test(row.description)) return undefined;
|
||||
if (row.description.includes("up to 3 targets") && row.description.includes("Inflicts")) return 6;
|
||||
if (!/\b(?:multiple targets|all targets|surrounding (?:enemies|targets)|party members within|all teammates within|friendly targets within|(?:enemies|targets) within (?:a )?range)\b/i.test(row.description)) return undefined;
|
||||
const sourceRange = row.description.match(/\b(?:range|radius) of\s+(\d+(?:\.\d+)?)/i)?.[1];
|
||||
return Math.max(3, Math.min(40, sourceRange ? Number(sourceRange) / 10 : row.range || 8));
|
||||
return Math.max(3, Math.min(40, sourceRange ? Number(sourceRange) / 10 : row.range > 1 ? row.range : 10));
|
||||
}
|
||||
|
||||
function sourceTargeting(sourceSkillId: number): AbilityDefinition["targeting"] | undefined {
|
||||
if (sourceSkillId === 490400) return { shape: "line", maximumTargets: 8 };
|
||||
if (sourceSkillId === 490457) return { shape: "chain", maximumTargets: 3, jumpRange: 6 };
|
||||
if (sourceSkillId === 492920) return { shape: "circle", radius: 5, maximumTargets: 8 };
|
||||
if (sourceSkillId === 497959 || sourceSkillId === 497966) return { shape: "circle", radius: 6, maximumTargets: 3 };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition {
|
||||
@@ -685,7 +725,10 @@ function ability(row: GeneratedSkill): AbilityDefinition {
|
||||
description: row.description,
|
||||
target: row.target,
|
||||
range: { min: 0, max: row.range },
|
||||
...(areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
|
||||
...(sourceTargeting(row.sourceSkillId)?.radius !== undefined
|
||||
? { radius: sourceTargeting(row.sourceSkillId)!.radius }
|
||||
: areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
|
||||
...(sourceTargeting(row.sourceSkillId) ? { targeting: sourceTargeting(row.sourceSkillId) } : {}),
|
||||
castMode: row.castTimeMs > 0 ? "cast" : "instant",
|
||||
castTimeMs: row.castTimeMs,
|
||||
cooldownMs: row.cooldownMs,
|
||||
|
||||
@@ -127,6 +127,7 @@ export type RomAbilitySemantic =
|
||||
})
|
||||
| (RomSemanticBase & {
|
||||
readonly kind: "dot" | "hot";
|
||||
readonly school: string;
|
||||
readonly resource: string;
|
||||
readonly intervalSeconds: number;
|
||||
readonly durationSeconds: number;
|
||||
@@ -530,6 +531,7 @@ function componentSemantics(component: RomSourceComponent, target: RomAbilityTar
|
||||
const healing = target !== "hostile" && dot.base > 0;
|
||||
addIfMissing(rows, {
|
||||
kind: healing ? "hot" : "dot",
|
||||
school: schoolFor(component),
|
||||
resource,
|
||||
intervalSeconds: dot.timeSeconds,
|
||||
durationSeconds: positiveDuration(component),
|
||||
@@ -758,6 +760,7 @@ function tooltipSemantics(ability: RomGeneratedAbilityLike, rows: RomAbilitySema
|
||||
if (/\bbleed|damage over time|damage every|damage each (?:second|time)/.test(lower) && !hasKind(rows, "dot")) {
|
||||
rows.push({
|
||||
kind: "dot",
|
||||
school: "tooltip-defined",
|
||||
resource: "health",
|
||||
intervalSeconds: firstNumber(lower, /every\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0,
|
||||
durationSeconds,
|
||||
@@ -769,6 +772,7 @@ function tooltipSemantics(ability: RomGeneratedAbilityLike, rows: RomAbilitySema
|
||||
if (/restores? .*hp.*(?:every|each) /.test(lower) && !hasKind(rows, "hot")) {
|
||||
rows.push({
|
||||
kind: "hot",
|
||||
school: "healing",
|
||||
resource: "health",
|
||||
intervalSeconds: firstNumber(lower, /every\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0,
|
||||
durationSeconds,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import { romPriestKnightCastEffects } from "./romPriestKnightOverrides";
|
||||
import { romMageWarlockCastEffects } from "./romMageWarlockOverrides";
|
||||
import { romScoutRogueCastEffects } from "./romScoutRogueOverrides";
|
||||
|
||||
const ROGUE_BLEED_ABILITIES = new Set([
|
||||
"rom-rogue-primary-1", // Low Blow's conditional Grievous Wound
|
||||
@@ -13,6 +16,8 @@ export interface RomCastMechanics {
|
||||
readonly charged: boolean;
|
||||
readonly mainHandDagger: boolean;
|
||||
readonly casterMaxHealth: number;
|
||||
readonly passiveTags?: readonly string[];
|
||||
readonly targetTags?: readonly string[];
|
||||
}
|
||||
|
||||
/** Source periodic values for self-sacrifice toggle skills are percentages of HP. */
|
||||
@@ -61,10 +66,29 @@ function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect {
|
||||
/** Select source-conditional RoM payloads once for both player and companion casts. */
|
||||
export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMechanics): readonly AbilityEffect[] {
|
||||
if (ability.source !== "runewaker") return ability.effects;
|
||||
const effects = ability.effects;
|
||||
const priestKnightEffects = romPriestKnightCastEffects(
|
||||
ability, ability.effects, context.passiveTags ?? [], context.casterMaxHealth,
|
||||
);
|
||||
const mageWarlockEffects = romMageWarlockCastEffects(ability, priestKnightEffects, new Set(context.passiveTags ?? []));
|
||||
const effects = romScoutRogueCastEffects(ability, mageWarlockEffects, context.passiveTags ?? [], context.targetTags ?? []);
|
||||
const damage = effects.filter((effect) => effect.kind === "damage");
|
||||
const seals = Math.max(0, Math.min(4, context.sealStacks));
|
||||
switch (ability.dbcSpellId) {
|
||||
case 492625: // Infection's poison rider needs Poisonous active.
|
||||
return context.passiveTags?.includes("rom-poisonous")
|
||||
? effects : effects.filter((effect) => effect.kind !== "dot");
|
||||
case 491156: // Electrostatic Charge's source shield is a percent of max HP.
|
||||
return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
|
||||
? {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
absorbs: effect.aura.absorbs.map((pool) => ({
|
||||
...pool,
|
||||
amount: Math.max(1, Math.round(context.casterMaxHealth * pool.amount / 100)),
|
||||
})),
|
||||
},
|
||||
} : effect);
|
||||
case 490148: // A new Holy Strike increases the target's Holy Seal bleed stack.
|
||||
case 490150: { // Charge adds two stacks.
|
||||
const gained = ability.dbcSpellId === 490148 ? 1 : 2;
|
||||
|
||||
@@ -9,9 +9,10 @@ import { partyRolesForClass } from "./partyRoles";
|
||||
import { createConfiguredPartyMember, createDungeonParty, createSpectatorDungeonParty, usePartyStore } from "./partyStore";
|
||||
import { ROM_ORDINARY_ABILITIES_BY_CLASS } from "./romAbilityCatalog";
|
||||
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
|
||||
import { romGearProfile } from "./specializationCatalog";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
import { baseAttributesFor, baseResourcesFor, deriveCharacterStats, healthFromTotalStamina, manaFromTotalIntellect, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||
|
||||
const CASES = [
|
||||
{ classId: "rom-priest", role: "healer", layout: "weapon-shield" },
|
||||
@@ -22,6 +23,34 @@ const CASES = [
|
||||
] as const;
|
||||
|
||||
describe("RoM five class validation scope", () => {
|
||||
it.each([1, 10, 30, 60, 79])("uses WoW base attribute and resource curves for the accepted pairs at level %i", (level) => {
|
||||
const referenceRace: Record<string, "human" | "night-elf"> = {
|
||||
"rom-knight": "human",
|
||||
"rom-priest": "human",
|
||||
"rom-scout": "night-elf",
|
||||
"rom-rogue": "human",
|
||||
"rom-mage": "human",
|
||||
};
|
||||
for (const identity of ROM_FIVE_IDENTITIES) {
|
||||
const secondary = level >= 10 ? identity.secondaryClassId : null;
|
||||
const primaryBase = baseAttributesFor(identity.classId, level, referenceRace[identity.classId] ?? "human");
|
||||
const secondaryBase = secondary ? baseAttributesFor(secondary, level, referenceRace[secondary] ?? "human") : null;
|
||||
const expected = Object.fromEntries((Object.keys(primaryBase) as (keyof typeof primaryBase)[]).map((stat) => [
|
||||
stat, Math.round(primaryBase[stat] + (secondaryBase?.[stat] ?? 0) * .1),
|
||||
]));
|
||||
const combat = deriveCharacterStats(identity.classId, level, EMPTY_ITEM_STATS, identity.raceId, {}, secondary);
|
||||
expect(combat, `${identity.name} at ${level}`).toMatchObject(expected);
|
||||
const baseResources = baseResourcesFor(identity.classId, level);
|
||||
const secondaryResources = secondary ? baseResourcesFor(secondary, level) : null;
|
||||
expect(combat.health).toBe(Math.round(baseResources.health + (secondaryResources?.health ?? 0) * .1
|
||||
+ healthFromTotalStamina(combat.stamina)));
|
||||
if (identity.classId === "rom-knight" || identity.classId === "rom-priest" || identity.classId === "rom-mage") {
|
||||
expect(combat.mana).toBe(MANA_POOL_MULTIPLIER * Math.round(baseResources.mana
|
||||
+ (secondaryResources?.mana ?? 0) * .1 + manaFromTotalIntellect(combat.intellect)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each([10, 30, 60, 80])("keeps each secondary class at the primary level for stat growth at level %i", (level) => {
|
||||
for (const identity of ROM_FIVE_IDENTITIES) {
|
||||
const primary = romNativeClassBaseStats(identity.classId, level);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
|
||||
/** The absorb amounts in Electrostatic Charge's source row are HP percentages. */
|
||||
export const ROM_ELECTROSTATIC_PERCENT_ABSORB_TAG = "rom-electrostatic-percent-max-health";
|
||||
|
||||
export const ROM_MAGE_WARLOCK_PASSIVE_TAGS = Object.freeze({
|
||||
industryFire: "rom-mage-warlock-industry-fire",
|
||||
lightningDisplay: "rom-mage-warlock-lightning-display",
|
||||
elementsOfPride: "rom-mage-warlock-elements-of-pride",
|
||||
soulSpeed: "rom-mage-warlock-soul-speed",
|
||||
elementalExtraction: "rom-mage-warlock-elemental-extraction",
|
||||
} as const);
|
||||
|
||||
export const ROM_SOUL_STEPPING_CAST_TAG = "rom-mage-warlock-soul-stepping-cast";
|
||||
|
||||
// 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;
|
||||
|
||||
function stateAura(id: string, tag: string, durationMs: number | null): AbilityEffect {
|
||||
return {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: `rom-mage-warlock-state-${id}`,
|
||||
name: "Mage/Warlock class state",
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
utilityTags: [tag],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-specific mappings for the accepted Mage/Warlock build. These auras
|
||||
* are state markers; the combat runtime applies the conditional skill effects
|
||||
* only when the named source spells execute or critically hit their target.
|
||||
*/
|
||||
export function overrideRomMageWarlockEffects(
|
||||
row: RomGeneratedAbilityLike,
|
||||
translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
if (row.sourceSkillId === 491156) {
|
||||
return translated.map((effect) => {
|
||||
if (effect.kind !== "apply-aura" || !effect.aura.absorbs?.length) return effect;
|
||||
return {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_ELECTROSTATIC_PERCENT_ABSORB_TAG],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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)];
|
||||
if (row.sourceSkillId === 499813) return [stateAura("soul-speed", ROM_MAGE_WARLOCK_PASSIVE_TAGS.soulSpeed, null)];
|
||||
if (row.sourceSkillId === 499814) return [stateAura("elemental-extraction", ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction, null)];
|
||||
if (row.sourceSkillId === 498757) return [
|
||||
...translated,
|
||||
stateAura("soul-stepping-cast", ROM_SOUL_STEPPING_CAST_TAG, 6_000),
|
||||
];
|
||||
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",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: `rom-mage-warlock-elements-${school}`,
|
||||
name: `Elements of Pride: ${school}`,
|
||||
disposition: "buff",
|
||||
durationMs: 13_000,
|
||||
maxStacks: 2,
|
||||
stackBehavior: "add",
|
||||
modifiers: [{ kind: "damage", direction: "dealt", school, operation: "percent", value, perStack: true }],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 498751,
|
||||
};
|
||||
}
|
||||
|
||||
/** Add pair-elite riders to an executable cast after conditional source selection. */
|
||||
export function romMageWarlockCastEffects(
|
||||
ability: AbilityDefinition,
|
||||
effects: readonly AbilityEffect[],
|
||||
passiveTags: ReadonlySet<string>,
|
||||
): readonly AbilityEffect[] {
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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 } };
|
||||
}
|
||||
return (effect.kind === "damage" || effect.kind === "dot") && effect.school === "nature"
|
||||
? scaleDamage(effect, 1.01)
|
||||
: 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 (ability.dbcSpellId === 498757 && !passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.soulSpeed)) {
|
||||
result = result.filter((effect) => effect.kind !== "apply-aura"
|
||||
|| (effect.sourceAuraType !== 24 && effect.sourceAuraType !== 51));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Apply only after Electric Explosion or source 499812 critically hits. */
|
||||
export function romMageWarlockCriticalEffects(
|
||||
ability: AbilityDefinition,
|
||||
passiveTags: ReadonlySet<string>,
|
||||
): readonly AbilityEffect[] {
|
||||
if (!passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction)
|
||||
|| (ability.dbcSpellId !== 491170 && ability.dbcSpellId !== 499812)) return [];
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
aura: {
|
||||
id: "rom-mage-warlock-elemental-extraction-debuff",
|
||||
name: "Elemental Extraction",
|
||||
disposition: "debuff",
|
||||
durationMs: 10_000,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "armor", operation: "percent", value: -0.3 },
|
||||
{ kind: "stat", stat: "magical-defense", operation: "percent", value: -0.3 },
|
||||
],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 499814,
|
||||
}];
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, abilityById, learnedAbilityForBuild } from "./abilityCatalog";
|
||||
import { abilityDamageSchool, useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
||||
import {
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS,
|
||||
romMageWarlockCastEffects,
|
||||
romMageWarlockCriticalEffects,
|
||||
} from "./romMageWarlockOverrides";
|
||||
|
||||
const ELITES = [
|
||||
[15, 498744, "Static Resonance"],
|
||||
[20, 498745, "Industry Fire"],
|
||||
[25, 498748, "Lightning Display"],
|
||||
[30, 498750, "Breath Erase"],
|
||||
[35, 498751, "Elements of Pride"],
|
||||
[40, 498757, "Soul Stepping"],
|
||||
[45, 498758, "Deep Inspiration"],
|
||||
[50, 498759, "Fire Lightning Burst"],
|
||||
[60, 499813, "Soul Speed"],
|
||||
[70, 499814, "Elemental Extraction"],
|
||||
] as const;
|
||||
|
||||
function elite(level: number) {
|
||||
const result = abilityById(`rom-mage-rom-warlock-elite-${level}`);
|
||||
expect(result, `Mage/Warlock elite ${level}`).toBeDefined();
|
||||
return result!;
|
||||
}
|
||||
|
||||
function initialize(level: number): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-mage", secondaryClassId: "rom-warlock", raceId: "rom-dwarf", level,
|
||||
});
|
||||
}
|
||||
|
||||
function registerTarget(id = "mage-warlock-dummy", health = 100_000): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: health });
|
||||
setMobPosition(id, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(id);
|
||||
}
|
||||
|
||||
describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it.each([10, 30, 60, 79])("learns the correct primary, secondary, and elite kit at level %i", (level) => {
|
||||
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-mage")!;
|
||||
expect(identity).toMatchObject({ raceId: "rom-dwarf", secondaryClassId: "rom-warlock", role: "damage" });
|
||||
const learned = abilitiesForCharacter(identity.classId, identity.secondaryClassId)
|
||||
.filter((ability) => learnedAbilityForBuild(ability, level, {}) !== null);
|
||||
expect(learned.some((ability) => ability.id === "rom-mage-primary-1")).toBe(true);
|
||||
expect(learned.some((ability) => ability.id === "rom-warlock-general-0")).toBe(true);
|
||||
expect(learned.filter((ability) => ability.secondaryClassId === "rom-warlock")
|
||||
.map((ability) => [ability.unlockLevel, ability.dbcSpellId, ability.name]))
|
||||
.toEqual(ELITES.filter(([unlock]) => unlock <= level));
|
||||
});
|
||||
|
||||
it("preserves source multi-hit and area damage, defensive absorb, control, and threat tools", () => {
|
||||
expect(abilityById("rom-mage-primary-5")!.effects.filter((effect) => effect.kind === "damage")).toHaveLength(5);
|
||||
expect(abilityById("rom-mage-primary-12")!.effects.filter((effect) => effect.kind === "damage")).toHaveLength(3);
|
||||
expect(abilityById("rom-mage-primary-8")!.radius).toBeGreaterThan(0);
|
||||
expect(abilityById("rom-mage-primary-2")!.effects.some((effect) => (
|
||||
effect.kind === "apply-aura" && (effect.aura.absorbs?.length ?? 0) > 0
|
||||
))).toBe(true);
|
||||
expect(abilityById("rom-mage-primary-56")!.effects).toEqual([
|
||||
expect.objectContaining({ kind: "status", maxTargets: 1, breakOnDamage: true }),
|
||||
]);
|
||||
expect(elite(30).effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({
|
||||
modifiers: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "stat", stat: "threat", operation: "percent", value: -0.1 }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("uses magic schools for Mage and Warlock magic projectiles and dark spells", () => {
|
||||
for (const [id, school] of [
|
||||
["rom-mage-primary-1", "fire"],
|
||||
["rom-mage-primary-52", "nature"],
|
||||
["rom-warlock-general-0", "shadow"],
|
||||
["rom-warlock-general-4", "shadow"],
|
||||
["rom-warlock-general-5", "shadow"],
|
||||
] as const) {
|
||||
expect(abilityDamageSchool("rom-mage", abilityById(id)!), id).toBe(school);
|
||||
}
|
||||
});
|
||||
|
||||
it("damages the selected target using both class resources", () => {
|
||||
initialize(30);
|
||||
registerTarget();
|
||||
const manaBefore = useCombatStore.getState().resourcePools.mana!;
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 4_100);
|
||||
expect(useCombatStore.getState().mobs["mage-warlock-dummy"]!.health).toBeLessThan(100_000);
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBeLessThan(manaBefore);
|
||||
|
||||
initialize(30);
|
||||
registerTarget("warlock-dummy");
|
||||
const focusBefore = useCombatStore.getState().resourcePools.focus!;
|
||||
expect(useCombatStore.getState().castAbility("rom-warlock-general-0", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 3_100);
|
||||
expect(useCombatStore.getState().mobs["warlock-dummy"]!.health).toBeLessThan(100_000);
|
||||
expect(useCombatStore.getState().resourcePools.focus).toBeLessThan(focusBefore);
|
||||
});
|
||||
|
||||
it("Electrostatic Charge creates its source percentage of maximum HP absorb", () => {
|
||||
initialize(60);
|
||||
const before = useCombatStore.getState();
|
||||
expect(before.castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 1_100);
|
||||
const charge = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.name.includes("Electrostatic Charge") && aura.absorbs.length > 0
|
||||
));
|
||||
expect(charge).toBeDefined();
|
||||
expect(charge!.absorbs[0]?.remaining).toBe(Math.round(before.maxHealth * 0.3));
|
||||
});
|
||||
|
||||
it("hits nearby enemies with Mage and Warlock area spells, respecting the source target caps", () => {
|
||||
initialize(30);
|
||||
for (const [id, x] of [["area-a", 2], ["area-b", 3], ["area-c", 4], ["area-far", 20]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
|
||||
setMobPosition(id, [x, 0, 0]);
|
||||
}
|
||||
useCombatStore.getState().selectMob("area-a");
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-8", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
for (const id of ["area-a", "area-b", "area-c"]) {
|
||||
expect(useCombatStore.getState().mobs[id]!.health, `Purgatory Fire ${id}`).toBeLessThan(100_000);
|
||||
}
|
||||
expect(useCombatStore.getState().mobs["area-far"]!.health).toBe(100_000);
|
||||
|
||||
initialize(30);
|
||||
for (const [id, x] of [["weave-a", 2], ["weave-b", 3], ["weave-c", 4], ["weave-far", 20]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
|
||||
setMobPosition(id, [x, 0, 0]);
|
||||
}
|
||||
useCombatStore.getState().selectMob("weave-a");
|
||||
expect(useCombatStore.getState().castAbility("rom-warlock-general-1", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 2_100);
|
||||
for (const id of ["weave-a", "weave-b", "weave-c"]) {
|
||||
expect(useCombatStore.getState().mobs[id]!.health, `Weakening Weave ${id}`).toBeLessThan(100_000);
|
||||
}
|
||||
expect(useCombatStore.getState().mobs["weave-far"]!.health).toBe(100_000);
|
||||
});
|
||||
|
||||
it("Static Resonance resets only Electrostatic Charge", () => {
|
||||
initialize(60);
|
||||
useCombatStore.setState({ cooldowns: {
|
||||
"rom-mage-primary-2": 90_000,
|
||||
"rom-mage-primary-9": 90_000,
|
||||
"rom-mage-general-0": 90_000,
|
||||
} });
|
||||
expect(useCombatStore.getState().castAbility(elite(15).id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(0);
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-9"]).toBe(90_000);
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-general-0"]).toBe(90_000);
|
||||
});
|
||||
|
||||
it("Deep Inspiration resets Intensification and Energy Well only", () => {
|
||||
initialize(60);
|
||||
useCombatStore.setState({ cooldowns: {
|
||||
"rom-mage-general-3": 90_000,
|
||||
"rom-mage-primary-10": 90_000,
|
||||
"rom-mage-primary-2": 90_000,
|
||||
} });
|
||||
expect(useCombatStore.getState().castAbility(elite(45).id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-general-3"]).toBe(0);
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-10"]).toBe(0);
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(90_000);
|
||||
});
|
||||
|
||||
it("Soul Stepping permits a cast to finish after the Mage moves", () => {
|
||||
initialize(60);
|
||||
registerTarget("ordinary-moving-cast-dummy");
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
|
||||
useCombatStore.getState().tick(0.1, 4_100);
|
||||
expect(useCombatStore.getState().mobs["ordinary-moving-cast-dummy"]!.health).toBe(100_000);
|
||||
|
||||
initialize(60);
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
registerTarget("moving-cast-dummy");
|
||||
expect(useCombatStore.getState().castAbility(elite(40).id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 2_600))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
|
||||
useCombatStore.getState().tick(0.1, 5_700);
|
||||
expect(useCombatStore.getState().mobs["moving-cast-dummy"]!.health).toBeLessThan(100_000);
|
||||
});
|
||||
|
||||
it("Fire Lightning Burst hits twice and resets its own cooldown only if the target survives", () => {
|
||||
initialize(60);
|
||||
registerTarget();
|
||||
useCombatStore.setState({ cooldowns: { "rom-mage-primary-2": 90_000 } });
|
||||
expect(elite(50).effects.filter((effect) => effect.kind === "damage")).toHaveLength(2);
|
||||
expect(useCombatStore.getState().castAbility(elite(50).id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 3_100);
|
||||
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);
|
||||
});
|
||||
|
||||
it("does not apply conditional passive buffs or enemy defense penalties to the caster on login", () => {
|
||||
initialize(79);
|
||||
const auraNames = useCombatStore.getState().auras.map((aura) => aura.definition.name);
|
||||
for (const level of [25, 35, 60, 70]) {
|
||||
expect(auraNames.some((name) => name.startsWith(elite(level).name)), elite(level).name).toBe(false);
|
||||
}
|
||||
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", () => {
|
||||
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" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("extends Charged, creates conditional Elements stacks, and enhances Soul Stepping", () => {
|
||||
const tags = new Set([
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS.lightningDisplay,
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementsOfPride,
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS.soulSpeed,
|
||||
]);
|
||||
const plasma = abilityById("rom-mage-primary-52")!;
|
||||
const charged = romMageWarlockCastEffects(plasma, plasma.effects, tags).find((effect) => (
|
||||
effect.kind === "apply-aura" && effect.aura.utilityTags?.includes("rom-charged")
|
||||
));
|
||||
expect(charged).toMatchObject({ aura: { durationMs: 18_000 } });
|
||||
|
||||
const flame = abilityById("rom-mage-primary-1")!;
|
||||
expect(romMageWarlockCastEffects(flame, flame.effects, tags)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura", recipient: "caster",
|
||||
aura: expect.objectContaining({ id: "rom-mage-warlock-elements-nature", maxStacks: 2 }),
|
||||
}),
|
||||
]));
|
||||
const stepping = elite(40);
|
||||
expect(romMageWarlockCastEffects(stepping, stepping.effects, new Set()).some((effect) => (
|
||||
effect.kind === "apply-aura" && [24, 51].includes(effect.sourceAuraType)
|
||||
))).toBe(false);
|
||||
expect(romMageWarlockCastEffects(stepping, stepping.effects, tags).some((effect) => (
|
||||
effect.kind === "apply-aura" && [24, 51].includes(effect.sourceAuraType)
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
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 }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
||||
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||
import { baseAttributesFor, baseResourcesFor, deriveCharacterStats, manaFromTotalIntellect, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||
|
||||
const RACE_MATRIX = {
|
||||
"rom-human": ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-priest", "rom-knight"],
|
||||
@@ -117,9 +117,10 @@ describe("generated RuneWaker player catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gives level-one healers native mana pools and imports their real spell costs", () => {
|
||||
it("uses hybrid Priest and native Druid mana curves while retaining source spell costs", () => {
|
||||
expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(
|
||||
103 * MANA_POOL_MULTIPLIER,
|
||||
(baseResourcesFor("rom-priest", 1).mana + manaFromTotalIntellect(baseAttributesFor("rom-priest", 1, "human").intellect))
|
||||
* MANA_POOL_MULTIPLIER,
|
||||
);
|
||||
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(
|
||||
98 * MANA_POOL_MULTIPLIER,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
|
||||
function passiveState(sourceId: number, name: string, tag: string): readonly AbilityEffect[] {
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: `rom-pair-passive-${sourceId}`,
|
||||
name,
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
utilityTags: [tag],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: sourceId,
|
||||
}];
|
||||
}
|
||||
|
||||
/** Source-specific pair mechanics that the generic component translator cannot infer. */
|
||||
export function overrideRomPriestKnightEffects(
|
||||
row: RomGeneratedAbilityLike,
|
||||
translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
switch (row.sourceSkillId) {
|
||||
case 492355: // Enhanced Grace of Life: Grace of Life also raises maximum MP.
|
||||
return passiveState(492355, row.name, "rom-enhanced-grace-of-life");
|
||||
case 492361: // Life Link: Urgent Heal also heals its caster.
|
||||
return passiveState(492361, row.name, "rom-life-link");
|
||||
case 499968: // Splendor Ripple: a triggered extra heal on source skill 499967.
|
||||
return passiveState(499968, row.name, "rom-splendor-ripple");
|
||||
case 499957: // Light Guidance: activate healing bonus only with source state 499966.
|
||||
return passiveState(499957, row.name, "rom-light-guidance");
|
||||
case 491610: // Enhanced Holy Strike: improves Holy Strike weapon damage.
|
||||
return passiveState(491610, row.name, "rom-enhanced-holy-strike");
|
||||
case 490066: // Weaken Weapon Strength: Heavenly Arrow weakens target physical damage.
|
||||
return passiveState(490066, row.name, "rom-weaken-weapon-strength");
|
||||
case 499926: // Evil Shackles: Holy Chains can root any creature with shorter duration.
|
||||
return passiveState(499926, row.name, "rom-evil-shackles");
|
||||
case 499923: // Blood Punishment: source skill 499924 grants conditional bonuses.
|
||||
return passiveState(499923, row.name, "rom-blood-punishment");
|
||||
case 491648: // Last Prayer affects only the next spell's cast time.
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-pair-last-prayer-next-spell",
|
||||
name: row.name,
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
utilityTags: ["rom-last-prayer"],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 51,
|
||||
miscValue: -1_000,
|
||||
triggerSpellId: 0,
|
||||
}];
|
||||
case 493271: // Light Fairy has source lifetime -1; keep it until dismissal or session end.
|
||||
return translated.map((effect) => effect.kind === "summon"
|
||||
? { ...effect, durationMs: 86_400_000 }
|
||||
: effect);
|
||||
case 492639: // Calm Heart, component 502908: 10 seconds of Fear immunity.
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
aura: {
|
||||
id: "rom-pair-calm-heart-fear-immunity",
|
||||
name: row.name,
|
||||
disposition: "buff",
|
||||
durationMs: 10_000,
|
||||
controlImmunities: ["fear"],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 0,
|
||||
}];
|
||||
case 493046: // Sacred Protection, component 503290: +100 shield block for 20 seconds.
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-pair-sacred-protection-block",
|
||||
name: row.name,
|
||||
disposition: "buff",
|
||||
durationMs: 20_000,
|
||||
modifiers: [{ kind: "stat", stat: "block-chance", operation: "flat", value: 100 }],
|
||||
utilityTags: ["rom-sacred-protection"],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 172,
|
||||
miscValue: 100,
|
||||
triggerSpellId: 0,
|
||||
}];
|
||||
case 493273: // Disregard Danger: redirect 40% of one hit; caster gains 20 Parry.
|
||||
return [
|
||||
{
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
aura: {
|
||||
id: "rom-pair-disregard-danger-redirect",
|
||||
name: row.name,
|
||||
disposition: "buff",
|
||||
durationMs: 10_000,
|
||||
redirectDamagePercent: 0.4,
|
||||
utilityTags: ["rom-disregard-danger-one-hit"],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 143,
|
||||
miscValue: 40,
|
||||
triggerSpellId: 491614,
|
||||
},
|
||||
{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-pair-disregard-danger-parry",
|
||||
name: `${row.name}: Parry`,
|
||||
disposition: "buff",
|
||||
durationMs: 10_000,
|
||||
modifiers: [{ kind: "stat", stat: "parry", operation: "flat", value: 20 }],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 22,
|
||||
miscValue: 20,
|
||||
triggerSpellId: 0,
|
||||
},
|
||||
];
|
||||
case 491613: // Holy Chains only roots; source flag 67 is not three controls.
|
||||
return translated.filter((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasTag(tags: readonly string[] | ReadonlySet<string>, tag: string): boolean {
|
||||
return Array.isArray(tags) ? tags.includes(tag) : (tags as ReadonlySet<string>).has(tag);
|
||||
}
|
||||
|
||||
/** Apply passive pair upgrades when their parent skill is actually cast. */
|
||||
export function romPriestKnightCastEffects(
|
||||
ability: AbilityDefinition,
|
||||
effects: readonly AbilityEffect[],
|
||||
passiveTags: readonly string[] | ReadonlySet<string>,
|
||||
casterMaxHealth: number,
|
||||
): readonly AbilityEffect[] {
|
||||
if (ability.source !== "runewaker" || casterMaxHealth <= 0) return effects;
|
||||
switch (ability.dbcSpellId) {
|
||||
case 490290: // Enhanced Grace of Life mirrors source Grace's +50 and +1% HP as MP.
|
||||
if (!hasTag(passiveTags, "rom-enhanced-grace-of-life")) return effects;
|
||||
return [...effects, {
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
aura: {
|
||||
id: "rom-pair-enhanced-grace-mana",
|
||||
name: "Enhanced Grace of Life: Mana",
|
||||
disposition: "buff",
|
||||
durationMs: 1_800_000,
|
||||
modifiers: [
|
||||
{ kind: "resource", resource: "mana", property: "maximum", operation: "flat", value: 50 },
|
||||
{ kind: "resource", resource: "mana", property: "maximum", operation: "percent", value: 0.01 },
|
||||
],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 8,
|
||||
miscValue: 50,
|
||||
triggerSpellId: 492355,
|
||||
}];
|
||||
case 491147: // Life Link duplicates Urgent Heal's effective payload on the caster.
|
||||
if (!hasTag(passiveTags, "rom-life-link")) return effects;
|
||||
return [...effects, ...effects.filter((effect) => effect.kind === "heal")
|
||||
.map((effect) => ({ ...effect, recipient: "caster" as const }))];
|
||||
case 490148: // Enhanced Holy Strike adds a conservative hybrid weapon bonus.
|
||||
if (!hasTag(passiveTags, "rom-enhanced-holy-strike")) return effects;
|
||||
return effects.map((effect) => effect.kind === "damage"
|
||||
? { ...effect, coefficient: effect.coefficient * 1.1 }
|
||||
: effect);
|
||||
case 491354: // Weaken Weapon Strength debuffs targets hit by Heavenly Arrow.
|
||||
if (!hasTag(passiveTags, "rom-weaken-weapon-strength")) return effects;
|
||||
return [...effects, {
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
aura: {
|
||||
id: "rom-pair-heavenly-arrow-weaken",
|
||||
name: "Weaken Weapon Strength",
|
||||
disposition: "debuff",
|
||||
durationMs: 10_000,
|
||||
modifiers: [{ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: -0.1 }],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: -10,
|
||||
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;
|
||||
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);
|
||||
}
|
||||
default:
|
||||
return effects;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, isAbilityUnlocked, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
||||
import { romPriestKnightCastEffects } from "./romPriestKnightOverrides";
|
||||
|
||||
const PAIRED_SKILLS = [
|
||||
["rom-priest", "rom-knight", 15, 492355, "Enhanced Grace of Life", "passive"],
|
||||
["rom-priest", "rom-knight", 20, 492361, "Life Link", "passive"],
|
||||
["rom-priest", "rom-knight", 25, 491648, "Last Prayer", "elite"],
|
||||
["rom-priest", "rom-knight", 30, 491366, "Divine Incarnation", "elite"],
|
||||
["rom-priest", "rom-knight", 35, 492639, "Calm Heart", "elite"],
|
||||
["rom-priest", "rom-knight", 40, 492934, "Sacrificial Cure", "elite"],
|
||||
["rom-priest", "rom-knight", 45, 493044, "Holy Power", "elite"],
|
||||
["rom-priest", "rom-knight", 50, 493271, "Light Fairy", "elite"],
|
||||
["rom-priest", "rom-knight", 60, 499968, "Splendor Ripple", "passive"],
|
||||
["rom-priest", "rom-knight", 70, 499957, "Light Guidance", "passive"],
|
||||
["rom-knight", "rom-scout", 15, 491616, "Arrow of Vengeance", "elite"],
|
||||
["rom-knight", "rom-scout", 20, 491610, "Enhanced Holy Strike", "passive"],
|
||||
["rom-knight", "rom-scout", 25, 491613, "Holy Chains", "elite"],
|
||||
["rom-knight", "rom-scout", 30, 491354, "Heavenly Arrow", "elite"],
|
||||
["rom-knight", "rom-scout", 35, 490066, "Weaken Weapon Strength", "passive"],
|
||||
["rom-knight", "rom-scout", 40, 492939, "Sacred Resistance", "elite"],
|
||||
["rom-knight", "rom-scout", 45, 493046, "Sacred Protection", "elite"],
|
||||
["rom-knight", "rom-scout", 50, 493273, "Disregard Danger", "elite"],
|
||||
["rom-knight", "rom-scout", 60, 499926, "Evil Shackles", "passive"],
|
||||
["rom-knight", "rom-scout", 70, 499923, "Blood Punishment", "passive"],
|
||||
] as const;
|
||||
|
||||
function pairedSkill(sourceId: number): AbilityDefinition {
|
||||
const found = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === sourceId);
|
||||
if (!found) throw new Error(`Missing paired RoM source skill ${sourceId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function executable(effect: AbilityEffect): boolean {
|
||||
if (effect.kind === "scripted" || effect.kind === "unsupported") return false;
|
||||
if (effect.kind === "apply-aura") return Boolean(
|
||||
effect.control || effect.aura.modifiers?.length || effect.aura.absorbs?.length
|
||||
|| effect.aura.procs?.length || effect.aura.utilityTags?.length
|
||||
|| effect.aura.controlImmunities?.length || effect.aura.redirectDamagePercent,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
describe("accepted RoM Priest/Knight and Knight/Scout pairs", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it("has exactly the twenty RuneWaker paired skills, including passive upgrades", () => {
|
||||
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);
|
||||
const kit = abilitiesForCharacter(primary, secondary);
|
||||
for (const skill of pair) expect(kit).toContain(skill);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(PAIRED_SKILLS)("maps %s/%s level %i source %i %s (%s) with the right gate", (
|
||||
primary, secondary, unlockLevel, sourceId, name, group,
|
||||
) => {
|
||||
const skill = pairedSkill(sourceId);
|
||||
expect(skill).toMatchObject({
|
||||
classId: primary, secondaryClassId: secondary, unlockLevel,
|
||||
dbcSpellId: sourceId, name, romSkillGroup: group, passive: group === "passive",
|
||||
});
|
||||
for (const level of [10, 30, 60, 79]) {
|
||||
expect(isAbilityUnlocked(skill, level), `${name} at level ${level}`)
|
||||
.toBe(level >= unlockLevel);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(PAIRED_SKILLS)("gives %s/%s level %i source %i %s (%s) an executable mechanic", (
|
||||
_primary, _secondary, _unlockLevel, sourceId, name,
|
||||
) => {
|
||||
const skill = pairedSkill(sourceId);
|
||||
expect(skill.effects.some(executable), `${name} (${sourceId})`).toBe(true);
|
||||
});
|
||||
|
||||
it("retains direct and area Knight/Scout taunts", () => {
|
||||
expect(pairedSkill(491616).effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "taunt" }),
|
||||
]));
|
||||
expect(pairedSkill(491354).effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "taunt", maxTargets: expect.any(Number) }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("retains Priest/Knight healing costs and offensive/defensive effects", () => {
|
||||
const sacrifice = pairedSkill(492934);
|
||||
expect(sacrifice.costs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ resource: "health", amount: 70, percentage: true }),
|
||||
]));
|
||||
expect(sacrifice.effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "heal", percentMaxHealth: expect.any(Number) }),
|
||||
]));
|
||||
expect(pairedSkill(491366).effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({
|
||||
modifiers: expect.arrayContaining([expect.objectContaining({ kind: "healing", direction: "done", value: 0.15 })]),
|
||||
}) }),
|
||||
]));
|
||||
expect(pairedSkill(493271).effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "summon", creatureId: 102108 }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("retains Knight/Scout damage and Dexterity buff on Sacred Resistance", () => {
|
||||
const skill = pairedSkill(492939);
|
||||
expect(skill.effects.some((effect) => effect.kind === "damage" && effect.coefficient > 0)).toBe(true);
|
||||
expect(skill.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "dexterity" && modifier.value > 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("makes Calm Heart resist Fear rather than applying an empty buff", () => {
|
||||
expect(pairedSkill(492639).effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.controlImmunities?.includes("fear"))).toBe(true);
|
||||
});
|
||||
|
||||
it("maps Sacred Protection to a real shield block bonus", () => {
|
||||
expect(pairedSkill(493046).effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "block-chance" && modifier.value > 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("redirects damage with Disregard Danger", () => {
|
||||
expect(pairedSkill(493273).effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& (effect.aura.redirectDamagePercent ?? 0) > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("applies only the advertised root from Holy Chains", () => {
|
||||
const controls = pairedSkill(491613).effects.flatMap((effect) => effect.kind === "apply-aura"
|
||||
&& effect.control ? [effect.control.kind] : []);
|
||||
expect(controls).toEqual(["root"]);
|
||||
});
|
||||
|
||||
it("increases healing received through Blood Punishment", () => {
|
||||
expect(pairedSkill(499923).effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.utilityTags?.includes("rom-blood-punishment"))).toBe(true);
|
||||
expect(pairedSkill(499923).effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps passive pair upgrades conditional on their parent casts", () => {
|
||||
for (const [sourceId, tag] of [
|
||||
[492355, "rom-enhanced-grace-of-life"],
|
||||
[492361, "rom-life-link"],
|
||||
[499968, "rom-splendor-ripple"],
|
||||
[499957, "rom-light-guidance"],
|
||||
[491610, "rom-enhanced-holy-strike"],
|
||||
[490066, "rom-weaken-weapon-strength"],
|
||||
[499926, "rom-evil-shackles"],
|
||||
[499923, "rom-blood-punishment"],
|
||||
] as const) {
|
||||
expect(pairedSkill(sourceId).effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.utilityTags?.includes(tag)), `${sourceId} ${tag}`).toBe(true);
|
||||
}
|
||||
expect(pairedSkill(491648).effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.utilityTags?.includes("rom-last-prayer"))).toBe(true);
|
||||
});
|
||||
|
||||
it("applies passive upgrades to Grace, Urgent Heal, Holy Strike, and Heavenly Arrow casts", () => {
|
||||
const ability = (sourceId: number) => ROM_ALL_ABILITIES.find((row) => row.dbcSpellId === sourceId)!;
|
||||
const grace = ability(490290);
|
||||
const enhancedGrace = romPriestKnightCastEffects(grace, grace.effects, ["rom-enhanced-grace-of-life"], 1_000);
|
||||
expect(enhancedGrace.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "resource"
|
||||
&& modifier.resource === "mana" && modifier.property === "maximum"))).toBe(true);
|
||||
const urgent = ability(491147);
|
||||
const linked = romPriestKnightCastEffects(urgent, urgent.effects, ["rom-life-link"], 1_000);
|
||||
expect(linked.filter((effect) => effect.kind === "heal" && effect.recipient === "caster")).toHaveLength(1);
|
||||
const holyStrike = ability(490148);
|
||||
const enhancedStrike = romPriestKnightCastEffects(holyStrike, holyStrike.effects, ["rom-enhanced-holy-strike"], 1_000);
|
||||
const originalDamage = holyStrike.effects.find((effect) => effect.kind === "damage");
|
||||
const enhancedDamage = enhancedStrike.find((effect) => effect.kind === "damage");
|
||||
expect(enhancedDamage?.kind === "damage" && originalDamage?.kind === "damage"
|
||||
&& enhancedDamage.coefficient > originalDamage.coefficient).toBe(true);
|
||||
const arrow = ability(491354);
|
||||
const weakened = romPriestKnightCastEffects(arrow, arrow.effects, ["rom-weaken-weapon-strength"], 1_000);
|
||||
expect(weakened.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
|
||||
&& modifier.direction === "dealt" && modifier.school === "physical" && modifier.value < 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("halves Holy Chains duration against ordinary targets after Evil Shackles unlocks", () => {
|
||||
const chains = pairedSkill(491613);
|
||||
const rootDuration = (effects: readonly AbilityEffect[]) => effects
|
||||
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
||||
.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);
|
||||
expect(rootDuration(ordinary)).toBe(rootDuration(chains.effects)! / 2);
|
||||
expect(rootDuration(undead)).toBe(rootDuration(chains.effects));
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
usePartyStore.getState().initializeParty("priest-group-heal", 30, useCombatStore.getState().health, "healer", 3, "rom");
|
||||
const members = usePartyStore.getState().members.slice(0, 2);
|
||||
expect(members).toHaveLength(2);
|
||||
members.forEach((member, index) => {
|
||||
usePartyStore.getState().damageMember(member.id, Math.max(100, Math.floor(member.maxHealth * 0.4)));
|
||||
registerPartyRuntimePosition(member.id, [3 + index * 2, 0, 0]);
|
||||
});
|
||||
const injured = members.map((member) => usePartyStore.getState().members.find((entry) => entry.id === member.id)!.health);
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-primary-8", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 4_100);
|
||||
members.forEach((member, index) => {
|
||||
const healed = usePartyStore.getState().members.find((entry) => entry.id === member.id)!.health;
|
||||
expect(healed, `Group Heal recipient ${index + 1}`).toBeGreaterThan(injured[index]!);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves Soul Source's 90-source-unit all-teammate heal", () => {
|
||||
const soulSource = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490288)!;
|
||||
expect(soulSource.radius).toBe(9);
|
||||
expect(soulSource.effects.some((effect) => effect.kind === "heal"
|
||||
&& effect.scope === "party")).toBe(true);
|
||||
});
|
||||
|
||||
it("gives Holy Shield physical immunity to the party", () => {
|
||||
const holyShield = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490495)!;
|
||||
expect(holyShield.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.scope === "party" && effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
|
||||
&& modifier.direction === "taken" && modifier.school === "physical" && modifier.value <= -1))).toBe(true);
|
||||
});
|
||||
|
||||
it("taunts nearby enemies with Knight/Scout Heavenly Arrow", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 30, role: "tank",
|
||||
});
|
||||
for (const [id, x] of [["arrow-a", 3], ["arrow-b", 5]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 10_000 });
|
||||
setMobPosition(id, [x, 0, 0]);
|
||||
}
|
||||
useCombatStore.getState().selectMob("arrow-a");
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-30", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().mobs["arrow-a"]!.forcedTarget?.actorId).toBe(PLAYER_AGGRO_ID);
|
||||
expect(useCombatStore.getState().mobs["arrow-b"]!.forcedTarget?.actorId).toBe(PLAYER_AGGRO_ID);
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
for (const [id, x] of [["shield-a", 3], ["shield-b", 4]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 10_000 });
|
||||
setMobPosition(id, [x, 0, 0]);
|
||||
}
|
||||
useCombatStore.getState().selectMob("shield-a");
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-primary-3", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().mobs["shield-a"]!.health).toBeLessThan(10_000);
|
||||
expect(useCombatStore.getState().mobs["shield-b"]!.health).toBeLessThan(10_000);
|
||||
});
|
||||
|
||||
it("pays Sacrificial Cure's HP cost while healing an ally", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 60, role: "healer",
|
||||
});
|
||||
usePartyStore.getState().initializeParty("priest-sacrifice", 60, useCombatStore.getState().health, "healer", 2, "rom");
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
usePartyStore.getState().damageMember(member.id, Math.floor(member.maxHealth * 0.6));
|
||||
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
const casterHealth = useCombatStore.getState().health;
|
||||
const allyHealth = usePartyStore.getState().members[0]!.health;
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-40", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().health).toBeLessThan(casterHealth);
|
||||
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(allyHealth);
|
||||
});
|
||||
|
||||
it("Life Link lets Urgent Heal also heal the Priest", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30, role: "healer",
|
||||
});
|
||||
usePartyStore.getState().initializeParty("priest-life-link", 30, useCombatStore.getState().health, "healer", 2, "rom");
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
useCombatStore.getState().damagePlayer(Math.floor(useCombatStore.getState().maxHealth * 0.5));
|
||||
usePartyStore.getState().damageMember(member.id, Math.floor(member.maxHealth * 0.5));
|
||||
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
const casterHealth = useCombatStore.getState().health;
|
||||
const allyHealth = usePartyStore.getState().members[0]!.health;
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-general-0", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 2_100);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(casterHealth);
|
||||
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(allyHealth);
|
||||
});
|
||||
|
||||
it("Last Prayer shortens only the next cast to one second", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30, role: "healer",
|
||||
});
|
||||
usePartyStore.getState().initializeParty("priest-last-prayer", 30, 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);
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-25", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-primary-2", undefined, 2_600))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeLessThanOrEqual(1_000);
|
||||
useCombatStore.getState().tick(0.1, 3_700);
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-primary-2", undefined, 4_300))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(1_000);
|
||||
});
|
||||
|
||||
it("summons the Priest/Knight Light Fairy companion", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 60, role: "healer",
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-50", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 7_100);
|
||||
expect(useCombatStore.getState().summons.some((summon) => summon.creatureId === 102108)).toBe(true);
|
||||
useCombatStore.getState().tick(1, 8_100);
|
||||
expect(useCombatStore.getState().summons.some((summon) => summon.creatureId === 102108)).toBe(true);
|
||||
});
|
||||
|
||||
it("places Disregard Danger's redirect on a teammate and Parry on the Knight", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 60, role: "tank",
|
||||
});
|
||||
usePartyStore.getState().initializeParty("knight-redirect", 60, useCombatStore.getState().health, "tank", 2, "rom");
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-50", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
|
||||
&& (aura.definition.redirectDamagePercent ?? 0) > 0)).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "parry"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import type { AuraDefinition, AuraModifier, AuraProcDefinition } from "./combatAuras";
|
||||
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
|
||||
function firstDamage(effects: readonly AbilityEffect[]): AbilityEffect[] {
|
||||
return effects.filter((effect) => effect.kind === "damage").slice(0, 1);
|
||||
}
|
||||
|
||||
function taggedPassive(
|
||||
sourceId: number,
|
||||
name: string,
|
||||
tag: string,
|
||||
proc?: AuraProcDefinition,
|
||||
): AbilityEffect[] {
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: `rom-pair-passive-${sourceId}`,
|
||||
name,
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
utilityTags: [tag],
|
||||
...(proc ? { procs: [proc] } : {}),
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: sourceId,
|
||||
}];
|
||||
}
|
||||
|
||||
function customProc(
|
||||
sourceId: number,
|
||||
trigger: "hit" | "crit",
|
||||
actionId: string,
|
||||
options: { readonly chance?: number; readonly internalCooldownMs?: number } = {},
|
||||
): AuraProcDefinition {
|
||||
return {
|
||||
id: `rom-pair-proc-${sourceId}`,
|
||||
triggers: trigger === "hit" ? ["hit", "crit"] : trigger,
|
||||
chance: options.chance ?? 1,
|
||||
...(options.internalCooldownMs ? { internalCooldownMs: options.internalCooldownMs } : {}),
|
||||
action: { kind: "custom", id: actionId },
|
||||
};
|
||||
}
|
||||
|
||||
function timedCasterAura(
|
||||
sourceId: number,
|
||||
name: string,
|
||||
durationMs: number,
|
||||
modifiers: readonly AuraModifier[],
|
||||
): AbilityEffect {
|
||||
return {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: `rom-pair-buff-${sourceId}`,
|
||||
name,
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
modifiers,
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: sourceId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-specific translations for the accepted Scout/Rogue and Rogue/Warrior
|
||||
* pairings. Passive tags and custom proc IDs are consumed by combat-store
|
||||
* transitions; they intentionally do not apply unrelated generic stat buffs.
|
||||
*/
|
||||
export function overrideRomScoutRogueEffects(
|
||||
row: RomGeneratedAbilityLike,
|
||||
translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
switch (row.sourceSkillId) {
|
||||
case 490400: // Piercing Arrow: one projectile, then a line hit behind its target.
|
||||
return firstDamage(translated);
|
||||
case 490424: { // Combo Shot: three impacts of the same source shot.
|
||||
const hit = firstDamage(translated)[0];
|
||||
return hit ? [hit, hit, hit] : translated;
|
||||
}
|
||||
case 490457: { // Reflected Shot: two jumps, each retaining half the prior hit.
|
||||
const hit = firstDamage(translated)[0];
|
||||
return hit?.kind === "damage" ? [{ ...hit, falloffPerJump: 0.5 }] : translated;
|
||||
}
|
||||
case 490354: // Vanish is an aggro/stealth transition, not a self-heal.
|
||||
return translated.filter((effect) => effect.kind !== "heal").map((effect) => effect.kind === "apply-aura"
|
||||
? { ...effect, aura: { ...effect.aura, utilityTags: ["stealth", "rom-vanish"] } }
|
||||
: effect);
|
||||
case 491493: { // Weak Spot's buff is component 501895, not its permanent hit modifier.
|
||||
const source = row.source?.components?.find((component) => component.id === 501895);
|
||||
const critical = source?.abilityModifiers?.find((modifier) => modifier.type === 18)?.value ?? 20;
|
||||
const durationMs = Math.max(1_000, Math.round((source?.effectTime ?? 15) * 1_000));
|
||||
return [
|
||||
...firstDamage(translated),
|
||||
timedCasterAura(491493, "Weak Spot critical strike", durationMs, [
|
||||
{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: critical },
|
||||
]),
|
||||
];
|
||||
}
|
||||
case 492342:
|
||||
return taggedPassive(492342, "Lasso Master", "rom-lasso-master");
|
||||
case 492916:
|
||||
return taggedPassive(492916, "Exploiting Shot", "rom-exploiting-shot",
|
||||
customProc(492916, "crit", "rom-exploiting-shot", { internalCooldownMs: 20_000 }));
|
||||
case 493007:
|
||||
return taggedPassive(493007, "Deadly Counterattack", "rom-deadly-counterattack",
|
||||
customProc(493007, "hit", "rom-deadly-counterattack", { internalCooldownMs: 20_000 }));
|
||||
case 493253:
|
||||
return taggedPassive(493253, "Poisonous Spit", "rom-poisonous-spit",
|
||||
customProc(493253, "hit", "rom-poisonous-spit", { internalCooldownMs: 20_000 }));
|
||||
case 499534:
|
||||
return taggedPassive(499534, "Master Reflected Shot", "rom-master-reflected-shot");
|
||||
case 499536:
|
||||
return taggedPassive(499536, "Meridian Attack", "rom-meridian-attack");
|
||||
case 491526:
|
||||
return taggedPassive(491526, "Throwing Mastery", "rom-throwing-mastery");
|
||||
case 491527: // The bonus applies to one-hand axes only, never all melee damage.
|
||||
return taggedPassive(491527, "One-Hand Axe Mastery", "rom-one-hand-axe-mastery");
|
||||
case 492349:
|
||||
return taggedPassive(492349, "Whirlwind Mastery", "rom-whirlwind-mastery");
|
||||
case 491339: // A negative RuneWaker value means reduced received healing.
|
||||
return translated.map((effect) => effect.kind === "apply-aura"
|
||||
? {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
modifiers: effect.aura.modifiers?.map((modifier) => modifier.kind === "healing"
|
||||
&& modifier.direction === "taken"
|
||||
? { ...modifier, value: -Math.abs(modifier.value) }
|
||||
: modifier),
|
||||
},
|
||||
}
|
||||
: effect);
|
||||
case 492625: // Source attack component 620287 must not make its victim immune to physical damage.
|
||||
return translated.filter((effect) => effect.kind === "damage" || effect.kind === "dot");
|
||||
case 492920: { // Poison Spray spreads Infection's ten-second poison to nearby enemies.
|
||||
const hit = firstDamage(translated)[0];
|
||||
const poison: AbilityEffect = {
|
||||
kind: "dot",
|
||||
coefficient: 0,
|
||||
basePoints: 12,
|
||||
pointsPerLevel: 4.2,
|
||||
sourceLevel: 1,
|
||||
ticks: 5,
|
||||
intervalMs: 2_000,
|
||||
tag: "rom-poisonous-infection",
|
||||
};
|
||||
return hit ? [hit, poison] : [poison];
|
||||
}
|
||||
case 493017: // Decay's empty indefinite state is not its timed critical buff.
|
||||
return translated.filter((effect) => effect.kind !== "apply-aura"
|
||||
|| effect.aura.modifiers?.length || effect.aura.procs?.length || effect.aura.utilityTags?.length);
|
||||
case 493257:
|
||||
return taggedPassive(493257, "Reclaim Energy", "rom-reclaim-energy",
|
||||
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"));
|
||||
case 499480:
|
||||
return taggedPassive(499480, "Poison Battle Technique", "rom-poison-battle-technique",
|
||||
customProc(499480, "hit", "rom-poison-battle-technique"));
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Select source-conditional pair effects after the target's existing states are known. */
|
||||
export function romScoutRogueCastEffects(
|
||||
ability: AbilityDefinition,
|
||||
effects: readonly AbilityEffect[],
|
||||
passiveTags: readonly string[],
|
||||
targetTags: readonly string[],
|
||||
): readonly AbilityEffect[] {
|
||||
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;
|
||||
return effects.map((effect) => effect.kind === "damage" ? {
|
||||
...effect,
|
||||
coefficient: effect.coefficient * masteryFactor,
|
||||
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * masteryFactor }),
|
||||
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * masteryFactor }),
|
||||
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * masteryFactor }),
|
||||
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * masteryFactor }),
|
||||
...(effect.bonusFlat === undefined ? {} : { bonusFlat: effect.bonusFlat * masteryFactor }),
|
||||
falloffPerJump: passives.has("rom-poisonous-spit") ? 1 : effect.falloffPerJump,
|
||||
} : effect);
|
||||
}
|
||||
if (ability.dbcSpellId === 490454 && passives.has("rom-lasso-master")) {
|
||||
return [...effects, {
|
||||
kind: "status",
|
||||
status: "disarm",
|
||||
durationMs: 10_000,
|
||||
maxTargets: 1,
|
||||
breakOnDamage: true,
|
||||
}];
|
||||
}
|
||||
if (ability.dbcSpellId === 490309 && passives.has("rom-throwing-mastery")) {
|
||||
return [...effects, { kind: "status", status: "slow", durationMs: 5_000, magnitude: 0.4 }];
|
||||
}
|
||||
if (ability.dbcSpellId === 492621) {
|
||||
const hasSourcePoison = target.has("rom-deadly-poison") || target.has("rom-501690");
|
||||
let seenHits = 0;
|
||||
return effects.filter((effect) => {
|
||||
if (effect.kind !== "damage") return true;
|
||||
seenHits += 1;
|
||||
return seenHits === 1 || (seenHits === 2 && hasSourcePoison);
|
||||
});
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
export type RomScoutRogueProcActionSpec =
|
||||
| {
|
||||
readonly kind: "aura";
|
||||
readonly recipient: "caster" | "target";
|
||||
readonly aura: AuraDefinition;
|
||||
readonly provenance: "source" | "hybrid-estimate";
|
||||
}
|
||||
| {
|
||||
readonly kind: "dot";
|
||||
readonly recipient: "target";
|
||||
readonly effect: Extract<AbilityEffect, { kind: "dot" }>;
|
||||
readonly provenance: "source" | "hybrid-estimate";
|
||||
}
|
||||
| {
|
||||
readonly kind: "resource";
|
||||
readonly recipient: "caster";
|
||||
readonly resource: "energy" | "rage";
|
||||
readonly amount: number;
|
||||
readonly provenance: "source" | "hybrid-estimate";
|
||||
}
|
||||
| {
|
||||
readonly kind: "damage";
|
||||
readonly recipient: "target";
|
||||
readonly effect: Extract<AbilityEffect, { kind: "damage" }>;
|
||||
readonly provenance: "source" | "hybrid-estimate";
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function romScoutRogueProcActionSpec(
|
||||
actionId: string,
|
||||
triggeringSourceSkillId: number,
|
||||
casterPoisonous: boolean,
|
||||
): RomScoutRogueProcActionSpec | null {
|
||||
switch (actionId) {
|
||||
case "rom-exploiting-shot":
|
||||
if (triggeringSourceSkillId !== 490423) return null;
|
||||
return {
|
||||
kind: "aura",
|
||||
recipient: "target",
|
||||
aura: {
|
||||
id: "rom-pair-exploiting-shot-vulnerability",
|
||||
name: "Exploiting Shot",
|
||||
disposition: "debuff",
|
||||
durationMs: 12_000, // Source component 503169.
|
||||
maxStacks: 5, // Explicit hybrid cap; source tooltip references a cap without a numeric value.
|
||||
stackBehavior: "add",
|
||||
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.02 }],
|
||||
},
|
||||
provenance: "hybrid-estimate",
|
||||
};
|
||||
case "rom-deadly-counterattack":
|
||||
if (triggeringSourceSkillId !== 490400) return null;
|
||||
return {
|
||||
kind: "aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-pair-deadly-counterattack-crit",
|
||||
name: "Deadly Counterattack",
|
||||
disposition: "buff",
|
||||
durationMs: 5_000, // Hybrid estimate; source specifies a 20s internal cooldown, not buff duration.
|
||||
modifiers: [{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 }],
|
||||
},
|
||||
provenance: "hybrid-estimate",
|
||||
};
|
||||
case "rom-poisonous-spit":
|
||||
if (!casterPoisonous || ![490457, 490424].includes(triggeringSourceSkillId)) return null;
|
||||
return {
|
||||
kind: "dot",
|
||||
recipient: "target",
|
||||
effect: {
|
||||
kind: "dot",
|
||||
coefficient: 0,
|
||||
basePoints: 16,
|
||||
pointsPerLevel: 8,
|
||||
sourceLevel: 1,
|
||||
ticks: 5,
|
||||
intervalMs: 2_000,
|
||||
tag: "rom-poisonous-spit",
|
||||
},
|
||||
provenance: "hybrid-estimate", // 501690 poison scale is a proxy; Poison Powder payload is not in this catalog.
|
||||
};
|
||||
case "rom-reclaim-energy":
|
||||
if (triggeringSourceSkillId === 490306) return {
|
||||
kind: "resource", recipient: "caster", resource: "energy", amount: 15, provenance: "hybrid-estimate",
|
||||
};
|
||||
if (triggeringSourceSkillId === 492625) return {
|
||||
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.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
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";
|
||||
|
||||
const scoutRogue = abilitiesForCharacter("rom-scout", "rom-rogue");
|
||||
const rogueWarrior = abilitiesForCharacter("rom-rogue", "rom-warrior");
|
||||
|
||||
function skill(abilities: readonly AbilityDefinition[], id: string): AbilityDefinition {
|
||||
const result = abilities.find((ability) => ability.id === id);
|
||||
if (!result) throw new Error(`Missing skill ${id}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function modifiers(ability: AbilityDefinition) {
|
||||
return ability.effects.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : []);
|
||||
}
|
||||
|
||||
function workingPassive(ability: AbilityDefinition): boolean {
|
||||
return ability.effects.some((effect) => effect.kind === "apply-aura" && Boolean(
|
||||
effect.aura.modifiers?.length || effect.aura.procs?.length || effect.aura.utilityTags?.length,
|
||||
));
|
||||
}
|
||||
|
||||
function prime(primary: "rom-scout" | "rom-rogue", level: number): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: primary,
|
||||
secondaryClassId: primary === "rom-scout" ? "rom-rogue" : "rom-warrior",
|
||||
raceId: primary === "rom-scout" ? "rom-elf" : "rom-human",
|
||||
level,
|
||||
});
|
||||
for (const [id, x, z] of [["target", 3, 0], ["behind", 6, 0], ["nearby", 3, 3]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
|
||||
setMobPosition(id, [x, 0, z]);
|
||||
}
|
||||
useCombatStore.getState().selectMob("target");
|
||||
}
|
||||
|
||||
describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
|
||||
beforeEach(() => clearMobRuntimeRegistry());
|
||||
|
||||
it.each([
|
||||
["rom-scout", "rom-rogue", [491493, 491336, 492342, 491496, 492621, 492916, 493007, 493253, 499534, 499536]],
|
||||
["rom-rogue", "rom-warrior", [491526, 491527, 492349, 491339, 492625, 492920, 493017, 493257, 499476, 499480]],
|
||||
] as const)("learns all ten %s/%s source elites at their primary levels", (primary, secondary, ids) => {
|
||||
const elites = abilitiesForCharacter(primary, secondary).filter((ability) => ability.secondaryClassId);
|
||||
expect(elites.map((ability) => ability.dbcSpellId)).toEqual(ids);
|
||||
expect(elites.map((ability) => ability.unlockLevel)).toEqual([15, 20, 25, 30, 35, 40, 45, 50, 60, 70]);
|
||||
for (const [level, count] of [[10, 0], [30, 4], [60, 9], [79, 10]] as const) {
|
||||
expect(elites.filter((ability) => learnedAbilityForBuild(ability, level, {}) !== null), `level ${level}`).toHaveLength(count);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([10, 30, 60, 79])("deals ordinary single-target damage with both pairings at level %i", (level) => {
|
||||
for (const [primary, id, pool] of [
|
||||
["rom-scout", "rom-scout-primary-1", "focus"],
|
||||
["rom-rogue", "rom-rogue-general-0", "energy"],
|
||||
] as const) {
|
||||
prime(primary, level);
|
||||
const before = useCombatStore.getState().mobs.target!.health;
|
||||
const resource = useCombatStore.getState().resourcePools[pool]!;
|
||||
expect(useCombatStore.getState().castAbility(id, undefined, 1_000), id).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().mobs.target!.health, id).toBeLessThan(before);
|
||||
expect(useCombatStore.getState().resourcePools[pool], id).toBeLessThan(resource);
|
||||
}
|
||||
});
|
||||
|
||||
it("runs Scout and Rogue offensive cooldowns and Rogue's defensive Evasion", () => {
|
||||
prime("rom-scout", 60);
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-primary-0", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.modifiers?.some((modifier) =>
|
||||
modifier.kind === "damage" && modifier.direction === "dealt" && modifier.attackKind === "ranged"))).toBe(true);
|
||||
prime("rom-rogue", 60);
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-0", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.modifiers?.some((modifier) =>
|
||||
modifier.kind === "stat" && modifier.stat === "physical-critical-strike"))).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-14", undefined, 2_600)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.modifiers?.some((modifier) =>
|
||||
modifier.kind === "stat" && modifier.stat === "dodge"))).toBe(true);
|
||||
});
|
||||
|
||||
it("Piercing Arrow hits a line without applying an unrelated focus drain (490400)", () => {
|
||||
prime("rom-scout", 60);
|
||||
const cast = useCombatStore.getState().castAbility("rom-scout-primary-5", undefined, 1_000);
|
||||
expect(cast).toMatchObject({ ok: true, affectedMobIds: ["target", "behind"] });
|
||||
expect(useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "rom-scout-primary-5"
|
||||
&& event.kind === "damage" && event.rawAmount > 0).map((event) => event.targetActorId))
|
||||
.toEqual(["target", "behind"]);
|
||||
expect(modifiers(skill(scoutRogue, "rom-scout-primary-5")).some((modifier) =>
|
||||
modifier.kind === "resource" && modifier.resource === "focus")).toBe(false);
|
||||
});
|
||||
|
||||
it("Combo Shot hits three times and Reflected Shot has two weaker bounces (490424, 490457)", () => {
|
||||
expect(skill(scoutRogue, "rom-scout-primary-9").effects.filter((effect) => effect.kind === "damage")).toHaveLength(3);
|
||||
prime("rom-scout", 40);
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 1_000))
|
||||
.toMatchObject({ ok: true, affectedMobIds: expect.arrayContaining(["target", "behind", "nearby"]) });
|
||||
const rawDamage = useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "rom-scout-primary-11"
|
||||
&& event.kind === "damage").map((event) => event.rawAmount);
|
||||
expect(rawDamage).toHaveLength(3);
|
||||
expect(rawDamage[0]).toBeGreaterThan(rawDamage[1]!);
|
||||
expect(rawDamage[1]).toBeGreaterThan(rawDamage[2]!);
|
||||
});
|
||||
|
||||
it("Poisonous Spit removes bounce falloff and Master Reflected Shot adds two jumps (493253, 499534)", () => {
|
||||
prime("rom-scout", 60);
|
||||
setMobPosition("behind", [4, 0, 0]);
|
||||
setMobPosition("nearby", [5, 0, 0]);
|
||||
for (const [id, x, z] of [["fourth", 6, 0], ["fifth", 7, 0]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
|
||||
setMobPosition(id, [x, 0, z]);
|
||||
}
|
||||
const cast = useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 1_000);
|
||||
expect(cast).toMatchObject({ ok: true, affectedMobIds: expect.arrayContaining([
|
||||
"target", "behind", "nearby", "fourth", "fifth",
|
||||
]) });
|
||||
const damage = useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "rom-scout-primary-11"
|
||||
&& event.kind === "damage").map((event) => event.rawAmount);
|
||||
expect(damage).toHaveLength(5);
|
||||
expect(new Set(damage).size).toBe(1);
|
||||
});
|
||||
|
||||
it("Master Reflected Shot applies a labeled 20% hybrid damage bonus", () => {
|
||||
const ability = skill(scoutRogue, "rom-scout-primary-11");
|
||||
const base = ability.effects.find((effect) => effect.kind === "damage");
|
||||
const mastered = romScoutRogueCastEffects(ability, ability.effects,
|
||||
["rom-poisonous-spit", "rom-master-reflected-shot"], [])
|
||||
.find((effect) => effect.kind === "damage");
|
||||
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.falloffPerJump).toBe(1);
|
||||
});
|
||||
|
||||
it("proc resolver gates Shot crit vulnerability and preserves source 2% / 12s", () => {
|
||||
expect(romScoutRogueProcActionSpec("rom-exploiting-shot", 490400, false)).toBeNull();
|
||||
expect(romScoutRogueProcActionSpec("rom-exploiting-shot", 490423, false)).toMatchObject({
|
||||
kind: "aura", recipient: "target", aura: {
|
||||
durationMs: 12_000, modifiers: [
|
||||
{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.02 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const proc = skill(scoutRogue, "rom-scout-rom-rogue-elite-40").effects.find((effect) => effect.kind === "apply-aura");
|
||||
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000);
|
||||
});
|
||||
|
||||
it("proc resolver gates Piercing Arrow critical buff to its own hit", () => {
|
||||
expect(romScoutRogueProcActionSpec("rom-deadly-counterattack", 490423, false)).toBeNull();
|
||||
expect(romScoutRogueProcActionSpec("rom-deadly-counterattack", 490400, false)).toMatchObject({
|
||||
kind: "aura", recipient: "caster", provenance: "hybrid-estimate", aura: {
|
||||
durationMs: 5_000,
|
||||
modifiers: [{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 }],
|
||||
},
|
||||
});
|
||||
const proc = skill(scoutRogue, "rom-scout-rom-rogue-elite-45").effects.find((effect) => effect.kind === "apply-aura");
|
||||
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", () => {
|
||||
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", 490423, true)).toBeNull();
|
||||
const proc = skill(scoutRogue, "rom-scout-rom-rogue-elite-50").effects.find((effect) => effect.kind === "apply-aura");
|
||||
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000);
|
||||
});
|
||||
|
||||
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"
|
||||
&& effect.targetId === "target")).toBe(true);
|
||||
});
|
||||
|
||||
it("proc resolver routes Reclaim Energy by trigger skill", () => {
|
||||
expect(romScoutRogueProcActionSpec("rom-reclaim-energy", 490306, false)).toMatchObject({
|
||||
kind: "resource", resource: "energy", amount: 15, provenance: "hybrid-estimate",
|
||||
});
|
||||
expect(romScoutRogueProcActionSpec("rom-reclaim-energy", 492625, true)).toMatchObject({
|
||||
kind: "resource", resource: "rage", amount: 10, provenance: "hybrid-estimate",
|
||||
});
|
||||
expect(romScoutRogueProcActionSpec("rom-reclaim-energy", 490309, false)).toBeNull();
|
||||
const proc = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-50").effects.find((effect) => effect.kind === "apply-aura");
|
||||
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.chance : null).toBe(0.25);
|
||||
});
|
||||
|
||||
it("keeps absent 499477/499481 replacement-skill procs explicitly unresolved", () => {
|
||||
expect(romScoutRogueProcActionSpec("rom-toxic-splash", 499477, true)).toBeNull();
|
||||
expect(romScoutRogueProcActionSpec("rom-poison-battle-technique", 499481, true)).toBeNull();
|
||||
});
|
||||
|
||||
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);
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 2_100);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-poisonous")
|
||||
&& Boolean(aura.definition.procs?.length))).toBe(true);
|
||||
});
|
||||
|
||||
it("Rogue Vanish drops combat without performing an unrelated heal (490354)", () => {
|
||||
prime("rom-rogue", 60);
|
||||
useCombatStore.getState().damagePlayer(100);
|
||||
const health = useCombatStore.getState().health;
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-11", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().health).toBe(health);
|
||||
expect(skill(rogueWarrior, "rom-rogue-primary-11").effects.some((effect) => effect.kind === "heal")).toBe(false);
|
||||
});
|
||||
|
||||
it("Weak Spot has one hit and a timed critical buff (491493)", () => {
|
||||
const ability = skill(scoutRogue, "rom-scout-rom-rogue-elite-15");
|
||||
expect(ability.effects.filter((effect) => effect.kind === "damage")).toHaveLength(1);
|
||||
expect(ability.effects.some((effect) => effect.kind === "apply-aura" && effect.recipient === "caster"
|
||||
&& effect.aura.durationMs !== null && effect.aura.durationMs > 0
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "physical-critical-strike"))).toBe(true);
|
||||
});
|
||||
|
||||
it("Blinding Powder control breaks on damage (491336)", () => {
|
||||
prime("rom-scout", 30);
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-rom-rogue-elite-20", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().mobs.target!.statuses.some((status) => status.kind === "blind")).toBe(true);
|
||||
useCombatStore.getState().damageMob("target", 1, 1_100);
|
||||
expect(useCombatStore.getState().mobs.target!.statuses.some((status) => status.kind === "blind")).toBe(false);
|
||||
});
|
||||
|
||||
it("Lasso Master prevents movement and attacks (492342)", () => {
|
||||
prime("rom-scout", 30);
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-primary-4", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 2_100);
|
||||
expect(useCombatStore.getState().mobs.target!.statuses.map((status) => status.kind))
|
||||
.toEqual(expect.arrayContaining(["root", "disarm"]));
|
||||
});
|
||||
|
||||
it("Sapping Arrow lowers physical and magical defense (491496)", () => {
|
||||
const values = modifiers(skill(scoutRogue, "rom-scout-rom-rogue-elite-30"));
|
||||
expect(values.some((modifier) => modifier.kind === "stat" && modifier.stat === "armor" && modifier.value < 0)).toBe(true);
|
||||
expect(values.some((modifier) => modifier.kind === "stat" && modifier.stat === "magical-defense" && modifier.value < 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("Deadly Poison Bite has one base hit and a conditional bonus (492621)", () => {
|
||||
prime("rom-scout", 60);
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-rom-rogue-elite-35", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 2_100);
|
||||
expect(useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "rom-scout-rom-rogue-elite-35"
|
||||
&& event.kind === "damage" && event.targetActorId === "target")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["rom-scout-rom-rogue-elite-40", 492916],
|
||||
["rom-scout-rom-rogue-elite-45", 493007],
|
||||
] as const)("%s uses an attack-triggered passive (source %i)", (id, sourceId) => {
|
||||
const ability = skill(scoutRogue, id);
|
||||
expect(ability.dbcSpellId).toBe(sourceId);
|
||||
expect(ability.effects.some((effect) => effect.kind === "apply-aura" && Boolean(effect.aura.procs?.length))).toBe(true);
|
||||
expect(ability.effects.some((effect) => effect.kind === "apply-aura" && effect.recipient === "caster"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken"))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["rom-scout-rom-rogue-elite-50", 493253],
|
||||
["rom-scout-rom-rogue-elite-60", 499534],
|
||||
["rom-scout-rom-rogue-elite-70", 499536],
|
||||
] as const)("%s has executable cross-skill effects (source %i)", (id, sourceId) => {
|
||||
const ability = skill(scoutRogue, id);
|
||||
expect(ability.dbcSpellId).toBe(sourceId);
|
||||
expect(workingPassive(ability)).toBe(true);
|
||||
});
|
||||
|
||||
it("Throwing Mastery gives Throw a five-second slow (491526)", () => {
|
||||
prime("rom-rogue", 30);
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-general-1", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().mobs.target!.statuses.some((status) => status.kind === "slow"
|
||||
&& status.endsAt >= 6_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("One-Hand Axe Mastery does not boost dagger damage (491527)", () => {
|
||||
const ability = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-20");
|
||||
expect(ability.effects.some((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) =>
|
||||
modifier.kind === "damage" && modifier.attackKind === "melee"))).toBe(false);
|
||||
});
|
||||
|
||||
it("Whirlwind Mastery lowers rage cost and cooldown (492349)", () => {
|
||||
prime("rom-rogue", 30);
|
||||
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 40 } }));
|
||||
expect(useCombatStore.getState().castAbility("rom-warrior-general-2", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(10);
|
||||
expect(useCombatStore.getState().cooldowns["rom-warrior-general-2"]).toBeLessThanOrEqual(11_000);
|
||||
});
|
||||
|
||||
it("Death's Touch damages and reduces received healing (491339)", () => {
|
||||
const ability = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-30");
|
||||
expect(ability.effects.some((effect) => effect.kind === "damage")).toBe(true);
|
||||
expect(modifiers(ability).some((modifier) => modifier.kind === "healing"
|
||||
&& modifier.direction === "taken" && modifier.value < 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("Poisonous Infection damages and poisons without making the victim immune (492625)", () => {
|
||||
const ability = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-35");
|
||||
expect(ability.effects.some((effect) => effect.kind === "damage")).toBe(true);
|
||||
expect(ability.effects.some((effect) => effect.kind === "dot")).toBe(true);
|
||||
expect(modifiers(ability).some((modifier) => modifier.kind === "damage" && modifier.direction === "taken"
|
||||
&& modifier.school === "physical" && modifier.value <= -1)).toBe(false);
|
||||
});
|
||||
|
||||
it("Poison Spray spreads poison and damage to nearby enemies (492920)", () => {
|
||||
const ability = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-40");
|
||||
expect(ability.radius).toBeGreaterThan(0);
|
||||
expect(ability.effects.some((effect) => effect.kind === "damage")).toBe(true);
|
||||
expect(ability.effects.some((effect) => effect.kind === "dot" || effect.kind === "apply-aura")).toBe(true);
|
||||
});
|
||||
|
||||
it("Decay requires Poisonous and grants timed critical power (493017)", () => {
|
||||
prime("rom-rogue", 60);
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warrior-elite-45", undefined, 1_000))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
expect(modifiers(skill(rogueWarrior, "rom-rogue-rom-warrior-elite-45")).some((modifier) =>
|
||||
modifier.kind === "stat" && modifier.stat === "physical-critical-strike")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["rom-rogue-rom-warrior-elite-50", 493257],
|
||||
["rom-rogue-rom-warrior-elite-60", 499476],
|
||||
["rom-rogue-rom-warrior-elite-70", 499480],
|
||||
] as const)("%s has executable triggered effects (source %i)", (id, sourceId) => {
|
||||
const ability = skill(rogueWarrior, id);
|
||||
expect(ability.dbcSpellId).toBe(sourceId);
|
||||
expect(workingPassive(ability)).toBe(true);
|
||||
});
|
||||
});
|
||||
+41
-4
@@ -114,6 +114,42 @@ const RACE_DBC = Object.fromEntries(RACES.map((entry) => [entry.id, entry.dbcId]
|
||||
|
||||
const classUsesMana = (classId: ClassId): boolean => classById(classId).resourceSummary.includes("Mana");
|
||||
|
||||
/** The five playable RoM pairs use Wrath base curves alongside their hybrid heirlooms. */
|
||||
const ROM_HYBRID_BASE_RACE: Partial<Readonly<Record<ClassId, RaceId>>> = Object.freeze({
|
||||
"rom-knight": "human",
|
||||
"rom-priest": "human",
|
||||
"rom-scout": "night-elf",
|
||||
"rom-rogue": "human",
|
||||
// Dwarf Mage was not a 3.3.5 race/class combination; Human supplies its class curve.
|
||||
"rom-mage": "human",
|
||||
});
|
||||
|
||||
function romHybridBase(
|
||||
classId: ClassId,
|
||||
secondaryClassId: ClassId | null | undefined,
|
||||
level: number,
|
||||
): { readonly attributes: PrimaryAttributes; readonly resources: { readonly health: number; readonly mana: number } } | null {
|
||||
const race = ROM_HYBRID_BASE_RACE[classId];
|
||||
if (!race) return null;
|
||||
const primaryAttributes = baseAttributesFor(classId, level, race);
|
||||
const primaryResources = baseResourcesFor(classId, level);
|
||||
const secondary = level >= 10 && secondaryClassId && isRomClassId(secondaryClassId)
|
||||
? secondaryClassId : null;
|
||||
const secondaryAttributes = secondary
|
||||
? baseAttributesFor(secondary, level, ROM_HYBRID_BASE_RACE[secondary] ?? "human") : null;
|
||||
const secondaryResources = secondary ? baseResourcesFor(secondary, level) : null;
|
||||
const attributes = Object.fromEntries((Object.keys(primaryAttributes) as (keyof PrimaryAttributes)[]).map((key) => [
|
||||
key, primaryAttributes[key] + (secondaryAttributes?.[key] ?? 0) * .1,
|
||||
])) as unknown as PrimaryAttributes;
|
||||
return {
|
||||
attributes,
|
||||
resources: {
|
||||
health: primaryResources.health + (secondaryResources?.health ?? 0) * .1,
|
||||
mana: primaryResources.mana + (secondaryResources?.mana ?? 0) * .1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic racial auras from Ascension's effective Spell.dbc that can change
|
||||
* a character's starting attributes or resource pools.
|
||||
@@ -259,15 +295,16 @@ export function deriveCharacterStats(
|
||||
secondaryClassId?: ClassId | null,
|
||||
): CharacterStats {
|
||||
const safe = safeLevel(level);
|
||||
const romBase = romDualClassBaseStats(classId, secondaryClassId, safe);
|
||||
const base = romBase ? {
|
||||
const hybridBase = romHybridBase(classId, secondaryClassId, safe);
|
||||
const romBase = hybridBase ? null : romDualClassBaseStats(classId, secondaryClassId, safe);
|
||||
const base = hybridBase?.attributes ?? (romBase ? {
|
||||
strength: romBase.strength,
|
||||
agility: romBase.agility,
|
||||
stamina: romBase.stamina,
|
||||
intellect: romBase.intellect,
|
||||
spirit: romBase.wisdom,
|
||||
} : baseAttributesFor(classId, safe, raceId);
|
||||
const resources = romBase ? { health: romBase.health, mana: romBase.mana } : baseResourcesFor(classId, safe);
|
||||
} : baseAttributesFor(classId, safe, raceId));
|
||||
const resources = hybridBase?.resources ?? (romBase ? { health: romBase.health, mana: romBase.mana } : baseResourcesFor(classId, safe));
|
||||
const racial = RACIAL_STAT_PASSIVES[raceId];
|
||||
const strength = percentAdjusted(base.strength + gear.strength, racial?.strength, modifiers.strength);
|
||||
const agility = percentAdjusted(base.agility + gear.agility, racial?.agility, modifiers.agility);
|
||||
|
||||
Reference in new issue
Block a user