Validate RoM primary five hybrid stats and pair mechanics
This commit is contained in:
1 parent
8e95480d4f
commit
c8b3a3d95a
18 files changed
+2061
-48
No files matched your search
+154
-15
@@ -156,6 +156,8 @@ import {
|
||||
type HeirloomContext,
|
||||
} from "./heirloomEquipment";
|
||||
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount } from "./romCombatMechanics";
|
||||
import { romMageWarlockCriticalEffects } from "./romMageWarlockOverrides";
|
||||
import { romScoutRogueProcActionSpec } from "./romScoutRogueOverrides";
|
||||
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
|
||||
import { specializationById } from "./specializationCatalog";
|
||||
import {
|
||||
@@ -1269,6 +1271,12 @@ function talentAdjustedMaxHealth(
|
||||
}
|
||||
|
||||
function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind {
|
||||
if (ability.source === "runewaker") {
|
||||
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
|
||||
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school && sourceHit.school !== "physical") {
|
||||
return "spell";
|
||||
}
|
||||
}
|
||||
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
||||
const healing = ability.effects.some((effect) => (
|
||||
effect.kind === "heal"
|
||||
@@ -1285,6 +1293,10 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
|
||||
}
|
||||
|
||||
export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
|
||||
if (ability.source === "runewaker") {
|
||||
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
|
||||
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school) return sourceHit.school;
|
||||
}
|
||||
if (["Envenom", "Instant Poison", "Deadly Poison"].includes(ability.name)) return "nature";
|
||||
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
|
||||
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
||||
@@ -1306,7 +1318,8 @@ export function equipmentAdjustedCombatAmount(
|
||||
classId: ClassId,
|
||||
ability: AbilityDefinition,
|
||||
): number {
|
||||
const kind = abilityAttackKind(classId, ability);
|
||||
const kind = typeof scaling !== "number" && "school" in scaling && scaling.school && scaling.school !== "physical"
|
||||
? "spell" : abilityAttackKind(classId, ability);
|
||||
const attackPower = equipmentAttackPower(classId, gearStats, kind);
|
||||
const weapon = kind === "ranged" ? gearStats.rangedWeapon : kind === "melee" ? gearStats.meleeWeapon : null;
|
||||
const weaponSpeed = weapon?.speedMs ? weapon.speedMs / 1_000 : kind === "melee" ? 2.4 : 2.8;
|
||||
@@ -2202,7 +2215,7 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
|
||||
spellHastePercent: stat(base.spellHastePercent, "haste", "spell-haste"),
|
||||
dodgePercent: stat(base.dodgePercent, "dodge"),
|
||||
parryPercent: stat(base.parryPercent, "parry"),
|
||||
blockPercent: stat(base.blockPercent, "block"),
|
||||
blockPercent: stat(base.blockPercent, "block", "block-chance"),
|
||||
expertise: stat(base.expertise, "expertise"),
|
||||
arcaneResistance: resistance(base.arcaneResistance, "arcane"),
|
||||
fireResistance: resistance(base.fireResistance, "fire"),
|
||||
@@ -2309,6 +2322,52 @@ function executeTriggeredAuraProcsInWork(
|
||||
): void {
|
||||
for (const triggered of triggeredProcs) {
|
||||
const action = triggered.action;
|
||||
if (action.kind === "custom" && action.id.startsWith("rom-")) {
|
||||
const triggeringAbility = triggered.event.abilityId
|
||||
? work.abilities.find((ability) => ability.id === triggered.event.abilityId)
|
||||
?? abilityById(triggered.event.abilityId)
|
||||
: null;
|
||||
const poisonous = work.auras.some((aura) => aura.targetId === triggered.sourceId
|
||||
&& aura.definition.utilityTags?.includes("rom-poisonous"));
|
||||
const spec = romScoutRogueProcActionSpec(action.id, triggeringAbility?.dbcSpellId ?? 0, poisonous);
|
||||
if (spec) {
|
||||
const recipient = spec.recipient === "caster"
|
||||
? triggered.sourceId : triggered.event.otherId ?? triggered.targetId;
|
||||
if (spec.kind === "aura" && recipient) {
|
||||
work.auras = [...applyCombatAura(work.auras, {
|
||||
definition: spec.aura, sourceId: triggered.sourceId, targetId: recipient, now,
|
||||
}).auras];
|
||||
} else if (spec.kind === "resource" && recipient === PLAYER_AURA_ENTITY_ID) {
|
||||
const maximum = work.maxResourcePools[spec.resource] ?? defaultResourceMaximum(spec.resource);
|
||||
work.resourcePools[spec.resource] = clamp(
|
||||
(work.resourcePools[spec.resource] ?? 0) + spec.amount, 0, maximum,
|
||||
);
|
||||
} else if (spec.kind === "resource" && recipient && work.actors[recipient]) {
|
||||
const actor = work.actors[recipient];
|
||||
const pool = actor.resources[spec.resource];
|
||||
if (pool) {
|
||||
const next = { ...actor, resources: {
|
||||
...actor.resources,
|
||||
[spec.resource]: { ...pool, current: clamp(pool.current + spec.amount, 0, pool.maximum) },
|
||||
} };
|
||||
work.actors[recipient] = next;
|
||||
mirrorCompanionActor(next);
|
||||
}
|
||||
} else if (spec.kind === "dot" && recipient && work.mobs[recipient]) {
|
||||
work.effects = work.effects.filter((effect) => !(effect.kind === "dot"
|
||||
&& effect.sourceActorId === triggered.sourceId
|
||||
&& effect.targetId === recipient && effect.tag === spec.effect.tag));
|
||||
work.effects.push(timedEffect(
|
||||
"dot", triggeringAbility?.id ?? triggered.procId,
|
||||
triggered.sourceId, recipient, spec.effect,
|
||||
spec.effect.ticks, spec.effect.intervalMs, now,
|
||||
spec.effect.tag, spec.effect.school ?? "nature",
|
||||
work.gearStats.spellPenetration,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const actionTarget = action.kind === "custom"
|
||||
? null
|
||||
: action.target === "aura-source"
|
||||
@@ -2897,6 +2956,19 @@ function resolveAndDamageMobInWork(
|
||||
work.gearStats.spellPenetration,
|
||||
combatResult,
|
||||
);
|
||||
if (ability?.source === "runewaker" && combatResult.critical && result.amount > 0) {
|
||||
const passiveTags = new Set(aurasForEntity(work.auras, source.actorId)
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []));
|
||||
for (const rider of romMageWarlockCriticalEffects(ability, passiveTags)) {
|
||||
if (rider.kind !== "apply-aura") continue;
|
||||
work.auras = [...applyCombatAura(work.auras, {
|
||||
definition: auraDefinitionForAbility(rider.aura, ability),
|
||||
sourceId: source.actorId,
|
||||
targetId: id,
|
||||
now,
|
||||
}).auras];
|
||||
}
|
||||
}
|
||||
if (combustion && combatResult.landed && result.amount > 0) {
|
||||
const criticalHits = (combustion.combustionCriticalHits ?? 0) + Number(combatResult.critical);
|
||||
work.auras = work.auras.flatMap((aura) => aura.instanceId !== combustion.instanceId
|
||||
@@ -3063,6 +3135,7 @@ function executeTriggeredSpellInWork(
|
||||
ability,
|
||||
);
|
||||
for (const mobId of mobTargets) {
|
||||
const effectSchool = effect.school ?? school;
|
||||
const result = resolveAndDamageMobInWork(
|
||||
work.classId,
|
||||
work,
|
||||
@@ -3070,8 +3143,8 @@ function executeTriggeredSpellInWork(
|
||||
amount,
|
||||
now,
|
||||
source,
|
||||
attackKind,
|
||||
school,
|
||||
effectSchool === "physical" ? attackKind : "spell",
|
||||
effectSchool,
|
||||
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
|
||||
ability,
|
||||
);
|
||||
@@ -4773,10 +4846,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const catalogAbility = abilityById(abilityId);
|
||||
const talentNodeId = catalogAbility ? abilityTalentNodeId(catalogAbility) : null;
|
||||
const learnedTalentRank = catalogAbility ? learnedAbilityRank(catalogAbility, state.talentRanks) : undefined;
|
||||
const ability = catalogAbility
|
||||
let ability = catalogAbility
|
||||
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
|
||||
: null;
|
||||
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
|
||||
if (ability?.dbcSpellId === 490133 && activePlayerAuras.some((aura) =>
|
||||
aura.definition.utilityTags?.includes("rom-whirlwind-mastery"))) {
|
||||
const adjustedCosts = (ability.costs ?? [ability.cost]).map((cost) => cost.resource === "rage"
|
||||
? { ...cost, amount: Math.max(0, cost.amount - 20) } : cost);
|
||||
ability = {
|
||||
...ability,
|
||||
cooldownMs: Math.max(0, ability.cooldownMs - 20_000),
|
||||
cost: adjustedCosts[0] ?? ability.cost,
|
||||
costs: adjustedCosts,
|
||||
};
|
||||
}
|
||||
const innerFocusAura = activePlayerAuras.find((aura) => (
|
||||
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
||||
));
|
||||
@@ -4815,7 +4899,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const resourceCost = ability && !consumesInnerFocus ? talentAdjustedResourceCost(ability, modifiers) : 0;
|
||||
const costs = ability && !consumesInnerFocus ? resolvedAbilityCosts(ability, modifiers, state) : [];
|
||||
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
|
||||
const targeting = ability ? targetingRulesForAbility(ability) : null;
|
||||
const baseTargeting = ability ? targetingRulesForAbility(ability) : null;
|
||||
const targeting = ability?.dbcSpellId === 490457 && baseTargeting && hasRuneWakerState("rom-master-reflected-shot")
|
||||
? { ...baseTargeting, maximumTargets: 5 }
|
||||
: baseTargeting;
|
||||
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
|
||||
const runeCost = ability && baseClassFor(state.classId) === "death-knight"
|
||||
? runeCostForAbility(ability)
|
||||
@@ -4843,6 +4930,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked";
|
||||
else if (ability.id === "rom-mage-primary-55" && !hasRuneWakerState("rom-charged")) failure = "condition-not-met";
|
||||
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
|
||||
else if (ability.dbcSpellId === 493017 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met";
|
||||
else if (ability.dbcSpellId === 492920 && !state.effects.some((effect) =>
|
||||
effect.kind === "dot" && effect.targetId === targetId && effect.remainingTicks > 0
|
||||
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")) failure = "condition-not-met";
|
||||
else if (abilityRequiresOutOfCombat(ability)
|
||||
&& !(ability.source === "runewaker" && state.classId === "rom-rogue" && hasRuneWakerState("rom-vanish"))
|
||||
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
|
||||
@@ -5005,6 +5096,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
affectedMobIds.push(id);
|
||||
previous = position;
|
||||
}
|
||||
} else if (targeting?.shape === "line" && effectiveTarget === "hostile" && targetId && targetPosition) {
|
||||
const directionX = targetPosition[0] - playerOrigin[0];
|
||||
const directionZ = targetPosition[2] - playerOrigin[2];
|
||||
const targetDistance = Math.max(0.001, Math.hypot(directionX, directionZ));
|
||||
const reach = targetDistance + 4;
|
||||
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, reach).filter((id) => {
|
||||
const mob = state.mobs[id];
|
||||
const position = getMobPosition(id);
|
||||
if (!mob || mob.dead || !position || !combatHasLineOfSight(playerOrigin, position)) return false;
|
||||
const offsetX = position[0] - playerOrigin[0];
|
||||
const offsetZ = position[2] - playerOrigin[2];
|
||||
const forward = (offsetX * directionX + offsetZ * directionZ) / targetDistance;
|
||||
const sideways = Math.abs(offsetX * directionZ - offsetZ * directionX) / targetDistance;
|
||||
return forward >= 0 && forward <= reach && sideways <= 1.25;
|
||||
});
|
||||
} else if (targeting?.shape === "cone" && effectiveTarget === "hostile") {
|
||||
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
|
||||
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, maximumRange).filter((id) => {
|
||||
@@ -5024,14 +5130,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
} else if (effectiveTarget === "hostile" && targetId) {
|
||||
affectedMobIds = [targetId];
|
||||
}
|
||||
if (targeting?.maximumTargets && affectedMobIds.length > targeting.maximumTargets) {
|
||||
affectedMobIds = affectedMobIds.slice(0, targeting.maximumTargets);
|
||||
}
|
||||
|
||||
const castTimeMs = talentAdjustedCastTime(
|
||||
const lastPrayerReady = ability.source === "runewaker" && ability.dbcSpellId !== 491648
|
||||
&& ability.castTimeMs > 0 && hasRuneWakerState("rom-last-prayer");
|
||||
const ordinaryCastTimeMs = talentAdjustedCastTime(
|
||||
ability,
|
||||
modifiers,
|
||||
state.gearStats,
|
||||
state.level,
|
||||
state.classId,
|
||||
);
|
||||
const castTimeMs = lastPrayerReady ? Math.min(1_000, ordinaryCastTimeMs) : ordinaryCastTimeMs;
|
||||
const gcdMs = talentAdjustedGcd(
|
||||
ability,
|
||||
modifiers,
|
||||
@@ -5070,7 +5182,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
originalDurationMs: castTimeMs,
|
||||
pushbackHits: 0,
|
||||
school: spellSchool,
|
||||
canCastWhileMoving: canAbilityCastWhileMoving(ability),
|
||||
canCastWhileMoving: canAbilityCastWhileMoving(ability)
|
||||
|| (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")),
|
||||
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
|
||||
groundPosition,
|
||||
totalTicks: ability.castMode === "channel" ? (ability.name === "Divine Hymn" ? 4 : 5) : 0,
|
||||
@@ -5083,6 +5196,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
set({
|
||||
activeCast,
|
||||
...(lastPrayerReady ? { auras: state.auras.filter((aura) => !(
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("rom-last-prayer")
|
||||
)) } : {}),
|
||||
...autoAttackUpdate,
|
||||
playerAnimationEvent: nextAnimationEvent("ability-start", ability.id),
|
||||
health: activeCast.mode === "channel" ? healthAfterCosts : state.health,
|
||||
@@ -5231,10 +5348,22 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
charged: hasRuneWakerState("rom-charged"),
|
||||
mainHandDagger: romMainHand?.weaponType === "dagger",
|
||||
casterMaxHealth: state.maxHealth,
|
||||
passiveTags: work.auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID)
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []),
|
||||
targetTags: work.auras.filter((aura) => aura.targetId === targetId)
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
|
||||
work.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0)
|
||||
.flatMap((effect) => [
|
||||
...(effect.tag ? [effect.tag] : []),
|
||||
...(effect.tag?.includes("501690") ? ["rom-501690"] : []),
|
||||
...(effect.tag?.includes("rom-deadly-poison") ? ["rom-deadly-poison"] : []),
|
||||
]),
|
||||
),
|
||||
});
|
||||
const effectsToResolve = completingCast?.mode === "channel"
|
||||
? castEffects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind))
|
||||
: castEffects;
|
||||
let clearedSingleTargetControls = false;
|
||||
for (const effect of effectsToResolve) {
|
||||
if (ability.name === "Penance" && effectiveTarget === "friendly"
|
||||
&& (effect.kind === "damage" || effect.kind === "dot")) continue;
|
||||
@@ -5265,7 +5394,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
"damage-percent",
|
||||
ability,
|
||||
);
|
||||
for (const id of affectedMobIds) {
|
||||
for (const [targetIndex, id] of affectedMobIds.entries()) {
|
||||
const effectSchool = effect.school ?? damageSchool;
|
||||
const sunderStacks = effect.bonusPerSunderStack
|
||||
? work.auras.find((aura) => aura.targetId === id && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0
|
||||
: 0;
|
||||
@@ -5273,11 +5403,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
state.classId,
|
||||
work,
|
||||
id,
|
||||
amount + sunderStacks * (effect.bonusPerSunderStack ?? 0),
|
||||
Math.round((amount + sunderStacks * (effect.bonusPerSunderStack ?? 0))
|
||||
* Math.pow(effect.falloffPerJump ?? 1, targetIndex)),
|
||||
now,
|
||||
castThreatSource,
|
||||
attackKind,
|
||||
damageSchool,
|
||||
effectSchool === "physical" ? attackKind : "spell",
|
||||
effectSchool,
|
||||
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
|
||||
ability,
|
||||
false,
|
||||
@@ -5754,7 +5885,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
effect.intervalMs,
|
||||
now,
|
||||
effect.tag,
|
||||
damageSchool,
|
||||
effect.school ?? damageSchool,
|
||||
work.gearStats.spellPenetration,
|
||||
));
|
||||
}
|
||||
@@ -5808,12 +5939,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
} else if (effect.kind === "status") {
|
||||
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
|
||||
if (romSingleTargetControl(ability) && candidates.length) {
|
||||
if (romSingleTargetControl(ability) && candidates.length && !clearedSingleTargetControls) {
|
||||
for (const [mobId, mob] of Object.entries(work.mobs)) {
|
||||
work.mobs[mobId] = { ...mob, statuses: mob.statuses.filter((status) => !(
|
||||
status.sourceAbilityId === ability.id && status.sourceActorId === PLAYER_AGGRO_ID
|
||||
)) };
|
||||
}
|
||||
clearedSingleTargetControls = true;
|
||||
}
|
||||
for (const id of candidates) {
|
||||
const mob = work.mobs[id];
|
||||
@@ -6298,6 +6430,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ability.dbcSpellId === 498759 && targetId && work.mobs[targetId]
|
||||
&& !work.mobs[targetId].dead && work.mobs[targetId].health > 0) {
|
||||
cooldowns[ability.id] = 0;
|
||||
}
|
||||
|
||||
const primaryCombatResult = work.combatResults.length === 1 ? work.combatResults[0] : null;
|
||||
const levelMessage = work.progression.levelsGained > 0
|
||||
@@ -8716,6 +8852,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const redirected = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID
|
||||
? Math.round(namedAbsorb.remainingDamage * redirectFraction)
|
||||
: 0;
|
||||
const aurasAfterRedirect = redirected > 0 && redirectAura?.definition.utilityTags?.includes("rom-disregard-danger-one-hit")
|
||||
? namedAbsorb.auras.filter((aura) => aura.instanceId !== redirectAura.instanceId)
|
||||
: namedAbsorb.auras;
|
||||
const damaged = Math.min(
|
||||
authoritativeActor.health,
|
||||
Math.max(0, Math.round(namedAbsorb.remainingDamage - redirected)),
|
||||
@@ -8761,7 +8900,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
set({
|
||||
actors: { ...get().actors, [nextActor.id]: nextActor },
|
||||
auras: consumeInnerFireCharges(
|
||||
namedAbsorb.auras, member.id, combatResult.landed, enemyAttackKind(hit.attack),
|
||||
aurasAfterRedirect, member.id, combatResult.landed, enemyAttackKind(hit.attack),
|
||||
),
|
||||
combatEvents: recordCombatEvent(get().combatEvents, {
|
||||
occurredAt: now,
|
||||
|
||||
Reference in new issue
Block a user