fix: spire combat 11 high-severity discrepancies (issue #333)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s

D-01: Implement per-weapon cast progress (weaponCastProgress record)
D-04: Bypass Executioner/Berserker discipline specials for golem attacks
D-09: Fix lightning counter direction (lightning→water, not lightning→earth)
D-10: Add full composite element counters (blackflame/radiantflames ↔ frost/water/light/dark)
D-15: Fix Executioner to check per-enemy HP < 25% instead of floorHP ratio
D-20: Fix dodge formula to match spec (min(0.55, floor × 0.003), starts at 0)
D-22: Fix shield modifier to use flat HP pool instead of percentage barrier
D-23: Wire up applyMageBarrierRecharge in the damage pipeline
D-25: Move guardian regen from per-damage-event to once-per-tick
D-26: Add guardian armor reduction to the guardian defensive pipeline
D-31: Fix armor_corrode to be temporary (restore armor on effect expiry)
D-38: Implement AoE damage distribution across enemies

All 1069 tests pass. No files exceed 400 lines.
This commit is contained in:
2026-06-08 18:25:05 +02:00
parent d07e74c396
commit 098ec86189
21 changed files with 203 additions and 75 deletions
+78 -17
View File
@@ -89,7 +89,14 @@ export function applyEnemyDefenses(
return 0;
}
// 2. Barrier absorption (percentage, spec §5.2) — skipped if bypassBarrier
// 2a. Shield pool absorption (flat HP one-time pool, spec §5.1) — skipped if bypassBarrier
if (!bypassBarrier && enemy.shieldPool && enemy.shieldPool > 0) {
const absorb = Math.min(enemy.shieldPool, dmg);
enemy.shieldPool -= absorb;
dmg -= absorb;
}
// 2b. Barrier absorption (percentage, spec §5.2) — skipped if bypassBarrier
if (!bypassBarrier && enemy.barrier && enemy.barrier > 0) {
dmg *= (1 - enemy.barrier);
}
@@ -135,6 +142,7 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
/** Mage barrier recharge rate (spec §5.2): 5% per tick */
const MAGE_BARRIER_RECHARGE_RATE = 0.05;
const MAGE_BARRIER_MAX = 0.4; // maxBarrier from MODIFIER_CONFIG.mage
/**
* Apply mage barrier recharge (spec §5.2).
@@ -160,45 +168,63 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
defCtx: EnemyDefenseCtx,
addLog: (msg: string) => void,
) => {
return (damage: number) => {
return (damage: number, skipSpecials?: boolean) => {
const rawMana = rawManaRef();
const elements = elementsRef();
let dmg = damage;
// Discipline specials (Executioner, Berserker) — before enemy defenses
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) {
dmg *= 2;
}
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
dmg *= 1.5;
// Skipped for golem attacks (spec §9.4, D-04 fix)
if (!skipSpecials) {
// Executioner: per-enemy HP check (spec §4.4, D-15 fix)
const executionerTarget = defCtx.enemy;
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && executionerTarget && executionerTarget.hp < executionerTarget.maxHP * 0.25) {
dmg *= 2;
}
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
dmg *= 1.5;
}
}
// Apply regular enemy defenses for ALL enemies (spec §5.2)
dmg = defApply(dmg, defCtx.enemy, defCtx.roomType, addLog);
// Guardian-specific defensive pipeline (shield → barrier → health regen, spec §5.3)
// Mage barrier recharge (spec §5.2) — after damage, recharge mage barrier on the enemy
if (defCtx.enemy && defCtx.enemy.barrier && defCtx.enemy.barrier > 0 && defCtx.enemy.name.startsWith('Mage')) {
const rechargedBarrier = Math.min(
MAGE_BARRIER_MAX,
defCtx.enemy.barrier + MAGE_BARRIER_RECHARGE_RATE * HOURS_PER_TICK,
);
// Update enemy barrier on the store (mutates via reference)
defCtx.enemy.barrier = rechargedBarrier;
}
// Guardian-specific defensive pipeline (spec §5.3, §11)
const guardian = getGuardianForFloor(ctx.combat.currentFloor);
if (guardian && (guardian.shield || guardian.barrier || guardian.healthRegen)) {
if (guardian) {
let shield = ctx.combat.guardianShield;
const shieldMax = ctx.combat.guardianShieldMax;
let barrier = ctx.combat.guardianBarrier;
const barrierMax = ctx.combat.guardianBarrierMax;
if (guardian.shieldRegen && shield < shieldMax) {
shield = Math.min(shieldMax, shield + guardian.shieldRegen * HOURS_PER_TICK);
}
if (guardian.barrierRegen && barrier < barrierMax) {
barrier = Math.min(barrierMax, barrier + guardian.barrierRegen * HOURS_PER_TICK);
}
// Shield absorption (flat pool, per-hit)
if (shield > 0 && dmg > 0) {
const absorb = Math.min(shield, dmg);
shield -= absorb;
dmg -= absorb;
}
// Barrier reduction (percentage, per-hit)
if (barrier > 0 && dmg > 0) {
dmg *= (1 - barrier);
}
// Guardian armor reduction (spec §11, D-26 fix)
if (guardian.armor && guardian.armor > 0 && dmg > 0) {
dmg *= (1 - guardian.armor);
}
// Health regen reduces net damage (per-hit, already scaled by HOURS_PER_TICK)
if (guardian.healthRegen && guardian.healthRegen > 0) {
const healAmount = guardian.healthRegenIsPercent
? Math.floor(ctx.combat.floorMaxHP * guardian.healthRegen / 100 * HOURS_PER_TICK)
@@ -218,5 +244,40 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
};
};
return { onFloorCleared, makeOnDamageDealt, applyMageBarrierRecharge };
// ─── Guardian Regen (once per tick, spec §5.3, D-25 fix) ─────────────────
/**
* Apply guardian shield/barrier regen once per tick.
* Must be called exactly once per combat tick, not per-damage-event.
*/
const applyGuardianRegen = () => {
const guardian = getGuardianForFloor(ctx.combat.currentFloor);
if (!guardian) return;
let shield = ctx.combat.guardianShield;
const shieldMax = ctx.combat.guardianShieldMax;
let barrier = ctx.combat.guardianBarrier;
const barrierMax = ctx.combat.guardianBarrierMax;
let changed = false;
if (guardian.shieldRegen && shield < shieldMax) {
shield = Math.min(shieldMax, shield + guardian.shieldRegen * HOURS_PER_TICK);
changed = true;
}
if (guardian.barrierRegen && barrier < barrierMax) {
barrier = Math.min(barrierMax, barrier + guardian.barrierRegen * HOURS_PER_TICK);
changed = true;
}
if (changed) {
useCombatStore.setState({
guardianShield: shield,
guardianShieldMax: shieldMax,
guardianBarrier: barrier,
guardianBarrierMax: barrierMax,
});
}
};
return { onFloorCleared, makeOnDamageDealt, applyMageBarrierRecharge, applyGuardianRegen };
}