forked from phenom/healer-man
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,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
@@ -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.";
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user