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

+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,