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
+14 -9
View File
@@ -38,6 +38,7 @@ function makeDefaultCombatTickResult(
floorMaxHP: state.floorMaxHP,
maxFloorReached: state.maxFloorReached,
castProgress: state.castProgress,
weaponCastProgress: state.weaponCastProgress,
equipmentSpellStates: state.equipmentSpellStates,
activeGolems,
meleeSwordProgress: state.meleeSwordProgress,
@@ -55,6 +56,7 @@ export interface CombatTickResult {
floorMaxHP: number;
maxFloorReached: number;
castProgress: number;
weaponCastProgress: Record<string, number>;
equipmentSpellStates: CombatState['equipmentSpellStates'];
activeGolems: RuntimeActiveGolem[];
meleeSwordProgress: Record<string, number>;
@@ -71,7 +73,7 @@ export function processCombatTick(
_maxMana: number,
attackSpeedMult: number,
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => {
onDamageDealt: (damage: number, skipSpecials?: boolean) => {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
modifiedDamage?: number;
@@ -123,7 +125,7 @@ export function processCombatTick(
let floorHP = state.floorHP;
let currentFloor = state.currentFloor;
let floorMaxHP = state.floorMaxHP;
let castProgress = state.castProgress;
const weaponCastProgress = { ...state.weaponCastProgress };
let currentRoom = state.currentRoom;
const updatedEquipmentSpellStates = [...state.equipmentSpellStates];
@@ -135,12 +137,14 @@ export function processCombatTick(
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
const isSpellAoe = !!spellDef.isAoe;
castProgress = (castProgress || 0) + progressPerTick;
// Per-weapon cast progress for primary spell (spec §3.1, D-01 fix)
// Fall back to legacy castProgress for backward compatibility
weaponCastProgress['primary'] = ((weaponCastProgress['primary'] ?? state.castProgress) || 0) + progressPerTick;
// Process complete casts for active spell
let safetyCounter = 0;
const MAX_CASTS_PER_TICK = 100;
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) {
while (weaponCastProgress['primary'] >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) {
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
rawMana = afterCost.rawMana;
elements = afterCost.elements;
@@ -163,11 +167,11 @@ export function processCombatTick(
}
// Apply damage per-enemy (spec §3.2)
const roomResult = applyDamageToRoom(get, set, finalDamage, isSpellAoe);
const roomResult = applyDamageToRoom(get, set, finalDamage, isSpellAoe, spellDef.aoeTargets);
floorHP = roomResult.floorHP;
floorMaxHP = roomResult.floorMaxHP;
currentRoom = get().currentRoom;
castProgress -= 1;
weaponCastProgress['primary'] -= 1;
safetyCounter++;
applyOnHitEffect(get, set, spellId, logMessages);
@@ -182,7 +186,7 @@ export function processCombatTick(
floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP;
currentRoom = newState.currentRoom;
castProgress = 0;
weaponCastProgress['primary'] = 0;
if (guardian) {
logMessages.push(`⚔️ ${guardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
@@ -224,7 +228,7 @@ export function processCombatTick(
if (!Number.isFinite(eFinalDamage)) break;
// Apply damage per-enemy (spec §3.2)
const eRoomResult = applyDamageToRoom(get, set, eFinalDamage, isESpellAoe);
const eRoomResult = applyDamageToRoom(get, set, eFinalDamage, isESpellAoe, eSpellDef.aoeTargets);
floorHP = eRoomResult.floorHP;
floorMaxHP = eRoomResult.floorMaxHP;
currentRoom = get().currentRoom;
@@ -360,7 +364,8 @@ export function processCombatTick(
floorHP,
floorMaxHP,
maxFloorReached: newMaxFloorReached,
castProgress,
castProgress: weaponCastProgress['primary'] ?? state.castProgress,
weaponCastProgress,
equipmentSpellStates: updatedEquipmentSpellStates,
activeGolems,
meleeSwordProgress: updatedMeleeSwordProgress,
+17 -3
View File
@@ -23,11 +23,12 @@ export function lowestHPEnemy(enemies: EnemyState[]): EnemyState | null {
* Apply damage to enemies in the current room.
*
* Spec §3.2:
* - AoE spells: each enemy takes full damage
* - AoE spells: damage is distributed across up to aoeTargets enemies
* - Single-target: target the enemy with lowest HP (focus-fire)
* - Recalculates floorHP as sum of all enemy HP
* - Triggers onRoomCleared when all enemies reach 0 HP
*
* @param aoeTargets — max number of enemies hit by AoE (spec §3.2, D-38 fix)
* @returns { floorHP, floorMaxHP, roomCleared }
*/
export function applyDamageToRoom(
@@ -35,6 +36,7 @@ export function applyDamageToRoom(
set: (state: Partial<CombatState>) => void,
dmg: number,
isAoe: boolean,
aoeTargets?: number,
): { floorHP: number; floorMaxHP: number; roomCleared: boolean } {
const state = get();
const room = state.currentRoom;
@@ -48,11 +50,23 @@ export function applyDamageToRoom(
// For single-target, find the lowest HP enemy once (focus-fire)
const singleTarget = isAoe ? null : lowestHPEnemy(room.enemies);
// For AoE, select up to aoeTargets living enemies (focus-fire: lowest HP first)
let aoeTargetSet: Set<string> | null = null;
if (isAoe && aoeTargets && aoeTargets > 0) {
const livingEnemies = room.enemies
.filter(e => e.hp > 0)
.sort((a, b) => a.hp - b.hp);
const targets = livingEnemies.slice(0, aoeTargets);
aoeTargetSet = new Set(targets.map(e => e.id));
}
const updatedEnemies = room.enemies.map((enemy) => {
if (enemy.hp <= 0) return enemy;
if (isAoe) {
// AoE: each enemy takes full damage
return { ...enemy, hp: Math.max(0, enemy.hp - dmg) };
// AoE: distribute damage across up to aoeTargets enemies (spec §3.2, D-38 fix)
if (aoeTargetSet && !aoeTargetSet.has(enemy.id)) return enemy;
const dmgPerEnemy = aoeTargetSet ? dmg / aoeTargetSet.size : dmg;
return { ...enemy, hp: Math.max(0, enemy.hp - dmgPerEnemy) };
}
// Single-target: only damage the lowest HP enemy
if (singleTarget && enemy.id === singleTarget.id) {
@@ -72,6 +72,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
floorHP: newFloorHP,
floorMaxHP: newFloorHP,
castProgress: 0,
weaponCastProgress: {},
});
get().addActivityLog('floor_transition', `Descended to Floor ${newFloor}`);
} else {
@@ -84,6 +85,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
floorHP: newFloorHP,
floorMaxHP: newFloorHP,
castProgress: 0,
weaponCastProgress: {},
});
}
@@ -110,6 +112,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
floorHP: newFloorHP,
floorMaxHP: newFloorHP,
castProgress: 0,
weaponCastProgress: {},
});
get().addActivityLog('floor_transition', `Ascending to Floor ${newFloor}`);
} else {
@@ -122,6 +125,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
floorHP: newFloorHP,
floorMaxHP: newFloorHP,
castProgress: 0,
weaponCastProgress: {},
});
}
@@ -216,7 +220,7 @@ export function onEnterRoomDescend(get: GetFn, set: SetFn): void {
if (didReset) {
const newRoom = generateSpireFloorState(s.currentFloor, s.currentRoomIndex, s.roomsPerFloor, s.runId);
set({ currentRoom: newRoom, castProgress: 0 });
set({ currentRoom: newRoom, castProgress: 0, weaponCastProgress: {} });
get().addActivityLog('floor_transition',
`Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1} has reset — enemies respawned`);
@@ -263,6 +267,7 @@ export function createEnterSpireMode(get: GetFn, set: SetFn) {
floorMaxHP: calcRoomHP(freshRoom),
currentRoom: freshRoom,
castProgress: 0,
weaponCastProgress: {},
climbDirection: 'up',
isDescending: false,
clearedFloors: {},
+5 -1
View File
@@ -61,6 +61,10 @@ export interface CombatState {
// Equipment spell states for multi-casting
equipmentSpellStates: EquipmentSpellState[];
// Per-weapon cast progress records (spec §3.1, D-01 fix)
// Key: 'primary' for active spell, or instanceId for equipment spells
weaponCastProgress: Record<string, number>;
// Combat special effect tracking
comboHitCount: number;
floorHitCount: number;
@@ -158,7 +162,7 @@ export interface CombatActions {
maxMana: number,
attackSpeedMult: number,
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
onDamageDealt: (damage: number, skipSpecials?: boolean) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
signedPacts: number[],
golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
+6 -1
View File
@@ -67,6 +67,9 @@ export const useCombatStore = create<CombatStore>()(
// Equipment spell states
equipmentSpellStates: [],
// Per-weapon cast progress records (spec §3.1, D-01 fix)
weaponCastProgress: {},
// Combat tracking
comboHitCount: 0,
floorHitCount: 0,
@@ -178,6 +181,7 @@ export const useCombatStore = create<CombatStore>()(
floorMaxHP: getFloorMaxHP(newFloor),
floorHP: getFloorMaxHP(newFloor),
castProgress: 0,
weaponCastProgress: {},
};
});
},
@@ -290,7 +294,7 @@ export const useCombatStore = create<CombatStore>()(
maxMana: number,
attackSpeedMult: number,
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
onDamageDealt: (damage: number, skipSpecials?: boolean) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
signedPacts: number[],
golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
@@ -355,6 +359,7 @@ export const useCombatStore = create<CombatStore>()(
clearedFloors: state.clearedFloors,
golemancy: state.golemancy,
equipmentSpellStates: state.equipmentSpellStates,
weaponCastProgress: state.weaponCastProgress,
comboHitCount: state.comboHitCount,
floorHitCount: state.floorHitCount,
activityLog: state.activityLog,
+7 -4
View File
@@ -86,8 +86,8 @@ export function processDoTPhase(
}
let enemyHp = enemy.hp;
let enemyArmor = enemy.effectiveArmor;
const remainingEffects: ActiveEffect[] = [];
let totalCorrodeMagnitude = 0;
for (const effect of enemy.activeEffects) {
if (DOT_EFFECT_TYPES.has(effect.type)) {
@@ -110,8 +110,8 @@ export function processDoTPhase(
// Curse: amplifies incoming damage (tracked on enemy, applied next tick)
// No immediate HP effect — curse multiplier is stored on the enemy
} else if (effect.type === 'armor_corrode') {
// Armor corrode: reduce effective armor
enemyArmor = Math.max(0, enemyArmor - effect.magnitude);
// Armor corrode: accumulate magnitude for temporary reduction (spec §6.3, D-31 fix)
totalCorrodeMagnitude += effect.magnitude;
}
// freeze, slow, blind: soft CC — no HP effect, just tracked
@@ -122,10 +122,13 @@ export function processDoTPhase(
}
}
// Compute effectiveArmor from base armor minus active corrode effects (temporary)
const effectiveArmor = Math.max(0, enemy.armor - totalCorrodeMagnitude);
updatedEnemies.push({
...enemy,
hp: enemyHp,
effectiveArmor: enemyArmor,
effectiveArmor,
activeEffects: remainingEffects,
});
}
+3 -1
View File
@@ -247,6 +247,8 @@ export const useGameStore = create<GameCoordinatorStore>()(
// Combat
if (ctx.combat.currentAction === 'climb') {
const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore });
// Guardian regen once per tick (not per-damage-event, spec §5.3, D-25 fix)
combatCbs.applyGuardianRegen();
const roomEnemies = ctx.combat.currentRoom?.enemies ?? [];
const primaryEnemy = roomEnemies[0] ?? null;
const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy;
@@ -272,7 +274,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
rawMana = cr.rawMana; elements = cr.elements;
totalManaGathered += cr.totalManaGathered || 0;
if (cr.logMessages) cr.logMessages.forEach(msg => addLog(msg));
writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom };
writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, weaponCastProgress: cr.weaponCastProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom } as Partial<CombatState>;
}
// Non-combat room tick (library, recovery, treasure, puzzle)
+3 -3
View File
@@ -244,7 +244,7 @@ export function processGolemManaRegen(
export function processGolemAttacks(
activeGolems: RuntimeActiveGolem[],
golemDesigns: Record<string, SerializedDesign>,
onDamageDealt: (damage: number) => {
onDamageDealt: (damage: number, skipSpecials?: boolean) => {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
modifiedDamage?: number;
@@ -295,7 +295,7 @@ export function processGolemAttacks(
updatedGolem.currentMana -= spellManaCost;
updatedGolem.spellCastIndex = (updatedGolem.spellCastIndex + 1) % design.selectedSpells.length;
const dmgResult = onDamageDealt(spellDmg);
const dmgResult = onDamageDealt(spellDmg, true);
const finalDamage = dmgResult.modifiedDamage || spellDmg;
if (Number.isFinite(finalDamage)) {
@@ -326,7 +326,7 @@ export function processGolemAttacks(
onApplyEnchantmentEffects(targetEnemy.id, enchantmentEffects);
}
const dmgResult = onDamageDealt(dmg);
const dmgResult = onDamageDealt(dmg, true);
const finalDamage = dmgResult.modifiedDamage || dmg;
if (Number.isFinite(finalDamage)) {
+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 };
}