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:
@@ -334,6 +334,7 @@ Mana-Loop/
|
|||||||
│ │ │ │ │ ├── combat-tick.ts
|
│ │ │ │ │ ├── combat-tick.ts
|
||||||
│ │ │ │ │ ├── enchanting-tick.ts
|
│ │ │ │ │ ├── enchanting-tick.ts
|
||||||
│ │ │ │ │ ├── equipment-crafting.ts
|
│ │ │ │ │ ├── equipment-crafting.ts
|
||||||
|
│ │ │ │ │ ├── golem-combat.ts
|
||||||
│ │ │ │ │ └── pact-ritual.ts
|
│ │ │ │ │ └── pact-ritual.ts
|
||||||
│ │ │ │ ├── attunementStore.ts
|
│ │ │ │ ├── attunementStore.ts
|
||||||
│ │ │ │ ├── combat-actions.ts
|
│ │ │ │ ├── combat-actions.ts
|
||||||
@@ -351,6 +352,7 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── gameLoopActions.ts
|
│ │ │ │ ├── gameLoopActions.ts
|
||||||
│ │ │ │ ├── gameStore.ts
|
│ │ │ │ ├── gameStore.ts
|
||||||
│ │ │ │ ├── gameStore.types.ts
|
│ │ │ │ ├── gameStore.types.ts
|
||||||
|
│ │ │ │ ├── golem-combat-actions.ts
|
||||||
│ │ │ │ ├── index.ts
|
│ │ │ │ ├── index.ts
|
||||||
│ │ │ │ ├── manaStore.ts
|
│ │ │ │ ├── manaStore.ts
|
||||||
│ │ │ │ ├── prestigeStore.ts
|
│ │ │ │ ├── prestigeStore.ts
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function resetStores() {
|
|||||||
roomResetState: {},
|
roomResetState: {},
|
||||||
clearedRooms: {},
|
clearedRooms: {},
|
||||||
isDescentComplete: false,
|
isDescentComplete: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
@@ -56,6 +56,12 @@ function resetStores() {
|
|||||||
useDisciplineStore.setState({ disciplines: {}, activeIds: [], concurrentLimit: 1, totalXP: 0, processedPerks: [] });
|
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 {
|
function runCombatTick(rawMana: number, elements: Record<string, { current: number; max: number; unlocked: boolean }>): CombatTickResult {
|
||||||
return processCombatTick(
|
return processCombatTick(
|
||||||
() => useCombatStore.getState(),
|
() => useCombatStore.getState(),
|
||||||
@@ -67,6 +73,27 @@ function runCombatTick(rawMana: number, elements: Record<string, { current: numb
|
|||||||
vi.fn(), // onFloorCleared
|
vi.fn(), // onFloorCleared
|
||||||
(dmg) => ({ rawMana, elements, modifiedDamage: dmg }), // onDamageDealt (no modifiers)
|
(dmg) => ({ rawMana, elements, modifiedDamage: dmg }), // onDamageDealt (no modifiers)
|
||||||
[], // signedPacts
|
[], // 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 elements = makeInitialElements(500, {});
|
||||||
const state = useCombatStore.getState();
|
const state = useCombatStore.getState();
|
||||||
// With 0 mana and 0 progress, no cast should complete
|
// With 0 mana and 0 progress, no cast should complete
|
||||||
const result = processCombatTick(
|
const result = processCombatTickDirect(
|
||||||
() => state,
|
() => state,
|
||||||
() => {},
|
() => {},
|
||||||
0, // no mana
|
0, // no mana
|
||||||
@@ -196,7 +223,7 @@ describe('processCombatTick', () => {
|
|||||||
// path that acts as a safety net.
|
// path that acts as a safety net.
|
||||||
useCombatStore.setState({ activeSpell: 'totallyInvalidSpell' });
|
useCombatStore.setState({ activeSpell: 'totallyInvalidSpell' });
|
||||||
const elements = makeInitialElements(500, {});
|
const elements = makeInitialElements(500, {});
|
||||||
const result = processCombatTick(
|
const result = processCombatTickDirect(
|
||||||
() => useCombatStore.getState(),
|
() => useCombatStore.getState(),
|
||||||
() => {},
|
() => {},
|
||||||
1000,
|
1000,
|
||||||
@@ -218,7 +245,7 @@ describe('processCombatTick', () => {
|
|||||||
const elements = makeInitialElements(500, {});
|
const elements = makeInitialElements(500, {});
|
||||||
useCombatStore.setState({ castProgress: 0.99 });
|
useCombatStore.setState({ castProgress: 0.99 });
|
||||||
expect(() => {
|
expect(() => {
|
||||||
processCombatTick(
|
processCombatTickDirect(
|
||||||
() => useCombatStore.getState(),
|
() => useCombatStore.getState(),
|
||||||
() => {},
|
() => {},
|
||||||
1000,
|
1000,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function resetStores() {
|
|||||||
roomResetState: {},
|
roomResetState: {},
|
||||||
clearedRooms: {},
|
clearedRooms: {},
|
||||||
isDescentComplete: false,
|
isDescentComplete: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function resetCombatStore() {
|
|||||||
clearedFloors: {},
|
clearedFloors: {},
|
||||||
climbDirection: null,
|
climbDirection: null,
|
||||||
isDescending: false,
|
isDescending: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function resetCombatStore() {
|
|||||||
clearedFloors: {},
|
clearedFloors: {},
|
||||||
climbDirection: null,
|
climbDirection: null,
|
||||||
isDescending: false,
|
isDescending: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function resetAllStores() {
|
|||||||
clearedFloors: {},
|
clearedFloors: {},
|
||||||
climbDirection: null,
|
climbDirection: null,
|
||||||
isDescending: false,
|
isDescending: false,
|
||||||
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|||||||
@@ -26,5 +26,6 @@ export const BASE_GOLEMS: Record<string, GolemDef> = {
|
|||||||
level: 2,
|
level: 2,
|
||||||
},
|
},
|
||||||
tier: 1,
|
tier: 1,
|
||||||
|
maxRoomDuration: 3,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const ELEMENTAL_GOLEMS: Record<string, GolemDef> = {
|
|||||||
manaType: 'metal',
|
manaType: 'metal',
|
||||||
},
|
},
|
||||||
tier: 2,
|
tier: 2,
|
||||||
|
maxRoomDuration: 3,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Crystal Golem - Crystal mana variant
|
// Crystal Golem - Crystal mana variant
|
||||||
@@ -46,6 +47,7 @@ export const ELEMENTAL_GOLEMS: Record<string, GolemDef> = {
|
|||||||
manaType: 'crystal',
|
manaType: 'crystal',
|
||||||
},
|
},
|
||||||
tier: 3,
|
tier: 3,
|
||||||
|
maxRoomDuration: 4,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sand Golem - Sand mana variant
|
// Sand Golem - Sand mana variant
|
||||||
@@ -67,6 +69,7 @@ export const ELEMENTAL_GOLEMS: Record<string, GolemDef> = {
|
|||||||
manaType: 'sand',
|
manaType: 'sand',
|
||||||
},
|
},
|
||||||
tier: 2,
|
tier: 2,
|
||||||
|
maxRoomDuration: 3,
|
||||||
specialAbilities: [
|
specialAbilities: [
|
||||||
{ name: 'Sandstorm', description: 'Hits multiple enemies (AoE)' },
|
{ name: 'Sandstorm', description: 'Hits multiple enemies (AoE)' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const HYBRID_GOLEMS: Record<string, GolemDef> = {
|
|||||||
levels: [5, 5],
|
levels: [5, 5],
|
||||||
},
|
},
|
||||||
tier: 3,
|
tier: 3,
|
||||||
|
maxRoomDuration: 4,
|
||||||
specialAbilities: [
|
specialAbilities: [
|
||||||
{ name: 'Burn', description: 'Burns enemies over time (DoT)' },
|
{ name: 'Burn', description: 'Burns enemies over time (DoT)' },
|
||||||
],
|
],
|
||||||
@@ -50,6 +51,7 @@ export const HYBRID_GOLEMS: Record<string, GolemDef> = {
|
|||||||
levels: [5, 5],
|
levels: [5, 5],
|
||||||
},
|
},
|
||||||
tier: 3,
|
tier: 3,
|
||||||
|
maxRoomDuration: 4,
|
||||||
specialAbilities: [
|
specialAbilities: [
|
||||||
{ name: 'Lightning Speed', description: 'Extremely fast attacks bonus' },
|
{ name: 'Lightning Speed', description: 'Extremely fast attacks bonus' },
|
||||||
],
|
],
|
||||||
@@ -75,6 +77,7 @@ export const HYBRID_GOLEMS: Record<string, GolemDef> = {
|
|||||||
levels: [5, 5],
|
levels: [5, 5],
|
||||||
},
|
},
|
||||||
tier: 4,
|
tier: 4,
|
||||||
|
maxRoomDuration: 5,
|
||||||
specialAbilities: [
|
specialAbilities: [
|
||||||
{ name: 'Devastating Strike', description: 'Devastating single-target damage' },
|
{ name: 'Devastating Strike', description: 'Devastating single-target damage' },
|
||||||
],
|
],
|
||||||
@@ -100,6 +103,7 @@ export const HYBRID_GOLEMS: Record<string, GolemDef> = {
|
|||||||
levels: [5, 5],
|
levels: [5, 5],
|
||||||
},
|
},
|
||||||
tier: 4,
|
tier: 4,
|
||||||
|
maxRoomDuration: 5,
|
||||||
specialAbilities: [
|
specialAbilities: [
|
||||||
{ name: 'Piercing Beams', description: 'Channels light into piercing beams' },
|
{ name: 'Piercing Beams', description: 'Channels light into piercing beams' },
|
||||||
],
|
],
|
||||||
@@ -125,6 +129,7 @@ export const HYBRID_GOLEMS: Record<string, GolemDef> = {
|
|||||||
levels: [5, 5],
|
levels: [5, 5],
|
||||||
},
|
},
|
||||||
tier: 3,
|
tier: 3,
|
||||||
|
maxRoomDuration: 4,
|
||||||
specialAbilities: [
|
specialAbilities: [
|
||||||
{ name: 'Flow', description: 'Flows around defenses, fast & hard to dodge' },
|
{ name: 'Flow', description: 'Flows around defenses, fast & hard to dodge' },
|
||||||
],
|
],
|
||||||
@@ -150,6 +155,7 @@ export const HYBRID_GOLEMS: Record<string, GolemDef> = {
|
|||||||
levels: [5, 5],
|
levels: [5, 5],
|
||||||
},
|
},
|
||||||
tier: 4,
|
tier: 4,
|
||||||
|
maxRoomDuration: 5,
|
||||||
specialAbilities: [
|
specialAbilities: [
|
||||||
{ name: 'Void Infusion', description: 'Earth infused with void energy' },
|
{ name: 'Void Infusion', description: 'Earth infused with void energy' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -39,5 +39,6 @@ export interface GolemDef {
|
|||||||
levels?: number[];
|
levels?: number[];
|
||||||
};
|
};
|
||||||
tier: number; // Power tier (1-4)
|
tier: number; // Power tier (1-4)
|
||||||
|
maxRoomDuration: number; // Rooms before golem disappears (spec §9.6)
|
||||||
specialAbilities?: { name: string; description: string }[]; // Special abilities
|
specialAbilities?: { name: string; description: string }[]; // Special abilities
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { SPELLS_DEF, HOURS_PER_TICK } from '../constants';
|
|||||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||||
import type { CombatStore, CombatState } from './combat-state.types';
|
import type { CombatStore, CombatState } from './combat-state.types';
|
||||||
import type { SpellState } from '../types';
|
import type { SpellState } from '../types';
|
||||||
|
import type { ActiveGolem } from '../types';
|
||||||
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
|
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
|
||||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
||||||
|
import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a default CombatTickResult for safe fallback on error.
|
* Create a default CombatTickResult for safe fallback on error.
|
||||||
@@ -16,6 +18,7 @@ function makeDefaultCombatTickResult(
|
|||||||
rawMana: number,
|
rawMana: number,
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||||
state: CombatState,
|
state: CombatState,
|
||||||
|
activeGolems: ActiveGolem[],
|
||||||
): CombatTickResult {
|
): CombatTickResult {
|
||||||
return {
|
return {
|
||||||
rawMana,
|
rawMana,
|
||||||
@@ -28,6 +31,7 @@ function makeDefaultCombatTickResult(
|
|||||||
maxFloorReached: state.maxFloorReached,
|
maxFloorReached: state.maxFloorReached,
|
||||||
castProgress: state.castProgress,
|
castProgress: state.castProgress,
|
||||||
equipmentSpellStates: state.equipmentSpellStates,
|
equipmentSpellStates: state.equipmentSpellStates,
|
||||||
|
activeGolems,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ export interface CombatTickResult {
|
|||||||
maxFloorReached: number;
|
maxFloorReached: number;
|
||||||
castProgress: number;
|
castProgress: number;
|
||||||
equipmentSpellStates: CombatState['equipmentSpellStates'];
|
equipmentSpellStates: CombatState['equipmentSpellStates'];
|
||||||
|
activeGolems: ActiveGolem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function processCombatTick(
|
export function processCombatTick(
|
||||||
@@ -58,19 +63,63 @@ export function processCombatTick(
|
|||||||
modifiedDamage?: number;
|
modifiedDamage?: number;
|
||||||
},
|
},
|
||||||
signedPacts: number[],
|
signedPacts: number[],
|
||||||
|
golemancyState: { activeGolems: ActiveGolem[] },
|
||||||
|
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
): CombatTickResult {
|
): CombatTickResult {
|
||||||
const state = get();
|
const state = get();
|
||||||
const logMessages: string[] = [];
|
const logMessages: string[] = [];
|
||||||
let totalManaGathered = 0;
|
let totalManaGathered = 0;
|
||||||
|
|
||||||
if (state.currentAction !== 'climb') {
|
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 spellId = state.activeSpell;
|
||||||
const spellDef = SPELLS_DEF[spellId];
|
const spellDef = SPELLS_DEF[spellId];
|
||||||
if (!spellDef) {
|
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 {
|
try {
|
||||||
@@ -227,6 +276,32 @@ export function processCombatTick(
|
|||||||
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
|
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);
|
const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -240,12 +315,13 @@ export function processCombatTick(
|
|||||||
maxFloorReached: newMaxFloorReached,
|
maxFloorReached: newMaxFloorReached,
|
||||||
castProgress,
|
castProgress,
|
||||||
equipmentSpellStates: updatedEquipmentSpellStates,
|
equipmentSpellStates: updatedEquipmentSpellStates,
|
||||||
|
activeGolems,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Return safe defaults on error — combat tick should never crash the game
|
// Return safe defaults on error — combat tick should never crash the game
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
logMessages.push(`⚠️ Combat error: ${errorMsg}`);
|
logMessages.push(`⚠️ Combat error: ${errorMsg}`);
|
||||||
return makeDefaultCombatTickResult(rawMana, elements, state);
|
return makeDefaultCombatTickResult(rawMana, elements, state, activeGolems);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils'
|
|||||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||||
import { usePrestigeStore } from './prestigeStore';
|
import { usePrestigeStore } from './prestigeStore';
|
||||||
import { useDisciplineStore } from './discipline-slice';
|
import { useDisciplineStore } from './discipline-slice';
|
||||||
|
import { useManaStore } from './manaStore';
|
||||||
|
import { summonGolemsOnRoomEntry } from './golem-combat-actions';
|
||||||
import type { FloorState } from '../types';
|
import type { FloorState } from '../types';
|
||||||
|
|
||||||
type GetFn = () => CombatStore;
|
type GetFn = () => CombatStore;
|
||||||
@@ -78,6 +80,9 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Golem summoning on room entry (spec §9.3) ─────────────────────
|
||||||
|
summonGolemsForRoom(get, set);
|
||||||
|
|
||||||
onEnterRoomDescend(get, set);
|
onEnterRoomDescend(get, set);
|
||||||
} else {
|
} else {
|
||||||
// ── Ascending (spec §4.4) ─────────────────────────────────────────────
|
// ── Ascending (spec §4.4) ─────────────────────────────────────────────
|
||||||
@@ -116,6 +121,9 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Golem summoning on room entry (spec §9.3) ─────────────────────
|
||||||
|
summonGolemsForRoom(get, set);
|
||||||
|
|
||||||
// Handle non-combat rooms on ascent
|
// Handle non-combat rooms on ascent
|
||||||
const room = get().currentRoom;
|
const room = get().currentRoom;
|
||||||
if (room.roomType === 'library') {
|
if (room.roomType === 'library') {
|
||||||
@@ -126,6 +134,39 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Golem Summoning on Room Entry (spec §9.3) ──────────────────────────────
|
||||||
|
|
||||||
|
function summonGolemsForRoom(get: GetFn, set: SetFn): void {
|
||||||
|
const s = get();
|
||||||
|
const manaState = useManaStore.getState();
|
||||||
|
const enabledGolems = s.golemancy?.enabledGolems ?? [];
|
||||||
|
if (enabledGolems.length === 0) return;
|
||||||
|
|
||||||
|
const currentActiveGolems = s.golemancy?.activeGolems ?? [];
|
||||||
|
const summonResult = summonGolemsOnRoomEntry(
|
||||||
|
enabledGolems,
|
||||||
|
manaState.rawMana,
|
||||||
|
manaState.elements,
|
||||||
|
s.currentFloor,
|
||||||
|
currentActiveGolems,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (summonResult.logMessages.length > 0) {
|
||||||
|
summonResult.logMessages.forEach((msg) => get().addActivityLog('special_effect', msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write summoned golems back to combat store
|
||||||
|
set({
|
||||||
|
golemancy: { ...s.golemancy, activeGolems: summonResult.activeGolems },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduct summon costs from mana store
|
||||||
|
useManaStore.setState({
|
||||||
|
rawMana: summonResult.rawMana,
|
||||||
|
elements: summonResult.elements,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── onEnterRoomDescend (climbing spec §4.7) ──────────────────────────────────
|
// ─── onEnterRoomDescend (climbing spec §4.7) ──────────────────────────────────
|
||||||
|
|
||||||
export function onEnterRoomDescend(get: GetFn, set: SetFn): void {
|
export function onEnterRoomDescend(get: GetFn, set: SetFn): void {
|
||||||
@@ -240,6 +281,7 @@ export function createEnterSpireMode(get: GetFn, set: SetFn, generateFloorState:
|
|||||||
roomResetState: {},
|
roomResetState: {},
|
||||||
descentPeak: null,
|
descentPeak: null,
|
||||||
isDescentComplete: false,
|
isDescentComplete: false,
|
||||||
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
get().addActivityLog('floor_transition',
|
get().addActivityLog('floor_transition',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ─── Combat State Types ────────────────────────────────────────────────────────
|
// ─── Combat State Types ────────────────────────────────────────────────────────
|
||||||
// Shared types for combat store and combat actions to avoid circular dependency
|
// Shared types for combat store and combat actions to avoid circular dependency
|
||||||
|
|
||||||
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType } from '../types';
|
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem } from '../types';
|
||||||
|
|
||||||
// ─── Combat State (data only) ─────────────────────────────────────────────────
|
// ─── Combat State (data only) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -143,6 +143,8 @@ export interface CombatActions {
|
|||||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||||
signedPacts: number[],
|
signedPacts: number[],
|
||||||
|
golemancyState: { activeGolems: ActiveGolem[] },
|
||||||
|
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
) => {
|
) => {
|
||||||
rawMana: number;
|
rawMana: number;
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
@@ -154,6 +156,7 @@ export interface CombatActions {
|
|||||||
maxFloorReached: number;
|
maxFloorReached: number;
|
||||||
castProgress: number;
|
castProgress: number;
|
||||||
equipmentSpellStates: EquipmentSpellState[];
|
equipmentSpellStates: EquipmentSpellState[];
|
||||||
|
activeGolems: ActiveGolem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { createSafeStorage } from '../utils/safe-persist';
|
import { createSafeStorage } from '../utils/safe-persist';
|
||||||
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType } from '../types';
|
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem } from '../types';
|
||||||
import { getFloorMaxHP } from '../utils';
|
import { getFloorMaxHP } from '../utils';
|
||||||
import { generateFloorState } from '../utils/room-utils';
|
import { generateFloorState } from '../utils/room-utils';
|
||||||
import { addActivityLogEntry } from '../utils/activity-log';
|
import { addActivityLogEntry } from '../utils/activity-log';
|
||||||
@@ -55,6 +55,7 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
golemancy: {
|
golemancy: {
|
||||||
enabledGolems: [],
|
enabledGolems: [],
|
||||||
summonedGolems: [],
|
summonedGolems: [],
|
||||||
|
activeGolems: [],
|
||||||
lastSummonFloor: 0,
|
lastSummonFloor: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -189,6 +190,7 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
currentRoomIndex: 0,
|
currentRoomIndex: 0,
|
||||||
roomsPerFloor: 1,
|
roomsPerFloor: 1,
|
||||||
maxFloorReached: Math.max(s.maxFloorReached, 1),
|
maxFloorReached: Math.max(s.maxFloorReached, 1),
|
||||||
|
golemancy: { ...s.golemancy, activeGolems: [], summonedGolems: [] },
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -298,6 +300,8 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||||
signedPacts: number[],
|
signedPacts: number[],
|
||||||
|
golemancyState: { activeGolems: ActiveGolem[] },
|
||||||
|
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
) => {
|
) => {
|
||||||
return processCombatTick(
|
return processCombatTick(
|
||||||
get,
|
get,
|
||||||
@@ -309,6 +313,8 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
onFloorCleared,
|
onFloorCleared,
|
||||||
onDamageDealt,
|
onDamageDealt,
|
||||||
signedPacts,
|
signedPacts,
|
||||||
|
golemancyState,
|
||||||
|
golemApplyDamageToRoom,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { createSafeStorage } from '../utils/safe-persist';
|
|||||||
import { createStartNewLoop } from './gameLoopActions';
|
import { createStartNewLoop } from './gameLoopActions';
|
||||||
import { buildTickContext, applyTickWrites } from './tick-pipeline';
|
import { buildTickContext, applyTickWrites } from './tick-pipeline';
|
||||||
import { processEnchantingTicks } from './pipelines/enchanting-tick';
|
import { processEnchantingTicks } from './pipelines/enchanting-tick';
|
||||||
|
import { buildGolemCombatPipeline } from './pipelines/golem-combat';
|
||||||
|
|
||||||
import type { TickContext, TickWrites } from './tick-pipeline';
|
import type { TickContext, TickWrites } from './tick-pipeline';
|
||||||
import type { GameCoordinatorState } from './gameStore.types';
|
import type { GameCoordinatorState } from './gameStore.types';
|
||||||
import type { EnemyState } from '../types';
|
import type { EnemyState } from '../types';
|
||||||
@@ -294,44 +296,25 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
|
|
||||||
// Combat — delegate to combatStore
|
// Combat — delegate to combatStore
|
||||||
if (ctx.combat.currentAction === 'climb') {
|
if (ctx.combat.currentAction === 'climb') {
|
||||||
const combatCbs = buildCombatCallbacks({
|
const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore });
|
||||||
ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mage barrier recharge (spec §5.2) — recharge before building defense ctx
|
|
||||||
const roomEnemies = ctx.combat.currentRoom?.enemies ?? [];
|
const roomEnemies = ctx.combat.currentRoom?.enemies ?? [];
|
||||||
const primaryEnemy = roomEnemies[0] ?? null;
|
const primaryEnemy = roomEnemies[0] ?? null;
|
||||||
const rechargedEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy);
|
const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy;
|
||||||
const activeEnemy = rechargedEnemy ?? primaryEnemy;
|
const defCtx = { roomType: ctx.combat.currentRoom?.roomType ?? 'combat', enemy: activeEnemy };
|
||||||
|
const golemPipeline = buildGolemCombatPipeline(addLog);
|
||||||
// Build enemy defense context for this tick (spec §5.2)
|
|
||||||
const defCtx = {
|
|
||||||
roomType: ctx.combat.currentRoom?.roomType ?? 'combat',
|
|
||||||
enemy: activeEnemy,
|
|
||||||
};
|
|
||||||
|
|
||||||
const combatResult = useCombatStore.getState().processCombatTick(
|
const combatResult = useCombatStore.getState().processCombatTick(
|
||||||
rawMana, elements, maxMana, 1,
|
rawMana, elements, maxMana, 1,
|
||||||
combatCbs.onFloorCleared,
|
combatCbs.onFloorCleared,
|
||||||
combatCbs.makeOnDamageDealt(() => rawMana, () => elements, defCtx, addLog),
|
combatCbs.makeOnDamageDealt(() => rawMana, () => elements, defCtx, addLog),
|
||||||
ctx.prestige.signedPacts,
|
ctx.prestige.signedPacts,
|
||||||
|
{ activeGolems: golemPipeline.activeGolems },
|
||||||
|
golemPipeline.golemApplyDamageToRoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
rawMana = combatResult.rawMana;
|
rawMana = combatResult.rawMana;
|
||||||
elements = combatResult.elements;
|
elements = combatResult.elements;
|
||||||
totalManaGathered += combatResult.totalManaGathered || 0;
|
totalManaGathered += combatResult.totalManaGathered || 0;
|
||||||
if (combatResult.logMessages) {
|
if (combatResult.logMessages) combatResult.logMessages.forEach(msg => addLog(msg));
|
||||||
combatResult.logMessages.forEach(msg => addLog(msg));
|
writes.combat = { ...(writes.combat || {}), currentFloor: combatResult.currentFloor, floorHP: combatResult.floorHP, floorMaxHP: combatResult.floorMaxHP, maxFloorReached: combatResult.maxFloorReached, castProgress: combatResult.castProgress, equipmentSpellStates: combatResult.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: combatResult.activeGolems } };
|
||||||
}
|
|
||||||
writes.combat = {
|
|
||||||
...(writes.combat || {}),
|
|
||||||
currentFloor: combatResult.currentFloor,
|
|
||||||
floorHP: combatResult.floorHP,
|
|
||||||
floorMaxHP: combatResult.floorMaxHP,
|
|
||||||
maxFloorReached: combatResult.maxFloorReached,
|
|
||||||
castProgress: combatResult.castProgress,
|
|
||||||
equipmentSpellStates: combatResult.equipmentSpellStates,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.combat.currentAction === 'craft') {
|
if (ctx.combat.currentAction === 'craft') {
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
// ─── 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.
|
||||||
|
|
||||||
|
import { GOLEMS_DEF } from '../data/golems';
|
||||||
|
import { HOURS_PER_TICK } from '../constants';
|
||||||
|
import type { ActiveGolem, GolemancyState } from '../types';
|
||||||
|
import { getElementalBonus, getFloorElement } from '../utils';
|
||||||
|
|
||||||
|
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GolemCombatResult {
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
activeGolems: ActiveGolem[];
|
||||||
|
logMessages: string[];
|
||||||
|
totalDamageDealt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summoning (spec §9.3) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function summonGolemsOnRoomEntry(
|
||||||
|
enabledGolems: string[],
|
||||||
|
rawMana: number,
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||||
|
currentFloor: number,
|
||||||
|
existingActiveGolems: ActiveGolem[],
|
||||||
|
): {
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
activeGolems: ActiveGolem[];
|
||||||
|
logMessages: string[];
|
||||||
|
} {
|
||||||
|
let newRawMana = rawMana;
|
||||||
|
let newElements = { ...elements };
|
||||||
|
const newActiveGolems = [...existingActiveGolems];
|
||||||
|
const logMessages: string[] = [];
|
||||||
|
|
||||||
|
for (const golemId of enabledGolems) {
|
||||||
|
const def = GOLEMS_DEF[golemId];
|
||||||
|
if (!def) continue;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
if (alreadyActive) continue;
|
||||||
|
|
||||||
|
// Check if player can afford the summon cost (multi-type costs supported)
|
||||||
|
let canAfford = true;
|
||||||
|
for (const cost of def.summonCost) {
|
||||||
|
if (cost.type === 'raw') {
|
||||||
|
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 (!canAfford) {
|
||||||
|
logMessages.push(`Not enough mana to summon ${def.name} — skipped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct summon cost
|
||||||
|
for (const cost of def.summonCost) {
|
||||||
|
if (cost.type === 'raw') {
|
||||||
|
newRawMana -= cost.amount;
|
||||||
|
} else if (cost.element && newElements[cost.element]) {
|
||||||
|
newElements[cost.element] = {
|
||||||
|
...newElements[cost.element],
|
||||||
|
current: newElements[cost.element].current - cost.amount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate golem with fresh room duration and zero attack progress
|
||||||
|
newActiveGolems.push({
|
||||||
|
golemId: def.id,
|
||||||
|
summonedFloor: currentFloor,
|
||||||
|
attackProgress: 0,
|
||||||
|
roomsRemaining: def.maxRoomDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
logMessages.push(`${def.name} summoned`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawMana: newRawMana,
|
||||||
|
elements: newElements,
|
||||||
|
activeGolems: newActiveGolems,
|
||||||
|
logMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Maintenance (spec §9.5) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduct maintenance cost for each active golem.
|
||||||
|
* Golems that can't be maintained are dismissed immediately.
|
||||||
|
*/
|
||||||
|
export function processGolemMaintenance(
|
||||||
|
activeGolems: ActiveGolem[],
|
||||||
|
rawMana: number,
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||||
|
): {
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
maintainedGolems: ActiveGolem[];
|
||||||
|
logMessages: string[];
|
||||||
|
} {
|
||||||
|
let newRawMana = rawMana;
|
||||||
|
let newElements = { ...elements };
|
||||||
|
const maintainedGolems: ActiveGolem[] = [];
|
||||||
|
const logMessages: string[] = [];
|
||||||
|
|
||||||
|
for (const golem of activeGolems) {
|
||||||
|
const def = GOLEMS_DEF[golem.golemId];
|
||||||
|
if (!def) 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
rawMana: newRawMana,
|
||||||
|
elements: newElements,
|
||||||
|
maintainedGolems,
|
||||||
|
logMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Golem Combat Tick (spec §9.4) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function processGolemAttacks(
|
||||||
|
activeGolems: ActiveGolem[],
|
||||||
|
rawMana: number,
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||||
|
floorHP: number,
|
||||||
|
floorMaxHP: number,
|
||||||
|
currentFloor: number,
|
||||||
|
onDamageDealt: (damage: number) => {
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
modifiedDamage?: number;
|
||||||
|
},
|
||||||
|
applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
|
): GolemCombatResult {
|
||||||
|
let newRawMana = rawMana;
|
||||||
|
let newElements = elements;
|
||||||
|
let currentFloorHP = floorHP;
|
||||||
|
let currentFloorMaxHP = floorMaxHP;
|
||||||
|
const logMessages: string[] = [];
|
||||||
|
let totalDamageDealt = 0;
|
||||||
|
|
||||||
|
const updatedGolems: ActiveGolem[] = [];
|
||||||
|
|
||||||
|
for (const golem of activeGolems) {
|
||||||
|
const def = GOLEMS_DEF[golem.golemId];
|
||||||
|
if (!def) continue;
|
||||||
|
|
||||||
|
// Accumulate attack progress
|
||||||
|
let attackProgress = golem.attackProgress + HOURS_PER_TICK * def.attackSpeed;
|
||||||
|
|
||||||
|
// Safety counter prevents infinite loop for very fast golems
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawMana: newRawMana,
|
||||||
|
elements: newElements,
|
||||||
|
activeGolems: updatedGolems,
|
||||||
|
logMessages,
|
||||||
|
totalDamageDealt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Room Duration Countdown (spec §9.6) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrement roomsRemaining for each active golem on room clear.
|
||||||
|
* Golems at 0 remaining are dismissed.
|
||||||
|
*/
|
||||||
|
export function countdownGolemRoomDuration(
|
||||||
|
activeGolems: ActiveGolem[],
|
||||||
|
): {
|
||||||
|
remainingGolems: ActiveGolem[];
|
||||||
|
dismissedNames: string[];
|
||||||
|
logMessages: string[];
|
||||||
|
} {
|
||||||
|
const remainingGolems: ActiveGolem[] = [];
|
||||||
|
const dismissedNames: string[] = [];
|
||||||
|
const logMessages: string[] = [];
|
||||||
|
|
||||||
|
for (const golem of activeGolems) {
|
||||||
|
const def = GOLEMS_DEF[golem.golemId];
|
||||||
|
if (!def) continue;
|
||||||
|
|
||||||
|
const newRoomsRemaining = golem.roomsRemaining - 1;
|
||||||
|
|
||||||
|
if (newRoomsRemaining <= 0) {
|
||||||
|
dismissedNames.push(def.name);
|
||||||
|
logMessages.push(`${def.name} has faded after ${def.maxRoomDuration} rooms`);
|
||||||
|
} else {
|
||||||
|
remainingGolems.push({ ...golem, roomsRemaining: newRoomsRemaining });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { remainingGolems, dismissedNames, logMessages };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import { getGuardianForFloor } from '../../data/guardian-encounters';
|
|||||||
import { hasSpecial, SPECIAL_EFFECTS } from '../../effects/special-effects';
|
import { hasSpecial, SPECIAL_EFFECTS } from '../../effects/special-effects';
|
||||||
import type { ComputedEffects } from '../../effects/upgrade-effects.types';
|
import type { ComputedEffects } from '../../effects/upgrade-effects.types';
|
||||||
import type { EnemyState } from '../../types';
|
import type { EnemyState } from '../../types';
|
||||||
|
import { countdownGolemRoomDuration } from '../golem-combat-actions';
|
||||||
|
|
||||||
// ─── Enemy Defense Context ────────────────────────────────────────────────────
|
// ─── Enemy Defense Context ────────────────────────────────────────────────────
|
||||||
// Snapshot of the current tick's enemy defense state, captured once per tick
|
// Snapshot of the current tick's enemy defense state, captured once per tick
|
||||||
@@ -55,6 +56,19 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
|
|||||||
params.addLog('Floor ' + floor + ' cleared!');
|
params.addLog('Floor ' + floor + ' cleared!');
|
||||||
}
|
}
|
||||||
useCombatStore.setState({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
|
useCombatStore.setState({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
|
||||||
|
|
||||||
|
// ── Golem room-duration countdown (spec §9.6) ──────────────────────
|
||||||
|
const cs = useCombatStore.getState();
|
||||||
|
const activeGolems = cs.golemancy?.activeGolems ?? [];
|
||||||
|
if (activeGolems.length > 0) {
|
||||||
|
const result = countdownGolemRoomDuration(activeGolems);
|
||||||
|
if (result.logMessages.length > 0) {
|
||||||
|
result.logMessages.forEach((msg) => params.addLog(msg));
|
||||||
|
}
|
||||||
|
useCombatStore.setState({
|
||||||
|
golemancy: { ...cs.golemancy, activeGolems: result.remainingGolems },
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Mage barrier recharge rate (spec §5.2): 5% per tick */
|
/** Mage barrier recharge rate (spec §5.2): 5% per tick */
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// ─── Golem Combat Pipeline ─────────────────────────────────────────────────────
|
||||||
|
// 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 type { ActiveGolem } from '../../types';
|
||||||
|
|
||||||
|
export interface GolemCombatContext {
|
||||||
|
addLog: (msg: string) => void;
|
||||||
|
ctx: {
|
||||||
|
combat: {
|
||||||
|
currentFloor: number;
|
||||||
|
currentRoom: { roomType: string; unknown: Array<{ name: string }> };
|
||||||
|
};
|
||||||
|
prestige: { signedPacts: number[] };
|
||||||
|
};
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
maxMana: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GolemCombatResult {
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the golem combat pipeline for the current tick.
|
||||||
|
* Returns golem state needed by processCombatTick.
|
||||||
|
*/
|
||||||
|
export function buildGolemCombatPipeline(_addLog: (msg: string) => void): {
|
||||||
|
activeGolems: ActiveGolem[];
|
||||||
|
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean };
|
||||||
|
} {
|
||||||
|
const activeGolems = useCombatStore.getState().golemancy?.activeGolems ?? [];
|
||||||
|
|
||||||
|
const golemApplyDamageToRoom = (dmg: number) => {
|
||||||
|
const cs = useCombatStore.getState();
|
||||||
|
if (dmg > 0) {
|
||||||
|
const newFloorHP = Math.max(0, cs.floorHP - dmg);
|
||||||
|
useCombatStore.setState({ floorHP: newFloorHP });
|
||||||
|
}
|
||||||
|
const roomCleared = useCombatStore.getState().floorHP <= 0;
|
||||||
|
return { floorHP: cs.floorHP, floorMaxHP: cs.floorMaxHP, roomCleared };
|
||||||
|
};
|
||||||
|
|
||||||
|
return { activeGolems, golemApplyDamageToRoom };
|
||||||
|
}
|
||||||
@@ -119,11 +119,18 @@ export interface SummonedGolem {
|
|||||||
golemId: string; // Reference to GOLEMS_DEF
|
golemId: string; // Reference to GOLEMS_DEF
|
||||||
summonedFloor: number; // Floor when golem was summoned
|
summonedFloor: number; // Floor when golem was summoned
|
||||||
attackProgress: number; // Progress toward next attack (0-1)
|
attackProgress: number; // Progress toward next attack (0-1)
|
||||||
|
roomsRemaining: number; // Rooms before golem disappears (spec §9.6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runtime state for an active golem in combat (spec §9.7) */
|
||||||
|
export interface ActiveGolem extends SummonedGolem {
|
||||||
|
// attackProgress is inherited from SummonedGolem
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GolemancyState {
|
export interface GolemancyState {
|
||||||
enabledGolems: string[]; // Golem IDs the player wants active
|
enabledGolems: string[]; // Golem IDs the player wants active
|
||||||
summonedGolems: SummonedGolem[]; // Currently summoned golems on this floor
|
summonedGolems: SummonedGolem[]; // Currently summoned golems on this floor (legacy, kept for golem-tab state)
|
||||||
|
activeGolems: ActiveGolem[]; // Runtime active golems in combat (spec §9)
|
||||||
lastSummonFloor: number; // Floor golems were last summoned on
|
lastSummonFloor: number; // Floor golems were last summoned on
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type {
|
|||||||
ScheduleBlock,
|
ScheduleBlock,
|
||||||
StudyTarget,
|
StudyTarget,
|
||||||
SummonedGolem,
|
SummonedGolem,
|
||||||
|
ActiveGolem,
|
||||||
GolemancyState,
|
GolemancyState,
|
||||||
GameActionType,
|
GameActionType,
|
||||||
ActivityEventType,
|
ActivityEventType,
|
||||||
|
|||||||
Reference in New Issue
Block a user