33be133813
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m1s
- #354: unlockAttunement now uses _get() instead of undefined 'state' variable - #353: startPreparing now deducts raw mana from the mana store after validation - #352: processGolemAttacks/processBasicAttack accept current mana as params instead of initializing to 0/{} - Updated golem-combat-actions.test.ts to pass new currentRawMana/currentElements params - Added regression tests for all 3 bugs (16 new tests, all passing)
221 lines
9.1 KiB
TypeScript
221 lines
9.1 KiB
TypeScript
// ─── Golem Combat Helpers ─────────────────────────────────────────────────────
|
||
// Shared helpers for golem combat: enchantment resolution, basic attack damage,
|
||
// and store-wrapper for processGolemAttacks.
|
||
// Extracted from golem-combat-actions.ts to stay under the 400-line file limit.
|
||
|
||
import { GOLEM_ENCHANTMENTS } from '../data/golems';
|
||
import { getElementalBonus } from '../utils';
|
||
import type { CombatStore, CombatState } from './combat-state.types';
|
||
import type {
|
||
ActiveEffect,
|
||
EnemyState,
|
||
RuntimeActiveGolem,
|
||
} from '../types';
|
||
|
||
// ─── Enchantment Effect Types ────────────────────────────────────────────────
|
||
|
||
export interface GolemEnchantmentEffect {
|
||
type: 'burn' | 'slow' | 'shock' | 'weaken' | 'armorPierce' | 'criticalChance' | 'soak' | 'shieldBreak';
|
||
magnitude: number;
|
||
}
|
||
|
||
// ─── Enchantment Resolution ──────────────────────────────────────────────────
|
||
|
||
/** Resolve enchantment effects from a list of enchantment IDs. */
|
||
export function resolveEnchantmentEffects(enchantmentIds: string[]): GolemEnchantmentEffect[] {
|
||
const effects: GolemEnchantmentEffect[] = [];
|
||
for (const id of enchantmentIds) {
|
||
const ench = GOLEM_ENCHANTMENTS[id];
|
||
if (!ench) continue;
|
||
switch (ench.effect) {
|
||
case 'burn': effects.push({ type: 'burn', magnitude: 3 }); break;
|
||
case 'slow': effects.push({ type: 'slow', magnitude: 0.3 }); break;
|
||
case 'shock': effects.push({ type: 'shock', magnitude: 0.25 }); break;
|
||
case 'weaken': effects.push({ type: 'weaken', magnitude: 0.2 }); break;
|
||
case 'armorPierce': effects.push({ type: 'armorPierce', magnitude: 0.15 }); break;
|
||
case 'criticalChance': effects.push({ type: 'criticalChance', magnitude: 0.1 }); break;
|
||
case 'soak': effects.push({ type: 'soak', magnitude: 0.3 }); break;
|
||
case 'shieldBreak': effects.push({ type: 'shieldBreak', magnitude: 0.25 }); break;
|
||
}
|
||
}
|
||
return effects;
|
||
}
|
||
|
||
// ─── Basic Attack Damage ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Compute basic attack damage for a golem.
|
||
* Formula per spire-combat spec §9.4: dmg = frame.baseDamage × (1 + frame.armorPierce)
|
||
* Also applies elemental matchup bonus and enchantment armor pierce bonus.
|
||
* Enemy armor reduction is handled separately in onDamageDealt.
|
||
*/
|
||
export function computeBasicAttackDamage(
|
||
frame: { baseDamage: number; armorPierce: number; element?: string },
|
||
enchantmentBonusArmorPierce: number,
|
||
_enemyArmor: number,
|
||
enemyElement: string,
|
||
): number {
|
||
let dmg = frame.baseDamage;
|
||
if (frame.element) {
|
||
dmg *= getElementalBonus(frame.element, enemyElement);
|
||
}
|
||
const totalArmorPierce = frame.armorPierce + enchantmentBonusArmorPierce;
|
||
dmg *= (1 + totalArmorPierce);
|
||
return Math.max(0, dmg);
|
||
}
|
||
|
||
// ─── Basic Attack Processing ────────────────────────────────────────────────
|
||
|
||
export interface BasicAttackContext {
|
||
frame: { baseDamage: number; armorPierce: number; element?: string; aoeTargets: number };
|
||
bonusArmorPierce: number;
|
||
enchantmentEffects: GolemEnchantmentEffect[];
|
||
enemyElement: string;
|
||
getTargetEnemy: () => EnemyState | null;
|
||
getTargetEnemies: () => EnemyState[];
|
||
onDamageDealt: (damage: number, skipSpecials?: boolean) => {
|
||
rawMana: number;
|
||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||
modifiedDamage?: number;
|
||
};
|
||
applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean };
|
||
onApplyEnchantmentEffects: (enemyId: string, effects: GolemEnchantmentEffect[]) => void;
|
||
}
|
||
|
||
export interface BasicAttackResult {
|
||
rawMana: number;
|
||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||
floorHP: number;
|
||
floorMaxHP: number;
|
||
totalDamageDealt: number;
|
||
}
|
||
|
||
/**
|
||
* Process a single basic attack (AoE or single-target) for a golem.
|
||
* AoE frames distribute damage across up to frame.aoeTargets enemies (spec §11).
|
||
* Single-target frames attack the lowest-HP enemy.
|
||
*/
|
||
export function processBasicAttack(ctx: BasicAttackContext, currentRawMana: number, currentElements: Record<string, { current: number; max: number; unlocked: boolean }>): BasicAttackResult {
|
||
let rawMana = currentRawMana;
|
||
let elements: Record<string, { current: number; max: number; unlocked: boolean }> = { ...currentElements };
|
||
let floorHP = 0;
|
||
let floorMaxHP = 0;
|
||
let totalDamageDealt = 0;
|
||
|
||
if (ctx.frame.aoeTargets > 1) {
|
||
const allEnemies = ctx.getTargetEnemies();
|
||
if (allEnemies.length > 0) {
|
||
const targets = allEnemies.slice(0, ctx.frame.aoeTargets);
|
||
const dmgPerTarget = computeBasicAttackDamage(ctx.frame, ctx.bonusArmorPierce, 0, ctx.enemyElement) / targets.length;
|
||
for (const target of targets) {
|
||
if (ctx.enchantmentEffects.length > 0) {
|
||
ctx.onApplyEnchantmentEffects(target.id, ctx.enchantmentEffects);
|
||
}
|
||
const dmgResult = ctx.onDamageDealt(dmgPerTarget, true);
|
||
const finalDamage = dmgResult.modifiedDamage || dmgPerTarget;
|
||
if (Number.isFinite(finalDamage)) {
|
||
const roomResult = ctx.applyDamageToRoom(finalDamage);
|
||
floorHP = roomResult.floorHP;
|
||
floorMaxHP = roomResult.floorMaxHP;
|
||
totalDamageDealt += Math.max(0, finalDamage);
|
||
rawMana = dmgResult.rawMana;
|
||
elements = dmgResult.elements;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
const targetEnemy = ctx.getTargetEnemy();
|
||
const dmg = computeBasicAttackDamage(ctx.frame, ctx.bonusArmorPierce, 0, ctx.enemyElement);
|
||
if (ctx.enchantmentEffects.length > 0 && targetEnemy) {
|
||
ctx.onApplyEnchantmentEffects(targetEnemy.id, ctx.enchantmentEffects);
|
||
}
|
||
const dmgResult = ctx.onDamageDealt(dmg, true);
|
||
const finalDamage = dmgResult.modifiedDamage || dmg;
|
||
if (Number.isFinite(finalDamage)) {
|
||
const roomResult = ctx.applyDamageToRoom(finalDamage);
|
||
floorHP = roomResult.floorHP;
|
||
floorMaxHP = roomResult.floorMaxHP;
|
||
totalDamageDealt += Math.max(0, finalDamage);
|
||
rawMana = dmgResult.rawMana;
|
||
elements = dmgResult.elements;
|
||
}
|
||
}
|
||
|
||
return { rawMana, elements, floorHP, floorMaxHP, totalDamageDealt };
|
||
}
|
||
|
||
// ─── Golem Attacks Store Wrapper ─────────────────────────────────────────────
|
||
|
||
// Import here is safe: only used inside the function body, not at module init time.
|
||
import { processGolemAttacks } from './golem-combat-actions'; // eslint-disable-line
|
||
import type { GolemCombatResult } from './golem-combat-actions';
|
||
|
||
interface SerializedDesign {
|
||
id: string;
|
||
name: string;
|
||
coreId: string;
|
||
frameId: string;
|
||
mindCircuitId: string;
|
||
enchantmentIds: string[];
|
||
selectedManaTypes: string[];
|
||
selectedSpells: string[];
|
||
}
|
||
|
||
/** Convenience wrapper that wires up processGolemAttacks with store callbacks. */
|
||
export function processGolemAttacksFromStore(
|
||
activeGolems: RuntimeActiveGolem[],
|
||
golemDesigns: Record<string, SerializedDesign>,
|
||
onDamageDealt: (damage: number) => {
|
||
rawMana: number;
|
||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||
modifiedDamage?: number;
|
||
},
|
||
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||
enemyElement: string,
|
||
get: () => CombatStore,
|
||
set: (s: Partial<CombatState>) => void,
|
||
currentRawMana: number,
|
||
currentElements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||
): GolemCombatResult {
|
||
return processGolemAttacks(
|
||
activeGolems,
|
||
golemDesigns,
|
||
onDamageDealt,
|
||
golemApplyDamageToRoom,
|
||
enemyElement,
|
||
() => {
|
||
const room = get().currentRoom;
|
||
const living = room.enemies.filter((e) => e.hp > 0);
|
||
if (living.length === 0) return null;
|
||
return living.reduce((lowest, e) => (e.hp < lowest.hp ? e : lowest));
|
||
},
|
||
() => {
|
||
const room = get().currentRoom;
|
||
return room.enemies.filter((e) => e.hp > 0);
|
||
},
|
||
(enemyId, effects) => {
|
||
const room = get().currentRoom;
|
||
const updatedEnemies = room.enemies.map((e) => {
|
||
if (e.id !== enemyId) return e;
|
||
const newEffects = [...e.activeEffects];
|
||
for (const effect of effects) {
|
||
const idx = newEffects.findIndex((ae) => ae.type === effect.type);
|
||
if (idx >= 0) {
|
||
newEffects[idx] = {
|
||
...newEffects[idx],
|
||
remainingDuration: 4,
|
||
magnitude: Math.max(newEffects[idx].magnitude, effect.magnitude),
|
||
};
|
||
} else {
|
||
newEffects.push({ type: effect.type, remainingDuration: 4, magnitude: effect.magnitude, source: 'golem' });
|
||
}
|
||
}
|
||
return { ...e, activeEffects: newEffects };
|
||
});
|
||
set({ currentRoom: { ...room, enemies: updatedEnemies } });
|
||
},
|
||
currentRawMana,
|
||
currentElements,
|
||
);
|
||
}
|