fix: split multi-type golem core upkeep across all mana types (issue #315)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s

This commit is contained in:
2026-06-08 13:29:30 +02:00
parent 0894ee8c55
commit b4b499c1b1
9 changed files with 294 additions and 26 deletions
@@ -1,10 +1,11 @@
// ─── Golem Combat Actions Regression Tests (Issue #313) ────────────────────────
// Tests the 5 fixes applied to processGolemAttacks:
// ─── Golem Combat Actions Regression Tests ────────────────────────────────────
// Issue #313: Tests the 5 fixes applied to processGolemAttacks:
// 1. Spell damage uses actual SPELLS_DEF[spellId].dmg * frame.magicAffinity
// 2. Spell mana cost uses actual SPELLS_DEF[spellId].cost.amount
// 3. Elemental matchup applied to basic attacks
// 4. Enchantment effects applied to basic attacks
// 5. Armor pierce bypasses armor fraction instead of multiplying damage
//
import { describe, it, expect } from 'vitest';
import {
@@ -344,4 +345,3 @@ describe('processGolemAttacks - armor pierce (fix #5)', () => {
});
});
+31 -10
View File
@@ -168,19 +168,40 @@ export function processGolemMaintenance(
if (!core) continue;
const upkeepPerTick = core.manaRegen * 2 * HOURS_PER_TICK;
const upkeepElement = core.primaryManaType;
const elem = upkeepElement ? newElements[upkeepElement] : null;
// For multi-type cores, split upkeep evenly across all mana types (spec §13)
const upkeepManaTypes =
design.selectedManaTypes.length > 0
? design.selectedManaTypes
: core.manaTypes;
const splitCount = Math.max(1, upkeepManaTypes.length);
const upkeepPerType = upkeepPerTick / splitCount;
if (upkeepElement && elem && elem.unlocked && elem.current >= upkeepPerTick) {
newElements[upkeepElement] = { ...elem, current: elem.current - upkeepPerTick };
// Check if player can afford upkeep across all required mana types
let canMaintain = true;
for (const manaType of upkeepManaTypes) {
const elem = newElements[manaType];
if (!elem || !elem.unlocked || elem.current < upkeepPerType) {
canMaintain = false;
break;
}
}
if (canMaintain) {
for (const manaType of upkeepManaTypes) {
const elem = newElements[manaType]!;
newElements[manaType] = { ...elem, current: elem.current - upkeepPerType };
}
maintainedGolems.push(golem);
} else if (!upkeepElement && newRawMana >= upkeepPerTick) {
newRawMana -= upkeepPerTick;
maintainedGolems.push(golem);
} else if (upkeepElement && (!elem || !elem.unlocked || elem.current < upkeepPerTick)) {
logMessages.push(`${design.name} dismissed — insufficient ${upkeepElement} mana for upkeep`);
} else {
logMessages.push(`${design.name} dismissed — insufficient mana for upkeep`);
const missingTypes = upkeepManaTypes
.filter((t) => {
const elem = newElements[t];
return !elem || !elem.unlocked || elem.current < upkeepPerType;
})
.join(', ');
logMessages.push(
`${design.name} dismissed — insufficient mana for upkeep (${missingTypes})`,
);
}
}
@@ -0,0 +1,202 @@
// ─── Golem Maintenance Upkeep Tests (Issue #315) ───────────────────────────────
// Tests that multi-type golem cores split upkeep evenly across all mana types.
import { describe, it, expect } from 'vitest';
import {
CORES, FRAMES, MIND_CIRCUITS,
} from '@/lib/game/data/golems';
import type { GolemDesign, SerializedDesign } from '@/lib/game/data/golems/types';
import type { RuntimeActiveGolem } from '@/lib/game/types';
import { processGolemMaintenance } from './golem-combat-actions';
// ─── Helpers ───────────────────────────────────────────────────────────────────
function makeDesign(
coreId: string,
frameId: string,
circuitId: string,
enchantIds: string[] = [],
selectedSpells: string[] = [],
): GolemDesign {
return {
id: `test_${coreId}_${frameId}_${circuitId}`,
name: `Test ${coreId} ${frameId}`,
core: CORES[coreId],
frame: FRAMES[frameId],
mindCircuit: MIND_CIRCUITS[circuitId],
enchantments: [],
selectedManaTypes: [],
selectedSpells,
};
}
function makeSerialized(design: GolemDesign): SerializedDesign {
return {
id: design.id,
name: design.name,
coreId: design.core.id,
frameId: design.frame.id,
mindCircuitId: design.mindCircuit.id,
enchantmentIds: [],
selectedManaTypes: design.selectedManaTypes,
selectedSpells: design.selectedSpells,
};
}
function makeActiveGolem(design: GolemDesign, currentMana?: number, attackProgress = 0): RuntimeActiveGolem {
return {
designId: design.id,
summonedFloor: 1,
attackProgress,
roomsRemaining: 3,
currentMana: currentMana ?? design.core.manaCapacity,
spellCastIndex: 0,
};
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe('processGolemMaintenance - multi-type core upkeep splitting (fix #315)', () => {
it('single-type core (basic) deducts upkeep from one element', () => {
const design = makeDesign('basic', 'earth', 'simple');
const serialized = makeSerialized(design);
const golem = makeActiveGolem(design);
const elements = {
earth: { current: 50, max: 100, unlocked: true },
};
const result = processGolemMaintenance(
[golem],
{ [design.id]: serialized },
100,
elements,
);
expect(result.maintainedGolems.length).toBe(1);
// Basic core: manaRegen=0.5, upkeep=1.0/hr, HOURS_PER_TICK=0.04 => 0.04 per tick
const expectedDeduction = 0.5 * 2 * 0.04; // 0.04
expect(result.elements.earth.current).toBeCloseTo(50 - expectedDeduction);
});
it('multi-type core splits upkeep evenly across selected mana types', () => {
const design = makeDesign('intermediate', 'earth', 'simple');
design.selectedManaTypes = ['fire', 'water'];
const serialized = makeSerialized(design);
const golem = makeActiveGolem(design);
const elements = {
fire: { current: 50, max: 100, unlocked: true },
water: { current: 50, max: 100, unlocked: true },
};
const result = processGolemMaintenance(
[golem],
{ [design.id]: serialized },
100,
elements,
);
expect(result.maintainedGolems.length).toBe(1);
// Intermediate core: manaRegen=1.5, upkeep=3.0/hr split across 2 types
// Per type: 1.5/hr * 0.04 = 0.06 per tick
const expectedDeduction = (1.5 * 2 * 0.04) / 2; // 0.06
expect(result.elements.fire.current).toBeCloseTo(50 - expectedDeduction);
expect(result.elements.water.current).toBeCloseTo(50 - expectedDeduction);
});
it('advanced core (3 types) splits upkeep three ways', () => {
const design = makeDesign('advanced', 'steel', 'simple');
design.selectedManaTypes = ['fire', 'water', 'earth'];
const serialized = makeSerialized(design);
const golem = makeActiveGolem(design);
const elements = {
fire: { current: 50, max: 100, unlocked: true },
water: { current: 50, max: 100, unlocked: true },
earth: { current: 50, max: 100, unlocked: true },
};
const result = processGolemMaintenance(
[golem],
{ [design.id]: serialized },
100,
elements,
);
expect(result.maintainedGolems.length).toBe(1);
// Advanced core: manaRegen=3.0, upkeep=6.0/hr split across 3 types
// Per type: 2.0/hr * 0.04 = 0.08 per tick
const expectedDeduction = (3.0 * 2 * 0.04) / 3; // 0.08
expect(result.elements.fire.current).toBeCloseTo(50 - expectedDeduction);
expect(result.elements.water.current).toBeCloseTo(50 - expectedDeduction);
expect(result.elements.earth.current).toBeCloseTo(50 - expectedDeduction);
});
it('dismisses golem when one mana type is insufficient for split upkeep', () => {
const design = makeDesign('intermediate', 'earth', 'simple');
design.selectedManaTypes = ['fire', 'water'];
const serialized = makeSerialized(design);
const golem = makeActiveGolem(design);
const elements = {
fire: { current: 50, max: 100, unlocked: true },
water: { current: 0.001, max: 100, unlocked: true }, // Almost empty
};
const result = processGolemMaintenance(
[golem],
{ [design.id]: serialized },
100,
elements,
);
expect(result.maintainedGolems.length).toBe(0);
expect(result.logMessages[0]).toContain('dismissed');
expect(result.logMessages[0]).toContain('water');
});
it('dismisses golem when one mana type is not unlocked', () => {
const design = makeDesign('intermediate', 'earth', 'simple');
design.selectedManaTypes = ['fire', 'water'];
const serialized = makeSerialized(design);
const golem = makeActiveGolem(design);
const elements = {
fire: { current: 50, max: 100, unlocked: true },
// water not present => not unlocked
};
const result = processGolemMaintenance(
[golem],
{ [design.id]: serialized },
100,
elements,
);
expect(result.maintainedGolems.length).toBe(0);
expect(result.logMessages[0]).toContain('dismissed');
});
it('does not deduct any mana when upkeep cannot be paid (atomic check)', () => {
const design = makeDesign('intermediate', 'earth', 'simple');
design.selectedManaTypes = ['fire', 'water'];
const serialized = makeSerialized(design);
const golem = makeActiveGolem(design);
const elements = {
fire: { current: 50, max: 100, unlocked: true },
water: { current: 0.001, max: 100, unlocked: true },
};
const result = processGolemMaintenance(
[golem],
{ [design.id]: serialized },
100,
elements,
);
// fire should NOT have been deducted since water couldn't pay its share
expect(result.elements.fire.current).toBe(50);
});
});