feat(golemancy): Phase 1 - Component-based construction system data definitions
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s

- Add new golem component types (Core, Frame, MindCircuit, Enchantment)
- Create 4 Core tiers, 7 Frames, 4 Mind Circuits, 8 Enchantments
- Rewrite golem utils for component-based stat computation
- Update GolemancyState with new fields (golemDesigns, golemLoadout, activeGolems)
- Update combat store, actions, and pipelines for new golem system
- Rewrite GolemancyTab with component selection UI
- Update fabricator discipline perks for new system
- Add comprehensive tests for component registries and utilities
- All files under 400 lines, all 743 tests passing
This commit is contained in:
2026-06-06 16:50:26 +02:00
parent c40e4ee940
commit 4b7aa82953
43 changed files with 2763 additions and 944 deletions
+17 -12
View File
@@ -7,10 +7,14 @@ import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore, CombatState } from './combat-state.types';
import type { SpellState, EnemyState, EquipmentInstance, FloorState } from '../types';
import { applyOnHitEffect, processDoTPhase } from './dot-runtime';
import type { ActiveGolem } from '../types';
import type { ActiveGolem, RuntimeActiveGolem } from '../types';
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions';
import {
processGolemMaintenance,
processGolemAttacks,
processGolemManaRegen,
} from './golem-combat-actions';
import { applyDamageToRoom } from './combat-damage';
// ─── Result Type ───────────────────────────────────────────────────────────────
@@ -22,7 +26,7 @@ function makeDefaultCombatTickResult(
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
state: CombatState,
activeGolems: ActiveGolem[],
activeGolems: RuntimeActiveGolem[],
): CombatTickResult {
return {
rawMana,
@@ -52,7 +56,7 @@ export interface CombatTickResult {
maxFloorReached: number;
castProgress: number;
equipmentSpellStates: CombatState['equipmentSpellStates'];
activeGolems: ActiveGolem[];
activeGolems: RuntimeActiveGolem[];
meleeSwordProgress: Record<string, number>;
currentRoom: FloorState;
}
@@ -73,7 +77,7 @@ export function processCombatTick(
modifiedDamage?: number;
},
signedPacts: number[],
golemancyState: { activeGolems: ActiveGolem[] },
golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: (
dmg: number,
@@ -94,9 +98,11 @@ export function processCombatTick(
}
try {
// ─── Golem maintenance (spec §9.5) ──────────────────────────────────────
// ─── Golem maintenance (spec §13) ──────────────────────────────────────
const golemDesigns = state.golemancy.golemDesigns || {};
const maintenanceResult = processGolemMaintenance(
golemancyState.activeGolems,
golemDesigns,
rawMana,
elements,
);
@@ -105,6 +111,9 @@ export function processCombatTick(
elements = maintenanceResult.elements;
logMessages.push(...maintenanceResult.logMessages);
// ─── Golem mana regen (spec §12) ───────────────────────────────────────
activeGolems = processGolemManaRegen(activeGolems, golemDesigns);
// Write maintained golems back immediately so tick state stays consistent
set({ golemancy: { ...state.golemancy, activeGolems } });
@@ -289,15 +298,11 @@ export function processCombatTick(
}
}
// ─── Golem attacks (spec §9.4) ───────────────────────────────────────────
// ─── Golem attacks (spec §11) ───────────────────────────────────────────
if (activeGolems.length > 0 && floorHP > 0) {
const golemResult = processGolemAttacks(
activeGolems,
rawMana,
elements,
floorHP,
floorMaxHP,
currentFloor,
golemDesigns,
onDamageDealt,
golemApplyDamageToRoom,
);
@@ -244,7 +244,7 @@ export function createEnterSpireMode(get: GetFn, set: SetFn) {
roomResetState: {},
descentPeak: null,
isDescentComplete: false,
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
});
get().addActivityLog('floor_transition',
+6 -3
View File
@@ -1,7 +1,7 @@
// ─── Combat State Types ────────────────────────────────────────────────────────
// Shared types for combat store and combat actions to avoid circular dependency
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState, EquipmentInstance } from '../types';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, RuntimeActiveGolem, EnemyState, EquipmentInstance, SerializedGolemDesign } from '../types';
/** Signature for the advanceRoomOrFloor callback to break circular dependency */
export type AdvanceRoomFn = (get: () => CombatStore, set: (s: Partial<CombatState>) => void) => void;
@@ -130,6 +130,9 @@ export interface CombatActions {
// Golemancy
toggleGolem: (golemId: string) => void;
setEnabledGolems: (golemIds: string[]) => void;
addGolemDesign: (design: SerializedGolemDesign) => void;
removeGolemDesign: (designId: string) => void;
toggleGolemLoadoutEntry: (designId: string) => void;
// Spells
learnSpell: (spellId: string) => void;
@@ -155,7 +158,7 @@ export interface CombatActions {
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
signedPacts: number[],
golemancyState: { activeGolems: ActiveGolem[] },
golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: (
dmg: number,
@@ -177,7 +180,7 @@ export interface CombatActions {
maxFloorReached: number;
castProgress: number;
equipmentSpellStates: EquipmentSpellState[];
activeGolems: ActiveGolem[];
activeGolems: RuntimeActiveGolem[];
meleeSwordProgress: Record<string, number>;
currentRoom: FloorState;
};
+20 -19
View File
@@ -4,7 +4,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState, EquipmentInstance } from '../types';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, RuntimeActiveGolem, EnemyState, EquipmentInstance } from '../types';
import { getFloorMaxHP } from '../utils';
import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils';
import { addActivityLogEntry } from '../utils/activity-log';
@@ -17,6 +17,9 @@ import {
import {
onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom,
} from './non-combat-room-actions';
import {
addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry,
} from './golemancy-actions';
export const useCombatStore = create<CombatStore>()(
persist(
@@ -50,12 +53,17 @@ export const useCombatStore = create<CombatStore>()(
clearedRooms: {},
isDescentComplete: false,
// Golemancy
// Golemancy (component-based)
golemancy: {
// New component-based fields
golemDesigns: {},
golemLoadout: [],
activeGolems: [] as RuntimeActiveGolem[],
lastSummonFloor: 0,
// Legacy fields (deprecated)
enabledGolems: [],
summonedGolems: [],
activeGolems: [],
lastSummonFloor: 0,
legacyActiveGolems: [],
},
// Equipment spell states
@@ -196,24 +204,15 @@ export const useCombatStore = create<CombatStore>()(
currentRoomIndex: 0,
roomsPerFloor: 1,
maxFloorReached: Math.max(s.maxFloorReached, 1),
golemancy: { ...s.golemancy, activeGolems: [], summonedGolems: [] },
golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[], summonedGolems: [], legacyActiveGolems: [] },
};
});
},
startClimbUp: () => set({ climbDirection: 'up', currentAction: 'climb' }),
startClimbDown: () => set({ climbDirection: 'down', currentAction: 'climb' }),
startPracticing: () => set((s) => {
if (s.currentAction !== 'meditate') return s;
return { currentAction: 'practicing' };
}),
stopPracticing: () => set((s) => {
if (s.currentAction !== 'practicing') return s;
return { currentAction: 'meditate' };
}),
startPracticing: () => set((s) => s.currentAction !== 'meditate' ? s : { currentAction: 'practicing' }),
stopPracticing: () => set((s) => s.currentAction !== 'practicing' ? s : { currentAction: 'meditate' }),
// ─── Spec: Descent actions (delegated to combat-descent-actions.ts) ────
enterDescentMode: () => enterDescentMode(get, set),
@@ -246,6 +245,10 @@ export const useCombatStore = create<CombatStore>()(
}));
},
addGolemDesign: (d) => addGolemDesign(set, d),
removeGolemDesign: (id) => removeGolemDesign(set, id),
toggleGolemLoadoutEntry: (id) => toggleGolemLoadoutEntry(set, id),
enterSpireMode: createEnterSpireMode(get, set),
learnSpell: (spellId: string) => {
@@ -310,7 +313,7 @@ export const useCombatStore = create<CombatStore>()(
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
signedPacts: number[],
golemancyState: { activeGolems: ActiveGolem[] },
golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: (
dmg: number,
@@ -390,6 +393,4 @@ export const useCombatStore = create<CombatStore>()(
)
);
// makeInitialSpells is now in combat-actions.ts
// Re-export for backward compatibility
export { makeInitialSpells } from './combat-actions';
+207 -154
View File
@@ -1,80 +1,120 @@
// ─── Golem Combat Actions ──────────────────────────────────────────────────────
// Pure golem combat logic — no cross-store getState() calls.
// All external data is passed in as parameters.
// Implements spec §9: summoning, maintenance, attack, room-duration.
// ─── Golem Combat Actions (Component-Based) ──────────────────────────────────
// Runtime golem combat logic for the component-based construction system.
// All external data is passed in as parameters (no cross-store getState() calls).
// Implements spec §§10-14: summoning, maintenance, combat, mana, duration.
import { GOLEMS_DEF } from '../data/golems';
import { HOURS_PER_TICK } from '../constants';
import type { ActiveGolem, GolemancyState } from '../types';
import { getElementalBonus, getFloorElement } from '../utils';
import { CORES, FRAMES, MIND_CIRCUITS } from '../data/golems';
import { computeGolemStats, getGolemSlots } from '../data/golems/utils';
import type {
RuntimeActiveGolem,
GolemLoadoutEntry,
EnemyState,
ActiveEffect,
} from '../types';
// ─── Types ─────────────────────────────────────────────────────────────────────
// ─── Types ───────────────────────────────────────────────────────────────────
export interface GolemCombatResult {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
activeGolems: ActiveGolem[];
activeGolems: RuntimeActiveGolem[];
logMessages: string[];
totalDamageDealt: number;
}
// ─── Summoning (spec §9.3) ─────────────────────────────────────────────────────
interface SerializedDesign {
id: string;
name: string;
coreId: string;
frameId: string;
mindCircuitId: string;
enchantmentIds: string[];
selectedManaTypes: string[];
selectedSpells: string[];
}
// ─── Summoning (spec §10) ───────────────────────────────────────────────────
/**
* Attempt to summon golems from the enabled loadout on room entry.
* For each enabled golem: if the player has enough mana, deduct cost and activate.
* Golems that can't be skipped are NOT re-attempted mid-room.
* Attempt to summon golems from the loadout on room entry.
* For each enabled design: if player has enough mana, deduct cost and activate.
* Designs that can't be afforded are NOT re-attempted mid-room.
*/
export function summonGolemsOnRoomEntry(
enabledGolems: string[],
loadout: GolemLoadoutEntry[],
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
currentFloor: number,
existingActiveGolems: ActiveGolem[],
existingActiveGolems: RuntimeActiveGolem[],
disciplineSlotsBonus: number,
): {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
activeGolems: ActiveGolem[];
activeGolems: RuntimeActiveGolem[];
logMessages: string[];
} {
let newRawMana = rawMana;
let newElements = { ...elements };
const newElements = { ...elements };
const newActiveGolems = [...existingActiveGolems];
const logMessages: string[] = [];
for (const golemId of enabledGolems) {
const def = GOLEMS_DEF[golemId];
if (!def) continue;
const activeCount = newActiveGolems.length;
// Skip if this golem is already active (e.g. summoned on a previous floor
// and still within its room-duration)
const alreadyActive = newActiveGolems.some((ag) => ag.golemId === golemId);
for (const entry of loadout) {
if (!entry.enabled) continue;
// Check slot availability
if (newActiveGolems.length >= activeCount + disciplineSlotsBonus + getGolemSlots(0)) {
logMessages.push('No golem slots available');
break;
}
const design = entry.design as SerializedDesign;
// Resolve components
const core = CORES[design.coreId];
const frame = FRAMES[design.frameId];
const circuit = MIND_CIRCUITS[design.mindCircuitId];
if (!core || !frame || !circuit) {
logMessages.push(`${entry.design.name} has invalid components — skipped`);
continue;
}
// Skip if already active
const alreadyActive = newActiveGolems.some((ag) => ag.designId === entry.designId);
if (alreadyActive) continue;
// Check if player can afford the summon cost (multi-type costs supported)
// Build component-based design for cost calculation
const stats = computeGolemStats({
id: design.id,
name: design.name,
core: { ...core, manaTypes: design.selectedManaTypes.length > 0 ? design.selectedManaTypes : core.manaTypes },
frame,
mindCircuit: circuit,
enchantments: [], // Simplified — enchantments resolved by ID in full implementation
selectedManaTypes: design.selectedManaTypes,
selectedSpells: design.selectedSpells,
});
// Check affordability
let canAfford = true;
for (const cost of def.summonCost) {
for (const cost of stats.totalSummonCost) {
if (cost.type === 'raw') {
if (newRawMana < cost.amount) {
canAfford = false;
break;
}
if (newRawMana < cost.amount) { canAfford = false; break; }
} else if (cost.element) {
const elem = newElements[cost.element];
if (!elem || !elem.unlocked || elem.current < cost.amount) {
canAfford = false;
break;
}
if (!elem?.unlocked || elem.current < cost.amount) { canAfford = false; break; }
}
}
if (!canAfford) {
logMessages.push(`Not enough mana to summon ${def.name} — skipped`);
logMessages.push(`Not enough mana to summon ${entry.design.name} — skipped`);
continue;
}
// Deduct summon cost
for (const cost of def.summonCost) {
for (const cost of stats.totalSummonCost) {
if (cost.type === 'raw') {
newRawMana -= cost.amount;
} else if (cost.element && newElements[cost.element]) {
@@ -85,15 +125,16 @@ export function summonGolemsOnRoomEntry(
}
}
// Activate golem with fresh room duration and zero attack progress
newActiveGolems.push({
golemId: def.id,
designId: entry.designId,
summonedFloor: currentFloor,
attackProgress: 0,
roomsRemaining: def.maxRoomDuration,
roomsRemaining: stats.maxRoomDuration,
currentMana: stats.manaCapacity,
spellCastIndex: 0,
});
logMessages.push(`${def.name} summoned`);
logMessages.push(`${entry.design.name} summoned`);
}
return {
@@ -104,71 +145,58 @@ export function summonGolemsOnRoomEntry(
};
}
// ─── Maintenance (spec §9.5) ───────────────────────────────────────────────────
// ─── Maintenance Upkeep (spec §13) ───────────────────────────────────────────
/**
* Deduct maintenance cost for each active golem.
* Deduct player upkeep cost for each active golem per tick.
* Upkeep = Core.manaRegen × 2 per hour, converted to per-tick.
* Golems that can't be maintained are dismissed immediately.
*/
export function processGolemMaintenance(
activeGolems: ActiveGolem[],
activeGolems: RuntimeActiveGolem[],
golemDesigns: Record<string, SerializedDesign>,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
): {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
maintainedGolems: ActiveGolem[];
maintainedGolems: RuntimeActiveGolem[];
logMessages: string[];
} {
let newRawMana = rawMana;
let newElements = { ...elements };
const maintainedGolems: ActiveGolem[] = [];
const newElements = { ...elements };
const maintainedGolems: RuntimeActiveGolem[] = [];
const logMessages: string[] = [];
for (const golem of activeGolems) {
const def = GOLEMS_DEF[golem.golemId];
if (!def) continue;
const design = golemDesigns[golem.designId];
if (!design) continue;
// Calculate maintenance cost for this tick
let canMaintain = true;
for (const cost of def.maintenanceCost) {
const tickCost = cost.amount * HOURS_PER_TICK;
if (cost.type === 'raw') {
if (newRawMana < tickCost) {
canMaintain = false;
break;
}
} else if (cost.element) {
const elem = newElements[cost.element];
if (!elem || !elem.unlocked || elem.current < tickCost) {
canMaintain = false;
break;
}
}
const core = CORES[design.coreId];
if (!core) continue;
// Upkeep per tick = (manaRegen × 2) × HOURS_PER_TICK
const upkeepPerTick = core.manaRegen * 2 * HOURS_PER_TICK;
const upkeepElement = core.primaryManaType;
const elem = upkeepElement ? newElements[upkeepElement] : null;
if (upkeepElement && elem && elem.unlocked && elem.current >= upkeepPerTick) {
// Deduct from element mana
newElements[upkeepElement] = {
...elem,
current: elem.current - upkeepPerTick,
};
maintainedGolems.push(golem);
} else if (!upkeepElement && newRawMana >= upkeepPerTick) {
// Deduct from raw mana
newRawMana -= upkeepPerTick;
maintainedGolems.push(golem);
} else if (upkeepElement && (!elem || !elem.unlocked || elem.current < upkeepPerTick)) {
logMessages.push(`${design.name} dismissed — insufficient ${upkeepElement} mana for upkeep`);
} else {
logMessages.push(`${design.name} dismissed — insufficient mana for upkeep`);
}
if (!canMaintain) {
logMessages.push(
`${def.name} dismissed — insufficient ${def.maintenanceCost.map((c) => c.element || 'raw').join(', ')} mana`,
);
// Golem is dismissed — deduct no maintenance cost
continue;
}
// Deduct maintenance cost
for (const cost of def.maintenanceCost) {
const tickCost = cost.amount * HOURS_PER_TICK;
if (cost.type === 'raw') {
newRawMana -= tickCost;
} else if (cost.element && newElements[cost.element]) {
newElements[cost.element] = {
...newElements[cost.element],
current: newElements[cost.element].current - tickCost,
};
}
}
maintainedGolems.push(golem);
}
return {
@@ -179,21 +207,40 @@ export function processGolemMaintenance(
};
}
// ─── Golem Combat Tick (spec §9.4) ────────────────────────────────────────────
// ─── Golem Mana Regen (spec §12) ────────────────────────────────────────────
/**
* Regenerate golem mana pools per tick.
*/
export function processGolemManaRegen(
activeGolems: RuntimeActiveGolem[],
golemDesigns: Record<string, SerializedDesign>,
): RuntimeActiveGolem[] {
return activeGolems.map((golem) => {
const design = golemDesigns[golem.designId];
if (!design) return golem;
const core = CORES[design.coreId];
if (!core) return golem;
const manaGain = core.manaRegen * HOURS_PER_TICK;
return {
...golem,
currentMana: Math.min(core.manaCapacity, golem.currentMana + manaGain),
};
});
}
// ─── Golem Combat Tick (spec §11) ───────────────────────────────────────────
/**
* Process golem attacks for one combat tick.
* Each golem accumulates attackProgress and fires when >= 1.
* Golems apply elemental bonus based on their baseManaType.
* Golems ignore Executioner and Berserker discipline specials.
* Supports spell casting via Mind Circuit behavior.
*/
export function processGolemAttacks(
activeGolems: ActiveGolem[],
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
floorHP: number,
floorMaxHP: number,
currentFloor: number,
activeGolems: RuntimeActiveGolem[],
golemDesigns: Record<string, SerializedDesign>,
onDamageDealt: (damage: number) => {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
@@ -201,114 +248,122 @@ export function processGolemAttacks(
},
applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
): GolemCombatResult {
let newRawMana = rawMana;
let newElements = elements;
let currentFloorHP = floorHP;
let currentFloorMaxHP = floorMaxHP;
let rawMana = 0;
let elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
let floorHP = 0;
let floorMaxHP = 0;
const logMessages: string[] = [];
let totalDamageDealt = 0;
const updatedGolems: ActiveGolem[] = [];
const updatedGolems: RuntimeActiveGolem[] = [];
for (const golem of activeGolems) {
const def = GOLEMS_DEF[golem.golemId];
if (!def) continue;
const design = golemDesigns[golem.designId];
if (!design) continue;
// Accumulate attack progress
let attackProgress = golem.attackProgress + HOURS_PER_TICK * def.attackSpeed;
const core = CORES[design.coreId];
const frame = FRAMES[design.frameId];
const circuit = MIND_CIRCUITS[design.mindCircuitId];
if (!core || !frame || !circuit) continue;
// Safety counter prevents infinite loop for very fast golems
let attackProgress = golem.attackProgress + HOURS_PER_TICK * frame.attackSpeed;
const updatedGolem = { ...golem };
let safetyCounter = 0;
const MAX_GOLEM_ATTACKS_PER_TICK = 100;
while (attackProgress >= 1 && safetyCounter < MAX_GOLEM_ATTACKS_PER_TICK) {
// Calculate base damage
let dmg = def.damage;
// Try spell cast first if circuit supports it
if (circuit.spellSlots > 0 && design.selectedSpells.length > 0) {
const spellIdx = updatedGolem.spellCastIndex % design.selectedSpells.length;
const spellId = design.selectedSpells[spellIdx];
// Apply elemental bonus if golem has a baseManaType that matches an element
if (def.baseManaType && def.baseManaType !== 'raw') {
const floorElement = getFloorElement(currentFloor);
dmg *= getElementalBonus(def.baseManaType, floorElement);
// Spell casting simplified — full implementation needs spell cost/effect lookup
if (spellId && updatedGolem.currentMana >= 10) {
// Cast spell: damage scaled by magic affinity
const spellDmg = 20 * frame.magicAffinity; // Placeholder base spell damage
updatedGolem.currentMana -= 10;
updatedGolem.spellCastIndex = (updatedGolem.spellCastIndex + 1) % design.selectedSpells.length;
const dmgResult = onDamageDealt(spellDmg);
const finalDamage = dmgResult.modifiedDamage || spellDmg;
if (Number.isFinite(finalDamage)) {
const roomResult = applyDamageToRoom(finalDamage);
floorHP = roomResult.floorHP;
floorMaxHP = roomResult.floorMaxHP;
totalDamageDealt += Math.max(0, finalDamage);
rawMana = dmgResult.rawMana;
elements = dmgResult.elements;
}
attackProgress -= 1;
safetyCounter++;
continue;
}
}
// Apply armor pierce: reduce effective enemy armor by armorPierce fraction
// (armor pierce is implemented as a flat damage multiplier for simplicity,
// bypass fraction of enemy armor — the full armor integration depends on
// the DoT/debuff system from issue #258)
if (def.armorPierce > 0) {
dmg *= 1 + def.armorPierce;
}
// Basic attack
let dmg = frame.baseDamage * (1 + frame.armorPierce);
// Golems ignore Executioner and Berserker discipline specials (spec §9.4)
// The onDamageDealt callback is used for damage modifiers, but golem
// damage is not affected by discipline specials — we pass raw damage
// and use the result's base modifiedDamage path.
// Note: onDamageDealt may still apply guardian defenses (shield/barrier)
// which is correct since guardians defend against all damage sources.
const dmgResult = onDamageDealt(dmg);
newRawMana = dmgResult.rawMana;
newElements = dmgResult.elements;
const finalDamage = dmgResult.modifiedDamage || dmg;
if (!Number.isFinite(finalDamage)) {
break;
if (Number.isFinite(finalDamage)) {
const roomResult = applyDamageToRoom(finalDamage);
floorHP = roomResult.floorHP;
floorMaxHP = roomResult.floorMaxHP;
totalDamageDealt += Math.max(0, finalDamage);
rawMana = dmgResult.rawMana;
elements = dmgResult.elements;
}
// Apply damage to room
const roomResult = applyDamageToRoom(finalDamage);
currentFloorHP = roomResult.floorHP;
currentFloorMaxHP = roomResult.floorMaxHP;
totalDamageDealt += Math.max(0, finalDamage);
attackProgress -= 1;
safetyCounter++;
if (roomResult.roomCleared) {
// Room cleared by golem — stop attacking this golem,
// room advancement is handled by the caller
attackProgress = 0;
break;
}
}
updatedGolems.push({ ...golem, attackProgress });
updatedGolem.attackProgress = attackProgress;
updatedGolems.push(updatedGolem);
}
return {
rawMana: newRawMana,
elements: newElements,
rawMana,
elements,
activeGolems: updatedGolems,
logMessages,
totalDamageDealt,
};
}
// ─── Room Duration Countdown (spec §9.6) ─────────────────────────────────────
// ─── Room Duration Countdown (spec §14) ─────────────────────────────────────
/**
* Decrement roomsRemaining for each active golem on room clear.
* Golems at 0 remaining are dismissed.
*/
export function countdownGolemRoomDuration(
activeGolems: ActiveGolem[],
activeGolems: RuntimeActiveGolem[],
golemDesigns: Record<string, SerializedDesign>,
): {
remainingGolems: ActiveGolem[];
remainingGolems: RuntimeActiveGolem[];
dismissedNames: string[];
logMessages: string[];
} {
const remainingGolems: ActiveGolem[] = [];
const remainingGolems: RuntimeActiveGolem[] = [];
const dismissedNames: string[] = [];
const logMessages: string[] = [];
for (const golem of activeGolems) {
const def = GOLEMS_DEF[golem.golemId];
if (!def) continue;
const design = golemDesigns[golem.designId];
if (!design) continue;
const core = CORES[design.coreId];
if (!core) continue;
const newRoomsRemaining = golem.roomsRemaining - 1;
if (newRoomsRemaining <= 0) {
dismissedNames.push(def.name);
logMessages.push(`${def.name} has faded after ${def.maxRoomDuration} rooms`);
dismissedNames.push(design.name);
logMessages.push(`${design.name} has faded after ${core.maxRoomDuration} rooms`);
} else {
remainingGolems.push({ ...golem, roomsRemaining: newRoomsRemaining });
}
@@ -316,5 +371,3 @@ export function countdownGolemRoomDuration(
return { remainingGolems, dismissedNames, logMessages };
}
+28
View File
@@ -0,0 +1,28 @@
import type { SerializedGolemDesign } from '../types/game';
export function addGolemDesign(set: (fn: (s: any) => any) => void, design: SerializedGolemDesign) {
set((s: any) => {
const golemDesigns = { ...s.golemancy.golemDesigns, [design.id]: design };
const entry = { designId: design.id, design, enabled: true };
const golemLoadout = [...s.golemancy.golemLoadout, entry];
return { golemancy: { ...s.golemancy, golemDesigns, golemLoadout } };
});
}
export function removeGolemDesign(set: (fn: (s: any) => any) => void, designId: string) {
set((s: any) => {
const golemDesigns = { ...s.golemancy.golemDesigns };
delete golemDesigns[designId];
const golemLoadout = s.golemancy.golemLoadout.filter((e: any) => e.designId !== designId);
return { golemancy: { ...s.golemancy, golemDesigns, golemLoadout } };
});
}
export function toggleGolemLoadoutEntry(set: (fn: (s: any) => any) => void, designId: string) {
set((s: any) => {
const golemLoadout = s.golemancy.golemLoadout.map((e: any) =>
e.designId === designId ? { ...e, enabled: !e.enabled } : e,
);
return { golemancy: { ...s.golemancy, golemLoadout } };
});
}
+6 -4
View File
@@ -7,6 +7,7 @@ import { getGuardianForFloor } from '../../data/guardian-encounters';
import { hasSpecial, SPECIAL_EFFECTS } from '../../effects/special-effects';
import type { ComputedEffects } from '../../effects/upgrade-effects.types';
import type { EnemyState } from '../../types';
import type { CombatStore } from '../combat-state.types';
import { countdownGolemRoomDuration } from '../golem-combat-actions';
// ─── Enemy Defense Context ────────────────────────────────────────────────────
@@ -37,7 +38,7 @@ interface BuildCombatCallbacksParams {
effects: ComputedEffects;
maxMana: number;
addLog: (msg: string) => void;
useCombatStore: { setState: (s: Record<string, unknown>) => void; getState: () => Record<string, unknown> };
useCombatStore: { setState: (s: Partial<CombatStore>) => void; getState: () => CombatStore };
usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void } };
}
@@ -106,11 +107,12 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
}
useCombatStore.setState({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
// ── Golem room-duration countdown (spec §9.6) ──────────────────────
// ── Golem room-duration countdown (spec §14) ──────────────────────
const cs = useCombatStore.getState();
const activeGolems = cs.golemancy?.activeGolems ?? [];
const activeGolems = cs.golemancy.activeGolems;
const golemDesigns = cs.golemancy.golemDesigns;
if (activeGolems.length > 0) {
const result = countdownGolemRoomDuration(activeGolems);
const result = countdownGolemRoomDuration(activeGolems, golemDesigns);
if (result.logMessages.length > 0) {
result.logMessages.forEach((msg) => params.addLog(msg));
}
+64 -16
View File
@@ -1,19 +1,26 @@
// ─── Golem Combat Pipeline ─────────────────────────────────────────────────────
// Extracts golem combat setup from gameStore.ts tick()
// to keep the coordinator under the 400-line file limit.
// ─── Golem Combat Pipeline (Component-Based) ─────────────────────────────────
// Pipeline integration for the component-based golem combat system.
// Extracts golem combat setup from gameStore.ts tick() to keep the coordinator
// under the 400-line file limit.
import { useCombatStore } from '../combatStore';
import { useManaStore } from '../manaStore';
import { processGolemRoomDuration } from '../golem-combat-actions';
import { lowestHPEnemy } from '../combat-damage';
import type { ActiveGolem, EnemyState } from '../../types';
import {
summonGolemsOnRoomEntry,
processGolemMaintenance,
processGolemManaRegen,
processGolemAttacks,
countdownGolemRoomDuration,
} from '../golem-combat-actions';
import { useAttunementStore } from '../attunementStore';
import type { RuntimeActiveGolem } from '../../types';
export interface GolemCombatContext {
addLog: (msg: string) => void;
ctx: {
combat: {
currentFloor: number;
currentRoom: { roomType: string; unknown: Array<{ name: string }> };
currentRoom: { roomType: string; enemies: Array<{ name: string; hp: number; maxHP: number; armor: number }> };
};
prestige: { signedPacts: number[] };
};
@@ -22,20 +29,29 @@ export interface GolemCombatContext {
maxMana: number;
}
export interface GolemCombatResult {
export interface GolemCombatPipelineResult {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
activeGolems: RuntimeActiveGolem[];
logMessages: string[];
}
/**
* Build the golem combat pipeline for the current tick.
* Returns golem state needed by processCombatTick.
*/
export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
activeGolems: ActiveGolem[];
activeGolems: RuntimeActiveGolem[];
golemDesigns: Record<string, { id: string; name: string; coreId: string; frameId: string; mindCircuitId: string; enchantmentIds: string[]; selectedManaTypes: string[]; selectedSpells: string[] }>;
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean };
} {
const activeGolems = useCombatStore.getState().golemancy?.activeGolems ?? [];
const combatState = useCombatStore.getState();
const golemancy = combatState.golemancy;
// New component-based active golems
const activeGolems = golemancy?.activeGolems ?? [];
// Reconstruct golem designs from store
const golemDesigns = golemancy?.golemDesigns ?? {};
const golemApplyDamageToRoom = (dmg: number) => {
const cs = useCombatStore.getState();
@@ -44,14 +60,19 @@ export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
}
// Golems use focus-fire targeting (spec §9.4) — target lowest HP enemy
const target = lowestHPEnemy(room.enemies);
if (!target) {
// Focus-fire targeting: target lowest HP enemy
let target = room.enemies[0];
for (const e of room.enemies) {
if (e.hp > 0 && e.hp < (target?.hp ?? Infinity)) {
target = e;
}
}
if (!target || target.hp <= 0) {
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared: false };
}
const updatedEnemies = room.enemies.map((enemy) => {
if (enemy.id === target.id && enemy.hp > 0) {
if (enemy.id === target!.id && enemy.hp > 0) {
return { ...enemy, hp: Math.max(0, enemy.hp - dmg) };
}
return enemy;
@@ -68,5 +89,32 @@ export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: allDead };
};
return { activeGolems, golemApplyDamageToRoom };
return { activeGolems, golemDesigns, golemApplyDamageToRoom };
}
/**
* Process golem summoning on room entry.
*/
export function processGolemRoomEntry(
loadout: { enabled: boolean; designId: string; design: { name: string } }[],
currentFloor: number,
): {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
activeGolems: RuntimeActiveGolem[];
logMessages: string[];
} {
const cs = useCombatStore.getState();
const attStore = useAttunementStore.getState();
const fabLevel = attStore.attunements?.fabricator?.level ?? 0;
const discBonus = 0; // TODO: compute from discipline
return summonGolemsOnRoomEntry(
loadout as any,
useManaStore.getState().rawMana,
useManaStore.getState().elements as any,
currentFloor,
cs.golemancy.activeGolems as any[],
discBonus,
);
}