Release Healer Man 0.1.45
This commit is contained in:
1 parent
756ee4f64a
commit
f82daf1c13
242 files changed
+1440
-101
No files matched your search
@@ -5,12 +5,13 @@ ability manifest, across all ten WoW classes. There is no class filter in
|
||||
`package-wow-original.mjs`. Ascension and RuneWaker entries retain their existing
|
||||
source mappings and sound behavior.
|
||||
|
||||
Current coverage: 378 directly referenced models plus five particle model
|
||||
dependencies; 4,205 of 4,205 source phase assets; 260 mesh models, 1,367 particle
|
||||
emitters, 193 ribbon emitters, and 428 unique source textures. The generated
|
||||
Current coverage: 483 packaged models; 5,066 of 5,066 source phase assets; 311
|
||||
mesh models, 1,711 particle emitters, 249 ribbon emitters, and 479 unique source
|
||||
textures. The generated
|
||||
manifest includes per-class coverage. A spell rank without a source visual
|
||||
phase (for example, a passive or totem-management action) is not counted as a
|
||||
missing model. This does not invent visuals for spells with no source mapping.
|
||||
missing model. This does not invent original visuals for spells with no source
|
||||
mapping; the runtime still supplies its generated school fallback.
|
||||
|
||||
## Generate and verify
|
||||
|
||||
@@ -25,6 +26,12 @@ visuals. `effects:priest` remains a compatibility alias for `effects:wow` so it
|
||||
cannot accidentally revert the main manifest to Priest-only coverage. The old
|
||||
Priest decoder and fixtures remain for compatibility tests.
|
||||
|
||||
The manifest collector also reads effectful boss spell IDs from
|
||||
`dungeonCampaignCatalog.json`. Regenerate the dungeon catalog first whenever
|
||||
boss spell semantics change. Missing or undecodable source audio falls back to
|
||||
the runtime's short spatial school cue; enabled combat audio is never silently
|
||||
dropped merely because a source file is absent.
|
||||
|
||||
Source assets resolve from the extracted sibling `LadiksMPQEditor` base-client
|
||||
directories, with the sibling `wow335a/Data` archive stack recovering missing
|
||||
files. Locale archives supply shared interface textures used by spell models.
|
||||
|
||||
@@ -355,6 +355,21 @@ function collectAbilitySpellIds() {
|
||||
add(spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "ascension", spellId);
|
||||
}
|
||||
}
|
||||
|
||||
const dungeonCatalog = JSON.parse(fs.readFileSync(
|
||||
path.join(generatedRoot, "dungeonCampaignCatalog.json"),
|
||||
"utf8",
|
||||
));
|
||||
for (const dungeon of dungeonCatalog.definitions ?? []) {
|
||||
for (const entity of Object.values(dungeon.entities ?? {})) {
|
||||
if (entity.kind !== "boss") continue;
|
||||
for (const attack of entity.combat?.attacks ?? []) {
|
||||
if (!attack.effect) continue;
|
||||
const spellId = Number(attack.spellId);
|
||||
add(spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "wow335", spellId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { requested: [...requested.values()], runewakerImageBySpellId };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const catalog = JSON.parse(fs.readFileSync(
|
||||
path.join(projectRoot, "src/game/generated/dungeonCampaignCatalog.json"),
|
||||
"utf8",
|
||||
));
|
||||
const manifest = JSON.parse(fs.readFileSync(
|
||||
path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"),
|
||||
"utf8",
|
||||
));
|
||||
|
||||
const highConfidenceEffectName = /\b(?:dominat\w*|polymorph|hex|silence|slow|hamstring|frost nova|chill nova|summon|raise dead|ward|frost armor|drain life|death coil|vampiric touch|leech poison|pierce armor|sunder armor|dark shell|shock barrier|damage immunity)\b/i;
|
||||
const auxiliarySpellIds = new Set([21_708, 47_669, 54_888]);
|
||||
const manifestBySpellId = new Map();
|
||||
for (const entry of manifest.entries ?? []) manifestBySpellId.set(Number(entry.spellId), entry);
|
||||
|
||||
const bossCasts = [];
|
||||
for (const dungeon of catalog.definitions ?? []) {
|
||||
for (const entity of Object.values(dungeon.entities ?? {})) {
|
||||
if (entity.kind !== "boss") continue;
|
||||
for (const attack of entity.combat?.attacks ?? []) {
|
||||
if (!attack.spellId) continue;
|
||||
bossCasts.push({ dungeonId: dungeon.id, boss: entity.name, ...attack });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effectCasts = bossCasts.filter((attack) => attack.effect);
|
||||
const suspicious = bossCasts.filter((attack) => (
|
||||
!attack.effect && highConfidenceEffectName.test(attack.name)
|
||||
));
|
||||
const auxiliaryLeaks = bossCasts.filter((attack) => auxiliarySpellIds.has(attack.spellId));
|
||||
const invalidComposites = effectCasts.filter((attack) => (
|
||||
attack.effect.kind === "composite" && !attack.effect.effects?.length
|
||||
));
|
||||
const atomicEffects = (attack) => attack.effect?.kind === "composite"
|
||||
? attack.effect.effects
|
||||
: attack.effect ? [attack.effect] : [];
|
||||
const harmful = (attack) => atomicEffects(attack).some((effect) => (
|
||||
["damage", "leech", "target-aura", "control", "pull"].includes(effect.kind)
|
||||
));
|
||||
const inferredGroundTelegraph = (attack) => harmful(attack) && (
|
||||
attack.delivery === "area"
|
||||
|| attack.radius !== undefined
|
||||
|| /\b(?:breath|cleave|frontal|sweep|cone|beam|charge|line|lance)\b/i.test(attack.name)
|
||||
);
|
||||
const presentation = effectCasts.map((attack) => {
|
||||
const entry = manifestBySpellId.get(attack.spellId);
|
||||
const originalVisual = Boolean(entry && Object.values(entry.phases ?? {}).some(
|
||||
(assets) => Array.isArray(assets) && assets.length > 0,
|
||||
));
|
||||
const originalAudio = Boolean(entry?.sounds?.some((sound) => sound.url));
|
||||
return { ...attack, originalVisual, originalAudio };
|
||||
});
|
||||
|
||||
const issues = [...suspicious, ...auxiliaryLeaks, ...invalidComposites].map((attack) => (
|
||||
`${attack.dungeonId}:${attack.boss}:${attack.spellId}:${attack.name}`
|
||||
));
|
||||
const report = {
|
||||
status: issues.length ? "failed" : "complete",
|
||||
bosses: new Set(bossCasts.map((attack) => `${attack.dungeonId}:${attack.boss}`)).size,
|
||||
spellCasts: bossCasts.length,
|
||||
explicitEffectCasts: effectCasts.length,
|
||||
presentation: {
|
||||
originalVisual: presentation.filter((attack) => attack.originalVisual).length,
|
||||
generatedVisualFallback: presentation.filter((attack) => !attack.originalVisual).length,
|
||||
originalAudio: presentation.filter((attack) => attack.originalAudio).length,
|
||||
generatedAudioFallback: presentation.filter((attack) => !attack.originalAudio).length,
|
||||
},
|
||||
indicators: {
|
||||
castBars: effectCasts.filter((attack) => (
|
||||
(attack.castTimeMs ?? 0) > 0
|
||||
|| attack.animation === "cast"
|
||||
|| inferredGroundTelegraph(attack)
|
||||
)).length,
|
||||
groundTelegraphs: effectCasts.filter((attack) => (
|
||||
attack.telegraph || inferredGroundTelegraph(attack)
|
||||
)).length,
|
||||
releaseOrImpactCues: effectCasts.length,
|
||||
auraOrControlHud: effectCasts.filter((attack) => {
|
||||
return atomicEffects(attack).some((effect) => (
|
||||
["aura", "target-aura", "control"].includes(effect.kind)
|
||||
));
|
||||
}).length,
|
||||
},
|
||||
issues,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (issues.length) process.exitCode = 1;
|
||||
@@ -23,7 +23,7 @@ const validTargets = new Set([
|
||||
]);
|
||||
const validSchools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
|
||||
const validEffects = new Set([
|
||||
"damage", "heal", "aura", "target-aura", "control", "transform", "summon", "call-for-help", "encounter-event",
|
||||
"damage", "heal", "leech", "aura", "target-aura", "control", "transform", "pull", "summon", "call-for-help", "encounter-event", "composite",
|
||||
]);
|
||||
const basicOnlyBossExceptions = new Map(
|
||||
WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.map(({ key, reason }) => [key, reason]),
|
||||
@@ -102,6 +102,14 @@ function validateAttack(dungeonId, entityId, attack, issues) {
|
||||
if (attack?.effect && !validEffects.has(attack.effect.kind)) {
|
||||
issues.push(key + ": unsupported effect " + attack.effect.kind + ".");
|
||||
}
|
||||
if (attack?.effect?.kind === "composite") {
|
||||
if (!attack.effect.effects?.length) issues.push(key + ": composite effect is empty.");
|
||||
for (const effect of attack.effect.effects ?? []) {
|
||||
if (!validEffects.has(effect.kind) || effect.kind === "composite") {
|
||||
issues.push(key + ": unsupported composite effect " + effect.kind + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reportMarkdown(report) {
|
||||
|
||||
@@ -87,6 +87,14 @@ const epochSpellDbc = path.join(
|
||||
"DBFilesClient",
|
||||
"Spell.dbc",
|
||||
);
|
||||
const defaultSpellSupportRoot = path.join(
|
||||
projectRoot,
|
||||
"..",
|
||||
"LadiksMPQEditor",
|
||||
"client-current",
|
||||
"patch-S",
|
||||
"DBFilesClient",
|
||||
);
|
||||
const standardGameObjectDisplayDbc = path.join(
|
||||
workRoot,
|
||||
"deadmines",
|
||||
@@ -935,7 +943,37 @@ function schoolFor(mask) {
|
||||
return "physical";
|
||||
}
|
||||
|
||||
function parseSpells(buffer, wantedIds) {
|
||||
function numberTable(buffer, field, floating = false) {
|
||||
if (!buffer || buffer.toString("ascii", 0, 4) !== "WDBC") return new Map();
|
||||
const recordCount = buffer.readUInt32LE(4);
|
||||
const fieldCount = buffer.readUInt32LE(8);
|
||||
const recordSize = buffer.readUInt32LE(12);
|
||||
if (fieldCount <= field || recordSize < fieldCount * 4) return new Map();
|
||||
const result = new Map();
|
||||
for (let index = 0; index < recordCount; index += 1) {
|
||||
const base = 20 + index * recordSize;
|
||||
result.set(buffer.readUInt32LE(base), floating
|
||||
? buffer.readFloatLE(base + field * 4)
|
||||
: buffer.readInt32LE(base + field * 4));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function spellSupportTables(spellFile, fallbackRoot) {
|
||||
const siblingRoot = path.dirname(spellFile);
|
||||
const table = async (name, field, floating = false) => {
|
||||
const sibling = path.join(siblingRoot, `${name}.dbc`);
|
||||
const fallback = path.join(fallbackRoot, `${name}.dbc`);
|
||||
const file = await exists(sibling) ? sibling : fallback;
|
||||
return await exists(file) ? numberTable(await readFile(file), field, floating) : new Map();
|
||||
};
|
||||
return {
|
||||
durations: await table("SpellDuration", 3),
|
||||
radii: await table("SpellRadius", 3, true),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()) {
|
||||
if (!buffer || buffer.toString("ascii", 0, 4) !== "WDBC") return new Map();
|
||||
const recordCount = buffer.readUInt32LE(4);
|
||||
const fieldCount = buffer.readUInt32LE(8);
|
||||
@@ -944,6 +982,7 @@ function parseSpells(buffer, wantedIds) {
|
||||
if (fieldCount < 226 || recordSize < fieldCount * 4) return new Map();
|
||||
const stringsOffset = 20 + recordCount * recordSize;
|
||||
const localizedShift = fieldCount >= 239 ? 6 : 0;
|
||||
const shifted = (field) => field >= 77 ? field + localizedShift : field;
|
||||
const readString = (offset) => {
|
||||
if (!offset || offset >= stringSize) return "";
|
||||
const start = stringsOffset + offset;
|
||||
@@ -956,6 +995,7 @@ function parseSpells(buffer, wantedIds) {
|
||||
const id = buffer.readUInt32LE(base);
|
||||
if (!wantedIds.has(id)) continue;
|
||||
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
|
||||
const signed = (field) => buffer.readInt32LE(base + field * 4);
|
||||
let name = "";
|
||||
for (let locale = 0; locale < 16 && !name; locale += 1) {
|
||||
name = readString(unsigned(136 + localizedShift + locale));
|
||||
@@ -965,11 +1005,163 @@ function parseSpells(buffer, wantedIds) {
|
||||
name: name.replace(/\|c[0-9a-f]{8}|\|r/gi, "").trim() || `Spell ${id}`,
|
||||
recoveryTimeMs: unsigned(29),
|
||||
school: schoolFor(unsigned(225)),
|
||||
durationMs: durations.get(unsigned(40)) ?? 0,
|
||||
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
|
||||
effectType: unsigned(71 + effectIndex),
|
||||
auraType: unsigned(shifted(95 + effectIndex)),
|
||||
basePoints: signed(shifted(80 + effectIndex)) + 1,
|
||||
mechanic: unsigned(shifted(83 + effectIndex)),
|
||||
implicitTargetA: unsigned(shifted(86 + effectIndex)),
|
||||
implicitTargetB: unsigned(shifted(89 + effectIndex)),
|
||||
radius: radii.get(unsigned(shifted(92 + effectIndex))) ?? 0,
|
||||
miscValue: signed(shifted(110 + effectIndex)),
|
||||
})).filter((effect) => effect.effectType || effect.auraType),
|
||||
});
|
||||
}
|
||||
return spells;
|
||||
}
|
||||
|
||||
const CONTROL_AURA_MECHANICS = Object.freeze(new Map([
|
||||
[5, "confuse"],
|
||||
[6, "confuse"],
|
||||
[7, "fear"],
|
||||
[12, "stun"],
|
||||
[26, "root"],
|
||||
[27, "silence"],
|
||||
[33, "slow"],
|
||||
[56, "confuse"],
|
||||
[60, "silence"],
|
||||
[92, "root"],
|
||||
[193, "slow"],
|
||||
]));
|
||||
|
||||
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.
|
||||
"29573:54888", // The elemental uses this during reset, not as a combat attack.
|
||||
"13282:21708", // Noxxion's helper dummy is triggered by the actual summon spell.
|
||||
]));
|
||||
|
||||
function spellSemantic(spell, boss, area) {
|
||||
const durationMs = spell.durationMs < 0
|
||||
? 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 controlEffect = spell.effects.find((effect) => CONTROL_AURA_MECHANICS.has(effect.auraType));
|
||||
const controlMechanic = /\b(?:sleep|slumber)\b/i.test(spell.name)
|
||||
? "sleep"
|
||||
: controlEffect ? CONTROL_AURA_MECHANICS.get(controlEffect.auraType) : undefined;
|
||||
const leechEffect = spell.effects.find((effect) => effect.effectType === 9 || effect.auraType === 53);
|
||||
const summons = spell.effects.filter((effect) => (
|
||||
[28, 41].includes(effect.effectType) && effect.miscValue > 0
|
||||
));
|
||||
|
||||
if (spell.id === 52723) {
|
||||
return {
|
||||
kind: "aura",
|
||||
aura: `spell-${spell.id}`,
|
||||
durationMs: 30_000,
|
||||
magnitude: 0.5,
|
||||
lifeStealPercent: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
if (spell.effects.some((effect) => effect.effectType === 43)) {
|
||||
return { kind: "pull", destination: "source" };
|
||||
}
|
||||
|
||||
if (summons.length) {
|
||||
const summonEffects = summons.map((effect) => ({
|
||||
kind: "summon",
|
||||
creatureEntry: effect.miscValue,
|
||||
count: Math.max(1, Math.min(8, effect.basePoints)),
|
||||
}));
|
||||
const effects = hasDamage
|
||||
? [{ kind: "damage", multiplier: damageMultiplier }, ...summonEffects]
|
||||
: summonEffects;
|
||||
return effects.length === 1 ? effects[0] : { kind: "composite", effects };
|
||||
}
|
||||
|
||||
if (leechEffect) {
|
||||
const leech = {
|
||||
kind: "leech",
|
||||
damageMultiplier,
|
||||
healMultiplier: damageMultiplier,
|
||||
};
|
||||
if (!controlEffect) return leech;
|
||||
return {
|
||||
kind: "composite",
|
||||
effects: [
|
||||
leech,
|
||||
{
|
||||
kind: "control",
|
||||
mechanic: controlMechanic,
|
||||
durationMs,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (controlEffect) {
|
||||
return {
|
||||
kind: "control",
|
||||
mechanic: controlMechanic,
|
||||
durationMs,
|
||||
...(hasDamage ? { damageMultiplier } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const absorb = spell.effects.find((effect) => effect.auraType === 69);
|
||||
const reflect = spell.effects.find((effect) => effect.auraType === 28);
|
||||
const schoolDefense = spell.effects.find((effect) => effect.auraType === 87);
|
||||
const armorBuff = spell.effects.find((effect) => effect.auraType === 22);
|
||||
if (schoolDefense && schoolDefense.implicitTargetA !== 1) {
|
||||
const targetAura = {
|
||||
kind: "target-aura",
|
||||
aura: `spell-${spell.id}`,
|
||||
durationMs,
|
||||
damageTakenMultiplier: Math.max(0, 1 + schoolDefense.basePoints / 100),
|
||||
damageSchool: schoolFor(Math.max(0, schoolDefense.miscValue)),
|
||||
};
|
||||
return hasDamage
|
||||
? {
|
||||
kind: "composite",
|
||||
effects: [{ kind: "damage", multiplier: damageMultiplier }, targetAura],
|
||||
}
|
||||
: targetAura;
|
||||
}
|
||||
if (absorb || reflect || schoolDefense || armorBuff) {
|
||||
const defensive = absorb ?? reflect ?? schoolDefense ?? armorBuff;
|
||||
const school = schoolFor(Math.max(0, defensive.miscValue));
|
||||
const damageTakenMultiplier = schoolDefense
|
||||
? Math.max(0, Math.min(1, 1 + schoolDefense.basePoints / 100))
|
||||
: armorBuff ? 0.85 : undefined;
|
||||
return {
|
||||
kind: "aura",
|
||||
aura: `spell-${spell.id}`,
|
||||
durationMs,
|
||||
...(damageTakenMultiplier === undefined ? {} : { damageTakenMultiplier }),
|
||||
...((schoolDefense || armorBuff) ? { damageSchool: schoolDefense ? school : "physical" } : {}),
|
||||
...(absorb ? { absorbMultiplier: 2.5 } : {}),
|
||||
...(reflect ? { reflectSpellChance: Math.max(0, Math.min(1, reflect.basePoints / 100)) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const armorReduction = spell.effects.find((effect) => effect.auraType === 101);
|
||||
if (armorReduction) {
|
||||
return {
|
||||
kind: "target-aura",
|
||||
aura: `spell-${spell.id}`,
|
||||
durationMs,
|
||||
damageTakenMultiplier: 1.2,
|
||||
damageSchool: "physical",
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function basicAttack(cooldownMs = 1_900) {
|
||||
return {
|
||||
id: "basic-attack",
|
||||
@@ -986,13 +1178,19 @@ function basicAttack(cooldownMs = 1_900) {
|
||||
|
||||
function spellAttack(spell, hash, boss) {
|
||||
const area = /nova|roar|stomp|whirl|rain|volley|blizzard|explosion|quake|storm|breath|cleave|fear|horror|terror/i
|
||||
.test(spell.name);
|
||||
.test(spell.name) || spell.id === 53030;
|
||||
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
|
||||
.test(spell.name);
|
||||
const control = /\b(?:fear|horror|terror|terrify)\b/i.test(spell.name)
|
||||
? "fear"
|
||||
: /\b(?:hex|polymorph|dominat\w*)\b/i.test(spell.name)
|
||||
? "confuse"
|
||||
: /\b(?:silence)\b/i.test(spell.name)
|
||||
? "silence"
|
||||
: /\b(?:slow|hamstring)\b/i.test(spell.name)
|
||||
? "slow"
|
||||
: /\b(?:sleep|slumber)\b/i.test(spell.name)
|
||||
? "sleep"
|
||||
: /\b(?:root|entangl\w*|web wrap|web spray|freeze|frozen prison)\b/i.test(spell.name)
|
||||
@@ -1002,10 +1200,19 @@ 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 semanticEffects = semanticEffect?.kind === "composite"
|
||||
? semanticEffect.effects
|
||||
: semanticEffect ? [semanticEffect] : [];
|
||||
const semanticHas = (kind) => semanticEffects.some((entry) => entry.kind === kind);
|
||||
const target = healing
|
||||
? "lowest-health-friendly"
|
||||
: aura
|
||||
: semanticHas("aura") || aura
|
||||
? "self"
|
||||
: semanticEffects.length > 0 && semanticEffects.every((entry) => entry.kind === "summon")
|
||||
? "self"
|
||||
: semanticHas("control")
|
||||
? area ? "nearby-party" : "random-party"
|
||||
: control === "fear" && area
|
||||
? "nearby-party"
|
||||
: area
|
||||
@@ -1013,7 +1220,7 @@ function spellAttack(spell, hash, boss) {
|
||||
: control
|
||||
? "random-party"
|
||||
: "primary";
|
||||
const effect = healing
|
||||
const effect = semanticEffect ?? (healing
|
||||
? { kind: "heal", multiplier: boss ? 1.2 : 0.9 }
|
||||
: control
|
||||
? {
|
||||
@@ -1030,7 +1237,8 @@ function spellAttack(spell, hash, boss) {
|
||||
durationMs: boss ? 12_000 : 8_000,
|
||||
magnitude: boss ? 0.25 : 0.15,
|
||||
}
|
||||
: undefined;
|
||||
: undefined);
|
||||
const dbcRadius = spell.effects.reduce((maximum, entry) => Math.max(maximum, entry.radius), 0);
|
||||
return {
|
||||
id: `spell-${spell.id}`,
|
||||
name: spell.name,
|
||||
@@ -1042,7 +1250,7 @@ function spellAttack(spell, hash, boss) {
|
||||
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: boss ? 11 : 8 } : {}),
|
||||
...(area ? { radius: dbcRadius || (boss ? 11 : 8) } : {}),
|
||||
...(effect ? { effect } : {}),
|
||||
...(healing
|
||||
? {
|
||||
@@ -1189,13 +1397,15 @@ 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;
|
||||
mechanics.push({
|
||||
...attack,
|
||||
...event,
|
||||
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
|
||||
target: [5, 6].includes(Number(action.target_type))
|
||||
? "random-party"
|
||||
: attack.target,
|
||||
target,
|
||||
range: target === "self" ? attack.range : Math.max(attack.range, 28),
|
||||
source,
|
||||
});
|
||||
} else if (actionType === 12) {
|
||||
@@ -1290,7 +1500,11 @@ function combatFor(
|
||||
const seenSpellIds = new Set();
|
||||
const spellAttacks = orderedSpellDetails
|
||||
.filter((detail) => {
|
||||
if (!detail.spellId || seenSpellIds.has(detail.spellId)) return false;
|
||||
if (
|
||||
!detail.spellId
|
||||
|| seenSpellIds.has(detail.spellId)
|
||||
|| AUXILIARY_NON_BOSS_CASTS.has(`${entry}:${detail.spellId}`)
|
||||
) return false;
|
||||
seenSpellIds.add(detail.spellId);
|
||||
return true;
|
||||
})
|
||||
@@ -1310,6 +1524,9 @@ function combatFor(
|
||||
let mechanics = authored?.mode === "replace"
|
||||
? authored.mechanics ?? []
|
||||
: [...(authored?.mechanics ?? []), ...importedMechanics, ...spellAttacks];
|
||||
mechanics = mechanics.filter((mechanic) => (
|
||||
!mechanic.spellId || !AUXILIARY_NON_BOSS_CASTS.has(`${entry}:${mechanic.spellId}`)
|
||||
));
|
||||
mechanics = uniqueMechanics(mechanics);
|
||||
if (!mechanics.length && boss && !authored) mechanics.push(fallbackSpecial(entity.id, true));
|
||||
const levels = (entity.level ?? []).map(Number).filter(Number.isFinite);
|
||||
@@ -1582,11 +1799,23 @@ async function compile() {
|
||||
}
|
||||
}
|
||||
const spellDbc = path.resolve(process.env.ASCENSION_SPELL_DBC ?? defaultSpellDbc);
|
||||
const ascensionSpellSupport = await spellSupportTables(spellDbc, defaultSpellSupportRoot);
|
||||
const ascensionSpells = await exists(spellDbc)
|
||||
? parseSpells(await readFile(spellDbc), wantedSpellIds)
|
||||
? parseSpells(
|
||||
await readFile(spellDbc),
|
||||
wantedSpellIds,
|
||||
ascensionSpellSupport.durations,
|
||||
ascensionSpellSupport.radii,
|
||||
)
|
||||
: new Map();
|
||||
const epochSpellSupport = await spellSupportTables(epochSpellDbc, defaultSpellSupportRoot);
|
||||
const epochSpells = await exists(epochSpellDbc)
|
||||
? parseSpells(await readFile(epochSpellDbc), wantedSpellIds)
|
||||
? parseSpells(
|
||||
await readFile(epochSpellDbc),
|
||||
wantedSpellIds,
|
||||
epochSpellSupport.durations,
|
||||
epochSpellSupport.radii,
|
||||
)
|
||||
: ascensionSpells;
|
||||
const standardGameObjectDisplays = await exists(standardGameObjectDisplayDbc)
|
||||
? parseGameObjectDisplays(await readFile(standardGameObjectDisplayDbc))
|
||||
|
||||
@@ -3,7 +3,10 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const selectedBossName = process.env.BOSS_TEST_BOSS || 'Lady Anacondra';
|
||||
const directory = path.resolve('playtest-artifacts', selectedBossName === 'Celebras the Cursed' ? 'boss-testing-scaling' : 'boss-testing');
|
||||
const directory = path.resolve(process.env.BOSS_TEST_ARTIFACT_DIR || path.join(
|
||||
'playtest-artifacts',
|
||||
selectedBossName === 'Celebras the Cursed' ? 'boss-testing-scaling' : 'boss-testing',
|
||||
));
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
||||
(async () => {
|
||||
|
||||
Reference in new issue
Block a user