Correct minion sacrifice and passive talent semantics (refs #19)

Coordinator Root /root, session 01a12399-cfab-7c72-8455-e0e4cb7e156c.
Independently reviewed by /root/combat_source_finish_luna (GPT-6 Luna xhigh).
31 focused DK tests, required release check and complete source type check pass.
This commit is contained in:
warren committed 2026-10-15 09:09:45 -04:00
1 parent a9322a3897
commit 3687c423e2
11 files changed
+284 -10

No files matched your search

+54 -1
View File
@@ -1,3 +1,4 @@
import { eligibleSacrificeMinion } from "./sacrificeMinion";
import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery";
import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount";
import { romNativeNpcCombatant, romNativeTrainingCombatant, romNativePlayerHitProfile, romNativeNpcHitProfile, resolveRomNativeCombatResult,
@@ -1712,6 +1713,7 @@ export interface CombatState {
controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number, breakOnDamage?: boolean, sourceActorId?: string, magnitude?: number) => boolean;
dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number;
healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution;
sacrificeActorMinion: (ownerActorId: string, abilityId: string, effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>, origin: CombatPosition, now?: number) => boolean;
/** Applies the CoA Holy Candle self-damage trigger to a companion or channel caster. */
triggerRomPriestHolyCandle: (ownerActorId: string, healingAbilityId: string, now?: number) => number;
reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number;
@@ -1822,6 +1824,30 @@ let summonSequence = 0;
let areaEffectSequence = 0;
let combatEventSequence = 0;
function sacrificeMinionInWork(
work: Pick<MutableCastWork, "summons" | "actors" | "auras" | "effects" | "combatEvents">,
ownerActorId: string,
abilityId: string,
effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>,
origin: CombatPosition,
now: number,
): boolean {
const minion = eligibleSacrificeMinion(work.summons, ownerActorId, effect, origin, now);
if (!minion) return false;
work.summons = work.summons.map((summon) => summon.id === minion.id
? { ...summon, health: 0, targetId: null, cast: null } : summon);
const actor = work.actors[minion.id];
if (actor) work.actors[minion.id] = { ...actor, health: 0, alive: false, cast: null };
work.auras = work.auras.filter((aura) => aura.targetId !== minion.id);
work.effects = work.effects.filter((candidate) => candidate.targetId !== minion.id);
recordCombatEventInWork(work, {
occurredAt: now, kind: "death", sourceActorId: ownerActorId,
targetActorId: minion.id, abilityId, rawAmount: 0, effectiveAmount: 0,
overheal: 0, absorbed: 0, critical: false, outcome: "sacrificed",
});
return true;
}
function initialSummonCombatRuntime(): Pick<CombatSummon,
"abilityIds" | "resources" | "cooldowns" | "globalCooldownEndsAt" | "cast" | "controlledUntil" | "schoolLockedUntil"
> {
@@ -1908,7 +1934,7 @@ function recordCombatEvent(
return appendCombatEvent(events, input, combatEventSequence);
}
function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput): void {
function recordCombatEventInWork(work: Pick<MutableCastWork, "combatEvents">, input: CombatEventInput): void {
work.combatEvents = [...recordCombatEvent(work.combatEvents, input)];
}
@@ -13665,6 +13691,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
else if (ability.name === "Enraged Regeneration"
&& !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met";
else if (ability.effects.some((effect) => effect.kind === "sacrifice-minion"
&& !eligibleSacrificeMinion(state.summons, PLAYER_AGGRO_ID, effect, currentOrigin(state, origin), now))) failure = "condition-not-met";
else if (state.classId === "hunter" && ability.name === "Revive Pet"
&& !state.summons.some((summon) => summon.health <= 0
&& (summon.id.startsWith("player-pet-")
@@ -16879,6 +16907,22 @@ export const useCombatStore = create<CombatState>((set, get) => ({
]);
}
}
} else if (effect.kind === "sacrifice-minion") {
if (!sacrificeMinionInWork(work, PLAYER_AGGRO_ID, ability.id, effect, playerOrigin, now)) continue;
const amount = Math.round(resolveAuraHealingValue(
resolveAuraHealingValue(Math.round(work.maxHealth * effect.percentMaxHealth),
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "done"),
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "taken",
));
const healed = healPlayerInWork(work, amount);
work.totalHealing += healed;
recordCombatEventInWork(work, {
occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID,
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, school: spellSchool,
rawAmount: amount, effectiveAmount: healed, overheal: amount - healed,
absorbed: 0, critical: false,
});
addHealingThreatInWork(work, castThreatSource, healed, now);
} else if (effect.kind === "execute") {
for (const id of affectedMobIds) {
const mob = work.mobs[id];
@@ -18676,6 +18720,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return applied;
},
sacrificeActorMinion: (ownerActorId, abilityId, effect, origin, now = combatNow()) => {
const state = get();
const work = { summons: [...state.summons], actors: { ...state.actors },
auras: [...state.auras], effects: [...state.effects], combatEvents: [...state.combatEvents] };
if (!sacrificeMinionInWork(work, ownerActorId, abilityId, effect, origin, now)) return false;
set(work);
return true;
},
healActor: (id, amount, request) => {
const now = request.now ?? combatNow();
const state = get();