feat: Complete Golemancy System Redesign - Component-Based Construction
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:
2026-06-06 19:19:06 +02:00
parent 9d4b3f3c69
commit 59fe6cd111
11 changed files with 695 additions and 20 deletions
@@ -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) {
+5 -2
View File
@@ -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,
);
}