Release Healer Man 0.1.45

This commit is contained in:
phenom committed 2026-09-15 14:43:15 -04:00
1 parent 756ee4f64a
commit f82daf1c13
242 files changed
+1440 -101

No files matched your search

+5 -1
View File
@@ -5,6 +5,7 @@ import {
} from "../abilityCatalog";
import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules";
import { PLAYER_AGGRO_ID } from "../aggro";
import { enemyControlPreventsActions } from "../enemyAttacks";
import type { CastFailureReason, CombatState } from "../combatStore";
import { equipmentAdjustedCombatAmount, resolvedAbilityCosts, useCombatStore } from "../combatStore";
import { getMobPosition } from "../mobRuntimeRegistry";
@@ -187,7 +188,10 @@ export class BenchmarkController {
|| combat.health <= 0
|| combat.activeCast
|| combat.globalCooldownEndsAt > now
|| combat.controlledUntil > now
|| (
combat.controlledUntil > now
&& enemyControlPreventsActions(combat.controlMechanic)
)
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
const targets = friendlyTargets(combat, party);
+23 -6
View File
@@ -113,6 +113,12 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
return `${titleCase(attack.school)} damage to ${target} (${Number(multiplier.toFixed(2))}× boss power)`;
}
if (effect.kind === "heal") return `Heals ${target} for ${effect.multiplier}× boss power`;
if (effect.kind === "leech") {
return `Drains ${target} for ${effect.damageMultiplier}× boss power and heals the caster for ${effect.healMultiplier}× boss power`;
}
if (effect.kind === "composite") {
return effect.effects.map((entry) => describeEffect(entry, attack)).join("; ");
}
if (effect.kind === "aura") {
if (effect.meleeAutoCriticalProc) {
const proc = effect.meleeAutoCriticalProc;
@@ -131,6 +137,7 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
return `${damage}${titleCase(effect.mechanic)} controls ${target} for ${seconds(effect.durationMs)}`;
}
if (effect.kind === "transform") return `Transforms ${target} into ${titleCase(effect.form)}`;
if (effect.kind === "pull") return `Pulls ${target} to the boss`;
if (effect.kind === "summon") return `Summons ${effect.count} add${effect.count === 1 ? "" : "s"} (creature ${effect.creatureEntry})`;
if (effect.kind === "call-for-help") return `Calls hostile allies within ${effect.radius}m into combat`;
return `Advances encounter event “${effect.eventId}”`;
@@ -139,12 +146,17 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
function describeTelegraph(telegraph: EnemyAttackTelegraph | undefined, attack: EnemyAttackDefinition): string {
if (!telegraph) {
const warning = (attack.castTimeMs ?? 0) > 0 ? ` with a ${seconds(attack.castTimeMs!)} cast warning` : "";
return attack.effect?.kind === "heal"
|| attack.effect?.kind === "aura"
|| attack.effect?.kind === "transform"
|| attack.effect?.kind === "summon"
|| attack.effect?.kind === "call-for-help"
|| attack.effect?.kind === "encounter-event"
const effects = attack.effect?.kind === "composite"
? attack.effect.effects
: attack.effect ? [attack.effect] : [];
return effects.length > 0 && effects.every((effect) => (
effect.kind === "heal"
|| effect.kind === "aura"
|| effect.kind === "transform"
|| effect.kind === "summon"
|| effect.kind === "call-for-help"
|| effect.kind === "encounter-event"
))
? `No harmful ground area${warning}`
: `Targeted mechanic; no ground area${warning}`;
}
@@ -160,12 +172,17 @@ function describeTelegraph(telegraph: EnemyAttackTelegraph | undefined, attack:
function effectFollowup(effect: EnemyAbilityEffect | undefined): string {
if (!effect) return "Heal the affected party members after it resolves.";
if (effect.kind === "composite") {
return [...new Set(effect.effects.map((entry) => effectFollowup(entry)))].join(" ");
}
if (effect.kind === "leech") return "Limit the drain with mitigation and recover the health it steals.";
if (effect.kind === "control") return "Stabilize the controlled player and remove the control if an available ability allows it.";
if (effect.kind === "target-aura") return "Watch the affected player's debuff and respond with healing or removal if the effect permits it.";
if (effect.kind === "summon") return "Swap damage to the summoned adds and keep them away from vulnerable party members.";
if (effect.kind === "heal") return "Interrupt the heal when possible; otherwise plan for the boss to recover health.";
if (effect.kind === "call-for-help") return "Clear or control nearby enemies before this ability resolves.";
if (effect.kind === "transform") return "Expect the boss's behavior to change after the new form appears.";
if (effect.kind === "pull") return "Regain safe spacing after the forced movement resolves.";
if (effect.kind === "encounter-event") return "Watch for the encounter-state change and handle the newly activated objective.";
if (effect.kind === "aura") return "Adjust damage and healing for the aura while it remains active.";
return "Heal the affected party members after it resolves.";
+25 -2
View File
@@ -81,6 +81,26 @@ describe("Boss Testing", () => {
expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks);
});
it("keeps low extrapolated boss health alive long enough to test mechanics", () => {
const zereketh = BOSS_TESTING_CATALOG.find((entry) => entry.name === "Zereketh the Unbound")!;
const arcatraz = requireDungeonDefinition(zereketh.dungeonId);
const room = createBossTestingRoom(arcatraz, zereketh);
const entity = room.definition.entities[zereketh.entityId];
const nativeHealth = creatureRuntimeBaseStats(entity.combat!.serverEntry!, 2)!.health
* entity.combat!.healthMultiplier!;
const testing = dungeonEnemyScaling(
room.definition,
entity,
zereketh.spawn.id,
1,
room.definition.defaultDifficultyId,
1,
);
expect(nativeHealth).toBeLessThan(100);
expect(testing).toMatchObject({ level: 2, maxHealth: 540 });
});
it("omits the campaign finale bonus even when testing the source dungeon's final boss", () => {
const final = finalDungeonBoss(source)!;
const finalBoss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === source.id && entry.spawn.id === final.id)!;
@@ -88,8 +108,11 @@ describe("Boss Testing", () => {
const entity = room.definition.entities[finalBoss.entityId];
const testing = dungeonEnemyScaling(room.definition, entity, finalBoss.spawn.id, 1, source.defaultDifficultyId, 1);
const campaign = dungeonEnemyScaling(source, entity, finalBoss.spawn.id, 1, source.defaultDifficultyId, 1);
expect(testing.maxHealth).toBe(creatureRuntimeBaseStats(entity.combat!.serverEntry!, 2)!.health
* (entity.combat?.healthMultiplier ?? 1));
expect(testing.maxHealth).toBe(Math.max(
creatureRuntimeBaseStats(entity.combat!.serverEntry!, 2)!.health
* (entity.combat?.healthMultiplier ?? 1),
540,
));
expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 900 * 1.8));
});
+4 -1
View File
@@ -84,7 +84,10 @@ export function createBossTestingRoom(source: DungeonDefinition, boss: TestableB
// Summoned creature definitions are needed even though they are absent from the initial room.
for (const entityId of entityIds) {
for (const attack of source.entities[entityId]?.combat?.attacks ?? []) {
for (const effect of attack.effect ? [attack.effect] : []) {
const effects = attack.effect?.kind === "composite"
? attack.effect.effects
: attack.effect ? [attack.effect] : [];
for (const effect of effects) {
if (effect.kind !== "summon") continue;
for (const candidate of Object.values(source.entities)) {
if (candidate.id === effect.entityId || candidate.combat?.serverEntry === effect.creatureEntry) entityIds.add(candidate.id);
+46 -19
View File
@@ -117,32 +117,58 @@ describe("combat audio output policy", () => {
expect(AudioContextConstructor).not.toHaveBeenCalled();
});
it("stays silent without an original sound, even with combat audio enabled", () => {
combatMedia.ready = true;
const AudioContextConstructor = vi.fn();
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
playCombatAudio(fixtureEvent);
expect(AudioContextConstructor).not.toHaveBeenCalled();
expect(combatMedia.fetch).not.toHaveBeenCalled();
});
it.each(["original", "missing-file", "bad-audio"])("uses original audio or silence for %s", async (scenario) => {
vi.resetModules();
const audio = await import("./combatAudio");
const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null };
it("plays a generated spatial cue when an original sound is unavailable", () => {
const oscillator = {
connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(),
frequency: { setValueAtTime: vi.fn() }, type: "sine", onended: null,
};
const audioContext = {
currentTime: 0,
state: "running",
destination: {},
createGain: () => ({
connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), value: 1 },
connect: vi.fn(), disconnect: vi.fn(),
gain: {
setTargetAtTime: vi.fn(), setValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(), value: 1,
},
}),
createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }),
createOscillator: vi.fn(() => oscillator),
resume: vi.fn(async () => undefined),
};
vi.stubGlobal("window", { AudioContext: function () { return audioContext; } });
configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
playCombatAudio(fixtureEvent);
expect(audioContext.createOscillator).toHaveBeenCalledTimes(1);
expect(oscillator.start).toHaveBeenCalledTimes(1);
expect(oscillator.stop).toHaveBeenCalledTimes(1);
expect(combatMedia.fetch).not.toHaveBeenCalled();
});
it.each(["original", "missing-file", "bad-audio"])("uses original audio or a generated fallback for %s", async (scenario) => {
vi.resetModules();
const audio = await import("./combatAudio");
const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null };
const oscillator = {
connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(),
frequency: { setValueAtTime: vi.fn() }, type: "sine", onended: null,
};
const audioContext = {
currentTime: 0,
state: "running",
destination: {},
createGain: () => ({
connect: vi.fn(), disconnect: vi.fn(), gain: {
setTargetAtTime: vi.fn(), setValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(), value: 1,
},
}),
createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }),
createBufferSource: vi.fn(() => source),
createOscillator: vi.fn(),
createOscillator: vi.fn(() => oscillator),
decodeAudioData: vi.fn(async () => {
if (scenario === "bad-audio") throw new Error("Invalid audio");
return { duration: 1 };
@@ -165,7 +191,8 @@ describe("combat audio output policy", () => {
expect(combatMedia.fetch).toHaveBeenCalledWith("/assets/game/combat-effects/audio/flash-heal.wav");
expect(source.start).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0);
expect(audioContext.createBufferSource).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0);
expect(audioContext.createOscillator).not.toHaveBeenCalled();
expect(audioContext.createOscillator).toHaveBeenCalledTimes(scenario === "original" ? 0 : 1);
expect(oscillator.start).toHaveBeenCalledTimes(scenario === "original" ? 0 : 1);
audio.resetCombatAudio();
});
});
+67 -1
View File
@@ -256,6 +256,62 @@ async function playBufferedCombatCue(event: CombatPresentationEvent, sound: Comb
return true;
}
const GENERATED_SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationEvent["school"], number>> = {
physical: 150,
holy: 520,
fire: 230,
nature: 330,
frost: 410,
shadow: 110,
arcane: 620,
};
function playGeneratedCombatCue(event: CombatPresentationEvent): boolean {
if (
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return false;
const audioContext = ensureContext();
if (!audioContext || !groupGains) return false;
void audioContext.resume().catch(() => undefined);
const oscillator = audioContext.createOscillator();
const envelope = audioContext.createGain();
const panner = positionPanner(audioContext, event);
const duration = event.phase === "cast-start" ? 0.12 : event.phase === "aura-start" ? 0.2 : 0.09;
const phasePitch = event.phase === "release" ? 1.15 : event.phase === "impact" ? 0.82 : 1;
oscillator.type = event.school === "shadow" || event.school === "physical" ? "triangle" : "sine";
oscillator.frequency.setValueAtTime(
GENERATED_SCHOOL_FREQUENCY[event.school] * phasePitch,
audioContext.currentTime,
);
envelope.gain.setValueAtTime(0.0001, audioContext.currentTime);
envelope.gain.exponentialRampToValueAtTime(0.075, audioContext.currentTime + 0.01);
envelope.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + duration);
oscillator.connect(envelope);
envelope.connect(panner);
panner.connect(groupGains[event.actorGroup]);
let stopped = false;
const voice: ActiveVoice = {
group: event.actorGroup,
stop: () => {
if (stopped) return;
stopped = true;
try { oscillator.stop(); } catch { /* already stopped */ }
},
};
const unregister = registerVoice(voice);
oscillator.onended = () => {
stopped = true;
unregister();
oscillator.disconnect();
envelope.disconnect();
panner.disconnect();
};
oscillator.start();
oscillator.stop(audioContext.currentTime + duration);
return true;
}
export function playCombatAudio(event: CombatPresentationEvent): void {
if (event.phase === "cast-cancel") {
stopCombatAudioGroup(event.actorGroup);
@@ -266,7 +322,17 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return;
const sound = weightedSound(event);
if (sound) void playBufferedCombatCue(event, sound).catch(() => undefined);
if (sound) {
void playBufferedCombatCue(event, sound)
.then((played) => {
if (!played) playGeneratedCombatCue(event);
})
.catch(() => {
playGeneratedCombatCue(event);
});
} else {
playGeneratedCombatCue(event);
}
}
export function stopCombatAudioGroup(group: CombatActorGroup): void {
+1 -1
View File
@@ -150,7 +150,7 @@ export interface AuraDefinition {
/** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */
readonly utilityTags?: readonly string[];
/** Enemy control mechanics ignored while this aura is active. */
readonly controlImmunities?: readonly ("confuse" | "fear" | "sleep" | "root" | "stun" | "silence")[];
readonly controlImmunities?: readonly ("confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence")[];
/** Chance from 0 through 1 to reflect an incoming spell. */
readonly reflectSpellChance?: number;
/** Portion of incoming target damage transferred to the aura source. */
+128 -5
View File
@@ -179,6 +179,7 @@ import {
} from "./combatResults";
import {
DEFAULT_ENEMY_ATTACK,
enemyControlPreventsActions,
maximumEnemyAttackRange,
normalizeEnemyAttacks,
type EnemyAttackAnimation,
@@ -301,6 +302,7 @@ export interface MobAura {
readonly sourceAbilityId: string;
readonly endsAt: number;
readonly magnitude?: number;
readonly lifeStealPercent?: number;
/** Persistent driver for a content-authored main-hand auto-attack proc. */
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
/** A charged modifier consumed by a successful main-hand auto attack. */
@@ -4285,7 +4287,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
else if (ability.effects.every((effect) => effect.kind === "scripted" || effect.kind === "unsupported")) failure = "unsupported";
else if (!isAbilityUnlocked(ability, state.level)) failure = "level-locked";
else if (state.health <= 0) failure = "player-dead";
else if (state.controlledUntil > now) failure = "player-controlled";
else if (
state.controlledUntil > now
&& enemyControlPreventsActions(state.controlMechanic)
) failure = "player-controlled";
else if (
effectiveTarget === "hostile"
&& abilityAttackKind(state.classId, ability) === "melee"
@@ -6904,7 +6909,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|| !enemySpatial
|| isPositionInFacingArc(enemySpatial, candidate, Math.PI);
});
const presentationTargetId = targets[0] ?? resolution.targetActorId;
const presentationTargetId = attack.target === "self"
? id
: targets[0] ?? resolution.targetActorId;
const presentationTargetPosition = attack.target === "self"
? position
: actorPosition(runtimeState, presentationTargetId);
const instantSourceCast = !resolvedEnemyCast && attack.spellId !== undefined;
emitEnemyAttackPresentation(
mob,
@@ -6913,10 +6923,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
now,
position,
presentationTargetId,
actorPosition(runtimeState, presentationTargetId),
presentationTargetPosition,
instantSourceCast,
);
const effects = effect.kind === "composite" ? effect.effects : [effect];
let hasDamageResolution = false;
for (const effect of effects) {
if (effect.kind === "damage") {
hasDamageResolution = true;
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
const attackDamage = attack.runewakerFormula
? adaptRunewakerEnemyDamage({
@@ -6934,6 +6948,25 @@ export const useCombatStore = create<CombatState>((set, get) => ({
instantCast: instantSourceCast,
});
}
} else if (effect.kind === "leech") {
hasDamageResolution = true;
const attackDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier));
for (const actorId of targets) {
incomingHits.push({
actorId,
amount: attackDamage,
mob,
attack,
attackRevision,
instantCast: instantSourceCast,
});
}
if (targets.length) {
enemyHealing.push({
targetId: id,
amount: Math.max(1, Math.round(mob.attackDamage * effect.healMultiplier)),
});
}
} else if (effect.kind === "heal") {
const requiredMissingHealth = attack.condition?.kind === "friendly-missing-health"
? attack.condition.amount
@@ -6975,6 +7008,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
sourceAbilityId: attack.id,
endsAt,
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
...(effect.lifeStealPercent === undefined
? {}
: { lifeStealPercent: effect.lifeStealPercent }),
...(effect.meleeAutoCriticalProc === undefined
? {}
: { meleeAutoCriticalProc: effect.meleeAutoCriticalProc }),
@@ -6987,6 +7023,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
disposition: "buff",
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
dispelCategory: effect.aura === "enrage" ? "enrage" : "magic",
...(effect.damageTakenMultiplier === undefined
? {}
: {
modifiers: [{
kind: "damage" as const,
direction: "taken" as const,
operation: "percent" as const,
value: effect.damageTakenMultiplier - 1,
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
}],
}),
...(effect.absorbMultiplier === undefined
? {}
: {
absorbs: [{
id: `${attack.id}:absorb`,
amount: Math.max(1, Math.round(mob.attackDamage * effect.absorbMultiplier)),
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
}],
}),
...(effect.reflectSpellChance === undefined
? {}
: { reflectSpellChance: effect.reflectSpellChance }),
},
sourceId: id,
targetId: id,
@@ -7001,6 +7060,17 @@ export const useCombatStore = create<CombatState>((set, get) => ({
disposition: "debuff",
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
dispelCategory: "magic",
...(effect.damageTakenMultiplier === undefined
? {}
: {
modifiers: [{
kind: "damage" as const,
direction: "taken" as const,
operation: "percent" as const,
value: effect.damageTakenMultiplier - 1,
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
}],
}),
},
sourceId: id,
targetId: actorId,
@@ -7009,6 +7079,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
} else if (effect.kind === "control") {
if ((effect.damageMultiplier ?? 0) > 0) {
hasDamageResolution = true;
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier!));
const attackDamage = attack.runewakerFormula
? adaptRunewakerEnemyDamage({
@@ -7036,7 +7107,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
definition: {
id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`,
name: `${attack.name}: ${effect.mechanic}`,
description: `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`,
description: effect.mechanic === "slow"
? `${attack.name} reduces movement speed until the effect ends.`
: effect.mechanic === "root"
? `${attack.name} prevents movement until the effect ends.`
: `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`,
disposition: "debuff",
durationMs: effect.durationMs,
dispelCategory: "magic",
@@ -7055,6 +7130,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
endsAt: 0,
},
];
} else if (effect.kind === "pull") {
const pulledPosition: CombatPosition = [position[0], position[1], position[2]];
for (const actorId of targets) {
if (actorId === PLAYER_AGGRO_ID) {
set({ playerPosition: pulledPosition });
} else {
updatePartyRuntimePosition(
actorId,
pulledPosition[0],
pulledPosition[1],
pulledPosition[2],
);
}
}
} else if (effect.kind === "call-for-help") {
for (const helperId of nearbyMobRuntimeIds(position, effect.radius)) {
const helper = mobs[helperId];
@@ -7096,6 +7185,37 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} else if (effect.kind === "encounter-event") {
dispatchDungeonEncounterSignal(effect.eventId, now);
}
}
if (hasDamageResolution && targets.length) {
const lifeStealPercent = activeAuras.reduce(
(maximum, aura) => Math.max(maximum, aura.lifeStealPercent ?? 0),
0,
);
if (lifeStealPercent > 0) {
enemyHealing.push({
targetId: id,
amount: Math.max(1, Math.round(mob.attackDamage * lifeStealPercent)),
});
}
}
if (!hasDamageResolution) {
const auraResolution = effects.some((entry) => (
entry.kind === "aura"
|| entry.kind === "target-aura"
|| entry.kind === "control"
|| entry.kind === "transform"
));
emitEnemyAttackPresentation(
mob,
attack,
auraResolution ? "aura-start" : "impact",
now,
position,
presentationTargetId,
presentationTargetPosition,
instantSourceCast,
);
}
attackRevision += 1;
abilityCastCounts[attack.id] = (abilityCastCounts[attack.id] ?? 0) + 1;
attackAnimation = attack.animation;
@@ -7795,7 +7915,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const autoAttackSource = combatSpatialActor(PLAYER_AGGRO_ID);
const canAutoAttack = (
!state.activeCast
&& state.controlledUntil <= now
&& (
state.controlledUntil <= now
|| !enemyControlPreventsActions(state.controlMechanic)
)
&& Math.sqrt(mobRuntimeDistanceSquared(autoAttackTargetId!, state.playerPosition)) <= PLAYER_AUTO_ATTACK_RANGE
&& Boolean(autoAttackTargetPosition)
&& combatHasLineOfSight(state.playerPosition, autoAttackTargetPosition ?? state.playerPosition)
+10 -4
View File
@@ -14,6 +14,7 @@ const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])
[41, 23_000],
[80, 180_000],
];
const BOSS_TEST_HEALTH_FLOOR_MULTIPLIER = 0.6;
function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number {
const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel);
@@ -33,7 +34,7 @@ function partyBossHealthFloor(level: number, characterSource: ContentCategoryId)
return baseline * (characterSource === "rom" ? 1.15 : 1.75);
}
/** Campaign fights retain a durability floor; testing rooms use level-scaled authored stats. */
/** Campaign fights retain their full durability floor; testing rooms use a reduced floor. */
export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: PopulationEntityDefinition,
instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number,
characterSource: ContentCategoryId = "wow") {
@@ -50,8 +51,13 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
// Match durability to the character's game, including when crossing dungeon catalogs.
const partyHealthFloor = partyBossHealthFloor(level, characterSource);
const bossFloor = partyHealthFloor * (finalBoss ? 1.8 : 1);
// A test room's sole objective must not promote every boss to a campaign finale
// or replace low-level native health with the campaign's full-party minimum.
return { level, maxHealth: (boss && !testing ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health,
// A test room's sole objective must not promote every boss to a campaign
// finale. A reduced floor still prevents high-level source creatures with
// tiny extrapolated level-two health from dying before mechanics can run.
const testingBossFloor = partyHealthFloor * BOSS_TEST_HEALTH_FLOOR_MULTIPLIER;
const scaledHealth = boss
? Math.max(originalHealth, testing ? testingBossFloor : bossFloor)
: originalHealth;
return { level, maxHealth: scaledHealth * multipliers.health,
healthMultiplier: authoredHealth * multipliers.health, damageMultiplier: authoredDamage * multipliers.damage };
}
+103 -4
View File
@@ -560,23 +560,122 @@ describe("generated dungeon campaign catalog", () => {
dungeonId: string,
bossName: string,
attackName: string,
): { readonly effect?: unknown } | undefined => {
): { readonly damageMultiplier: number; readonly effect?: unknown } | undefined => {
const dungeon = generated.definitions.find((candidate) => candidate.id === dungeonId);
const entity = Object.values(dungeon?.entities ?? {}).find(
(candidate) => candidate.name === bossName,
) as undefined | {
readonly combat: {
readonly attacks: ReadonlyArray<{ readonly name: string; readonly effect?: unknown }>;
readonly attacks: ReadonlyArray<{
readonly name: string;
readonly damageMultiplier: number;
readonly effect?: unknown;
}>;
};
};
return entity?.combat.attacks.find((attack) => attack.name === attackName);
};
expect(attackFor("forge-of-souls", "Bronjahm", "Fear")).toMatchObject({
effect: { kind: "control", mechanic: "fear", durationMs: 4_500 },
effect: { kind: "control", mechanic: "fear", durationMs: 3_000 },
});
expect(attackFor("culling-of-stratholme", "Mal'Ganis", "Sleep")).toMatchObject({
effect: { kind: "control", mechanic: "sleep", durationMs: 4_500 },
effect: { kind: "control", mechanic: "sleep", durationMs: 10_000 },
});
expect(attackFor("blackrock-depths", "Warder Stilgiss", "Frostbolt")).toMatchObject({
damageMultiplier: 0,
effect: {
kind: "control",
mechanic: "slow",
durationMs: 8_000,
damageMultiplier: 1.35,
},
});
expect(attackFor("blackrock-depths", "Warder Stilgiss", "Frost Nova")).toMatchObject({
damageMultiplier: 0,
effect: {
kind: "control",
mechanic: "root",
durationMs: 8_000,
damageMultiplier: 0.82,
},
});
expect(attackFor("razorfen-kraul", "Aggem Thorncurse", "Summon Boar Spirit"))
.toMatchObject({
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry: 6021, count: 1 },
});
expect(attackFor("blackrock-depths", "Bael'Gar", "Summon Spawn of Bael'Gar"))
.toMatchObject({
effect: {
kind: "composite",
effects: [
{ kind: "damage", multiplier: 1.35 },
{ kind: "summon", creatureEntry: 9436, count: 1 },
],
},
});
expect(attackFor("shattered-halls", "Grand Warlock Nethekurse", "Death Coil"))
.toMatchObject({
effect: {
kind: "composite",
effects: [
{ kind: "leech", damageMultiplier: 1.35, healMultiplier: 1.35 },
{ kind: "control", mechanic: "fear", durationMs: 4_000 },
],
},
});
expect(attackFor("magisters-terrace", "Kael'thas Sunstrider", "Shock Barrier"))
.toMatchObject({
effect: { kind: "aura", absorbMultiplier: 2.5, durationMs: 10_000 },
});
expect(attackFor("blackrock-spire", "General Drakkisath", "Pierce Armor"))
.toMatchObject({
effect: {
kind: "target-aura",
damageTakenMultiplier: 1.2,
damageSchool: "physical",
},
});
expect(attackFor("culling-of-stratholme", "Mal'Ganis", "Vampiric Touch"))
.toMatchObject({
effect: { kind: "aura", lifeStealPercent: 0.5, durationMs: 30_000 },
});
expect(attackFor("underbog", "The Black Stalker", "Summon Player"))
.toMatchObject({ effect: { kind: "pull", destination: "source" } });
const effectRegressionSpellIds = new Set([
246, 6_016, 7_645, 8_286, 8_398, 8_988, 9_080, 10_061, 10_180, 11_086,
11_700, 12_097, 12_479, 12_480, 12_556, 12_642, 13_323, 13_747, 13_895,
14_515, 14_907, 15_041, 15_044, 15_487, 15_530, 15_572, 15_859,
16_097, 17_235, 17_238, 17_279, 17_503, 17_773, 18_099, 18_327, 20_279, 21_707,
22_714, 30_500, 31_692, 32_358, 32_364, 32_365, 32_371, 32_424, 34_309,
34_557, 35_032, 35_280, 36_931, 36_932, 37_162, 38_245, 44_219, 44_294, 46_165,
49_037, 52_723, 53_030, 53_418, 66_043, 67_534, 67_705, 67_715,
]);
const observedEffectSpellIds = new Set<number>();
for (const dungeon of generated.definitions) {
for (const entity of Object.values(dungeon.entities)) {
if (entity.kind !== "boss") continue;
for (const attack of entity.combat.attacks) {
if (!effectRegressionSpellIds.has(attack.spellId ?? 0)) continue;
expect(attack.effect, `${dungeon.id}:${entity.name}:${attack.name}`).toBeDefined();
observedEffectSpellIds.add(attack.spellId!);
}
}
}
expect([...observedEffectSpellIds].sort((left, right) => left - right))
.toEqual([...effectRegressionSpellIds].sort((left, right) => left - right));
const auxiliarySpellIds = new Set([21_708, 47_669, 54_888]);
for (const dungeon of generated.definitions) {
for (const entity of Object.values(dungeon.entities)) {
if (entity.kind !== "boss") continue;
expect(
entity.combat.attacks.some((attack: { readonly spellId?: number }) => (
auxiliarySpellIds.has(attack.spellId ?? 0)
)),
`${dungeon.id}:${entity.name}:auxiliary-cast`,
).toBe(false);
}
}
expect(
attackFor("escape-from-durnholde", "Captain Skarloc", "Holy Light"),
).toMatchObject({
+38 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { normalizeEnemyAttack, type EnemyAttackDefinition } from "./enemyAttacks";
import {
ENEMY_SLOW_MOVEMENT_MULTIPLIER,
enemyControlMovementMultiplier,
enemyControlPreventsActions,
enemyControlPreventsMovement,
normalizeEnemyAttack,
type EnemyAttackDefinition,
} from "./enemyAttacks";
function attack(overrides: Partial<EnemyAttackDefinition>): EnemyAttackDefinition {
return {
@@ -52,5 +59,35 @@ describe("enemy attack telegraphs", () => {
expect(normalizeEnemyAttack(attack({
effect: { kind: "heal", multiplier: 1 },
})).telegraph).toBeUndefined();
expect(normalizeEnemyAttack(attack({
effect: {
kind: "composite",
effects: [{ kind: "summon", creatureEntry: 1, count: 1 }],
},
})).telegraph).toBeUndefined();
expect(normalizeEnemyAttack(attack({
effect: {
kind: "composite",
effects: [
{ kind: "damage", multiplier: 1 },
{ kind: "summon", creatureEntry: 1, count: 1 },
],
},
})).telegraph).toMatchObject({ shape: "circle" });
});
});
describe("enemy control movement", () => {
it("keeps slowed actors active while reducing movement", () => {
expect(enemyControlPreventsActions("slow")).toBe(false);
expect(enemyControlPreventsMovement("slow")).toBe(false);
expect(enemyControlMovementMultiplier("slow")).toBe(ENEMY_SLOW_MOVEMENT_MULTIPLIER);
});
it("keeps roots movement-only while preserving hard-control action locks", () => {
expect(enemyControlPreventsActions("root")).toBe(false);
expect(enemyControlPreventsMovement("root")).toBe(true);
expect(enemyControlMovementMultiplier("root")).toBe(1);
expect(enemyControlPreventsActions("stun")).toBe(true);
});
});
+59 -5
View File
@@ -20,7 +20,28 @@ export type EnemyDamageSchool =
| "shadow"
| "holy";
export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "stun" | "silence";
export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence";
/** Frost-style slows preserve actions while reducing authored movement speed. */
export const ENEMY_SLOW_MOVEMENT_MULTIPLIER = 0.6;
export function enemyControlPreventsActions(
mechanic: EnemyControlMechanic | null,
): boolean {
return mechanic !== null && mechanic !== "root" && mechanic !== "slow";
}
export function enemyControlPreventsMovement(
mechanic: EnemyControlMechanic | null,
): boolean {
return mechanic !== null && mechanic !== "slow";
}
export function enemyControlMovementMultiplier(
mechanic: EnemyControlMechanic | null,
): number {
return mechanic === "slow" ? ENEMY_SLOW_MOVEMENT_MULTIPLIER : 1;
}
/**
* A persistent enemy aura that procs a short-lived, charged critical-strike
@@ -49,20 +70,35 @@ export interface EnemyAttackTelegraph {
readonly angleDegrees?: number;
}
export type EnemyAbilityEffect =
export type EnemyAtomicAbilityEffect =
| { readonly kind: "damage"; readonly multiplier: number }
| { readonly kind: "heal"; readonly multiplier: number }
| {
readonly kind: "leech";
readonly damageMultiplier: number;
readonly healMultiplier: number;
}
| {
readonly kind: "aura";
readonly aura: string;
readonly durationMs: number;
readonly magnitude?: number;
/** Multiplier applied to damage received while this aura is active. */
readonly damageTakenMultiplier?: number;
readonly damageSchool?: EnemyDamageSchool;
/** Absorb capacity expressed in multiples of the caster's attack power. */
readonly absorbMultiplier?: number;
readonly reflectSpellChance?: number;
/** Portion of subsequent outgoing attack power returned as healing. */
readonly lifeStealPercent?: number;
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
}
| {
readonly kind: "target-aura";
readonly aura: string;
readonly durationMs: number;
readonly damageTakenMultiplier?: number;
readonly damageSchool?: EnemyDamageSchool;
}
| {
readonly kind: "control";
@@ -72,6 +108,7 @@ export type EnemyAbilityEffect =
readonly damageMultiplier?: number;
}
| { readonly kind: "transform"; readonly form: string }
| { readonly kind: "pull"; readonly destination: "source" }
| {
readonly kind: "summon";
readonly creatureEntry: number;
@@ -84,6 +121,12 @@ export type EnemyAbilityEffect =
| { readonly kind: "call-for-help"; readonly radius: number }
| { readonly kind: "encounter-event"; readonly eventId: string };
export type EnemyAbilityEffect = EnemyAtomicAbilityEffect | {
/** A single source spell that resolves more than one supported mechanic. */
readonly kind: "composite";
readonly effects: readonly EnemyAtomicAbilityEffect[];
};
export type EnemyAbilityCondition =
| { readonly kind: "in-combat" }
| {
@@ -179,10 +222,21 @@ function normalizeCooldownRange(
}
function harmfulEnemyAttack(value: EnemyAttackDefinition): boolean {
return !value.effect
|| value.effect.kind === "damage"
if (!value.effect) return true;
if (value.effect.kind === "composite") {
return value.effect.effects.some((effect) => (
effect.kind === "damage"
|| effect.kind === "leech"
|| effect.kind === "control"
|| effect.kind === "target-aura"
|| effect.kind === "pull"
));
}
return value.effect.kind === "damage"
|| value.effect.kind === "leech"
|| value.effect.kind === "control"
|| value.effect.kind === "target-aura";
|| value.effect.kind === "target-aura"
|| value.effect.kind === "pull";
}
/**
+96
View File
@@ -161,4 +161,100 @@ describe("enemy casting and interrupts", () => {
engaged: false,
});
});
it("resolves composite drains as damage, healing, control, and one impact cue", () => {
const deathCoil: EnemyAttackDefinition = {
...spell("death-coil", "shadow", 0),
effect: {
kind: "composite",
effects: [
{ kind: "leech", damageMultiplier: 1, healMultiplier: 1 },
{ kind: "control", mechanic: "fear", durationMs: 4_000 },
],
},
};
registerCaster([deathCoil]);
useCombatStore.getState().damageMob("caster", 400, 999);
const bossHealth = useCombatStore.getState().mobs.caster.health;
const playerHealth = useCombatStore.getState().health;
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === "caster") {
events.push(message.event);
}
});
expect(useCombatStore.getState().advanceMobCombat(1_000)).toBeGreaterThan(0);
expect(useCombatStore.getState().health).toBeLessThan(playerHealth);
expect(useCombatStore.getState().mobs.caster.health).toBeGreaterThan(bossHealth);
expect(useCombatStore.getState()).toMatchObject({
controlMechanic: "fear",
controlledUntil: 5_000,
});
expect(events.map((event) => event.phase)).toEqual(["release", "impact"]);
unsubscribe();
});
it("anchors defensive aura indicators on the caster and applies their gameplay payload", () => {
const barrier: EnemyAttackDefinition = {
...spell("shock-barrier", "arcane", 0),
delivery: "area",
target: "self",
range: 0,
effect: {
kind: "aura",
aura: "shock-barrier",
durationMs: 10_000,
absorbMultiplier: 2.5,
reflectSpellChance: 0.5,
},
};
registerCaster([barrier]);
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === "caster") {
events.push(message.event);
}
});
useCombatStore.getState().advanceMobCombat(1_000);
expect(useCombatStore.getState().mobs.caster.auras).toContainEqual(expect.objectContaining({
id: "shock-barrier",
endsAt: 11_000,
}));
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
targetId: "caster",
definition: expect.objectContaining({
reflectSpellChance: 0.5,
absorbs: [expect.objectContaining({ id: "shock-barrier:absorb" })],
}),
}));
expect(events.map((event) => [event.phase, event.targetActorId])).toEqual([
["release", "caster"],
["aura-start", "caster"],
]);
unsubscribe();
});
it("resolves teleport-to-caster effects as forced movement with an impact cue", () => {
const summonPlayer: EnemyAttackDefinition = {
...spell("summon-player", "arcane", 0),
effect: { kind: "pull", destination: "source" },
};
registerCaster([summonPlayer]);
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === "caster") {
events.push(message.event);
}
});
useCombatStore.getState().advanceMobCombat(1_000);
expect(useCombatStore.getState().playerPosition).toEqual([2, 0, 0]);
expect(events.map((event) => event.phase)).toEqual(["release", "impact"]);
unsubscribe();
});
});
@@ -1,2 +1,2 @@
// Generated by npm run dungeon:catalog. Do not edit.
export default "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 truncated
export default "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 truncated
@@ -1 +1 @@
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
+24 -6
View File
@@ -54,6 +54,7 @@ import {
partyAbilityAmount,
} from "./partyAbilityAi";
import { normalizeActorResourcePool } from "./combatActors";
import { enemyControlPreventsActions } from "./enemyAttacks";
import { advanceCombatResourcePool } from "./combatResources";
import { channelAmountEffects } from "./combatChannelAmounts";
import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingRecipientIds } from "./healingAbilityRules";
@@ -405,7 +406,10 @@ export function applyHealerSupport(
.map((member, index) => ({ member, index, ability: partyResurrectionAbility(member) }))
.filter((candidate): candidate is typeof candidate & { ability: AbilityDefinition } => (
candidate.member.health > 0
&& candidate.member.controlledUntil <= now
&& (
candidate.member.controlledUntil <= now
|| !enemyControlPreventsActions(candidate.member.controlMechanic)
)
&& candidate.member.nextActionAt <= now
&& candidate.ability !== null
))
@@ -1175,7 +1179,15 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
if (shouldRegen) nextRegenAt = now + 1_000;
if (!targetId) {
members = members.map((member) => {
if (member.health <= 0 || member.controlledUntil > now || !shouldRegen || deltaSeconds <= 0) return member;
if (
member.health <= 0
|| (
member.controlledUntil > now
&& enemyControlPreventsActions(member.controlMechanic)
)
|| !shouldRegen
|| deltaSeconds <= 0
) return member;
return {
...member,
health: Math.min(
@@ -1200,7 +1212,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = { ...member, status: "advancing", activeCast: null, activeCastTargetId: null };
continue;
}
if (member.controlledUntil > now) {
if (
member.controlledUntil > now
&& enemyControlPreventsActions(member.controlMechanic)
) {
nextMembers[index] = member.status === "holding"
? member
: { ...member, status: "holding" };
@@ -1210,7 +1225,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = member.status === "holding" ? member : { ...member, status: "holding" };
continue;
}
const activeMember = member.controlMechanic
const activeMember = member.controlMechanic && member.controlledUntil <= now
? { ...member, controlledUntil: 0, controlMechanic: null }
: member;
member = activeMember;
@@ -1465,10 +1480,13 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
: "ready" as const;
members = members.map((member) => {
if (member.health <= 0) return member.status === "down" ? member : { ...member, status: "down" };
if (member.controlledUntil > now) {
if (
member.controlledUntil > now
&& enemyControlPreventsActions(member.controlMechanic)
) {
return member.status === "holding" ? member : { ...member, status: "holding" };
}
const activeMember = member.controlMechanic
const activeMember = member.controlMechanic && member.controlledUntil <= now
? { ...member, controlledUntil: 0, controlMechanic: null }
: member;
const recovered = activeMember.health;
+11 -3
View File
@@ -27,6 +27,10 @@ import { derivePartySpawnSlots } from "../game/partySpawnSlots";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
import { getMobPosition } from "../game/mobRuntimeRegistry";
import {
enemyControlMovementMultiplier,
enemyControlPreventsMovement,
} from "../game/enemyAttacks";
import {
appendPartyBreadcrumb,
createPartyBreadcrumbCursor,
@@ -1038,11 +1042,14 @@ function PartyActors({
let movementExpected = false;
let faceX = Math.sin(actor.rotation.y);
let faceZ = Math.cos(actor.rotation.y);
const activeControlMechanic = member.controlledUntil > combatNow
? member.controlMechanic
: null;
if (member.health <= 0) {
const signal = movingSignalsRef.current.get(member.id);
if (signal) signal.current = false;
} else if (member.activeCast || member.controlledUntil > combatNow) {
} else if (member.activeCast || enemyControlPreventsMovement(activeControlMechanic)) {
// Casting and crowd control are intentional immobility, not stuck navigation.
} else if (rejoining) {
const intent = partyRejoinMovement(member.id, current, playerNavigation, routeNow,
@@ -1304,7 +1311,8 @@ function PartyActors({
const currentPlanarDistance = planarDistance(current, playerNavigation);
if (member.health > 0 && (party.command !== "stop" || rejoining) && hasDesired) {
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
* movementProfile.speedMultiplier;
* movementProfile.speedMultiplier
* enemyControlMovementMultiplier(activeControlMechanic);
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
// Route progress, not radial player distance, is authoritative. A
// valid path may need to move away briefly around a U-bend.
@@ -1346,7 +1354,7 @@ function PartyActors({
paused: !active || game.paused || game.mapOpen || game.companionOpen,
stopped: party.command === "stop" && !rejoining,
casting: member.activeCast !== null,
controlled: member.controlledUntil > combatNow,
controlled: enemyControlPreventsMovement(activeControlMechanic),
nowMs: routeNow,
});
if (recovery !== "none") {
+10 -3
View File
@@ -59,6 +59,10 @@ import {
} from "../game/playerMovement";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store";
import {
enemyControlMovementMultiplier,
enemyControlPreventsMovement,
} from "../game/enemyAttacks";
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
@@ -126,6 +130,7 @@ function PlayerController({
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode);
const controlledUntil = useCombatStore((state) => state.controlledUntil);
const controlMechanic = useCombatStore((state) => state.controlMechanic);
const spawn = useGameStore((state) => state.activeSpawn);
const dungeon = requireDungeonDefinition(activeDungeonId);
const lastReported = useRef<[number, number, number]>([...spawn.footPosition]);
@@ -221,11 +226,12 @@ function PlayerController({
const groundedBeforeMove = verticalVelocityRef.current <= 0.5
&& isWalkableGroundHit(groundHit, groundProbeLength);
const requestedAtMs = consumeJumpRequest();
const activeControlMechanic = controlledUntil > Date.now() ? controlMechanic : null;
const gameplayBlocked = paused
|| mapOpen
|| companionOpen
|| useCombatStore.getState().health <= 0
|| controlledUntil > Date.now();
|| enemyControlPreventsMovement(activeControlMechanic);
if (gameplayBlocked) {
cancelBufferedPlayerJump(jumpTimingRef.current);
movingRef.current = false;
@@ -249,6 +255,7 @@ function PlayerController({
verticalVelocityRef.current,
);
const movementDeltaSeconds = playerFrameDeltaSeconds(delta);
const movementMultiplier = enemyControlMovementMultiplier(activeControlMechanic);
let verticalVelocity = shouldJump
? PLAYER_JUMP_VELOCITY
: groundedBeforeMove
@@ -264,9 +271,9 @@ function PlayerController({
characterController.computeColliderMovement(
body.collider(0),
{
x: worldX * MOVE_SPEED * movementDeltaSeconds,
x: worldX * MOVE_SPEED * movementMultiplier * movementDeltaSeconds,
y: verticalVelocity * movementDeltaSeconds,
z: worldZ * MOVE_SPEED * movementDeltaSeconds,
z: worldZ * MOVE_SPEED * movementMultiplier * movementDeltaSeconds,
},
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
ACTOR_TERRAIN_QUERY_GROUPS,
+1 -1
View File
@@ -13,7 +13,7 @@ const graphs = urls.map((url) => JSON.parse(readFileSync(path.join("public", url
describe("all-class original WoW models", () => {
it("loads every shipped model through the runtime parser and samples finite bone poses", () => {
expect(graphs.length).toBe(378);
expect(graphs.length).toBeGreaterThanOrEqual(378);
for (const raw of graphs) {
let graph;
try { graph = parseOriginalWowModelGraph(raw); }