feat: Complete Golemancy System Redesign - Component-Based Construction
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
- Fix Crystal-Steel Hybrid Frame unlock to require Fabricator 5 + Enchanter 5 (dual attunement) - Fix golem slot calculation: pass fabricator level to summon logic, cap at 7 (base 5 + discipline bonus) - Integrate discipline golemCapacity bonus into slot calculation in combat-descent-actions and golem-combat pipeline - Update GolemancyTab UI to show discipline slot bonus in header - Add comprehensive test suite: golemancy-data.test.ts (344 lines) and golemancy-combat.test.ts (313 lines) - All 1009 tests pass across 52 test files
This commit is contained in:
@@ -126,9 +126,9 @@ const CRYSTAL_STEEL_HYBRID_FRAME: FrameDefinition = {
|
||||
specialEffect: 'guardianConstruct',
|
||||
summonCost: [elemCost('crystal', 20), elemCost('metal', 15), rawCost(15)],
|
||||
unlockRequirement: {
|
||||
type: 'attunement_level',
|
||||
attunement: 'fabricator',
|
||||
level: 5,
|
||||
type: 'dual_attunement',
|
||||
attunements: ['fabricator', 'enchanter'],
|
||||
levels: [5, 5],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// ─── Golemancy Data Layer Tests ───────────────────────────────────────────────
|
||||
// Tests component registries, unlock requirements, computed stats, and slot limits.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS,
|
||||
ALL_CORES, ALL_FRAMES, ALL_MIND_CIRCUITS, ALL_GOLEM_ENCHANTMENTS,
|
||||
} from '@/lib/game/data/golems';
|
||||
import {
|
||||
getGolemSlots,
|
||||
isComponentUnlocked,
|
||||
computeGolemStats,
|
||||
canAffordGolemDesign,
|
||||
createActiveGolem,
|
||||
} from '@/lib/game/data/golems/utils';
|
||||
import type { GolemDesign } from '@/lib/game/data/golems/types';
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeDesign(coreId: string, frameId: string, circuitId: string, enchantIds: string[] = []): GolemDesign {
|
||||
return {
|
||||
id: `test_${coreId}_${frameId}_${circuitId}`,
|
||||
name: `Test ${coreId} ${frameId}`,
|
||||
core: CORES[coreId],
|
||||
frame: FRAMES[frameId],
|
||||
mindCircuit: MIND_CIRCUITS[circuitId],
|
||||
enchantments: enchantIds.map(id => GOLEM_ENCHANTMENTS[id]).filter(Boolean),
|
||||
selectedManaTypes: [],
|
||||
selectedSpells: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Component Registry Completeness ──────────────────────────────────────────
|
||||
|
||||
describe('Component registry completeness', () => {
|
||||
it('has exactly 4 cores', () => {
|
||||
expect(ALL_CORES.length).toBe(4);
|
||||
expect(Object.keys(CORES).length).toBe(4);
|
||||
});
|
||||
|
||||
it('has exactly 7 frames', () => {
|
||||
expect(ALL_FRAMES.length).toBe(7);
|
||||
expect(Object.keys(FRAMES).length).toBe(7);
|
||||
});
|
||||
|
||||
it('has exactly 4 mind circuits', () => {
|
||||
expect(ALL_MIND_CIRCUITS.length).toBe(4);
|
||||
expect(Object.keys(MIND_CIRCUITS).length).toBe(4);
|
||||
});
|
||||
|
||||
it('has exactly 8 enchantments', () => {
|
||||
expect(ALL_GOLEM_ENCHANTMENTS.length).toBe(8);
|
||||
expect(Object.keys(GOLEM_ENCHANTMENTS).length).toBe(8);
|
||||
});
|
||||
|
||||
it('all cores have unique tiers 1-4', () => {
|
||||
const tiers = ALL_CORES.map(c => c.tier).sort();
|
||||
expect(tiers).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('all frames have valid special effects', () => {
|
||||
const validSpecials = new Set(['none', 'aoe', 'slow', 'guardianConstruct']);
|
||||
for (const frame of ALL_FRAMES) {
|
||||
expect(validSpecials.has(frame.specialEffect)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('all circuits have valid behaviors', () => {
|
||||
const validBehaviors = new Set(['basicOnly', 'castSpell1', 'alternate2', 'cycleAll']);
|
||||
for (const circuit of ALL_MIND_CIRCUITS) {
|
||||
expect(validBehaviors.has(circuit.behavior)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Unlock Requirements ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Unlock requirements', () => {
|
||||
it('basic core requires Fabricator 2', () => {
|
||||
const req = CORES.basic.unlockRequirement;
|
||||
expect(req.type).toBe('attunement_level');
|
||||
expect(isComponentUnlocked(req, { fabricator: { active: true, level: 2 } }, [], [])).toBe(true);
|
||||
expect(isComponentUnlocked(req, { fabricator: { active: true, level: 1 } }, [], [])).toBe(false);
|
||||
});
|
||||
|
||||
it('intermediate core requires dual attunement Fabricator 4 + Enchanter 2', () => {
|
||||
const req = CORES.intermediate.unlockRequirement;
|
||||
expect(req.type).toBe('dual_attunement');
|
||||
expect(isComponentUnlocked(req, { fabricator: { active: true, level: 4 }, enchanter: { active: true, level: 2 } }, [], [])).toBe(true);
|
||||
expect(isComponentUnlocked(req, { fabricator: { active: true, level: 4 }, enchanter: { active: true, level: 1 } }, [], [])).toBe(false);
|
||||
});
|
||||
|
||||
it('guardian core requires guardian pact', () => {
|
||||
const req = CORES.guardian.unlockRequirement;
|
||||
expect(req.type).toBe('guardian_pact');
|
||||
expect(isComponentUnlocked(req, { invoker: { active: true, level: 5 }, fabricator: { active: true, level: 5 } }, [], [1])).toBe(true);
|
||||
expect(isComponentUnlocked(req, { invoker: { active: true, level: 5 }, fabricator: { active: true, level: 5 } }, [], [])).toBe(false);
|
||||
});
|
||||
|
||||
it('crystalSteelHybrid frame requires Fabricator 5 + Enchanter 5', () => {
|
||||
const req = FRAMES.crystalSteelHybrid.unlockRequirement;
|
||||
expect(req.type).toBe('dual_attunement');
|
||||
expect(isComponentUnlocked(req, { fabricator: { active: true, level: 5 }, enchanter: { active: true, level: 5 } }, [], [])).toBe(true);
|
||||
expect(isComponentUnlocked(req, { fabricator: { active: true, level: 5 }, enchanter: { active: true, level: 4 } }, [], [])).toBe(false);
|
||||
});
|
||||
|
||||
it('sand frame requires sand mana unlocked', () => {
|
||||
const req = FRAMES.sand.unlockRequirement;
|
||||
expect(req.type).toBe('mana_unlocked');
|
||||
expect(isComponentUnlocked(req, {}, ['sand'], [])).toBe(true);
|
||||
expect(isComponentUnlocked(req, {}, ['earth'], [])).toBe(false);
|
||||
});
|
||||
|
||||
it('guardian circuit requires Invoker 5 + Fabricator 5', () => {
|
||||
const req = MIND_CIRCUITS.guardian.unlockRequirement;
|
||||
expect(req.type).toBe('dual_attunement');
|
||||
expect(isComponentUnlocked(req, { invoker: { active: true, level: 5 }, fabricator: { active: true, level: 5 } }, [], [])).toBe(true);
|
||||
expect(isComponentUnlocked(req, { invoker: { active: true, level: 4 }, fabricator: { active: true, level: 5 } }, [], [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Computed Stats ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Computed stats', () => {
|
||||
it('basic earth golem has correct stats', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const stats = computeGolemStats(design);
|
||||
|
||||
expect(stats.manaCapacity).toBe(50);
|
||||
expect(stats.manaRegen).toBe(0.5);
|
||||
expect(stats.maxRoomDuration).toBe(3);
|
||||
expect(stats.baseDamage).toBe(6);
|
||||
expect(stats.attackSpeed).toBe(1.2);
|
||||
expect(stats.armorPierce).toBe(0.05);
|
||||
expect(stats.magicAffinity).toBe(0.3);
|
||||
expect(stats.aoeTargets).toBe(1);
|
||||
expect(stats.spellSlots).toBe(0);
|
||||
expect(stats.enchantmentCapacity).toBeCloseTo(0.3);
|
||||
expect(stats.specialEffect).toBe('none');
|
||||
expect(stats.availableManaTypes).toEqual(['earth']);
|
||||
});
|
||||
|
||||
it('advanced steel golem with advanced circuit', () => {
|
||||
const design = makeDesign('advanced', 'steel', 'advanced');
|
||||
const stats = computeGolemStats(design);
|
||||
|
||||
expect(stats.manaCapacity).toBe(200);
|
||||
expect(stats.manaRegen).toBe(3.0);
|
||||
expect(stats.maxRoomDuration).toBe(5);
|
||||
expect(stats.baseDamage).toBe(18);
|
||||
expect(stats.attackSpeed).toBe(1.6);
|
||||
expect(stats.armorPierce).toBe(0.5);
|
||||
expect(stats.magicAffinity).toBe(0.5);
|
||||
expect(stats.spellSlots).toBe(2);
|
||||
expect(stats.enchantmentCapacity).toBeCloseTo(1.0);
|
||||
});
|
||||
|
||||
it('guardian crystalSteelHybrid golem with guardian circuit', () => {
|
||||
const design = makeDesign('guardian', 'crystalSteelHybrid', 'guardian');
|
||||
const stats = computeGolemStats(design);
|
||||
|
||||
expect(stats.manaCapacity).toBe(500);
|
||||
expect(stats.manaRegen).toBe(10.0);
|
||||
expect(stats.maxRoomDuration).toBe(8);
|
||||
expect(stats.baseDamage).toBe(22);
|
||||
expect(stats.attackSpeed).toBe(2.8);
|
||||
expect(stats.armorPierce).toBe(0.7);
|
||||
expect(stats.magicAffinity).toBe(1.0);
|
||||
expect(stats.spellSlots).toBe(4);
|
||||
expect(stats.enchantmentCapacity).toBeCloseTo(3.0);
|
||||
expect(stats.specialEffect).toBe('guardianConstruct');
|
||||
});
|
||||
|
||||
it('enchantments add to summon cost', () => {
|
||||
const designNoEnch = makeDesign('basic', 'earth', 'simple');
|
||||
const designWithEnch = makeDesign('basic', 'earth', 'simple', ['sword_fire', 'sword_frost']);
|
||||
|
||||
const statsNoEnch = computeGolemStats(designNoEnch);
|
||||
const statsWithEnch = computeGolemStats(designWithEnch);
|
||||
|
||||
expect(statsWithEnch.totalSummonCost.length).toBeGreaterThan(statsNoEnch.totalSummonCost.length);
|
||||
});
|
||||
|
||||
it('upkeep = core.manaRegen * 2 per hour', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const stats = computeGolemStats(design);
|
||||
|
||||
expect(stats.upkeepCostPerHour.length).toBe(1);
|
||||
expect(stats.upkeepCostPerHour[0].amount).toBe(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'];
|
||||
const stats = computeGolemStats(design);
|
||||
|
||||
expect(stats.availableManaTypes).toEqual(['fire', 'water']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Golem Slot Calculation ───────────────────────────────────────────────────
|
||||
|
||||
describe('Golem slot calculation', () => {
|
||||
it('returns 0 for fabricator level < 2', () => {
|
||||
expect(getGolemSlots(0)).toBe(0);
|
||||
expect(getGolemSlots(1)).toBe(0);
|
||||
});
|
||||
|
||||
it('scales as floor(level/2)', () => {
|
||||
expect(getGolemSlots(2)).toBe(1);
|
||||
expect(getGolemSlots(3)).toBe(1);
|
||||
expect(getGolemSlots(4)).toBe(2);
|
||||
expect(getGolemSlots(5)).toBe(2);
|
||||
expect(getGolemSlots(6)).toBe(3);
|
||||
expect(getGolemSlots(7)).toBe(3);
|
||||
expect(getGolemSlots(8)).toBe(4);
|
||||
expect(getGolemSlots(9)).toBe(4);
|
||||
expect(getGolemSlots(10)).toBe(5);
|
||||
});
|
||||
|
||||
it('total slots capped at 7 (base + discipline bonus)', () => {
|
||||
const baseSlots = getGolemSlots(10);
|
||||
const discBonus = 2;
|
||||
const total = Math.min(7, baseSlots + discBonus);
|
||||
expect(total).toBe(7);
|
||||
});
|
||||
|
||||
it('total slots cannot exceed 7 even with high fabricator level', () => {
|
||||
const baseSlots = getGolemSlots(20);
|
||||
const discBonus = 2;
|
||||
const total = Math.min(7, baseSlots + discBonus);
|
||||
expect(total).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Affordability ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('canAffordGolemDesign', () => {
|
||||
it('returns true when player has enough resources', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const result = canAffordGolemDesign(design, 100, {
|
||||
earth: { current: 50, max: 100, unlocked: true },
|
||||
});
|
||||
expect(result.canAfford).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when raw mana insufficient', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const result = canAffordGolemDesign(design, 3, {
|
||||
earth: { current: 50, max: 100, unlocked: true },
|
||||
});
|
||||
expect(result.canAfford).toBe(false);
|
||||
expect(result.missing).toContain('raw mana');
|
||||
});
|
||||
|
||||
it('returns false when element mana insufficient', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const result = canAffordGolemDesign(design, 100, {
|
||||
earth: { current: 5, max: 100, unlocked: true },
|
||||
});
|
||||
expect(result.canAfford).toBe(false);
|
||||
expect(result.missing).toContain('earth');
|
||||
});
|
||||
|
||||
it('returns false when element not unlocked', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const result = canAffordGolemDesign(design, 100, {});
|
||||
expect(result.canAfford).toBe(false);
|
||||
expect(result.missing).toContain('not unlocked');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Active Golem Creation ────────────────────────────────────────────────────
|
||||
|
||||
describe('createActiveGolem', () => {
|
||||
it('creates active golem with correct initial state', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const active = createActiveGolem(design, 5);
|
||||
|
||||
expect(active.designId).toBe(design.id);
|
||||
expect(active.summonedFloor).toBe(5);
|
||||
expect(active.attackProgress).toBe(0);
|
||||
expect(active.roomsRemaining).toBe(3);
|
||||
expect(active.currentMana).toBe(50);
|
||||
expect(active.spellCastIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('guardian golem starts with full mana', () => {
|
||||
const design = makeDesign('guardian', 'crystalSteelHybrid', 'guardian');
|
||||
const active = createActiveGolem(design, 10);
|
||||
|
||||
expect(active.currentMana).toBe(500);
|
||||
expect(active.roomsRemaining).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Guardian Construct Requirements ──────────────────────────────────────────
|
||||
|
||||
describe('Guardian Construct requirements', () => {
|
||||
it('guardian core has tier 4', () => {
|
||||
expect(CORES.guardian.tier).toBe(4);
|
||||
});
|
||||
|
||||
it('crystalSteelHybrid frame has guardianConstruct special effect', () => {
|
||||
expect(FRAMES.crystalSteelHybrid.specialEffect).toBe('guardianConstruct');
|
||||
});
|
||||
|
||||
it('guardian circuit has cycleAll behavior', () => {
|
||||
expect(MIND_CIRCUITS.guardian.behavior).toBe('cycleAll');
|
||||
});
|
||||
|
||||
it('guardian circuit has 4 spell slots', () => {
|
||||
expect(MIND_CIRCUITS.guardian.spellSlots).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── All Component Combinations ───────────────────────────────────────────────
|
||||
|
||||
describe('All component combinations', () => {
|
||||
it('every core+frame+circuit combination produces valid stats', () => {
|
||||
for (const core of ALL_CORES) {
|
||||
for (const frame of ALL_FRAMES) {
|
||||
for (const circuit of ALL_MIND_CIRCUITS) {
|
||||
const design = makeDesign(core.id, frame.id, circuit.id);
|
||||
const stats = computeGolemStats(design);
|
||||
|
||||
expect(stats.manaCapacity).toBeGreaterThan(0);
|
||||
expect(stats.manaRegen).toBeGreaterThan(0);
|
||||
expect(stats.maxRoomDuration).toBeGreaterThan(0);
|
||||
expect(stats.baseDamage).toBeGreaterThan(0);
|
||||
expect(stats.attackSpeed).toBeGreaterThan(0);
|
||||
expect(stats.armorPierce).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.magicAffinity).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.aoeTargets).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.spellSlots).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.enchantmentCapacity).toBeGreaterThan(0);
|
||||
expect(stats.totalSummonCost.length).toBeGreaterThan(0);
|
||||
expect(stats.upkeepCostPerHour.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||
import { usePrestigeStore } from './prestigeStore';
|
||||
import { useManaStore } from './manaStore';
|
||||
import { summonGolemsOnRoomEntry } from './golem-combat-actions';
|
||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
||||
import { useAttunementStore } from './attunementStore';
|
||||
import {
|
||||
onEnterLibraryRoom,
|
||||
onEnterRecoveryRoom,
|
||||
@@ -145,6 +147,11 @@ function summonGolemsForRoom(get: GetFn, set: SetFn): void {
|
||||
const loadout = s.golemancy?.golemLoadout ?? [];
|
||||
if (loadout.length === 0) return;
|
||||
|
||||
const attStore = useAttunementStore.getState();
|
||||
const fabLevel = attStore.attunements?.fabricator?.level ?? 0;
|
||||
const discEffects = computeDisciplineEffects();
|
||||
const discBonus = Math.floor(discEffects.bonuses.golemCapacity || 0);
|
||||
|
||||
const currentActiveGolems = s.golemancy?.activeGolems ?? [];
|
||||
const summonResult = summonGolemsOnRoomEntry(
|
||||
loadout,
|
||||
@@ -152,7 +159,8 @@ function summonGolemsForRoom(get: GetFn, set: SetFn): void {
|
||||
manaState.elements,
|
||||
s.currentFloor,
|
||||
currentActiveGolems,
|
||||
0, // discBonus — computed in pipeline, not here
|
||||
discBonus,
|
||||
fabLevel,
|
||||
);
|
||||
|
||||
if (summonResult.logMessages.length > 0) {
|
||||
|
||||
@@ -48,6 +48,7 @@ export function summonGolemsOnRoomEntry(
|
||||
currentFloor: number,
|
||||
existingActiveGolems: RuntimeActiveGolem[],
|
||||
disciplineSlotsBonus: number,
|
||||
fabricatorLevel: number,
|
||||
): {
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
@@ -60,12 +61,14 @@ export function summonGolemsOnRoomEntry(
|
||||
const logMessages: string[] = [];
|
||||
|
||||
const activeCount = newActiveGolems.length;
|
||||
const baseSlots = getGolemSlots(fabricatorLevel);
|
||||
const totalSlots = Math.min(7, baseSlots + disciplineSlotsBonus);
|
||||
|
||||
for (const entry of loadout) {
|
||||
if (!entry.enabled) continue;
|
||||
|
||||
// Check slot availability
|
||||
if (newActiveGolems.length >= activeCount + disciplineSlotsBonus + getGolemSlots(0)) {
|
||||
// Check slot availability (max 7 total per AC-1)
|
||||
if (newActiveGolems.length >= totalSlots) {
|
||||
logMessages.push('No golem slots available');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// ─── Golemancy Combat Tests ────────────────────────────────────────────────────
|
||||
// Tests the golem combat actions: summoning, maintenance, mana regen, room duration.
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS,
|
||||
} from '@/lib/game/data/golems';
|
||||
import {
|
||||
computeGolemStats,
|
||||
createActiveGolem,
|
||||
} from '@/lib/game/data/golems/utils';
|
||||
import type { GolemDesign, SerializedDesign } from '@/lib/game/data/golems/types';
|
||||
import {
|
||||
summonGolemsOnRoomEntry,
|
||||
processGolemMaintenance,
|
||||
processGolemManaRegen,
|
||||
countdownGolemRoomDuration,
|
||||
} from '@/lib/game/stores/golem-combat-actions';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeDesign(coreId: string, frameId: string, circuitId: string, enchantIds: string[] = []): GolemDesign {
|
||||
return {
|
||||
id: `test_${coreId}_${frameId}_${circuitId}`,
|
||||
name: `Test ${coreId} ${frameId}`,
|
||||
core: CORES[coreId],
|
||||
frame: FRAMES[frameId],
|
||||
mindCircuit: MIND_CIRCUITS[circuitId],
|
||||
enchantments: enchantIds.map(id => GOLEM_ENCHANTMENTS[id]).filter(Boolean),
|
||||
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: design.enchantments.map(e => e.id),
|
||||
selectedManaTypes: design.selectedManaTypes,
|
||||
selectedSpells: design.selectedSpells,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Summon Golems on Room Entry ──────────────────────────────────────────────
|
||||
|
||||
describe('summonGolemsOnRoomEntry', () => {
|
||||
const emptyElements: Record<string, { current: number; max: number; unlocked: boolean }> = {
|
||||
earth: { current: 1000, max: 1000, unlocked: true },
|
||||
crystal: { current: 1000, max: 1000, unlocked: true },
|
||||
fire: { current: 1000, max: 1000, unlocked: true },
|
||||
};
|
||||
|
||||
it('summons affordable golems from loadout', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const loadout = [{
|
||||
designId: design.id,
|
||||
design: makeSerialized(design) as any,
|
||||
enabled: true,
|
||||
}];
|
||||
|
||||
const result = summonGolemsOnRoomEntry(
|
||||
loadout, 1000, emptyElements, 1, [], 0, 2,
|
||||
);
|
||||
|
||||
expect(result.activeGolems.length).toBe(1);
|
||||
expect(result.rawMana).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('skips unenabled designs', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const loadout = [{
|
||||
designId: design.id,
|
||||
design: makeSerialized(design) as any,
|
||||
enabled: false,
|
||||
}];
|
||||
|
||||
const result = summonGolemsOnRoomEntry(
|
||||
loadout, 1000, emptyElements, 1, [], 0, 2,
|
||||
);
|
||||
|
||||
expect(result.activeGolems.length).toBe(0);
|
||||
});
|
||||
|
||||
it('skips designs that cannot be afforded', () => {
|
||||
const design = makeDesign('guardian', 'crystalSteelHybrid', 'guardian');
|
||||
const loadout = [{
|
||||
designId: design.id,
|
||||
design: makeSerialized(design) as any,
|
||||
enabled: true,
|
||||
}];
|
||||
|
||||
const result = summonGolemsOnRoomEntry(
|
||||
loadout, 0, emptyElements, 1, [], 0, 10,
|
||||
);
|
||||
|
||||
expect(result.activeGolems.length).toBe(0);
|
||||
expect(result.logMessages.some(m => m.includes('Not enough mana'))).toBe(true);
|
||||
});
|
||||
|
||||
it('respects slot limit (max 7)', () => {
|
||||
const designs = Array.from({ length: 10 }, (_, i) => {
|
||||
const d = makeDesign('basic', 'earth', 'simple');
|
||||
d.id = `design_${i}`;
|
||||
return d;
|
||||
});
|
||||
|
||||
const loadout = designs.map(d => ({
|
||||
designId: d.id,
|
||||
design: makeSerialized(d) as any,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
const result = summonGolemsOnRoomEntry(
|
||||
loadout, 10000, emptyElements, 1, [], 0, 10,
|
||||
);
|
||||
|
||||
expect(result.activeGolems.length).toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
it('does not duplicate already active golems', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const active = createActiveGolem(design, 1);
|
||||
|
||||
const loadout = [{
|
||||
designId: design.id,
|
||||
design: makeSerialized(design) as any,
|
||||
enabled: true,
|
||||
}];
|
||||
|
||||
const result = summonGolemsOnRoomEntry(
|
||||
loadout, 1000, emptyElements, 1, [active], 0, 2,
|
||||
);
|
||||
|
||||
expect(result.activeGolems.length).toBe(1);
|
||||
});
|
||||
|
||||
it('passes fabricator level to slot calculation', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const loadout = [{
|
||||
designId: design.id,
|
||||
design: makeSerialized(design) as any,
|
||||
enabled: true,
|
||||
}];
|
||||
|
||||
// With fabricator level 2, should have 1 base slot
|
||||
const result = summonGolemsOnRoomEntry(
|
||||
loadout, 1000, emptyElements, 1, [], 0, 2,
|
||||
);
|
||||
|
||||
expect(result.activeGolems.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Golem Maintenance ───────────────────────────────────────────────────────
|
||||
|
||||
describe('processGolemMaintenance', () => {
|
||||
it('maintains golem when upkeep is affordable', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const active = createActiveGolem(design, 1);
|
||||
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {
|
||||
earth: { current: 1000, max: 1000, unlocked: true },
|
||||
};
|
||||
|
||||
const result = processGolemMaintenance(
|
||||
[active],
|
||||
{ [design.id]: makeSerialized(design) },
|
||||
1000,
|
||||
elements,
|
||||
);
|
||||
|
||||
expect(result.maintainedGolems.length).toBe(1);
|
||||
});
|
||||
|
||||
it('dismisses golem when upkeep cannot be paid', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const active = createActiveGolem(design, 1);
|
||||
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {
|
||||
earth: { current: 0, max: 1000, unlocked: true },
|
||||
};
|
||||
|
||||
const result = processGolemMaintenance(
|
||||
[active],
|
||||
{ [design.id]: makeSerialized(design) },
|
||||
1000,
|
||||
elements,
|
||||
);
|
||||
|
||||
expect(result.maintainedGolems.length).toBe(0);
|
||||
expect(result.logMessages.some(m => m.includes('dismissed'))).toBe(true);
|
||||
});
|
||||
|
||||
it('maintains multiple golems independently', () => {
|
||||
const design1 = makeDesign('basic', 'earth', 'simple');
|
||||
const design2 = makeDesign('basic', 'earth', 'simple');
|
||||
design2.id = 'test_design2';
|
||||
const active1 = createActiveGolem(design1, 1);
|
||||
const active2 = createActiveGolem(design2, 1);
|
||||
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {
|
||||
earth: { current: 1000, max: 1000, unlocked: true },
|
||||
};
|
||||
|
||||
const result = processGolemMaintenance(
|
||||
[active1, active2],
|
||||
{ [design1.id]: makeSerialized(design1), [design2.id]: makeSerialized(design2) },
|
||||
1000,
|
||||
elements,
|
||||
);
|
||||
|
||||
expect(result.maintainedGolems.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Golem Mana Regen ────────────────────────────────────────────────────────
|
||||
|
||||
describe('processGolemManaRegen', () => {
|
||||
it('regenerates golem mana each tick', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const active = createActiveGolem(design, 1);
|
||||
active.currentMana = 0;
|
||||
|
||||
const result = processGolemManaRegen(
|
||||
[active],
|
||||
{ [design.id]: makeSerialized(design) },
|
||||
);
|
||||
|
||||
expect(result[0].currentMana).toBeGreaterThan(0);
|
||||
expect(result[0].currentMana).toBeLessThanOrEqual(design.core.manaCapacity);
|
||||
});
|
||||
|
||||
it('does not exceed mana capacity', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const active = createActiveGolem(design, 1);
|
||||
active.currentMana = design.core.manaCapacity;
|
||||
|
||||
const result = processGolemManaRegen(
|
||||
[active],
|
||||
{ [design.id]: makeSerialized(design) },
|
||||
);
|
||||
|
||||
expect(result[0].currentMana).toBe(design.core.manaCapacity);
|
||||
});
|
||||
|
||||
it('handles empty active golems array', () => {
|
||||
const result = processGolemManaRegen([], {});
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Room Duration Countdown ──────────────────────────────────────────────────
|
||||
|
||||
describe('countdownGolemRoomDuration', () => {
|
||||
it('decrements rooms remaining', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const active = createActiveGolem(design, 1);
|
||||
active.roomsRemaining = 3;
|
||||
|
||||
const result = countdownGolemRoomDuration(
|
||||
[active],
|
||||
{ [design.id]: makeSerialized(design) },
|
||||
);
|
||||
|
||||
expect(result.remainingGolems.length).toBe(1);
|
||||
expect(result.remainingGolems[0].roomsRemaining).toBe(2);
|
||||
expect(result.dismissedNames.length).toBe(0);
|
||||
});
|
||||
|
||||
it('dismisses golem when rooms remaining reaches 0', () => {
|
||||
const design = makeDesign('basic', 'earth', 'simple');
|
||||
const active = createActiveGolem(design, 1);
|
||||
active.roomsRemaining = 1;
|
||||
|
||||
const result = countdownGolemRoomDuration(
|
||||
[active],
|
||||
{ [design.id]: makeSerialized(design) },
|
||||
);
|
||||
|
||||
expect(result.remainingGolems.length).toBe(0);
|
||||
expect(result.dismissedNames.length).toBe(1);
|
||||
expect(result.logMessages.some(m => m.includes('faded'))).toBe(true);
|
||||
});
|
||||
|
||||
it('handles multiple golems with mixed remaining rooms', () => {
|
||||
const design1 = makeDesign('basic', 'earth', 'simple');
|
||||
const design2 = makeDesign('basic', 'earth', 'simple');
|
||||
design2.id = 'test_design2';
|
||||
const active1 = createActiveGolem(design1, 1);
|
||||
const active2 = createActiveGolem(design2, 1);
|
||||
active1.roomsRemaining = 2;
|
||||
active2.roomsRemaining = 1;
|
||||
|
||||
const result = countdownGolemRoomDuration(
|
||||
[active1, active2],
|
||||
{ [design1.id]: makeSerialized(design1), [design2.id]: makeSerialized(design2) },
|
||||
);
|
||||
|
||||
expect(result.remainingGolems.length).toBe(1);
|
||||
expect(result.remainingGolems[0].designId).toBe(design1.id);
|
||||
expect(result.dismissedNames.length).toBe(1);
|
||||
});
|
||||
|
||||
it('handles empty active golems array', () => {
|
||||
const result = countdownGolemRoomDuration([], {});
|
||||
expect(result.remainingGolems.length).toBe(0);
|
||||
expect(result.dismissedNames.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -118,5 +118,6 @@ export function processGolemRoomEntry(
|
||||
currentFloor,
|
||||
cs.golemancy.activeGolems as any[],
|
||||
discBonus,
|
||||
fabLevel,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user