Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -0,0 +1,457 @@
|
||||
import type { ActiveAura, AuraModifier } from "./combatAuras";
|
||||
|
||||
/**
|
||||
* Source-pinned triggered child effects for enemy shout spells.
|
||||
*
|
||||
* The parent shout's explicit victim and its caster-centered area recipients
|
||||
* are separate target sets. This module describes that split without owning
|
||||
* actor eligibility, spatial queries, or combat-store state.
|
||||
*/
|
||||
|
||||
export type EnemyTriggeredShoutProfile = "ascension" | "epoch";
|
||||
|
||||
/** Utility tag identifying the control contributed by a separate source aura. */
|
||||
export function enemyTriggeredShoutControlTag(mechanic: "fear" | "stun"): string {
|
||||
return `enemy-control-source:${mechanic}`;
|
||||
}
|
||||
|
||||
export interface EnemyTriggeredShoutEffectSignature {
|
||||
readonly effectType: number;
|
||||
readonly auraType: number;
|
||||
readonly basePoints: number;
|
||||
readonly mechanic: number;
|
||||
readonly implicitTargetA: number;
|
||||
readonly implicitTargetB: number;
|
||||
readonly radiusIndex: number;
|
||||
readonly miscValue: number;
|
||||
readonly triggerSpellId: number;
|
||||
readonly periodMs: number;
|
||||
}
|
||||
|
||||
export interface EnemyTriggeredShoutSpellSignature {
|
||||
readonly spellId: number;
|
||||
readonly name: string;
|
||||
/** Spell.dbc Mechanic, distinct from each effect's EffectMechanic. */
|
||||
readonly spellMechanic: number;
|
||||
readonly dispel: number;
|
||||
readonly rangeIndex: number;
|
||||
readonly rangeYards: number;
|
||||
readonly durationMs: number;
|
||||
readonly schoolMask: number;
|
||||
readonly damageClass: number;
|
||||
readonly stackAmount: number;
|
||||
readonly maxAffectedTargets: number;
|
||||
readonly attributes0: number;
|
||||
readonly attributes1: number;
|
||||
readonly attributes2: number;
|
||||
readonly attributes3: number;
|
||||
readonly attributes4: number;
|
||||
readonly attributes5: number;
|
||||
readonly attributes6: number;
|
||||
readonly attributes7: number;
|
||||
readonly effects: readonly EnemyTriggeredShoutEffectSignature[];
|
||||
}
|
||||
|
||||
export type EnemyTriggeredShoutChildEffect =
|
||||
| {
|
||||
readonly kind: "control";
|
||||
readonly effectIndex: number;
|
||||
readonly mechanic: "fear" | "stun";
|
||||
readonly durationMs: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "damage-taken";
|
||||
readonly effectIndex: number;
|
||||
readonly damageSchool: "physical";
|
||||
readonly operation: "percent";
|
||||
readonly value: number;
|
||||
readonly durationMs: number;
|
||||
};
|
||||
|
||||
export interface EnemyTriggeredShoutContract {
|
||||
readonly parentSpellId: number;
|
||||
readonly sourceProfile: EnemyTriggeredShoutProfile;
|
||||
readonly parentSignature: EnemyTriggeredShoutSpellSignature;
|
||||
readonly triggerEffectIndex: 0;
|
||||
readonly childSpellId: number;
|
||||
readonly childSignature: EnemyTriggeredShoutSpellSignature;
|
||||
readonly childEffects: readonly EnemyTriggeredShoutChildEffect[];
|
||||
readonly targeting: {
|
||||
/** The trigger effect and child spell both name the parent's explicit hostile victim. */
|
||||
readonly childVictim: {
|
||||
readonly targetA: 6;
|
||||
readonly targetB: 0;
|
||||
readonly maximumTargets: 1;
|
||||
readonly rangeYards: number;
|
||||
readonly requiresLineOfSight: true;
|
||||
};
|
||||
/** Independent parent effect pair TARGET_SRC_CASTER + TARGET_UNIT_SRC_AREA_ENEMY. */
|
||||
readonly parentArea: {
|
||||
readonly center: "caster";
|
||||
readonly targetA: 22;
|
||||
readonly targetB: 15;
|
||||
readonly relation: "hostile";
|
||||
readonly radiusIndex: 14;
|
||||
readonly radiusYards: 8;
|
||||
/** Spell.dbc zero means the core applies no MaxAffectedTargets limit. */
|
||||
readonly maximumTargets: number;
|
||||
readonly effectIndexes: readonly [1, 2];
|
||||
/** The area search does not remove the explicit victim when both sets overlap. */
|
||||
readonly includesExplicitVictimWhenEligible: true;
|
||||
/** Core area target search checks radius/targetability, not per-recipient LOS. */
|
||||
readonly requiresLineOfSight: false;
|
||||
};
|
||||
};
|
||||
/** Source child auras have StackAmount 0 and no AuraUnique attribute. */
|
||||
readonly recast: {
|
||||
readonly sameCaster: "refresh";
|
||||
readonly differentCaster: "preserve-existing";
|
||||
};
|
||||
readonly unsupportedParentEffectIndexes: readonly number[];
|
||||
}
|
||||
|
||||
const effect = (
|
||||
effectType: number,
|
||||
auraType: number,
|
||||
basePoints: number,
|
||||
implicitTargetA: number,
|
||||
implicitTargetB: number,
|
||||
radiusIndex = 0,
|
||||
miscValue = 0,
|
||||
triggerSpellId = 0,
|
||||
): EnemyTriggeredShoutEffectSignature => Object.freeze({
|
||||
effectType,
|
||||
auraType,
|
||||
basePoints,
|
||||
mechanic: 0,
|
||||
implicitTargetA,
|
||||
implicitTargetB,
|
||||
radiusIndex,
|
||||
miscValue,
|
||||
triggerSpellId,
|
||||
periodMs: 0,
|
||||
});
|
||||
|
||||
const shoutSpeedTag = "enemy-triggered-shout:parent-movement-speed";
|
||||
|
||||
function sourceAttributes(
|
||||
attributes0: number,
|
||||
attributes1: number,
|
||||
attributes2 = 0,
|
||||
attributes3 = 0,
|
||||
attributes4 = 0,
|
||||
attributes5 = 0,
|
||||
attributes6 = 0,
|
||||
attributes7 = 0,
|
||||
): Pick<EnemyTriggeredShoutSpellSignature,
|
||||
"spellMechanic" | "attributes0" | "attributes1" | "attributes2" | "attributes3" | "attributes4"
|
||||
| "attributes5" | "attributes6" | "attributes7"> {
|
||||
return Object.freeze({
|
||||
spellMechanic: 5,
|
||||
attributes0,
|
||||
attributes1,
|
||||
attributes2,
|
||||
attributes3,
|
||||
attributes4,
|
||||
attributes5,
|
||||
attributes6,
|
||||
attributes7,
|
||||
});
|
||||
}
|
||||
|
||||
const spell = (
|
||||
spellId: number,
|
||||
name: string,
|
||||
rangeIndex: number,
|
||||
rangeYards: number,
|
||||
durationMs: number,
|
||||
damageClass: number,
|
||||
maxAffectedTargets: number,
|
||||
attributes: Pick<EnemyTriggeredShoutSpellSignature,
|
||||
"spellMechanic" | "attributes0" | "attributes1" | "attributes2" | "attributes3" | "attributes4"
|
||||
| "attributes5" | "attributes6" | "attributes7">,
|
||||
effects: readonly EnemyTriggeredShoutEffectSignature[],
|
||||
): EnemyTriggeredShoutSpellSignature => Object.freeze({
|
||||
spellId,
|
||||
name,
|
||||
...attributes,
|
||||
dispel: 0,
|
||||
rangeIndex,
|
||||
rangeYards,
|
||||
durationMs,
|
||||
schoolMask: 1,
|
||||
damageClass,
|
||||
stackAmount: 0,
|
||||
maxAffectedTargets,
|
||||
effects: Object.freeze([...effects]),
|
||||
});
|
||||
|
||||
const target = (
|
||||
rangeYards: number,
|
||||
maximumTargets: number,
|
||||
): EnemyTriggeredShoutContract["targeting"] => Object.freeze({
|
||||
childVictim: Object.freeze({
|
||||
targetA: 6,
|
||||
targetB: 0,
|
||||
maximumTargets: 1,
|
||||
rangeYards,
|
||||
requiresLineOfSight: true,
|
||||
}),
|
||||
parentArea: Object.freeze({
|
||||
center: "caster",
|
||||
targetA: 22,
|
||||
targetB: 15,
|
||||
relation: "hostile",
|
||||
radiusIndex: 14,
|
||||
radiusYards: 8,
|
||||
maximumTargets,
|
||||
effectIndexes: Object.freeze([1, 2]) as readonly [1, 2],
|
||||
includesExplicitVictimWhenEligible: true,
|
||||
requiresLineOfSight: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const control = (
|
||||
mechanic: "fear" | "stun",
|
||||
durationMs: number,
|
||||
): EnemyTriggeredShoutChildEffect => Object.freeze({ kind: "control", effectIndex: 0, mechanic, durationMs });
|
||||
|
||||
const movementSpeed = (signature: EnemyTriggeredShoutSpellSignature) => {
|
||||
const sourceEffect = signature.effects[2];
|
||||
if (signature.durationMs <= 0
|
||||
|| sourceEffect?.effectType !== 6
|
||||
|| sourceEffect.auraType !== 31
|
||||
|| sourceEffect.basePoints !== 25
|
||||
|| sourceEffect.implicitTargetA !== 22
|
||||
|| sourceEffect.implicitTargetB !== 15
|
||||
|| sourceEffect.radiusIndex !== 14) return undefined;
|
||||
return Object.freeze({
|
||||
effectIndex: 2,
|
||||
auraType: 31,
|
||||
increasePercent: sourceEffect.basePoints,
|
||||
durationMs: signature.durationMs,
|
||||
utilityTag: shoutSpeedTag,
|
||||
});
|
||||
};
|
||||
|
||||
const contracts: Readonly<Record<EnemyTriggeredShoutProfile, Readonly<Record<number, EnemyTriggeredShoutContract>>>> = Object.freeze({
|
||||
ascension: Object.freeze({
|
||||
5246: Object.freeze({
|
||||
parentSpellId: 5246,
|
||||
sourceProfile: "ascension",
|
||||
parentSignature: spell(5246, "Intimidating Shout", 137, 8, 5_000, 1, 5, sourceAttributes(0, 0, 0, 0, 0, 0, 33_554_432), [
|
||||
effect(64, 0, 1, 6, 0, 0, 0, 20511),
|
||||
effect(6, 7, 1, 22, 15, 14),
|
||||
effect(6, 31, 25, 22, 15, 14),
|
||||
]),
|
||||
triggerEffectIndex: 0,
|
||||
childSpellId: 20511,
|
||||
childSignature: spell(20511, "Intimidating Shout", 11, 15, 5_000, 1, 0, sourceAttributes(0, 0, 0, 589_824, 8_388_608), [
|
||||
effect(6, 12, 0, 6, 0),
|
||||
effect(6, 87, 15, 6, 0, 0, 1),
|
||||
]),
|
||||
childEffects: Object.freeze([
|
||||
control("stun", 5_000),
|
||||
Object.freeze({
|
||||
kind: "damage-taken",
|
||||
effectIndex: 1,
|
||||
damageSchool: "physical",
|
||||
operation: "percent",
|
||||
value: 0.15,
|
||||
durationMs: 5_000,
|
||||
}),
|
||||
]),
|
||||
targeting: target(8, 5),
|
||||
recast: Object.freeze({ sameCaster: "refresh", differentCaster: "preserve-existing" }),
|
||||
unsupportedParentEffectIndexes: Object.freeze([]),
|
||||
}),
|
||||
19134: Object.freeze({
|
||||
parentSpellId: 19134,
|
||||
sourceProfile: "ascension",
|
||||
parentSignature: spell(19134, "Frightening Shout", 7, 10, 4_000, 1, 0, sourceAttributes(327_696, 0), [
|
||||
effect(64, 0, 1, 6, 0, 0, 0, 29544),
|
||||
effect(6, 7, 1, 22, 15, 14),
|
||||
effect(6, 31, 25, 22, 15, 14),
|
||||
]),
|
||||
triggerEffectIndex: 0,
|
||||
childSpellId: 29544,
|
||||
childSignature: spell(29544, "Frightening Shout", 11, 15, 3_000, 0, 0, sourceAttributes(0, 0), [
|
||||
effect(6, 7, 0, 6, 0),
|
||||
]),
|
||||
childEffects: Object.freeze([control("fear", 3_000)]),
|
||||
targeting: target(10, 0),
|
||||
recast: Object.freeze({ sameCaster: "refresh", differentCaster: "preserve-existing" }),
|
||||
unsupportedParentEffectIndexes: Object.freeze([]),
|
||||
}),
|
||||
}),
|
||||
epoch: Object.freeze({
|
||||
5246: Object.freeze({
|
||||
parentSpellId: 5246,
|
||||
sourceProfile: "epoch",
|
||||
parentSignature: spell(5246, "Intimidating Shout", 137, 8, 8_000, 1, 5, sourceAttributes(327_696, 136, 0, 0, 0, 0, 8_388_608), [
|
||||
effect(64, 0, 1, 6, 0, 0, 0, 20511),
|
||||
effect(6, 7, 1, 22, 15, 14),
|
||||
effect(6, 31, 25, 22, 15, 14),
|
||||
]),
|
||||
triggerEffectIndex: 0,
|
||||
childSpellId: 20511,
|
||||
childSignature: spell(20511, "Intimidating Shout", 11, 15, 8_000, 0, 0, sourceAttributes(0, 0), [
|
||||
effect(6, 12, 0, 6, 0),
|
||||
]),
|
||||
childEffects: Object.freeze([control("stun", 8_000)]),
|
||||
targeting: target(8, 5),
|
||||
recast: Object.freeze({ sameCaster: "refresh", differentCaster: "preserve-existing" }),
|
||||
unsupportedParentEffectIndexes: Object.freeze([]),
|
||||
}),
|
||||
19134: Object.freeze({
|
||||
parentSpellId: 19134,
|
||||
sourceProfile: "epoch",
|
||||
parentSignature: spell(19134, "Frightening Shout", 7, 10, 6_000, 1, 0, sourceAttributes(327_696, 0), [
|
||||
effect(64, 0, 1, 6, 0, 0, 0, 29544),
|
||||
effect(6, 7, 1, 22, 15, 14),
|
||||
effect(6, 31, 25, 22, 15, 14),
|
||||
]),
|
||||
triggerEffectIndex: 0,
|
||||
childSpellId: 29544,
|
||||
childSignature: spell(29544, "Frightening Shout", 11, 15, 6_000, 0, 0, sourceAttributes(0, 0), [
|
||||
effect(6, 7, 0, 6, 0),
|
||||
]),
|
||||
childEffects: Object.freeze([control("fear", 6_000)]),
|
||||
targeting: target(10, 0),
|
||||
recast: Object.freeze({ sameCaster: "refresh", differentCaster: "preserve-existing" }),
|
||||
unsupportedParentEffectIndexes: Object.freeze([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Return one exact parent-to-child DBC trigger contract for a pinned profile. */
|
||||
export function enemyTriggeredShoutContract(
|
||||
parentSpellId: number,
|
||||
sourceProfile: EnemyTriggeredShoutProfile,
|
||||
): EnemyTriggeredShoutContract | undefined {
|
||||
return contracts[sourceProfile]?.[parentSpellId];
|
||||
}
|
||||
|
||||
export interface EnemyTriggeredShoutMovementSpeedEffect {
|
||||
readonly effectIndex: 2;
|
||||
readonly auraType: 31;
|
||||
readonly increasePercent: 25;
|
||||
readonly durationMs: number;
|
||||
readonly utilityTag: string;
|
||||
readonly modifier: Extract<AuraModifier, { readonly kind: "stat" }>;
|
||||
}
|
||||
|
||||
/** Utility tag for the sourced parent-area AuraType 31 speed bonus. */
|
||||
export function enemyTriggeredShoutMovementSpeedTag(): string {
|
||||
return shoutSpeedTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return parent effect 2 only when its pinned effect, target pair and radius
|
||||
* still match the source AuraType 31 +25% movement-speed row.
|
||||
*/
|
||||
export function enemyTriggeredShoutMovementSpeedEffect(
|
||||
contract: EnemyTriggeredShoutContract,
|
||||
): EnemyTriggeredShoutMovementSpeedEffect | undefined {
|
||||
const source = movementSpeed(contract.parentSignature);
|
||||
if (!source
|
||||
|| contract.targeting.parentArea.effectIndexes[1] !== source.effectIndex
|
||||
|| contract.targeting.parentArea.radiusIndex !== 14
|
||||
|| contract.targeting.parentArea.radiusYards !== 8) return undefined;
|
||||
return Object.freeze({
|
||||
...source,
|
||||
modifier: Object.freeze({
|
||||
kind: "stat",
|
||||
stat: "movement-speed",
|
||||
operation: "percent",
|
||||
value: source.increasePercent / 100,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Return the strongest active sourced shout speed boost on one target. */
|
||||
export function enemyTriggeredShoutMovementSpeedMultiplier(
|
||||
auras: readonly ActiveAura[],
|
||||
targetId: string,
|
||||
now: number,
|
||||
): number {
|
||||
if (!Number.isFinite(now)) return 1;
|
||||
let multiplier = 1;
|
||||
for (const aura of auras) {
|
||||
if (aura.targetId !== targetId
|
||||
|| aura.expiresAt === null
|
||||
|| aura.expiresAt <= now
|
||||
|| !aura.definition.utilityTags?.includes(shoutSpeedTag)) continue;
|
||||
for (const modifier of aura.definition.modifiers ?? []) {
|
||||
if (modifier.kind !== "stat" || modifier.stat !== "movement-speed" || modifier.operation !== "percent"
|
||||
|| !Number.isFinite(modifier.value)) continue;
|
||||
const stacks = modifier.perStack === false ? 1 : aura.stacks;
|
||||
multiplier = Math.max(multiplier, 1 + modifier.value * stacks);
|
||||
}
|
||||
}
|
||||
return multiplier;
|
||||
}
|
||||
|
||||
export interface EnemyTriggeredShoutTargetPlanInput {
|
||||
/** Target id snapshotted by the active parent cast. */
|
||||
readonly explicitVictimId: string | undefined;
|
||||
/** Explicit parent targets that pass the caller's hostile/range/LOS recheck. */
|
||||
readonly eligibleExplicitVictimIds: readonly string[];
|
||||
/** Parent caster-centered hostile area recipients after the caller's area query. */
|
||||
readonly areaRecipientIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface EnemyTriggeredShoutTargetPlan {
|
||||
/** At most one child recipient: the eligible explicit victim from the parent cast. */
|
||||
readonly childTargetIds: readonly string[];
|
||||
/** Unchanged area set, including the explicit victim if it also satisfies the area query. */
|
||||
readonly parentAreaTargetIds: readonly string[];
|
||||
readonly childMaximumTargets: 1;
|
||||
/** Zero is the DBC sentinel for no MaxAffectedTargets limit. */
|
||||
readonly parentAreaMaximumTargets: number;
|
||||
}
|
||||
|
||||
/** Keep child-victim routing independent from the parent's area-recipient selection. */
|
||||
export function planEnemyTriggeredShoutTargets(
|
||||
contract: EnemyTriggeredShoutContract,
|
||||
input: EnemyTriggeredShoutTargetPlanInput,
|
||||
): EnemyTriggeredShoutTargetPlan {
|
||||
const explicitVictimIsEligible = Boolean(input.explicitVictimId)
|
||||
&& input.eligibleExplicitVictimIds.includes(input.explicitVictimId!);
|
||||
return Object.freeze({
|
||||
childTargetIds: Object.freeze(explicitVictimIsEligible ? [input.explicitVictimId!] : []),
|
||||
parentAreaTargetIds: Object.freeze([...input.areaRecipientIds]),
|
||||
childMaximumTargets: 1,
|
||||
parentAreaMaximumTargets: contract.targeting.parentArea.maximumTargets,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ResolvedEnemyTriggeredShoutControl {
|
||||
readonly mechanic: "fear" | "stun";
|
||||
readonly endsAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve overlapping source shout controls for one actor. The longest active
|
||||
* control owns the runtime timer; a stun wins when expiries tie. These pinned
|
||||
* shout controls are all timed, so permanent tagged auras are ignored.
|
||||
*/
|
||||
export function resolveEnemyTriggeredShoutControl(
|
||||
auras: readonly ActiveAura[],
|
||||
targetId: string,
|
||||
now: number,
|
||||
): ResolvedEnemyTriggeredShoutControl | null {
|
||||
const candidates = auras.flatMap((aura) => {
|
||||
if (aura.targetId !== targetId || aura.expiresAt === null || aura.expiresAt <= now) return [];
|
||||
const mechanic: "fear" | "stun" | undefined = aura.definition.utilityTags?.includes(enemyTriggeredShoutControlTag("stun"))
|
||||
? "stun"
|
||||
: aura.definition.utilityTags?.includes(enemyTriggeredShoutControlTag("fear"))
|
||||
? "fear"
|
||||
: undefined;
|
||||
return mechanic ? [{ mechanic, endsAt: aura.expiresAt }] : [];
|
||||
});
|
||||
if (!candidates.length) return null;
|
||||
candidates.sort((left, right) => right.endsAt - left.endsAt
|
||||
|| (left.mechanic === right.mechanic ? 0 : left.mechanic === "stun" ? -1 : 1));
|
||||
return Object.freeze({ mechanic: candidates[0]!.mechanic, endsAt: candidates[0]!.endsAt });
|
||||
}
|
||||
Reference in new issue
Block a user