Release Healer Man 0.1.49

This commit is contained in:
phenom committed 2026-09-17 09:13:15 -04:00
1 parent 39425f2494
commit 612170afd0
91 files changed
+2278 -263

No files matched your search

+75 -16
View File
@@ -1,7 +1,9 @@
import {
abilitiesForClass,
abilitiesForCharacter,
abilityAtLevel,
abilityById,
learnedAbilityForBuild,
isAbilityUnlocked,
resourceProfileForClass,
triggeredAbilityBySpellId,
@@ -10,6 +12,7 @@ import {
type ResourceType,
} from "./abilityCatalog";
import {
aiHintsForAbility,
canAbilityCastWhileMoving,
targetingRulesForAbility,
} from "./abilityRules";
@@ -50,6 +53,7 @@ import {
} from "./combatPresentation";
import {
choosePartyAbility,
choosePartyRotationDecision,
commitPartyAbilityUse,
partyAbilityAmount,
} from "./partyAbilityAi";
@@ -231,9 +235,9 @@ export function partyUsesRangedAttacks(
function partyCatalogAttackRange(member: Pick<PartyMember, "classId"> & Partial<Pick<PartyMember, "level" | "talentRanks">>): number {
return Math.max(0, ...abilitiesForClass(member.classId)
.filter((ability) => !ability.passive && ability.unlockLevel <= (member.level ?? 80)
&& (ability.talentEntryId === undefined || (member.talentRanks?.[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
.map((ability) => abilityAtLevel(ability, member.level ?? 80))
.filter((ability) => !ability.passive)
.map((ability) => learnedAbilityForBuild(ability, member.level ?? 80, member.talentRanks ?? {}))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind)))
.map((ability) => ability.range.max));
}
@@ -367,11 +371,11 @@ function partyHealAmount(level: number, spellPower = 0): number {
}
function partyResurrectionAbility(member: PartyMember): AbilityDefinition | null {
const definition = abilitiesForClass(member.classId)
return abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((ability) => !ability.passive && (ability.target === "friendly" || ability.target === "any"))
.filter((ability) => isAbilityUnlocked(ability, member.level))
.find((ability) => ability.effects.some((effect) => effect.kind === "resurrection"));
return definition ? abilityAtLevel(definition, member.level) : null;
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null)
.find((ability) => ability.effects.some((effect) => effect.kind === "resurrection")) ?? null;
}
function partyResurrectionPresentation(
@@ -913,6 +917,11 @@ function choosePartySupportIntent(
target: null,
injuredAllies: injured.length,
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
friendlyTargets: injured.map((candidate) => ({
id: candidate.id,
health: candidate.health,
maxHealth: candidate.maxHealth,
})),
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind))
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
@@ -975,6 +984,9 @@ function executePartyAbility(
}
}
}
if (effect.kind === "finisher-damage") {
runtimeMember = { ...runtimeMember, comboPoints: 0, comboTargetId: null };
}
} else if (effect.kind === "heal") {
for (const friendlyTargetId of healingRecipientIds(effect, member.id, targetId, combat.actors,
id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now)) {
@@ -1074,6 +1086,14 @@ function executePartyAbility(
now,
);
}
} else if (effect.kind === "combo") {
const current = runtimeMember.comboTargetId === targetId ? runtimeMember.comboPoints : 0;
const comboPoints = Math.max(0, Math.min(5, current + effect.amount));
runtimeMember = {
...runtimeMember,
comboPoints,
comboTargetId: comboPoints > 0 ? targetId : null,
};
} else if (effect.kind === "summon") {
useCombatStore.getState().summonActor(member.id, ability, effect, origin, targetId, now);
} else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) {
@@ -1451,25 +1471,64 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = runtimeMember;
continue;
}
const selectedAbility = choosePartyAbility(runtimeMember, {
const selectedDecision = choosePartyRotationDecision(runtimeMember, {
now,
target: targetMob ?? null,
targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id,
canUseAbility: (ability) => partyAbilityTargetIsValid(runtimeMember, ability, memberTargetId)
hasDangerousThreat: member.role !== "tank" && targetMob?.targetActorId === member.id,
hostileCount: Object.values(currentCombat.mobs).filter((mob) => mob.engaged && !mob.dead).length,
maintainedAbilityIds: new Set([
...currentCombat.effects.filter((effect) => effect.sourceActorId === member.id && effect.remainingTicks > 0)
.map((effect) => effect.sourceAbilityId),
...currentCombat.auras.filter((aura) => aura.sourceId === member.id
&& (aura.expiresAt === null || aura.expiresAt > now))
.flatMap((aura) => abilitiesForCharacter(member.classId, member.secondaryClassId)
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.id === aura.definition.id))
.map((ability) => ability.id)),
]),
activeAuraExclusiveGroups: new Set(currentCombat.auras
.filter((aura) => aura.sourceId === member.id && (aura.expiresAt === null || aura.expiresAt > now))
.flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])),
activeSummonAbilityIds: new Set(currentCombat.summons
.filter((summon) => summon.ownerActorId === member.id && summon.expiresAt > now)
.map((summon) => summon.sourceAbilityId)),
hasActivePet: currentCombat.summons.some((summon) => {
if (summon.ownerActorId !== member.id || summon.expiresAt <= now) return false;
const source = abilityById(summon.sourceAbilityId);
return source?.effects.some((effect) => effect.kind === "pet"
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
}),
canUseAbility: (ability) => {
const hints = new Set(aiHintsForAbility(ability).categories);
const hostileSelfArea = ability.target === "self" && (ability.radius ?? 0) > 0
&& (["damage", "execute", "debuff", "control", "interrupt", "taunt"] as const)
.some((category) => hints.has(category));
const abilityTargetId = (ability.target === "self" && !hostileSelfArea) || ability.target === "friendly"
? member.id
: memberTargetId;
return partyAbilityTargetIsValid(runtimeMember, ability, abilityTargetId)
// Let maintained effects tick instead of resetting their first tick on every GCD.
&& !currentCombat.effects.some((effect) => effect.sourceActorId === member.id
&& effect.sourceAbilityId === ability.id && effect.targetId === memberTargetId && effect.remainingTicks > 0)
&& effect.sourceAbilityId === ability.id && effect.targetId === abilityTargetId && effect.remainingTicks > 0)
&& !ability.effects.some((effect) => effect.kind === "apply-aura"
&& currentCombat.auras.some((aura) => aura.sourceId === member.id
&& aura.definition.id === effect.aura.id && aura.targetId === memberTargetId)),
&& aura.definition.id === effect.aura.id && aura.targetId === abilityTargetId));
},
});
const selectedAbility = selectedDecision?.ability ?? null;
if (!selectedAbility) {
const inAttackRange = targetVisible && targetPosition
&& distanceSquared(memberPosition, targetPosition) <= partyAttackRange(member) ** 2;
nextMembers[index] = { ...runtimeMember, status: inAttackRange ? desiredStatus : "advancing", nextActionAt: now + 250 };
continue;
}
const inRange = targetVisible && partyAbilityTargetIsValid(runtimeMember, selectedAbility, memberTargetId);
const abilityTargetId = selectedDecision?.targetId
?? (selectedAbility.target === "self" || selectedAbility.target === "friendly" ? member.id : memberTargetId);
const inRange = (selectedAbility.target === "self" || targetVisible)
&& partyAbilityTargetIsValid(runtimeMember, selectedAbility, abilityTargetId);
if (!inRange) {
nextMembers[index] = runtimeMember.status === "advancing"
? runtimeMember
@@ -1494,14 +1553,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
originalDurationMs: selectedAbility.castTimeMs,
pushbackHits: 0,
origin: memberPosition,
targetId: memberTargetId,
targetId: abilityTargetId,
},
activeCastTargetId: memberTargetId,
activeCastTargetId: abilityTargetId,
nextActionAt: now + selectedAbility.castTimeMs,
};
emitPartyAbility(member, selectedAbility, memberTargetId, "cast-start", now);
emitPartyAbility(member, selectedAbility, abilityTargetId, "cast-start", now);
} else {
const execution = executePartyAbility(committed, selectedAbility, memberTargetId, now, nextMembers);
const execution = executePartyAbility(committed, selectedAbility, abilityTargetId, now, nextMembers);
nextMembers[index] = {
...execution.member,
status: desiredStatus,