diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 63828d5..44e11af 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -334,6 +334,7 @@ Mana-Loop/ │ │ │ │ │ ├── combat-tick.ts │ │ │ │ │ ├── enchanting-tick.ts │ │ │ │ │ ├── equipment-crafting.ts +│ │ │ │ │ ├── golem-combat.ts │ │ │ │ │ └── pact-ritual.ts │ │ │ │ ├── attunementStore.ts │ │ │ │ ├── combat-actions.ts @@ -351,6 +352,7 @@ Mana-Loop/ │ │ │ │ ├── gameLoopActions.ts │ │ │ │ ├── gameStore.ts │ │ │ │ ├── gameStore.types.ts +│ │ │ │ ├── golem-combat-actions.ts │ │ │ │ ├── index.ts │ │ │ │ ├── manaStore.ts │ │ │ │ ├── prestigeStore.ts diff --git a/src/lib/game/__tests__/combat-actions.test.ts b/src/lib/game/__tests__/combat-actions.test.ts index a542841..05267e5 100644 --- a/src/lib/game/__tests__/combat-actions.test.ts +++ b/src/lib/game/__tests__/combat-actions.test.ts @@ -36,7 +36,7 @@ function resetStores() { roomResetState: {}, clearedRooms: {}, isDescentComplete: false, - golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 }, + golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, equipmentSpellStates: [], comboHitCount: 0, floorHitCount: 0, @@ -56,6 +56,12 @@ function resetStores() { useDisciplineStore.setState({ disciplines: {}, activeIds: [], concurrentLimit: 1, totalXP: 0, processedPerks: [] }); } +function golemApplyDamageToRoom(dmg: number): { floorHP: number; floorMaxHP: number; roomCleared: boolean } { + const cs = useCombatStore.getState(); + const newFloorHP = Math.max(0, cs.floorHP - dmg); + return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: newFloorHP <= 0 }; +} + function runCombatTick(rawMana: number, elements: Record): CombatTickResult { return processCombatTick( () => useCombatStore.getState(), @@ -67,6 +73,27 @@ function runCombatTick(rawMana: number, elements: Record ({ rawMana, elements, modifiedDamage: dmg }), // onDamageDealt (no modifiers) [], // signedPacts + { activeGolems: [] }, // golemancyState + golemApplyDamageToRoom, + ); +} + +function processCombatTickDirect( + get: () => ReturnType, + set: (partial: Record) => void, + rawMana: number, + elements: Record, + maxMana: number, + attackSpeedMult: number, + onFloorCleared: (floor: number, wasGuardian: boolean) => void, + onDamageDealt: (dmg: number) => { rawMana: number; elements: Record; modifiedDamage?: number }, + signedPacts: number[], +): CombatTickResult { + return processCombatTick( + get, set, rawMana, elements, maxMana, attackSpeedMult, + onFloorCleared, onDamageDealt, signedPacts, + { activeGolems: [] }, + golemApplyDamageToRoom, ); } @@ -160,7 +187,7 @@ describe('processCombatTick', () => { const elements = makeInitialElements(500, {}); const state = useCombatStore.getState(); // With 0 mana and 0 progress, no cast should complete - const result = processCombatTick( + const result = processCombatTickDirect( () => state, () => {}, 0, // no mana @@ -196,7 +223,7 @@ describe('processCombatTick', () => { // path that acts as a safety net. useCombatStore.setState({ activeSpell: 'totallyInvalidSpell' }); const elements = makeInitialElements(500, {}); - const result = processCombatTick( + const result = processCombatTickDirect( () => useCombatStore.getState(), () => {}, 1000, @@ -218,7 +245,7 @@ describe('processCombatTick', () => { const elements = makeInitialElements(500, {}); useCombatStore.setState({ castProgress: 0.99 }); expect(() => { - processCombatTick( + processCombatTickDirect( () => useCombatStore.getState(), () => {}, 1000, diff --git a/src/lib/game/__tests__/enemy-defenses.test.ts b/src/lib/game/__tests__/enemy-defenses.test.ts index 6b7dde9..5d73a93 100644 --- a/src/lib/game/__tests__/enemy-defenses.test.ts +++ b/src/lib/game/__tests__/enemy-defenses.test.ts @@ -39,7 +39,7 @@ function resetStores() { roomResetState: {}, clearedRooms: {}, isDescentComplete: false, - golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 }, + golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, equipmentSpellStates: [], comboHitCount: 0, floorHitCount: 0, diff --git a/src/lib/game/__tests__/store-actions-combat-prestige.test.ts b/src/lib/game/__tests__/store-actions-combat-prestige.test.ts index 0cd0595..ce4df0a 100644 --- a/src/lib/game/__tests__/store-actions-combat-prestige.test.ts +++ b/src/lib/game/__tests__/store-actions-combat-prestige.test.ts @@ -18,7 +18,7 @@ function resetCombatStore() { clearedFloors: {}, climbDirection: null, isDescending: false, - golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 }, + golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, equipmentSpellStates: [], comboHitCount: 0, floorHitCount: 0, diff --git a/src/lib/game/__tests__/store-actions.test.ts b/src/lib/game/__tests__/store-actions.test.ts index 7a4ced8..aa43f37 100644 --- a/src/lib/game/__tests__/store-actions.test.ts +++ b/src/lib/game/__tests__/store-actions.test.ts @@ -30,7 +30,7 @@ function resetCombatStore() { clearedFloors: {}, climbDirection: null, isDescending: false, - golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 }, + golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, equipmentSpellStates: [], comboHitCount: 0, floorHitCount: 0, diff --git a/src/lib/game/__tests__/tick-integration.test.ts b/src/lib/game/__tests__/tick-integration.test.ts index 9db4667..c2d4c1a 100644 --- a/src/lib/game/__tests__/tick-integration.test.ts +++ b/src/lib/game/__tests__/tick-integration.test.ts @@ -46,7 +46,7 @@ function resetAllStores() { clearedFloors: {}, climbDirection: null, isDescending: false, - golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 }, + golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, equipmentSpellStates: [], comboHitCount: 0, floorHitCount: 0, diff --git a/src/lib/game/data/golems/base-golems.ts b/src/lib/game/data/golems/base-golems.ts index c350973..51eda72 100644 --- a/src/lib/game/data/golems/base-golems.ts +++ b/src/lib/game/data/golems/base-golems.ts @@ -26,5 +26,6 @@ export const BASE_GOLEMS: Record = { level: 2, }, tier: 1, + maxRoomDuration: 3, }, }; diff --git a/src/lib/game/data/golems/elemental-golems.ts b/src/lib/game/data/golems/elemental-golems.ts index d7d5d74..acf0107 100644 --- a/src/lib/game/data/golems/elemental-golems.ts +++ b/src/lib/game/data/golems/elemental-golems.ts @@ -25,6 +25,7 @@ export const ELEMENTAL_GOLEMS: Record = { manaType: 'metal', }, tier: 2, + maxRoomDuration: 3, }, // Crystal Golem - Crystal mana variant @@ -46,6 +47,7 @@ export const ELEMENTAL_GOLEMS: Record = { manaType: 'crystal', }, tier: 3, + maxRoomDuration: 4, }, // Sand Golem - Sand mana variant @@ -67,6 +69,7 @@ export const ELEMENTAL_GOLEMS: Record = { manaType: 'sand', }, tier: 2, + maxRoomDuration: 3, specialAbilities: [ { name: 'Sandstorm', description: 'Hits multiple enemies (AoE)' }, ], diff --git a/src/lib/game/data/golems/hybrid-golems.ts b/src/lib/game/data/golems/hybrid-golems.ts index 43ce04b..e9ea429 100644 --- a/src/lib/game/data/golems/hybrid-golems.ts +++ b/src/lib/game/data/golems/hybrid-golems.ts @@ -25,6 +25,7 @@ export const HYBRID_GOLEMS: Record = { levels: [5, 5], }, tier: 3, + maxRoomDuration: 4, specialAbilities: [ { name: 'Burn', description: 'Burns enemies over time (DoT)' }, ], @@ -50,6 +51,7 @@ export const HYBRID_GOLEMS: Record = { levels: [5, 5], }, tier: 3, + maxRoomDuration: 4, specialAbilities: [ { name: 'Lightning Speed', description: 'Extremely fast attacks bonus' }, ], @@ -75,6 +77,7 @@ export const HYBRID_GOLEMS: Record = { levels: [5, 5], }, tier: 4, + maxRoomDuration: 5, specialAbilities: [ { name: 'Devastating Strike', description: 'Devastating single-target damage' }, ], @@ -100,6 +103,7 @@ export const HYBRID_GOLEMS: Record = { levels: [5, 5], }, tier: 4, + maxRoomDuration: 5, specialAbilities: [ { name: 'Piercing Beams', description: 'Channels light into piercing beams' }, ], @@ -125,6 +129,7 @@ export const HYBRID_GOLEMS: Record = { levels: [5, 5], }, tier: 3, + maxRoomDuration: 4, specialAbilities: [ { name: 'Flow', description: 'Flows around defenses, fast & hard to dodge' }, ], @@ -150,6 +155,7 @@ export const HYBRID_GOLEMS: Record = { levels: [5, 5], }, tier: 4, + maxRoomDuration: 5, specialAbilities: [ { name: 'Void Infusion', description: 'Earth infused with void energy' }, ], diff --git a/src/lib/game/data/golems/types.ts b/src/lib/game/data/golems/types.ts index 226eda5..b43bfd9 100644 --- a/src/lib/game/data/golems/types.ts +++ b/src/lib/game/data/golems/types.ts @@ -39,5 +39,6 @@ export interface GolemDef { levels?: number[]; }; tier: number; // Power tier (1-4) + maxRoomDuration: number; // Rooms before golem disappears (spec §9.6) specialAbilities?: { name: string; description: string }[]; // Special abilities } diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts index 41c4f81..bded565 100644 --- a/src/lib/game/stores/combat-actions.ts +++ b/src/lib/game/stores/combat-actions.ts @@ -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, 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); } } diff --git a/src/lib/game/stores/combat-descent-actions.ts b/src/lib/game/stores/combat-descent-actions.ts index 5e5c8da..bb24100 100644 --- a/src/lib/game/stores/combat-descent-actions.ts +++ b/src/lib/game/stores/combat-descent-actions.ts @@ -7,6 +7,8 @@ import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils' import { getGuardianForFloor } from '../data/guardian-encounters'; import { usePrestigeStore } from './prestigeStore'; import { useDisciplineStore } from './discipline-slice'; +import { useManaStore } from './manaStore'; +import { summonGolemsOnRoomEntry } from './golem-combat-actions'; import type { FloorState } from '../types'; 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); } else { // ── 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 const room = get().currentRoom; 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) ────────────────────────────────── export function onEnterRoomDescend(get: GetFn, set: SetFn): void { @@ -240,6 +281,7 @@ export function createEnterSpireMode(get: GetFn, set: SetFn, generateFloorState: roomResetState: {}, descentPeak: null, isDescentComplete: false, + golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, }); get().addActivityLog('floor_transition', diff --git a/src/lib/game/stores/combat-state.types.ts b/src/lib/game/stores/combat-state.types.ts index 8aae3be..cabc282 100644 --- a/src/lib/game/stores/combat-state.types.ts +++ b/src/lib/game/stores/combat-state.types.ts @@ -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 } from '../types'; +import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem } from '../types'; // ─── Combat State (data only) ───────────────────────────────────────────────── @@ -143,6 +143,8 @@ export interface CombatActions { onFloorCleared: (floor: number, wasGuardian: boolean) => void, onDamageDealt: (damage: number) => { rawMana: number; elements: Record }, signedPacts: number[], + golemancyState: { activeGolems: ActiveGolem[] }, + golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }, ) => { rawMana: number; elements: Record; @@ -154,6 +156,7 @@ export interface CombatActions { maxFloorReached: number; castProgress: number; equipmentSpellStates: EquipmentSpellState[]; + activeGolems: ActiveGolem[]; }; // Reset diff --git a/src/lib/game/stores/combatStore.ts b/src/lib/game/stores/combatStore.ts index 5d8fdb3..d15d57d 100644 --- a/src/lib/game/stores/combatStore.ts +++ b/src/lib/game/stores/combatStore.ts @@ -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 } from '../types'; +import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem } from '../types'; import { getFloorMaxHP } from '../utils'; import { generateFloorState } from '../utils/room-utils'; import { addActivityLogEntry } from '../utils/activity-log'; @@ -55,6 +55,7 @@ export const useCombatStore = create()( golemancy: { enabledGolems: [], summonedGolems: [], + activeGolems: [], lastSummonFloor: 0, }, @@ -189,6 +190,7 @@ export const useCombatStore = create()( currentRoomIndex: 0, roomsPerFloor: 1, maxFloorReached: Math.max(s.maxFloorReached, 1), + golemancy: { ...s.golemancy, activeGolems: [], summonedGolems: [] }, })); }, @@ -298,6 +300,8 @@ export const useCombatStore = create()( onFloorCleared: (floor: number, wasGuardian: boolean) => void, onDamageDealt: (damage: number) => { rawMana: number; elements: Record }, signedPacts: number[], + golemancyState: { activeGolems: ActiveGolem[] }, + golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }, ) => { return processCombatTick( get, @@ -309,6 +313,8 @@ export const useCombatStore = create()( onFloorCleared, onDamageDealt, signedPacts, + golemancyState, + golemApplyDamageToRoom, ); }, diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index 393b72c..beb8c99 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -23,6 +23,8 @@ import { createSafeStorage } from '../utils/safe-persist'; import { createStartNewLoop } from './gameLoopActions'; import { buildTickContext, applyTickWrites } from './tick-pipeline'; import { processEnchantingTicks } from './pipelines/enchanting-tick'; +import { buildGolemCombatPipeline } from './pipelines/golem-combat'; + import type { TickContext, TickWrites } from './tick-pipeline'; import type { GameCoordinatorState } from './gameStore.types'; import type { EnemyState } from '../types'; @@ -294,44 +296,25 @@ export const useGameStore = create()( // Combat — delegate to combatStore if (ctx.combat.currentAction === 'climb') { - const combatCbs = buildCombatCallbacks({ - ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore, - }); - - // Mage barrier recharge (spec §5.2) — recharge before building defense ctx + const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore }); const roomEnemies = ctx.combat.currentRoom?.enemies ?? []; const primaryEnemy = roomEnemies[0] ?? null; - const rechargedEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy); - const activeEnemy = rechargedEnemy ?? primaryEnemy; - - // Build enemy defense context for this tick (spec §5.2) - const defCtx = { - roomType: ctx.combat.currentRoom?.roomType ?? 'combat', - enemy: activeEnemy, - }; - + const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy; + const defCtx = { roomType: ctx.combat.currentRoom?.roomType ?? 'combat', enemy: activeEnemy }; + const golemPipeline = buildGolemCombatPipeline(addLog); const combatResult = useCombatStore.getState().processCombatTick( rawMana, elements, maxMana, 1, combatCbs.onFloorCleared, combatCbs.makeOnDamageDealt(() => rawMana, () => elements, defCtx, addLog), ctx.prestige.signedPacts, + { activeGolems: golemPipeline.activeGolems }, + golemPipeline.golemApplyDamageToRoom, ); - rawMana = combatResult.rawMana; elements = combatResult.elements; totalManaGathered += combatResult.totalManaGathered || 0; - if (combatResult.logMessages) { - 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, - }; + if (combatResult.logMessages) 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 } }; } if (ctx.combat.currentAction === 'craft') { diff --git a/src/lib/game/stores/golem-combat-actions.ts b/src/lib/game/stores/golem-combat-actions.ts new file mode 100644 index 0000000..5252c69 --- /dev/null +++ b/src/lib/game/stores/golem-combat-actions.ts @@ -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; + 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, + currentFloor: number, + existingActiveGolems: ActiveGolem[], +): { + rawMana: number; + elements: Record; + 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, +): { + rawMana: number; + elements: Record; + 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, + floorHP: number, + floorMaxHP: number, + currentFloor: number, + onDamageDealt: (damage: number) => { + rawMana: number; + elements: Record; + 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 }; +} + + diff --git a/src/lib/game/stores/pipelines/combat-tick.ts b/src/lib/game/stores/pipelines/combat-tick.ts index cecd0c5..c674e15 100644 --- a/src/lib/game/stores/pipelines/combat-tick.ts +++ b/src/lib/game/stores/pipelines/combat-tick.ts @@ -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 { countdownGolemRoomDuration } from '../golem-combat-actions'; // ─── Enemy Defense Context ──────────────────────────────────────────────────── // 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!'); } 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 */ diff --git a/src/lib/game/stores/pipelines/golem-combat.ts b/src/lib/game/stores/pipelines/golem-combat.ts new file mode 100644 index 0000000..2f78bc6 --- /dev/null +++ b/src/lib/game/stores/pipelines/golem-combat.ts @@ -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; + maxMana: number; +} + +export interface GolemCombatResult { + rawMana: number; + elements: Record; +} + +/** + * 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 }; +} diff --git a/src/lib/game/types/game.ts b/src/lib/game/types/game.ts index 9794c5a..34cf692 100644 --- a/src/lib/game/types/game.ts +++ b/src/lib/game/types/game.ts @@ -119,11 +119,18 @@ export interface SummonedGolem { golemId: string; // Reference to GOLEMS_DEF summonedFloor: number; // Floor when golem was summoned 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 { 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 } diff --git a/src/lib/game/types/index.ts b/src/lib/game/types/index.ts index 09f8c9b..80d8261 100644 --- a/src/lib/game/types/index.ts +++ b/src/lib/game/types/index.ts @@ -41,6 +41,7 @@ export type { ScheduleBlock, StudyTarget, SummonedGolem, + ActiveGolem, GolemancyState, GameActionType, ActivityEventType,