Update Healer Man server
This commit is contained in:
1 parent
6cc1b9217f
commit
6114a2295f
67 files changed
+6241
-347
No files matched your search
@@ -973,7 +973,7 @@ async function spellSupportTables(spellFile, fallbackRoot) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()) {
|
||||
function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map(), sourceProfile) {
|
||||
if (!buffer || buffer.toString("ascii", 0, 4) !== "WDBC") return new Map();
|
||||
const recordCount = buffer.readUInt32LE(4);
|
||||
const fieldCount = buffer.readUInt32LE(8);
|
||||
@@ -1002,11 +1002,13 @@ function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()
|
||||
}
|
||||
spells.set(id, {
|
||||
id,
|
||||
sourceProfile,
|
||||
name: name.replace(/\|c[0-9a-f]{8}|\|r/gi, "").trim() || `Spell ${id}`,
|
||||
recoveryTimeMs: unsigned(29),
|
||||
school: schoolFor(unsigned(225)),
|
||||
schoolMask: unsigned(225),
|
||||
damageClass: unsigned(213),
|
||||
rangeIndex: unsigned(46),
|
||||
durationMs: durations.get(unsigned(40)) ?? 0,
|
||||
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
|
||||
effectType: unsigned(71 + effectIndex),
|
||||
@@ -1017,6 +1019,7 @@ function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()
|
||||
implicitTargetB: unsigned(shifted(89 + effectIndex)),
|
||||
radius: radii.get(unsigned(shifted(92 + effectIndex))) ?? 0,
|
||||
miscValue: signed(shifted(110 + effectIndex)),
|
||||
periodMs: unsigned(98 + effectIndex),
|
||||
})).filter((effect) => effect.effectType || effect.auraType),
|
||||
});
|
||||
}
|
||||
@@ -1037,6 +1040,191 @@ const CONTROL_AURA_MECHANICS = Object.freeze(new Map([
|
||||
[193, "slow"],
|
||||
]));
|
||||
|
||||
const DIRECT_DAMAGE_EFFECT_TYPES = Object.freeze([2, 31, 58, 121]);
|
||||
const pinnedEffect = (effectType, auraType, basePoints, targetA, targetB, options = {}) => ({
|
||||
effectType,
|
||||
auraType,
|
||||
basePoints,
|
||||
mechanic: options.mechanic ?? 0,
|
||||
implicitTargetA: targetA,
|
||||
implicitTargetB: targetB,
|
||||
radius: options.radius ?? 0,
|
||||
miscValue: options.miscValue ?? 0,
|
||||
periodMs: options.periodMs ?? 0,
|
||||
});
|
||||
const pinnedSourceSpell = (rangeIndex, schoolMask, damageClass, durationMs, effects, contract = {}) => {
|
||||
const { epochSignature, ...attackContract } = contract;
|
||||
const signature = { rangeIndex, schoolMask, damageClass, durationMs, effects };
|
||||
return {
|
||||
signatures: {
|
||||
ascension: signature,
|
||||
epoch: epochSignature ?? signature,
|
||||
},
|
||||
...attackContract,
|
||||
};
|
||||
};
|
||||
|
||||
const PINNED_SOURCE_SPELLS = Object.freeze(new Map([
|
||||
[7164, pinnedSourceSpell(1, 1, 0, 180_000, [pinnedEffect(6, 36, 0, 1, 0, { miscValue: 18 })], { unsupportedUtility: true })],
|
||||
[7165, pinnedSourceSpell(1, 1, 2, -1, [pinnedEffect(6, 36, 0, 1, 0, { miscValue: 17 })], { unsupportedUtility: true })],
|
||||
[16244, pinnedSourceSpell(1, 1, 1, 15_000, [pinnedEffect(6, 79, -10, 22, 15, { radius: 10, miscValue: 1 })], {
|
||||
unsupportedUtility: true,
|
||||
epochSignature: {
|
||||
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 30_000,
|
||||
effects: [pinnedEffect(6, 99, -20, 22, 15, { radius: 10 })],
|
||||
},
|
||||
})],
|
||||
[13730, pinnedSourceSpell(1, 1, 1, 30_000, [pinnedEffect(6, 166, -15, 22, 15, { radius: 10 })], {
|
||||
unsupportedUtility: true,
|
||||
epochSignature: {
|
||||
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 30_000,
|
||||
effects: [pinnedEffect(6, 99, -10, 22, 15, { radius: 10 })],
|
||||
},
|
||||
})],
|
||||
[3419, pinnedSourceSpell(1, 1, 0, 6_000, [pinnedEffect(6, 51, 75, 1, 0)], { unsupportedUtility: true })],
|
||||
[9128, pinnedSourceSpell(1, 1, 1, 120_000, [pinnedEffect(6, 79, 15, 20, 0, { radius: 20, miscValue: 1 })], {
|
||||
unsupportedUtility: true,
|
||||
epochSignature: {
|
||||
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 120_000,
|
||||
effects: [pinnedEffect(6, 99, 4, 20, 0, { radius: 20 })],
|
||||
},
|
||||
})],
|
||||
[674, pinnedSourceSpell(1, 1, 1, 0, [pinnedEffect(40, 0, 1, 1, 0)], { unsupportedUtility: true })],
|
||||
[29651, pinnedSourceSpell(1, 1, 0, 0, [pinnedEffect(40, 0, 1, 1, 0)], { unsupportedUtility: true })],
|
||||
[2457, pinnedSourceSpell(1, 1, 0, -1, [
|
||||
pinnedEffect(6, 36, 0, 1, 0, { miscValue: 17 }),
|
||||
pinnedEffect(6, 280, 10, 1, 0),
|
||||
pinnedEffect(6, 286, 100, 1, 0),
|
||||
], {
|
||||
unsupportedUtility: true,
|
||||
epochSignature: {
|
||||
rangeIndex: 1, schoolMask: 1, damageClass: 2, durationMs: -1,
|
||||
effects: [
|
||||
pinnedEffect(6, 36, 0, 1, 0, { miscValue: 17 }),
|
||||
pinnedEffect(6, 280, 10, 1, 0),
|
||||
],
|
||||
},
|
||||
})],
|
||||
[3391, pinnedSourceSpell(1, 1, 2, 0, [pinnedEffect(19, 0, 2, 1, 0)], { unsupportedUtility: true })],
|
||||
[6742, pinnedSourceSpell(4, 8, 1, 30_000, [pinnedEffect(6, 138, 30, 21, 0)], { unsupportedUtility: true })],
|
||||
[6713, pinnedSourceSpell(2, 1, 2, 5_000, [
|
||||
pinnedEffect(6, 67, 0, 6, 0),
|
||||
pinnedEffect(6, 278, 1, 6, 0),
|
||||
], {
|
||||
unsupportedUtility: true,
|
||||
epochSignature: {
|
||||
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 5_000,
|
||||
effects: [pinnedEffect(6, 67, 0, 6, 0)],
|
||||
},
|
||||
})],
|
||||
[17230, pinnedSourceSpell(2, 8, 0, 300_000, [pinnedEffect(6, 14, 6, 6, 0, { miscValue: 1 })], { unsupportedUtility: true })],
|
||||
[6253, pinnedSourceSpell(2, 1, 2, 2_000, [pinnedEffect(6, 12, 1, 6, 0)], {
|
||||
target: "primary",
|
||||
delivery: "melee",
|
||||
animation: "attack",
|
||||
range: 5,
|
||||
})],
|
||||
[8242, pinnedSourceSpell(2, 1, 2, 2_000, [
|
||||
pinnedEffect(6, 12, 1, 6, 0),
|
||||
pinnedEffect(2, 0, 53, 6, 0),
|
||||
], {
|
||||
target: "primary",
|
||||
delivery: "melee",
|
||||
animation: "attack",
|
||||
range: 5,
|
||||
epochSignature: {
|
||||
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 2_000,
|
||||
effects: [
|
||||
pinnedEffect(6, 12, 1, 6, 0),
|
||||
pinnedEffect(2, 0, 20, 6, 0),
|
||||
],
|
||||
},
|
||||
})],
|
||||
[11976, pinnedSourceSpell(2, 1, 2, 0, [pinnedEffect(58, 0, 5, 6, 0)], {
|
||||
target: "primary",
|
||||
delivery: "melee",
|
||||
animation: "attack",
|
||||
range: 5,
|
||||
})],
|
||||
[15657, pinnedSourceSpell(2, 1, 2, 0, [pinnedEffect(2, 0, 100, 6, 0)], {
|
||||
target: "primary",
|
||||
delivery: "melee",
|
||||
animation: "attack",
|
||||
range: 5,
|
||||
epochSignature: {
|
||||
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 0,
|
||||
effects: [pinnedEffect(58, 0, 20, 6, 0)],
|
||||
},
|
||||
})],
|
||||
[11978, pinnedSourceSpell(2, 1, 2, 4_000, [pinnedEffect(68, 0, 1, 6, 0, { mechanic: 26 })], {
|
||||
target: "primary",
|
||||
delivery: "melee",
|
||||
animation: "attack",
|
||||
range: 5,
|
||||
interruptControl: true,
|
||||
preserveSourceDamage: true,
|
||||
epochSignature: {
|
||||
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 6_000,
|
||||
effects: [
|
||||
pinnedEffect(2, 0, 2, 6, 0),
|
||||
pinnedEffect(68, 0, 1, 6, 0, { mechanic: 26 }),
|
||||
],
|
||||
},
|
||||
})],
|
||||
[421, pinnedSourceSpell(4, 8, 1, 0, [pinnedEffect(2, 0, 287, 6, 0)], {
|
||||
range: 30,
|
||||
epochSignature: {
|
||||
rangeIndex: 4, schoolMask: 8, damageClass: 1, durationMs: 0,
|
||||
effects: [pinnedEffect(2, 0, 191, 6, 0)],
|
||||
},
|
||||
})],
|
||||
[5246, pinnedSourceSpell(137, 1, 1, 5_000, [
|
||||
pinnedEffect(64, 0, 1, 6, 0),
|
||||
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
|
||||
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
|
||||
], {
|
||||
target: "nearby-party",
|
||||
delivery: "area",
|
||||
range: 8,
|
||||
radius: 8,
|
||||
epochSignature: {
|
||||
rangeIndex: 137, schoolMask: 1, damageClass: 1, durationMs: 8_000,
|
||||
effects: [
|
||||
pinnedEffect(64, 0, 1, 6, 0),
|
||||
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
|
||||
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
|
||||
],
|
||||
},
|
||||
})],
|
||||
[19134, pinnedSourceSpell(7, 1, 1, 4_000, [
|
||||
pinnedEffect(64, 0, 1, 6, 0),
|
||||
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
|
||||
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
|
||||
], {
|
||||
target: "nearby-party",
|
||||
delivery: "area",
|
||||
range: 10,
|
||||
radius: 8,
|
||||
epochSignature: {
|
||||
rangeIndex: 7, schoolMask: 1, damageClass: 1, durationMs: 6_000,
|
||||
effects: [
|
||||
pinnedEffect(64, 0, 1, 6, 0),
|
||||
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
|
||||
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
|
||||
],
|
||||
},
|
||||
})],
|
||||
[7964, pinnedSourceSpell(1, 1, 1, 4_000, [
|
||||
pinnedEffect(6, 12, 1, 22, 15, { mechanic: 12, radius: 5 }),
|
||||
pinnedEffect(3, 0, 1, 18, 0),
|
||||
], {
|
||||
target: "nearby-party",
|
||||
delivery: "area",
|
||||
range: 5,
|
||||
radius: 5,
|
||||
})],
|
||||
]));
|
||||
|
||||
const AUXILIARY_NON_BOSS_CASTS = Object.freeze(new Set([
|
||||
"26687:47669", // The stasis orb awakens Gortok; Gortok does not cast this spell.
|
||||
"29307:54888", // The Colossus does not own the elemental's spawn/reset visual.
|
||||
@@ -1049,7 +1237,7 @@ function spellSemantic(spell, boss, area) {
|
||||
? 0
|
||||
: spell.durationMs || (boss ? 8_000 : 5_000);
|
||||
const damageMultiplier = area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12;
|
||||
const hasDamage = spell.effects.some((effect) => [2, 31, 58, 121].includes(effect.effectType));
|
||||
const hasDamage = spell.effects.some((effect) => DIRECT_DAMAGE_EFFECT_TYPES.includes(effect.effectType));
|
||||
const controlEffect = spell.effects.find((effect) => CONTROL_AURA_MECHANICS.has(effect.auraType));
|
||||
const controlMechanic = /\b(?:sleep|slumber)\b/i.test(spell.name)
|
||||
? "sleep"
|
||||
@@ -1164,6 +1352,50 @@ function spellSemantic(spell, boss, area) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pinnedSourceSpellFor(spell) {
|
||||
const contract = PINNED_SOURCE_SPELLS.get(spell.id);
|
||||
if (!contract) return undefined;
|
||||
|
||||
const signature = contract.signatures[spell.sourceProfile];
|
||||
if (!signature) {
|
||||
throw new Error(
|
||||
`Pinned Spell.dbc contract for spell ${spell.id} has no ${spell.sourceProfile} source profile.`,
|
||||
);
|
||||
}
|
||||
const scalarFieldsMatch = ["rangeIndex", "schoolMask", "damageClass", "durationMs"]
|
||||
.every((field) => spell[field] === signature[field]);
|
||||
const effectsMatch = spell.effects.length === signature.effects.length
|
||||
&& signature.effects.every((expected, index) => {
|
||||
const actual = spell.effects[index];
|
||||
return [
|
||||
"effectType",
|
||||
"auraType",
|
||||
"basePoints",
|
||||
"mechanic",
|
||||
"implicitTargetA",
|
||||
"implicitTargetB",
|
||||
"radius",
|
||||
"miscValue",
|
||||
"periodMs",
|
||||
].every((field) => actual[field] === expected[field]);
|
||||
});
|
||||
if (!scalarFieldsMatch || !effectsMatch) {
|
||||
const actualSignature = {
|
||||
rangeIndex: spell.rangeIndex,
|
||||
schoolMask: spell.schoolMask,
|
||||
damageClass: spell.damageClass,
|
||||
durationMs: spell.durationMs,
|
||||
effects: spell.effects,
|
||||
};
|
||||
throw new Error(
|
||||
`Pinned ${spell.sourceProfile} Spell.dbc contract drift for spell ${spell.id}; `
|
||||
+ `review its source effects before changing the import. `
|
||||
+ `Expected ${JSON.stringify(signature)}; found ${JSON.stringify(actualSignature)}.`,
|
||||
);
|
||||
}
|
||||
return contract;
|
||||
}
|
||||
|
||||
function basicAttack(cooldownMs = 1_900) {
|
||||
return {
|
||||
id: "basic-attack",
|
||||
@@ -1179,8 +1411,10 @@ function basicAttack(cooldownMs = 1_900) {
|
||||
}
|
||||
|
||||
function spellAttack(spell, hash, boss) {
|
||||
const pinnedSource = pinnedSourceSpellFor(spell);
|
||||
const pinnedHostileMelee = pinnedSource?.delivery === "melee";
|
||||
const area = /nova|roar|stomp|whirl|rain|volley|blizzard|explosion|quake|storm|breath|cleave|fear|horror|terror/i
|
||||
.test(spell.name) || spell.id === 53030;
|
||||
.test(spell.name) || spell.id === 53030 || pinnedSource?.delivery === "area";
|
||||
const melee = spell.school === "physical"
|
||||
&& /strike|slash|cleave|bash|smash|rend|bite|claw|pummel/i.test(spell.name);
|
||||
const healing = /\b(?:heal|healing|mend|renew|rejuvenat\w*|restor\w*|holy light|lay on hands)\b/i
|
||||
@@ -1202,14 +1436,28 @@ function spellAttack(spell, hash, boss) {
|
||||
: null;
|
||||
const aura = /\b(?:enrage|frenzy|shield|reflection|reflect|defensive aura|battle aura|berserker aura|haste|hasten)\b/i
|
||||
.test(spell.name);
|
||||
const semanticEffect = spellSemantic(spell, boss, area);
|
||||
const hasDamage = spell.effects.some((entry) => DIRECT_DAMAGE_EFFECT_TYPES.includes(entry.effectType));
|
||||
const defaultDamageMultiplier = area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12;
|
||||
const semanticEffect = pinnedSource?.interruptControl
|
||||
? {
|
||||
kind: "control",
|
||||
mechanic: "interrupt",
|
||||
durationMs: spell.durationMs,
|
||||
...(pinnedSource.preserveSourceDamage && hasDamage
|
||||
? { damageMultiplier: defaultDamageMultiplier }
|
||||
: {}),
|
||||
}
|
||||
: pinnedSource?.effect ?? spellSemantic(spell, boss, area);
|
||||
if (pinnedSource?.unsupportedUtility && !semanticEffect && !hasDamage) return null;
|
||||
const semanticEffects = semanticEffect?.kind === "composite"
|
||||
? semanticEffect.effects
|
||||
: semanticEffect ? [semanticEffect] : [];
|
||||
const semanticHas = (kind) => semanticEffects.some((entry) => entry.kind === kind);
|
||||
const target = healing
|
||||
? "lowest-health-friendly"
|
||||
: semanticHas("aura") || aura
|
||||
const target = pinnedSource?.target
|
||||
? pinnedSource.target
|
||||
: healing
|
||||
? "lowest-health-friendly"
|
||||
: semanticHas("aura") || aura
|
||||
? "self"
|
||||
: semanticEffects.length > 0 && semanticEffects.every((entry) => entry.kind === "summon")
|
||||
? "self"
|
||||
@@ -1245,7 +1493,7 @@ function spellAttack(spell, hash, boss) {
|
||||
id: `spell-${spell.id}`,
|
||||
name: spell.name,
|
||||
spellId: spell.id,
|
||||
delivery: target === "self" ? "area" : area ? "area" : melee ? "melee" : "projectile",
|
||||
delivery: pinnedSource?.delivery ?? (target === "self" ? "area" : area ? "area" : melee ? "melee" : "projectile"),
|
||||
target,
|
||||
school: spell.school,
|
||||
schoolMask: spell.schoolMask,
|
||||
@@ -1254,11 +1502,11 @@ function spellAttack(spell, hash, boss) {
|
||||
...(spell.damageClass === 1 ? { attackKind: "spell" }
|
||||
: spell.damageClass === 2 ? { attackKind: "melee" }
|
||||
: spell.damageClass === 3 ? { attackKind: "ranged" } : {}),
|
||||
animation: melee ? "attack" : "cast",
|
||||
range: target === "self" ? 0 : melee ? 5.5 : area ? 22 : 28,
|
||||
animation: pinnedSource?.animation ?? (pinnedHostileMelee || melee ? "attack" : "cast"),
|
||||
range: pinnedSource?.range ?? (target === "self" ? 0 : melee ? 5.5 : area ? 22 : 28),
|
||||
cooldownMs: Math.max(2_500, Math.min(12_000, spell.recoveryTimeMs || 4_000 + hash % 3_500)),
|
||||
damageMultiplier: effect ? 0 : area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12,
|
||||
...(area ? { radius: dbcRadius || (boss ? 11 : 8) } : {}),
|
||||
...(area ? { radius: pinnedSource?.radius ?? (dbcRadius || (boss ? 11 : 8)) } : {}),
|
||||
...(effect ? { effect } : {}),
|
||||
...(healing
|
||||
? {
|
||||
@@ -1405,15 +1653,20 @@ function smartAiMechanicsFor(entity, boss, spells, worldAi) {
|
||||
if (!spell) continue;
|
||||
const hash = stableHash(`${entry}:${spellId}:${action.id}`);
|
||||
const attack = spellAttack(spell, hash, true);
|
||||
const target = [5, 6].includes(Number(action.target_type))
|
||||
? "random-party"
|
||||
: attack.target;
|
||||
if (!attack) continue;
|
||||
const targetType = Number(action.target_type);
|
||||
const pinnedSource = PINNED_SOURCE_SPELLS.get(spellId);
|
||||
const sourceTarget = pinnedSource?.target;
|
||||
const target = sourceTarget
|
||||
?? ([5, 6].includes(targetType) ? "random-party" : attack.target);
|
||||
mechanics.push({
|
||||
...attack,
|
||||
...event,
|
||||
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
|
||||
target,
|
||||
range: target === "self" ? attack.range : Math.max(attack.range, 28),
|
||||
range: pinnedSource?.range !== undefined || target === "self"
|
||||
? attack.range
|
||||
: Math.max(attack.range, 28),
|
||||
source,
|
||||
});
|
||||
} else if (actionType === 12) {
|
||||
@@ -1526,7 +1779,8 @@ function combatFor(
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.map((spell) => spellAttack(spell, hash, boss));
|
||||
.map((spell) => spellAttack(spell, hash, boss))
|
||||
.filter(Boolean);
|
||||
const importedMechanics = smartAiMechanicsFor(entity, boss, spells, worldAi);
|
||||
const authored = boss ? bossMechanics?.bosses?.[String(entry)] : undefined;
|
||||
let mechanics = authored?.mode === "replace"
|
||||
@@ -1814,6 +2068,7 @@ async function compile() {
|
||||
wantedSpellIds,
|
||||
ascensionSpellSupport.durations,
|
||||
ascensionSpellSupport.radii,
|
||||
"ascension",
|
||||
)
|
||||
: new Map();
|
||||
const epochSpellSupport = await spellSupportTables(epochSpellDbc, defaultSpellSupportRoot);
|
||||
@@ -1823,6 +2078,7 @@ async function compile() {
|
||||
wantedSpellIds,
|
||||
epochSpellSupport.durations,
|
||||
epochSpellSupport.radii,
|
||||
"epoch",
|
||||
)
|
||||
: ascensionSpells;
|
||||
const standardGameObjectDisplays = await exists(standardGameObjectDisplayDbc)
|
||||
|
||||
Reference in new issue
Block a user