Validate RoM primary five hybrid stats and pair mechanics

This commit is contained in:
phenom committed 2026-09-23 19:08:44 -04:00
1 parent 8e95480d4f
commit c8b3a3d95a
18 files changed
+2061 -48

No files matched your search

+3 -3
View File
@@ -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
View File
@@ -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,
+29 -1
View File
@@ -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]
+53 -10
View File
@@ -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,
+4
View File
@@ -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,
+25 -1
View File
@@ -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;
+30 -1
View File
@@ -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);
+186
View File
@@ -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 }),
]),
}),
}),
]);
});
});
+4 -3
View File
@@ -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,
+213
View File
@@ -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);
});
});
+319
View File
@@ -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
View File
@@ -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);