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
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Circular Dependencies
|
||||
Generated: 2026-06-08T10:56:12.135Z
|
||||
Generated: 2026-06-08T11:14:13.243Z
|
||||
Found: 1 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||
|
||||
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_meta": {
|
||||
"generated": "2026-06-08T10:56:10.139Z",
|
||||
"generated": "2026-06-08T11:14:11.296Z",
|
||||
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
||||
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
||||
},
|
||||
|
||||
@@ -392,6 +392,7 @@ Mana-Loop/
|
||||
│ │ │ │ ├── golem-combat-actions.ts
|
||||
│ │ │ │ ├── golem-combat-helpers.test.ts
|
||||
│ │ │ │ ├── golem-combat-helpers.ts
|
||||
│ │ │ │ ├── golem-combat-maintenance.test.ts
|
||||
│ │ │ │ ├── golemancy-actions.ts
|
||||
│ │ │ │ ├── golemancy-combat.test.ts
|
||||
│ │ │ │ ├── index.ts
|
||||
|
||||
@@ -83,10 +83,14 @@ describe('computeGolemStats', () => {
|
||||
// Total summon cost includes all components + enchantments
|
||||
expect(stats.totalSummonCost.length).toBeGreaterThan(0);
|
||||
|
||||
// Upkeep = core.manaRegen * 2 per hour
|
||||
expect(stats.upkeepCostPerHour.length).toBe(1);
|
||||
expect(stats.upkeepCostPerHour[0].amount).toBe(6.0); // 3.0 * 2
|
||||
// Upkeep = core.manaRegen * 2 per hour, split across selected mana types
|
||||
expect(stats.upkeepCostPerHour.length).toBe(3); // crystal, metal, fire
|
||||
expect(stats.upkeepCostPerHour[0].amount).toBe(2.0); // 6.0 / 3
|
||||
expect(stats.upkeepCostPerHour[0].element).toBe('crystal');
|
||||
expect(stats.upkeepCostPerHour[1].amount).toBe(2.0);
|
||||
expect(stats.upkeepCostPerHour[1].element).toBe('metal');
|
||||
expect(stats.upkeepCostPerHour[2].amount).toBe(2.0);
|
||||
expect(stats.upkeepCostPerHour[2].element).toBe('fire');
|
||||
});
|
||||
|
||||
it('computes total summon cost from all components', async () => {
|
||||
|
||||
@@ -186,11 +186,49 @@ describe('Computed stats', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const stats = computeGolemStats(design);
|
||||
|
||||
// Basic core has 1 mana type (earth), so all upkeep goes to earth
|
||||
expect(stats.upkeepCostPerHour.length).toBe(1);
|
||||
expect(stats.upkeepCostPerHour[0].amount).toBe(1.0);
|
||||
expect(stats.upkeepCostPerHour[0].element).toBe('earth');
|
||||
});
|
||||
|
||||
it('multi-type core splits upkeep evenly across selected mana types', () => {
|
||||
const design = makeDesign('intermediate', 'earth', 'simple');
|
||||
design.selectedManaTypes = ['fire', 'water'];
|
||||
const stats = computeGolemStats(design);
|
||||
|
||||
// Intermediate core: manaRegen=1.5, upkeep=3.0/hr split across 2 types = 1.5 each
|
||||
expect(stats.upkeepCostPerHour.length).toBe(2);
|
||||
expect(stats.upkeepCostPerHour[0].element).toBe('fire');
|
||||
expect(stats.upkeepCostPerHour[0].amount).toBe(1.5);
|
||||
expect(stats.upkeepCostPerHour[1].element).toBe('water');
|
||||
expect(stats.upkeepCostPerHour[1].amount).toBe(1.5);
|
||||
});
|
||||
|
||||
it('advanced core (3 types) splits upkeep three ways', () => {
|
||||
const design = makeDesign('advanced', 'steel', 'simple');
|
||||
design.selectedManaTypes = ['fire', 'water', 'earth'];
|
||||
const stats = computeGolemStats(design);
|
||||
|
||||
// Advanced core: manaRegen=3.0, upkeep=6.0/hr split across 3 types = 2.0 each
|
||||
expect(stats.upkeepCostPerHour.length).toBe(3);
|
||||
const totalUpkeep = stats.upkeepCostPerHour.reduce((sum, c) => sum + c.amount, 0);
|
||||
expect(totalUpkeep).toBe(6.0);
|
||||
for (const cost of stats.upkeepCostPerHour) {
|
||||
expect(cost.amount).toBe(2.0);
|
||||
}
|
||||
});
|
||||
|
||||
it('single-type core does not split upkeep', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
// Basic core has manaTypes=['earth'], selectedManaTypes=[] => uses core.manaTypes
|
||||
const stats = computeGolemStats(design);
|
||||
|
||||
expect(stats.upkeepCostPerHour.length).toBe(1);
|
||||
expect(stats.upkeepCostPerHour[0].amount).toBe(1.0); // 0.5 * 2 = 1.0
|
||||
expect(stats.upkeepCostPerHour[0].element).toBe('earth');
|
||||
});
|
||||
|
||||
it('selected mana types override core defaults', () => {
|
||||
const design = makeDesign('intermediate', 'earth', 'simple');
|
||||
design.selectedManaTypes = ['fire', 'water'];
|
||||
|
||||
@@ -81,14 +81,16 @@ export function computeGolemStats(design: GolemDesign): ComputedGolemStats {
|
||||
...enchantments.flatMap((e) => e.summonCost),
|
||||
];
|
||||
|
||||
// Player upkeep = Core.manaRegen × 2 per hour (spec §13)
|
||||
const upkeepCostPerHour: GolemManaCost[] = [
|
||||
{
|
||||
type: 'element',
|
||||
element: core.primaryManaType,
|
||||
amount: core.manaRegen * 2,
|
||||
},
|
||||
];
|
||||
// Player upkeep = Core.manaRegen × 2 per hour, split across all mana types (spec §13)
|
||||
const upkeepManaTypes = design.selectedManaTypes.length > 0
|
||||
? design.selectedManaTypes
|
||||
: core.manaTypes;
|
||||
const upkeepPerType = (core.manaRegen * 2) / Math.max(1, upkeepManaTypes.length);
|
||||
const upkeepCostPerHour: GolemManaCost[] = upkeepManaTypes.map((mt) => ({
|
||||
type: 'element' as const,
|
||||
element: mt,
|
||||
amount: upkeepPerType,
|
||||
}));
|
||||
|
||||
// Enchantment capacity = Frame.MagicAffinity(%) × Core.TierMultiplier
|
||||
// magicAffinity is stored as decimal (0.3 = 30%) per spec §5.1, so multiply by 100
|
||||
|
||||
@@ -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)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user