Files
Mana-Loop/src/lib/game/stores/pipelines/combat-tick.ts
T
n8n-gitea 93ffa0768b
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s
fix: #328 fabricator golem-2 interval 250→500 + golem-1 desc
- Fix Fabricator golem-2 capped perk interval from 250 to 500 (spec match)
- Update golem-1 description to 'Unlock golem summoning' (spec match)
2026-06-09 11:47:35 +02:00

294 lines
11 KiB
TypeScript

// ─── Combat Tick Callback Builder ─────────────────────────────────────────────
// Extracts the large combat callback lambdas from gameStore.ts tick()
// to keep the coordinator under the 400-line file limit.
import { HOURS_PER_TICK } from '../../constants';
import { getGuardianForFloor } from '../../data/guardian-encounters';
import { hasSpecial, SPECIAL_EFFECTS } from '../../effects/special-effects';
import type { ComputedEffects } from '../../effects/upgrade-effects.types';
import type { EnemyState } from '../../types';
import type { CombatStore } from '../combat-state.types';
import { countdownGolemRoomDuration } from '../golem-combat-actions';
import { useAttunementStore } from '../attunementStore';
// ─── Enemy Defense Context ────────────────────────────────────────────────────
// Snapshot of the current tick's enemy defense state, captured once per tick
// when makeOnDamageDealt is invoked. This avoids changing the onDamageDealt
// callback signature across the entire call chain.
export interface EnemyDefenseCtx {
roomType: string;
enemy: EnemyState | null;
}
// ─── Params ───────────────────────────────────────────────────────────────────
interface BuildCombatCallbacksParams {
ctx: {
combat: {
floorHP: number;
floorMaxHP: number;
currentFloor: number;
guardianShield: number;
guardianShieldMax: number;
guardianBarrier: number;
guardianBarrierMax: number;
currentRoom: { roomType: string; enemies: EnemyState[] };
};
};
effects: ComputedEffects;
maxMana: number;
addLog: (msg: string) => void;
useCombatStore: { setState: (s: Partial<CombatStore>) => void; getState: () => CombatStore };
usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void; defeatedGuardians: number[] } };
}
/** Speed-room bonus added to agile dodge chance (spec §4.5) */
const SPEED_ROOM_DODGE_BONUS = 0.20;
// ─── Standalone Enemy Defenses (for DoT/debuff pipeline) ─────────────────────
/**
* Apply regular enemy defenses: dodge → barrier → armor (spec §5.2).
* Returns modified damage, or 0 on dodge.
* Exported for use by the DoT/debuff tick processing system (spec §6.3).
*
* @param bypassArmor — if true, skip armor reduction entirely (spec §6.4, AC-13)
* @param bypassBarrier — if true, skip barrier absorption (spec §6.4)
*/
export function applyEnemyDefenses(
dmg: number,
enemy: EnemyState | null,
roomType: string,
addLog: (msg: string) => void,
bypassArmor?: boolean,
bypassBarrier?: boolean,
): number {
if (!enemy) return dmg;
// 0. Curse amplification (spec §6.3) — amplifies all incoming damage
let curseMult = 1;
for (const effect of enemy.activeEffects) {
if (effect.type === 'curse') {
curseMult *= (1 + effect.magnitude);
}
}
if (curseMult > 1) {
dmg *= curseMult;
}
// 1. Dodge check (spec §5.2, §4.5)
let effectiveDodge = enemy.dodgeChance;
if (roomType === 'speed') {
const hasAgile = enemy.name.toLowerCase().includes('agile');
if (hasAgile) {
effectiveDodge = Math.min(0.75, enemy.dodgeChance + SPEED_ROOM_DODGE_BONUS);
}
}
if (effectiveDodge > 0 && Math.random() < effectiveDodge) {
addLog('Attack dodged!');
return 0;
}
// 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);
}
// 3. Armor reduction — skipped if bypassArmor (spec §6.4, AC-13)
if (!bypassArmor) {
const armorValue = (enemy as EnemyState & { effectiveArmor?: number }).effectiveArmor ?? enemy.armor;
if (armorValue && armorValue > 0) {
dmg *= (1 - armorValue);
}
}
return dmg;
}
export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
const { ctx, effects, maxMana, useCombatStore, usePrestigeStore } = params;
const onFloorCleared = (floor: number, wasGuardian: boolean) => {
if (wasGuardian) {
const defeatedGuardian = getGuardianForFloor(floor);
params.addLog((defeatedGuardian?.name || 'Unknown') + ' defeated! Visit the Grimoire to sign a pact.');
usePrestigeStore.getState().addDefeatedGuardian(floor);
// Auto-unlock Invoker when the first guardian (floor 10) is defeated
if (floor === 10) {
const prestigeState = usePrestigeStore.getState();
const unlocked = useAttunementStore.getState().unlockAttunement('invoker', prestigeState.defeatedGuardians);
if (unlocked) {
params.addLog('💜 The path of the Invoker is now available!');
}
}
} else if (floor % 5 === 0) {
params.addLog('Floor ' + floor + ' cleared!');
}
useCombatStore.setState({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
// ── Golem room-duration countdown (spec §14) ──────────────────────
const cs = useCombatStore.getState();
const activeGolems = cs.golemancy.activeGolems;
const golemDesigns = cs.golemancy.golemDesigns;
if (activeGolems.length > 0) {
const result = countdownGolemRoomDuration(activeGolems, golemDesigns);
if (result.logMessages.length > 0) {
result.logMessages.forEach((msg) => params.addLog(msg));
}
useCombatStore.setState({
golemancy: { ...cs.golemancy, activeGolems: result.remainingGolems },
});
}
};
/** 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).
* Returns recharged enemy copy, or null if not a mage enemy.
*/
const applyMageBarrierRecharge = (enemy: EnemyState | null): EnemyState | null => {
if (!enemy || !enemy.barrier || enemy.barrier <= 0) return null;
if (!enemy.name.startsWith('Mage')) return null;
const recharged = enemy.barrier + MAGE_BARRIER_RECHARGE_RATE * HOURS_PER_TICK;
return { ...enemy, barrier: recharged };
};
// Local reference to the module-level applyEnemyDefenses
const defApply = applyEnemyDefenses;
/**
* Create the onDamageDealt callback for this tick.
* Closes over the enemy defense context (captured once per tick from currentRoom).
*/
const makeOnDamageDealt = (
rawManaRef: () => number,
elementsRef: () => Record<string, { current: number; max: number; unlocked: boolean }>,
defCtx: EnemyDefenseCtx,
addLog: (msg: string) => void,
) => {
return (damage: number, skipSpecials?: boolean) => {
const rawMana = rawManaRef();
const elements = elementsRef();
let dmg = damage;
// Discipline specials (Executioner, Berserker) — before enemy defenses
// 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);
// 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) {
let shield = ctx.combat.guardianShield;
const shieldMax = ctx.combat.guardianShieldMax;
let barrier = ctx.combat.guardianBarrier;
const barrierMax = ctx.combat.guardianBarrierMax;
// 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)
: Math.floor(guardian.healthRegen * HOURS_PER_TICK);
dmg -= healAmount;
}
useCombatStore.setState({
guardianShield: shield,
guardianShieldMax: shieldMax,
guardianBarrier: barrier,
guardianBarrierMax: barrierMax,
});
}
return { rawMana, elements, modifiedDamage: dmg };
};
};
// ─── 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 };
}