feat: implement golemancy combat system (spec §6, §9)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s

- Add ActiveGolem interface and activeGolems to GolemancyState
- Add maxRoomDuration to all 12 golem definitions
- Create golem-combat-actions.ts with pure golem combat logic (summoning, maintenance, attacks, room-duration)
- Create golem-combat.ts pipeline for golem combat setup
- Wire golem maintenance and attacks into processCombatTick
- Wire golem summoning into advanceRoomOrFloor on room entry
- Wire golem room-duration countdown into onFloorCleared callback
- Update combat-actions tests for new processCombatTick signature
- All 921 tests pass, all files under 400-line limit

Closes #259
This commit is contained in:
2026-06-03 15:40:39 +02:00
parent 7c0e740226
commit a2cdf6d21c
20 changed files with 583 additions and 41 deletions
+31 -4
View File
@@ -36,7 +36,7 @@ function resetStores() {
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
@@ -56,6 +56,12 @@ function resetStores() {
useDisciplineStore.setState({ disciplines: {}, activeIds: [], concurrentLimit: 1, totalXP: 0, processedPerks: [] });
}
function golemApplyDamageToRoom(dmg: number): { floorHP: number; floorMaxHP: number; roomCleared: boolean } {
const cs = useCombatStore.getState();
const newFloorHP = Math.max(0, cs.floorHP - dmg);
return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: newFloorHP <= 0 };
}
function runCombatTick(rawMana: number, elements: Record<string, { current: number; max: number; unlocked: boolean }>): CombatTickResult {
return processCombatTick(
() => useCombatStore.getState(),
@@ -67,6 +73,27 @@ function runCombatTick(rawMana: number, elements: Record<string, { current: numb
vi.fn(), // onFloorCleared
(dmg) => ({ rawMana, elements, modifiedDamage: dmg }), // onDamageDealt (no modifiers)
[], // signedPacts
{ activeGolems: [] }, // golemancyState
golemApplyDamageToRoom,
);
}
function processCombatTickDirect(
get: () => ReturnType<typeof useCombatStore.getState>,
set: (partial: Record<string, unknown>) => void,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
maxMana: number,
attackSpeedMult: number,
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (dmg: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }>; modifiedDamage?: number },
signedPacts: number[],
): CombatTickResult {
return processCombatTick(
get, set, rawMana, elements, maxMana, attackSpeedMult,
onFloorCleared, onDamageDealt, signedPacts,
{ activeGolems: [] },
golemApplyDamageToRoom,
);
}
@@ -160,7 +187,7 @@ describe('processCombatTick', () => {
const elements = makeInitialElements(500, {});
const state = useCombatStore.getState();
// With 0 mana and 0 progress, no cast should complete
const result = processCombatTick(
const result = processCombatTickDirect(
() => state,
() => {},
0, // no mana
@@ -196,7 +223,7 @@ describe('processCombatTick', () => {
// path that acts as a safety net.
useCombatStore.setState({ activeSpell: 'totallyInvalidSpell' });
const elements = makeInitialElements(500, {});
const result = processCombatTick(
const result = processCombatTickDirect(
() => useCombatStore.getState(),
() => {},
1000,
@@ -218,7 +245,7 @@ describe('processCombatTick', () => {
const elements = makeInitialElements(500, {});
useCombatStore.setState({ castProgress: 0.99 });
expect(() => {
processCombatTick(
processCombatTickDirect(
() => useCombatStore.getState(),
() => {},
1000,