Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
+192
-41
@@ -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.",
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
|
||||
Reference in new issue
Block a user