Release Healer Man 0.1.22

This commit is contained in:
phenom committed 2026-09-09 11:21:33 -04:00
1 parent f6f2375145
commit 7f6001723a
144 files changed
+54107 -690

No files matched your search

+192 -41
View File
@@ -179,6 +179,7 @@ import {
type EnemyAttackDefinition,
type EnemyControlMechanic,
type EnemyDamageSchool,
type EnemyMeleeAutoCriticalProc,
} from "./enemyAttacks";
import {
creatureRuntimeBaseStats,
@@ -294,6 +295,11 @@ export interface MobAura {
readonly sourceAbilityId: string;
readonly endsAt: number;
readonly magnitude?: 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. */
readonly nextMeleeCriticalChancePercent?: number;
readonly charges?: number;
}
export interface MobMagicResistances {
@@ -715,7 +721,10 @@ export interface CombatState {
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
tick: (deltaSeconds: number, now?: number) => void;
resetEncounter: () => void;
resetEncounter: (options?: {
readonly preserveDefeated?: boolean;
readonly preserveDefeatedIds?: readonly string[];
}) => void;
allocateTalent: (treeId: string) => boolean;
resetTalents: () => void;
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
@@ -1354,6 +1363,81 @@ function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind {
return "spell";
}
function isEnemyMainHandAutoAttack(attack: EnemyAttackDefinition): boolean {
return attack.id === DEFAULT_ENEMY_ATTACK.id
&& attack.delivery === "melee"
&& attack.animation === "attack"
&& (attack.castTimeMs ?? 0) === 0;
}
function enemyMeleeCriticalCharge(
mob: MobCombatState,
attack: EnemyAttackDefinition,
now: number,
): MobAura | undefined {
if (!isEnemyMainHandAutoAttack(attack)) return undefined;
return mob.auras.find((aura) => (
(aura.endsAt <= 0 || aura.endsAt > now)
&& (aura.charges ?? 0) > 0
&& (aura.nextMeleeCriticalChancePercent ?? 0) > 0
));
}
function advanceEnemyMeleeAutoProcAuras(
mob: MobCombatState,
attack: EnemyAttackDefinition,
now: number,
attackRevision: number,
landed: boolean,
consumedAuraId?: string,
): readonly MobAura[] {
if (!landed || !isEnemyMainHandAutoAttack(attack)) return mob.auras;
let changed = false;
let nextAuras = mob.auras.flatMap((aura): MobAura[] => {
if (aura.id !== consumedAuraId) return [aura];
changed = true;
const charges = Math.max(0, Math.round(aura.charges ?? 0) - 1);
return charges > 0 ? [{ ...aura, charges }] : [];
});
for (const driver of mob.auras) {
const proc = driver.meleeAutoCriticalProc;
if (!proc || (driver.endsAt > 0 && driver.endsAt <= now)) continue;
const chance = clamp(Number.isFinite(proc.chance) ? proc.chance : 0, 0, 1);
if (
chance <= 0
|| stableCombatUnit(
`${mob.id}:${driver.id}:${attack.id}:${attackRevision}:${now}:melee-auto-critical-proc`,
) >= chance
) {
continue;
}
const triggeredAura = proc.triggeredAura.trim();
const criticalChancePercent = Math.max(
0,
Number.isFinite(proc.criticalChancePercent) ? proc.criticalChancePercent : 0,
);
const charges = Math.max(1, Math.round(
Number.isFinite(proc.charges) ? proc.charges : 1,
));
if (!triggeredAura || criticalChancePercent <= 0) continue;
nextAuras = [
...nextAuras.filter((aura) => aura.id !== triggeredAura),
{
id: triggeredAura,
sourceAbilityId: driver.sourceAbilityId,
endsAt: proc.durationMs > 0 ? now + Math.round(proc.durationMs) : 0,
nextMeleeCriticalChancePercent: criticalChancePercent,
charges,
},
];
changed = true;
}
return changed ? nextAuras : mob.auras;
}
function resolveEnemyDamageResult(
mob: MobCombatState,
attack: EnemyAttackDefinition,
@@ -1362,6 +1446,7 @@ function resolveEnemyDamageResult(
actorId: AggroActorId,
attackRevision: number,
now: number,
bonusCriticalChancePercent = 0,
): CombatResult {
const attackKind = enemyAttackKind(attack);
return resolveCombatResult({
@@ -1370,7 +1455,7 @@ function resolveEnemyDamageResult(
damageSchool: attack.school,
attacker: {
level: mob.level,
criticalChancePercent: 5,
criticalChancePercent: 5 + Math.max(0, bonusCriticalChancePercent),
},
defender: combatDefenderFromCharacterStats(defender),
rules: {
@@ -2194,7 +2279,8 @@ function evaluateProcsInWork(
function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] {
return nearbyMobRuntimeIds(origin, maxRange).filter((id) => {
const mob = state.mobs[id];
return Boolean(mob && !mob.dead);
const position = getMobPosition(id);
return Boolean(mob && !mob.dead && position && combatHasLineOfSight(origin, position));
});
}
@@ -6603,6 +6689,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
sourceAbilityId: attack.id,
endsAt,
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
...(effect.meleeAutoCriticalProc === undefined
? {}
: { meleeAutoCriticalProc: effect.meleeAutoCriticalProc }),
},
];
namedAuras = [...applyCombatAura(namedAuras, {
@@ -6633,6 +6722,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}).auras];
}
} else if (effect.kind === "control") {
if ((effect.damageMultiplier ?? 0) > 0) {
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier!));
const attackDamage = attack.runewakerFormula
? adaptRunewakerEnemyDamage({
formula: attack.runewakerFormula,
baselineDamage: mob.attackDamage,
}).amount
: baselineDamage;
for (const actorId of targets) {
incomingHits.push({
actorId,
amount: attackDamage,
mob,
attack,
attackRevision,
});
}
}
for (const actorId of targets) {
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs };
@@ -6823,6 +6930,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
);
continue;
}
const procMob = get().mobs[hit.mob.id] ?? hit.mob;
const meleeCriticalCharge = enemyMeleeCriticalCharge(procMob, hit.attack, now);
const bonusCriticalChancePercent = meleeCriticalCharge?.nextMeleeCriticalChancePercent ?? 0;
const commitMeleeAutoProc = (landed: boolean): void => {
const current = get();
const currentMob = current.mobs[hit.mob.id];
if (!currentMob) return;
const auras = advanceEnemyMeleeAutoProcAuras(
currentMob,
hit.attack,
now,
hit.attackRevision,
landed,
meleeCriticalCharge?.id,
);
if (auras === currentMob.auras) return;
set({
mobs: {
...current.mobs,
[currentMob.id]: { ...currentMob, auras },
},
});
};
if (hit.actorId === PLAYER_AGGRO_ID) {
const current = get();
const defender = applyAuraStats(
@@ -6844,7 +6974,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.actorId,
hit.attackRevision,
now,
bonusCriticalChancePercent,
);
commitMeleeAutoProc(combatResult.landed);
const auraAdjustedDamage = resolveAuraDamageValue(
combatResult.finalDamage,
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
@@ -6984,8 +7116,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const summon = get().summons.find((candidate) => candidate.id === hit.actorId);
if (summon) {
const current = get();
const critical = bonusCriticalChancePercent > 0;
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
commitMeleeAutoProc(true);
const adjusted = resolveAuraDamageValue(
hit.amount,
incomingAmount,
aurasForEntity(current.auras, summon.id),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
);
@@ -7010,12 +7145,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetActorId: summon.id,
abilityId: hit.attack.id,
school: hit.attack.school,
rawAmount: hit.amount,
rawAmount: incomingAmount,
effectiveAmount: applied,
overheal: 0,
absorbed: absorbed.absorbed,
critical: false,
outcome: applied > 0 ? "hit" : "absorb",
critical,
outcome: applied > 0 ? (critical ? "crit" : "hit") : "absorb",
}),
});
appliedDamage += applied;
@@ -7024,8 +7159,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const onlineActor = onlinePlayerRuntimeActor(hit.actorId);
if (onlineActor) {
const current = get();
const critical = bonusCriticalChancePercent > 0;
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
commitMeleeAutoProc(true);
const adjusted = resolveAuraDamageValue(
hit.amount,
incomingAmount,
aurasForEntity(current.auras, onlineActor.id),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
);
@@ -7052,12 +7190,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetActorId: onlineActor.id,
abilityId: hit.attack.id,
school: hit.attack.school,
rawAmount: hit.amount,
rawAmount: incomingAmount,
effectiveAmount: damaged,
overheal: 0,
absorbed: absorbed.absorbed,
critical: false,
outcome: damaged > 0 ? "hit" : "absorb",
critical,
outcome: damaged > 0 ? (critical ? "crit" : "hit") : "absorb",
}),
});
appliedDamage += damaged;
@@ -7090,7 +7228,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.actorId,
hit.attackRevision,
now,
bonusCriticalChancePercent,
);
commitMeleeAutoProc(combatResult.landed);
const currentAuras = get().auras;
const auraAdjustedDamage = resolveAuraDamageValue(
combatResult.finalDamage,
@@ -8035,41 +8175,47 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
},
resetEncounter: () => set((state) => {
resetEncounter: (options = {}) => set((state) => {
resetCombatPresentationSession();
const preserveDefeatedIds = new Set(options.preserveDefeatedIds ?? []);
for (const mob of Object.values(state.mobs)) {
if (mob.summonedBy) removeMobPosition(mob.id);
}
const mobs = Object.fromEntries(Object.entries(state.mobs)
.filter(([, mob]) => !mob.summonedBy)
.map(([id, mob]) => [id, {
...mob,
health: mob.maxHealth,
dead: false,
pendingLoot: [],
looted: false,
despawned: false,
diedAt: null,
corpseDespawnAt: null,
experienceGranted: false,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
threatByActor: {},
targetActorId: null,
forcedTarget: null,
lastDamageAt: 0,
engaged: false,
combatPhase: "idle" as const,
homePosition: null,
nextAttackAt: 0,
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
nextWoundAnimationAt: 0,
}]));
.map(([id, mob]) => {
const preserveDefeated = mob.dead && (
options.preserveDefeated === true || preserveDefeatedIds.has(id)
);
return [id, {
...mob,
health: preserveDefeated ? 0 : mob.maxHealth,
dead: preserveDefeated,
pendingLoot: preserveDefeated ? mob.pendingLoot : [],
looted: preserveDefeated ? mob.looted : false,
despawned: preserveDefeated ? mob.despawned : false,
diedAt: preserveDefeated ? mob.diedAt : null,
corpseDespawnAt: preserveDefeated ? mob.corpseDespawnAt : null,
experienceGranted: preserveDefeated ? mob.experienceGranted : false,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
threatByActor: {},
targetActorId: null,
forcedTarget: null,
lastDamageAt: 0,
engaged: false,
combatPhase: "idle" as const,
homePosition: null,
nextAttackAt: 0,
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
nextWoundAnimationAt: 0,
}] as const;
}));
const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
@@ -8116,7 +8262,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
combatEvents: [],
actors: {},
lastCombatResult: null,
feedback: nextFeedback("success", "The encounter has been reset."),
feedback: nextFeedback(
"success",
options.preserveDefeated || preserveDefeatedIds.size > 0
? "The current pull has been reset; defeated enemies remain cleared."
: "The encounter has been reset.",
),
};
}),