feat: implement golemancy combat system (spec §6, §9)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user