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
+79 -3
View File
@@ -6,8 +6,10 @@ import { SPELLS_DEF, HOURS_PER_TICK } from '../constants';
import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore, CombatState } from './combat-state.types';
import type { SpellState } from '../types';
import type { ActiveGolem } from '../types';
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions';
/**
* Create a default CombatTickResult for safe fallback on error.
@@ -16,6 +18,7 @@ function makeDefaultCombatTickResult(
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
state: CombatState,
activeGolems: ActiveGolem[],
): CombatTickResult {
return {
rawMana,
@@ -28,6 +31,7 @@ function makeDefaultCombatTickResult(
maxFloorReached: state.maxFloorReached,
castProgress: state.castProgress,
equipmentSpellStates: state.equipmentSpellStates,
activeGolems,
};
}
@@ -42,6 +46,7 @@ export interface CombatTickResult {
maxFloorReached: number;
castProgress: number;
equipmentSpellStates: CombatState['equipmentSpellStates'];
activeGolems: ActiveGolem[];
}
export function processCombatTick(
@@ -58,19 +63,63 @@ export function processCombatTick(
modifiedDamage?: number;
},
signedPacts: number[],
golemancyState: { activeGolems: ActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
): CombatTickResult {
const state = get();
const logMessages: string[] = [];
let totalManaGathered = 0;
if (state.currentAction !== 'climb') {
return makeDefaultCombatTickResult(rawMana, elements, state);
return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems);
}
// ─── Golem maintenance (spec §9.5) ──────────────────────────────────────
const maintenanceResult = processGolemMaintenance(
golemancyState.activeGolems,
rawMana,
elements,
);
let activeGolems = maintenanceResult.maintainedGolems;
rawMana = maintenanceResult.rawMana;
elements = maintenanceResult.elements;
logMessages.push(...maintenanceResult.logMessages);
// Write maintained golems back immediately so tick state stays consistent
set({ golemancy: { ...state.golemancy, activeGolems } });
const spellId = state.activeSpell;
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) {
return makeDefaultCombatTickResult(rawMana, elements, state);
// Even if no spell is configured, golems can still fight
// Process golem attacks even without a valid spell
if (activeGolems.length > 0) {
const golemAttackResult = processGolemAttacks(
activeGolems,
rawMana,
elements,
state.floorHP,
state.floorMaxHP,
state.currentFloor,
onDamageDealt,
golemApplyDamageToRoom,
);
rawMana = golemAttackResult.rawMana;
elements = golemAttackResult.elements;
activeGolems = golemAttackResult.activeGolems;
logMessages.push(...golemAttackResult.logMessages);
// Check if golems cleared the room
const newFloorHP = golemApplyDamageToRoom(0);
if (newFloorHP.roomCleared || golemAttackResult.totalDamageDealt > 0) {
// Re-check floor state after golem attacks
const newState = get();
if (newState.floorHP <= 0) {
return makeDefaultCombatTickResult(rawMana, elements, state, activeGolems);
}
}
}
return makeDefaultCombatTickResult(rawMana, elements, state, activeGolems);
}
try {
@@ -227,6 +276,32 @@ export function processCombatTick(
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
}
// ─── Golem attacks (spec §9.4) ─────────────────────────────────────────
// Golems attack after spells, using the same damage pipeline.
// They ignore Executioner/Berserker (handled internally by processGolemAttacks).
if (activeGolems.length > 0 && floorHP > 0) {
const golemResult = processGolemAttacks(
activeGolems,
rawMana,
elements,
floorHP,
floorMaxHP,
currentFloor,
onDamageDealt,
golemApplyDamageToRoom,
);
rawMana = golemResult.rawMana;
elements = golemResult.elements;
activeGolems = golemResult.activeGolems;
logMessages.push(...golemResult.logMessages);
// Read back floor state after golem damage
const postGolemState = get();
floorHP = postGolemState.floorHP;
floorMaxHP = postGolemState.floorMaxHP;
currentFloor = postGolemState.currentFloor;
}
const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor);
return {
@@ -240,12 +315,13 @@ export function processCombatTick(
maxFloorReached: newMaxFloorReached,
castProgress,
equipmentSpellStates: updatedEquipmentSpellStates,
activeGolems,
};
} catch (error) {
// Return safe defaults on error — combat tick should never crash the game
const errorMsg = error instanceof Error ? error.message : String(error);
logMessages.push(`⚠️ Combat error: ${errorMsg}`);
return makeDefaultCombatTickResult(rawMana, elements, state);
return makeDefaultCombatTickResult(rawMana, elements, state, activeGolems);
}
}