feat(golemancy): Phase 1 - Component-based construction system data definitions
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
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:
@@ -36,7 +36,7 @@ function resetStores() {
|
||||
roomResetState: {},
|
||||
clearedRooms: {},
|
||||
isDescentComplete: false,
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||
equipmentSpellStates: [],
|
||||
comboHitCount: 0,
|
||||
floorHitCount: 0,
|
||||
|
||||
@@ -52,7 +52,7 @@ export function resetAllStores() {
|
||||
roomResetState: {},
|
||||
clearedRooms: {},
|
||||
isDescentComplete: false,
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||
equipmentSpellStates: [],
|
||||
comboHitCount: 0,
|
||||
floorHitCount: 0,
|
||||
|
||||
@@ -39,7 +39,7 @@ function resetStores() {
|
||||
roomResetState: {},
|
||||
clearedRooms: {},
|
||||
isDescentComplete: false,
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||
equipmentSpellStates: [],
|
||||
comboHitCount: 0,
|
||||
floorHitCount: 0,
|
||||
|
||||
@@ -38,7 +38,7 @@ function resetStores() {
|
||||
roomResetState: {},
|
||||
clearedRooms: {},
|
||||
isDescentComplete: false,
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||
equipmentSpellStates: [],
|
||||
comboHitCount: 0,
|
||||
floorHitCount: 0,
|
||||
|
||||
@@ -18,7 +18,7 @@ function resetCombatStore() {
|
||||
clearedFloors: {},
|
||||
climbDirection: null,
|
||||
isDescending: false,
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||
equipmentSpellStates: [],
|
||||
comboHitCount: 0,
|
||||
floorHitCount: 0,
|
||||
|
||||
@@ -29,7 +29,7 @@ function resetCombatStore() {
|
||||
clearedFloors: {},
|
||||
climbDirection: null,
|
||||
isDescending: false,
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||
equipmentSpellStates: [],
|
||||
comboHitCount: 0,
|
||||
floorHitCount: 0,
|
||||
|
||||
@@ -46,7 +46,7 @@ function resetAllStores() {
|
||||
clearedFloors: {},
|
||||
climbDirection: null,
|
||||
isDescending: false,
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [], legacyActiveGolems: [] },
|
||||
equipmentSpellStates: [],
|
||||
comboHitCount: 0,
|
||||
floorHitCount: 0,
|
||||
|
||||
@@ -22,7 +22,7 @@ export const fabricatorDisciplines: DisciplineDefinition[] = [
|
||||
type: 'once',
|
||||
threshold: 200,
|
||||
value: 0,
|
||||
description: 'Unlock golem summoning',
|
||||
description: 'Unlock golem design ability',
|
||||
},
|
||||
{
|
||||
id: 'golem-2',
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// ─── Core Definitions ────────────────────────────────────────────────────
|
||||
// Power sources for golems. Determine mana types, capacity, regen, upkeep, duration.
|
||||
|
||||
import type { CoreDefinition } from './types';
|
||||
import { elemCost } from './types';
|
||||
|
||||
// ───_BASIC CORE ────────────────────────────────────────────────────────────
|
||||
// Fabricator 2 — single Earth mana, modest stats
|
||||
const BASIC_CORE: CoreDefinition = {
|
||||
id: 'basic',
|
||||
tier: 1,
|
||||
name: 'Basic Core',
|
||||
description: 'A simple earth-infused core. Provides modest mana capacity and single-element support.',
|
||||
manaTypes: ['earth'],
|
||||
manaCapacity: 50,
|
||||
manaRegen: 0.5,
|
||||
maxRoomDuration: 3,
|
||||
summonCost: [elemCost('earth', 10)],
|
||||
primaryManaType: 'earth',
|
||||
tierMultiplier: 1.0,
|
||||
unlockRequirement: {
|
||||
type: 'attunement_level',
|
||||
attunement: 'fabricator',
|
||||
level: 2,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── INTERMEDIATE CORE ────────────────────────────────────────────────────
|
||||
// Fabricator 4 + Enchanter 2 — choose 2 mana types
|
||||
const INTERMEDIATE_CORE: CoreDefinition = {
|
||||
id: 'intermediate',
|
||||
tier: 2,
|
||||
name: 'Intermediate Core',
|
||||
description: 'A refined crystal core supporting two mana types. Player selects from unlocked elements.',
|
||||
manaTypes: ['earth'], // Default; player overrides with chosen types
|
||||
manaCapacity: 100,
|
||||
manaRegen: 1.5,
|
||||
maxRoomDuration: 4,
|
||||
summonCost: [elemCost('crystal', 20)],
|
||||
primaryManaType: 'crystal',
|
||||
tierMultiplier: 1.5,
|
||||
unlockRequirement: {
|
||||
type: 'dual_attunement',
|
||||
attunements: ['fabricator', 'enchanter'],
|
||||
levels: [4, 2],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── ADVANCED CORE ───────────────────────────────────────────────────────
|
||||
// Fabricator 6 + Enchanter 3 — choose 3 mana types
|
||||
const ADVANCED_CORE: CoreDefinition = {
|
||||
id: 'advanced',
|
||||
tier: 3,
|
||||
name: 'Advanced Core',
|
||||
description: 'A powerful crystal core supporting three mana types. Player selects from unlocked elements.',
|
||||
manaTypes: ['earth'], // Default; player overrides with chosen types
|
||||
manaCapacity: 200,
|
||||
manaRegen: 3.0,
|
||||
maxRoomDuration: 5,
|
||||
summonCost: [elemCost('crystal', 30)],
|
||||
primaryManaType: 'crystal',
|
||||
tierMultiplier: 2.0,
|
||||
unlockRequirement: {
|
||||
type: 'dual_attunement',
|
||||
attunements: ['fabricator', 'enchanter'],
|
||||
levels: [6, 3],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── GUARDIAN CORE ───────────────────────────────────────────────────────
|
||||
// Invoker 5 + Fabricator 5 + Guardian Pact — guardian-specific mana types
|
||||
const GUARDIAN_CORE: CoreDefinition = {
|
||||
id: 'guardian',
|
||||
tier: 4,
|
||||
name: 'Guardian Core',
|
||||
description: 'A legendary core imbued with guardian energy. Provides all mana types granted by the chosen Guardian. Required for Guardian Constructs.',
|
||||
manaTypes: ['earth'], // Overridden by guardian-specific types when assigned
|
||||
manaCapacity: 500,
|
||||
manaRegen: 10.0,
|
||||
maxRoomDuration: 8,
|
||||
summonCost: [elemCost('earth', 50)], // Guardian-specific in practice
|
||||
primaryManaType: 'earth', // Overridden by guardian pact
|
||||
tierMultiplier: 3.0,
|
||||
unlockRequirement: {
|
||||
type: 'guardian_pact',
|
||||
attunements: ['invoker', 'fabricator'],
|
||||
levels: [5, 5],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── CORE REGISTRY ───────────────────────────────────────────────────────
|
||||
|
||||
export const CORES: Record<string, CoreDefinition> = {
|
||||
[BASIC_CORE.id]: BASIC_CORE,
|
||||
[INTERMEDIATE_CORE.id]: INTERMEDIATE_CORE,
|
||||
[ADVANCED_CORE.id]: ADVANCED_CORE,
|
||||
[GUARDIAN_CORE.id]: GUARDIAN_CORE,
|
||||
};
|
||||
|
||||
export const ALL_CORES = [BASIC_CORE, INTERMEDIATE_CORE, ADVANCED_CORE, GUARDIAN_CORE];
|
||||
@@ -0,0 +1,155 @@
|
||||
// ─── Frame Definitions ───────────────────────────────────────────────────
|
||||
// Physical combat characteristics for golems: damage, speed, armor pierce, magic affinity, special.
|
||||
|
||||
import type { FrameDefinition } from './types';
|
||||
import { elemCost, rawCost } from './types';
|
||||
|
||||
const EARTH_FRAME: FrameDefinition = {
|
||||
id: 'earth',
|
||||
name: 'Earth Frame',
|
||||
description: 'A sturdy construct of stone and soil. Balanced but unremarkable.',
|
||||
baseDamage: 6,
|
||||
attackSpeed: 1.2,
|
||||
armorPierce: 0.05,
|
||||
magicAffinity: 0.3,
|
||||
aoeTargets: 1,
|
||||
element: 'earth',
|
||||
specialEffect: 'none',
|
||||
summonCost: [rawCost(5)],
|
||||
unlockRequirement: {
|
||||
type: 'attunement_level',
|
||||
attunement: 'fabricator',
|
||||
level: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const SAND_FRAME: FrameDefinition = {
|
||||
id: 'sand',
|
||||
name: 'Sand Frame',
|
||||
description: 'A shifting construct of sand particles. Hits multiple enemies with high armor pierce.',
|
||||
baseDamage: 8,
|
||||
attackSpeed: 1.0,
|
||||
armorPierce: 0.6,
|
||||
magicAffinity: 0.5,
|
||||
aoeTargets: 2,
|
||||
element: 'sand',
|
||||
specialEffect: 'aoe',
|
||||
summonCost: [elemCost('sand', 8)],
|
||||
unlockRequirement: {
|
||||
type: 'mana_unlocked',
|
||||
manaType: 'sand',
|
||||
},
|
||||
};
|
||||
|
||||
const FROST_FRAME: FrameDefinition = {
|
||||
id: 'frost',
|
||||
name: 'Frost Frame',
|
||||
description: 'An icy construct that slows enemies on hit. High magic affinity.',
|
||||
baseDamage: 10,
|
||||
attackSpeed: 1.2,
|
||||
armorPierce: 0.25,
|
||||
magicAffinity: 0.8,
|
||||
aoeTargets: 1,
|
||||
element: 'frost',
|
||||
specialEffect: 'slow',
|
||||
summonCost: [elemCost('frost', 10)],
|
||||
unlockRequirement: {
|
||||
type: 'mana_unlocked',
|
||||
manaType: 'frost',
|
||||
},
|
||||
};
|
||||
|
||||
const CRYSTAL_FRAME: FrameDefinition = {
|
||||
id: 'crystal',
|
||||
name: 'Crystal Frame',
|
||||
description: 'A prismatic construct dealing high damage with precision. Very high magic affinity.',
|
||||
baseDamage: 14,
|
||||
attackSpeed: 1.8,
|
||||
armorPierce: 0.15,
|
||||
magicAffinity: 0.9,
|
||||
aoeTargets: 1,
|
||||
element: 'crystal',
|
||||
specialEffect: 'none',
|
||||
summonCost: [elemCost('crystal', 12)],
|
||||
unlockRequirement: {
|
||||
type: 'mana_unlocked',
|
||||
manaType: 'crystal',
|
||||
},
|
||||
};
|
||||
|
||||
const STEEL_FRAME: FrameDefinition = {
|
||||
id: 'steel',
|
||||
name: 'Steel Frame',
|
||||
description: 'Forged from metal, this frame delivers devastating attacks with high armor pierce.',
|
||||
baseDamage: 18,
|
||||
attackSpeed: 1.6,
|
||||
armorPierce: 0.5,
|
||||
magicAffinity: 0.5,
|
||||
aoeTargets: 1,
|
||||
element: 'metal',
|
||||
specialEffect: 'none',
|
||||
summonCost: [elemCost('metal', 14)],
|
||||
unlockRequirement: {
|
||||
type: 'mana_unlocked',
|
||||
manaType: 'metal',
|
||||
},
|
||||
};
|
||||
|
||||
const SHADOWGLASS_FRAME: FrameDefinition = {
|
||||
id: 'shadowglass',
|
||||
name: 'Shadowglass Frame',
|
||||
description: 'Volcanic glass animated by shadow. Extremely fast AoE attacks with devastating magic affinity.',
|
||||
baseDamage: 20,
|
||||
attackSpeed: 2.5,
|
||||
armorPierce: 0.65,
|
||||
magicAffinity: 0.95,
|
||||
aoeTargets: 2,
|
||||
element: 'shadowglass',
|
||||
specialEffect: 'aoe',
|
||||
summonCost: [elemCost('shadowglass', 18), rawCost(10)],
|
||||
unlockRequirement: {
|
||||
type: 'mana_unlocked',
|
||||
manaType: 'shadowglass',
|
||||
},
|
||||
};
|
||||
|
||||
const CRYSTAL_STEEL_HYBRID_FRAME: FrameDefinition = {
|
||||
id: 'crystalSteelHybrid',
|
||||
name: 'Crystal-Steel Hybrid Frame',
|
||||
description: 'An advanced hybrid frame capable of housing Guardian Cores. Highest combined stats. Required for Guardian Constructs.',
|
||||
baseDamage: 22,
|
||||
attackSpeed: 2.8,
|
||||
armorPierce: 0.7,
|
||||
magicAffinity: 1.0,
|
||||
aoeTargets: 1,
|
||||
element: 'crystal',
|
||||
specialEffect: 'guardianConstruct',
|
||||
summonCost: [elemCost('crystal', 20), elemCost('metal', 15), rawCost(15)],
|
||||
unlockRequirement: {
|
||||
type: 'attunement_level',
|
||||
attunement: 'fabricator',
|
||||
level: 5,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── FRAME REGISTRY ─────────────────────────────────────────────────────
|
||||
|
||||
export const FRAMES: Record<string, FrameDefinition> = {
|
||||
[EARTH_FRAME.id]: EARTH_FRAME,
|
||||
[SAND_FRAME.id]: SAND_FRAME,
|
||||
[FROST_FRAME.id]: FROST_FRAME,
|
||||
[CRYSTAL_FRAME.id]: CRYSTAL_FRAME,
|
||||
[STEEL_FRAME.id]: STEEL_FRAME,
|
||||
[SHADOWGLASS_FRAME.id]: SHADOWGLASS_FRAME,
|
||||
[CRYSTAL_STEEL_HYBRID_FRAME.id]: CRYSTAL_STEEL_HYBRID_FRAME,
|
||||
};
|
||||
|
||||
export const ALL_FRAMES = [
|
||||
EARTH_FRAME,
|
||||
SAND_FRAME,
|
||||
FROST_FRAME,
|
||||
CRYSTAL_FRAME,
|
||||
STEEL_FRAME,
|
||||
SHADOWGLASS_FRAME,
|
||||
CRYSTAL_STEEL_HYBRID_FRAME,
|
||||
];
|
||||
@@ -0,0 +1,102 @@
|
||||
// ─── Golem Enchantment Definitions ───────────────────────────────────────
|
||||
// Optional sword effects applied to golem basic attacks.
|
||||
// Requires Enchanter 5 + Fabricator 5.
|
||||
|
||||
import type { GolemEnchantmentDefinition } from './types';
|
||||
import { elemCost } from './types';
|
||||
|
||||
const SWORD_FIRE: GolemEnchantmentDefinition = {
|
||||
id: 'sword_fire',
|
||||
name: 'Sword: Fire',
|
||||
description: 'Applies Burn DoT on basic attack.',
|
||||
effect: 'burn',
|
||||
capacityCost: 10,
|
||||
summonCost: [elemCost('fire', 5)],
|
||||
};
|
||||
|
||||
const SWORD_FROST: GolemEnchantmentDefinition = {
|
||||
id: 'sword_frost',
|
||||
name: 'Sword: Frost',
|
||||
description: 'Applies additional Slow on basic attack.',
|
||||
effect: 'slow',
|
||||
capacityCost: 10,
|
||||
summonCost: [elemCost('frost', 5)],
|
||||
};
|
||||
|
||||
const SWORD_LIGHTNING: GolemEnchantmentDefinition = {
|
||||
id: 'sword_lightning',
|
||||
name: 'Sword: Lightning',
|
||||
description: 'Chance to Shock (stun) on basic attack.',
|
||||
effect: 'shock',
|
||||
capacityCost: 12,
|
||||
summonCost: [elemCost('lightning', 6)],
|
||||
};
|
||||
|
||||
const SWORD_SHADOW: GolemEnchantmentDefinition = {
|
||||
id: 'sword_shadow',
|
||||
name: 'Sword: Shadow',
|
||||
description: 'Chance to Weaken (reduce enemy damage) on basic attack.',
|
||||
effect: 'weaken',
|
||||
capacityCost: 12,
|
||||
summonCost: [elemCost('dark', 6)],
|
||||
};
|
||||
|
||||
const SWORD_METAL: GolemEnchantmentDefinition = {
|
||||
id: 'sword_metal',
|
||||
name: 'Sword: Metal',
|
||||
description: 'Bonus Armor Pierce on basic attack.',
|
||||
effect: 'armorPierce',
|
||||
capacityCost: 8,
|
||||
summonCost: [elemCost('metal', 5)],
|
||||
};
|
||||
|
||||
const SWORD_CRYSTAL: GolemEnchantmentDefinition = {
|
||||
id: 'sword_crystal',
|
||||
name: 'Sword: Crystal',
|
||||
description: 'Bonus Critical Chance on basic attack.',
|
||||
effect: 'criticalChance',
|
||||
capacityCost: 14,
|
||||
summonCost: [elemCost('crystal', 7)],
|
||||
};
|
||||
|
||||
const SWORD_WATER: GolemEnchantmentDefinition = {
|
||||
id: 'sword_water',
|
||||
name: 'Sword: Water',
|
||||
description: 'Applies Soak on basic attack (increases lightning damage taken).',
|
||||
effect: 'soak',
|
||||
capacityCost: 8,
|
||||
summonCost: [elemCost('water', 4)],
|
||||
};
|
||||
|
||||
const SWORD_EARTH: GolemEnchantmentDefinition = {
|
||||
id: 'sword_earth',
|
||||
name: 'Sword: Earth',
|
||||
description: 'Bonus damage to shielded enemies on basic attack.',
|
||||
effect: 'shieldBreak',
|
||||
capacityCost: 10,
|
||||
summonCost: [elemCost('earth', 5)],
|
||||
};
|
||||
|
||||
// ─── ENCHANTMENT REGISTRY ────────────────────────────────────────────────
|
||||
|
||||
export const GOLEM_ENCHANTMENTS: Record<string, GolemEnchantmentDefinition> = {
|
||||
[SWORD_FIRE.id]: SWORD_FIRE,
|
||||
[SWORD_FROST.id]: SWORD_FROST,
|
||||
[SWORD_LIGHTNING.id]: SWORD_LIGHTNING,
|
||||
[SWORD_SHADOW.id]: SWORD_SHADOW,
|
||||
[SWORD_METAL.id]: SWORD_METAL,
|
||||
[SWORD_CRYSTAL.id]: SWORD_CRYSTAL,
|
||||
[SWORD_WATER.id]: SWORD_WATER,
|
||||
[SWORD_EARTH.id]: SWORD_EARTH,
|
||||
};
|
||||
|
||||
export const ALL_GOLEM_ENCHANTMENTS = [
|
||||
SWORD_FIRE,
|
||||
SWORD_FROST,
|
||||
SWORD_LIGHTNING,
|
||||
SWORD_SHADOW,
|
||||
SWORD_METAL,
|
||||
SWORD_CRYSTAL,
|
||||
SWORD_WATER,
|
||||
SWORD_EARTH,
|
||||
];
|
||||
@@ -1,14 +1,8 @@
|
||||
// ─── Golem Definitions Data ─────────────────────────
|
||||
// Combined golem definitions from all golem modules.
|
||||
// Extracted to a standalone module to avoid circular dependencies
|
||||
// between index.ts and utils.ts.
|
||||
// ─── Golem Definitions Data ──────────────────────────────────────────────
|
||||
// Combined component registries for the component-based golem system.
|
||||
// Extracted to a standalone module to avoid circular dependencies.
|
||||
|
||||
import { BASE_GOLEMS } from './base-golems';
|
||||
import { ELEMENTAL_GOLEMS } from './elemental-golems';
|
||||
import { HYBRID_GOLEMS } from './hybrid-golems';
|
||||
|
||||
export const GOLEMS_DEF = {
|
||||
...BASE_GOLEMS,
|
||||
...ELEMENTAL_GOLEMS,
|
||||
...HYBRID_GOLEMS,
|
||||
};
|
||||
export { CORES, ALL_CORES } from './cores';
|
||||
export { FRAMES, ALL_FRAMES } from './frames';
|
||||
export { MIND_CIRCUITS, ALL_MIND_CIRCUITS } from './mindCircuits';
|
||||
export { GOLEM_ENCHANTMENTS, ALL_GOLEM_ENCHANTMENTS } from './golemEnchantments';
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
// ─── Golem Definitions Index ───────────────────────
|
||||
// Re-exports from all golem modules
|
||||
// ─── Golem Definitions Index ─────────────────────────────────────────────
|
||||
// Barrel exports for the component-based golem system.
|
||||
|
||||
// Re-export types
|
||||
export type { GolemDef, GolemManaCost } from './types';
|
||||
export type {
|
||||
CoreDefinition,
|
||||
CoreId,
|
||||
FrameDefinition,
|
||||
FrameId,
|
||||
FrameSpecial,
|
||||
MindCircuitDefinition,
|
||||
MindCircuitId,
|
||||
CircuitBehavior,
|
||||
GolemEnchantmentDefinition,
|
||||
GolemDesign,
|
||||
ComputedGolemStats,
|
||||
GolemManaCost,
|
||||
GolemUnlockRequirement,
|
||||
ActiveGolemV2,
|
||||
} from './types';
|
||||
|
||||
// Re-export combined golems data (extracted to avoid circular deps)
|
||||
export { GOLEMS_DEF } from './golems-data';
|
||||
export { elemCost, rawCost } from './types';
|
||||
|
||||
// Re-export utility functions
|
||||
export {
|
||||
getGolemSlots,
|
||||
isGolemUnlocked,
|
||||
getUnlockedGolems,
|
||||
getGolemDamage,
|
||||
getGolemAttackSpeed,
|
||||
getGolemFloorDuration,
|
||||
getGolemMaintenanceMultiplier,
|
||||
canAffordGolemSummon,
|
||||
deductGolemSummonCost,
|
||||
canAffordGolemMaintenance,
|
||||
deductGolemMaintenance,
|
||||
} from './utils';
|
||||
// Re-export component registries
|
||||
export { CORES, ALL_CORES } from './cores';
|
||||
export { FRAMES, ALL_FRAMES } from './frames';
|
||||
export { MIND_CIRCUITS, ALL_MIND_CIRCUITS } from './mindCircuits';
|
||||
export { GOLEM_ENCHANTMENTS, ALL_GOLEM_ENCHANTMENTS } from './golemEnchantments';
|
||||
|
||||
// Legacy re-exports (deprecated, kept for migration)
|
||||
export type { GolemDef } from './types';
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// ─── Mind Circuit Definitions ────────────────────────────────────────────
|
||||
// Behavior logic for golems: basic attacks, spell casting patterns.
|
||||
|
||||
import type { MindCircuitDefinition } from './types';
|
||||
import { elemCost, rawCost } from './types';
|
||||
|
||||
const SIMPLE_CIRCUIT: MindCircuitDefinition = {
|
||||
id: 'simple',
|
||||
name: 'Simple Logic Circuit',
|
||||
description: 'Performs basic attacks only. Targets nearest enemy. No spell casting.',
|
||||
spellSlots: 0,
|
||||
behavior: 'basicOnly',
|
||||
summonCost: [rawCost(3)],
|
||||
unlockRequirement: {
|
||||
type: 'attunement_level',
|
||||
attunement: 'fabricator',
|
||||
level: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const INTERMEDIATE_CIRCUIT: MindCircuitDefinition = {
|
||||
id: 'intermediate',
|
||||
name: 'Intermediate Logic Circuit',
|
||||
description: 'Casts 1 selected spell when mana is available. Otherwise performs basic attacks.',
|
||||
spellSlots: 1,
|
||||
behavior: 'castSpell1',
|
||||
summonCost: [elemCost('crystal', 8)],
|
||||
unlockRequirement: {
|
||||
type: 'dual_attunement',
|
||||
attunements: ['enchanter', 'fabricator'],
|
||||
levels: [2, 3],
|
||||
},
|
||||
};
|
||||
|
||||
const ADVANCED_CIRCUIT: MindCircuitDefinition = {
|
||||
id: 'advanced',
|
||||
name: 'Advanced Logic Circuit',
|
||||
description: 'Casts 2 selected spells in alternating order: A → B → A → B... Falls back to basic attacks if mana is insufficient.',
|
||||
spellSlots: 2,
|
||||
behavior: 'alternate2',
|
||||
summonCost: [elemCost('crystal', 12)],
|
||||
unlockRequirement: {
|
||||
type: 'dual_attunement',
|
||||
attunements: ['enchanter', 'fabricator'],
|
||||
levels: [3, 4],
|
||||
},
|
||||
};
|
||||
|
||||
const GUARDIAN_CIRCUIT: MindCircuitDefinition = {
|
||||
id: 'guardian',
|
||||
name: 'Guardian Circuit',
|
||||
description: 'Required for Guardian Constructs. Cycles through one spell per mana type from the Guardian Core. Falls back to basic attacks if mana is insufficient.',
|
||||
spellSlots: 4, // Typically 3-4 depending on guardian
|
||||
behavior: 'cycleAll',
|
||||
summonCost: [elemCost('crystal', 25), rawCost(10)],
|
||||
unlockRequirement: {
|
||||
type: 'dual_attunement',
|
||||
attunements: ['invoker', 'fabricator'],
|
||||
levels: [5, 5],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── MIND CIRCUIT REGISTRY ───────────────────────────────────────────────
|
||||
|
||||
export const MIND_CIRCUITS: Record<string, MindCircuitDefinition> = {
|
||||
[SIMPLE_CIRCUIT.id]: SIMPLE_CIRCUIT,
|
||||
[INTERMEDIATE_CIRCUIT.id]: INTERMEDIATE_CIRCUIT,
|
||||
[ADVANCED_CIRCUIT.id]: ADVANCED_CIRCUIT,
|
||||
[GUARDIAN_CIRCUIT.id]: GUARDIAN_CIRCUIT,
|
||||
};
|
||||
|
||||
export const ALL_MIND_CIRCUITS = [
|
||||
SIMPLE_CIRCUIT,
|
||||
INTERMEDIATE_CIRCUIT,
|
||||
ADVANCED_CIRCUIT,
|
||||
GUARDIAN_CIRCUIT,
|
||||
];
|
||||
@@ -1,8 +1,11 @@
|
||||
// ─── Golem Types ─────────────────────────────────────────────────
|
||||
// ─── Golem Component Types ──────────────────────────────────────────────
|
||||
// Component-based construction system: Core + Frame + Mind Circuit + Enchantments.
|
||||
// Replaces the legacy predefined GolemDef system.
|
||||
|
||||
import type { SpellCost } from '../../types';
|
||||
|
||||
// Golem mana cost helper
|
||||
// ─── Mana Cost Helpers ───────────────────────────────────────────────────
|
||||
|
||||
export function elemCost(element: string, amount: number): SpellCost {
|
||||
return { type: 'element', element, amount };
|
||||
}
|
||||
@@ -17,19 +20,151 @@ export interface GolemManaCost {
|
||||
amount: number;
|
||||
}
|
||||
|
||||
// ─── Unlock Requirements ─────────────────────────────────────────────────
|
||||
|
||||
export interface GolemUnlockRequirement {
|
||||
type: 'attunement_level' | 'mana_unlocked' | 'dual_attunement' | 'guardian_pact';
|
||||
attunement?: string;
|
||||
level?: number;
|
||||
manaType?: string;
|
||||
attunements?: string[];
|
||||
levels?: number[];
|
||||
}
|
||||
|
||||
// ─── Core Definition ─────────────────────────────────────────────────────
|
||||
|
||||
export type CoreId = 'basic' | 'intermediate' | 'advanced' | 'guardian';
|
||||
|
||||
export interface CoreDefinition {
|
||||
id: CoreId;
|
||||
tier: 1 | 2 | 3 | 4;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Mana types available (basic = [earth], guardian = guardian-specific) */
|
||||
manaTypes: string[];
|
||||
manaCapacity: number;
|
||||
manaRegen: number;
|
||||
maxRoomDuration: number;
|
||||
summonCost: GolemManaCost[];
|
||||
/** Primary mana type for upkeep calculation */
|
||||
primaryManaType: string;
|
||||
tierMultiplier: number; // For enchantment capacity: 1.0 / 1.5 / 2.0 / 3.0
|
||||
unlockRequirement: GolemUnlockRequirement;
|
||||
}
|
||||
|
||||
// ─── Frame Definition ────────────────────────────────────────────────────
|
||||
|
||||
export type FrameId = 'earth' | 'sand' | 'frost' | 'crystal' | 'steel' | 'shadowglass' | 'crystalSteelHybrid';
|
||||
|
||||
export type FrameSpecial = 'none' | 'aoe' | 'slow' | 'guardianConstruct';
|
||||
|
||||
export interface FrameDefinition {
|
||||
id: FrameId;
|
||||
name: string;
|
||||
description: string;
|
||||
baseDamage: number;
|
||||
attackSpeed: number; // Attacks per in-game hour
|
||||
armorPierce: number; // 0–1 fraction of enemy armor bypassed
|
||||
magicAffinity: number; // 0.0–1.0+, spell damage efficiency
|
||||
aoeTargets: number; // 1 = single target, >1 = AoE
|
||||
/** Element for elemental matchup (derived from unlock mana type) */
|
||||
element?: string;
|
||||
specialEffect: FrameSpecial;
|
||||
summonCost: GolemManaCost[];
|
||||
unlockRequirement: GolemUnlockRequirement;
|
||||
}
|
||||
|
||||
// ─── Mind Circuit Definition ────────────────────────────────────────────
|
||||
|
||||
export type MindCircuitId = 'simple' | 'intermediate' | 'advanced' | 'guardian';
|
||||
|
||||
export type CircuitBehavior = 'basicOnly' | 'castSpell1' | 'alternate2' | 'cycleAll';
|
||||
|
||||
export interface MindCircuitDefinition {
|
||||
id: MindCircuitId;
|
||||
name: string;
|
||||
description: string;
|
||||
spellSlots: number;
|
||||
behavior: CircuitBehavior;
|
||||
summonCost: GolemManaCost[];
|
||||
unlockRequirement: GolemUnlockRequirement;
|
||||
}
|
||||
|
||||
// ─── Golem Enchantment Definition ───────────────────────────────────────
|
||||
|
||||
export interface GolemEnchantmentDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
effect: string;
|
||||
capacityCost: number;
|
||||
summonCost: GolemManaCost[];
|
||||
}
|
||||
|
||||
// ─── Golem Design (Player-Created) ──────────────────────────────────────
|
||||
|
||||
export interface GolemDesign {
|
||||
id: string; // Player-assigned or auto-generated
|
||||
name: string; // Player-defined name
|
||||
core: CoreDefinition;
|
||||
frame: FrameDefinition;
|
||||
mindCircuit: MindCircuitDefinition;
|
||||
enchantments: GolemEnchantmentDefinition[]; // Optional, 0-N
|
||||
/** Player-selected mana types for cores that support choice */
|
||||
selectedManaTypes: string[];
|
||||
/** Player-selected spell IDs for mind circuits with spell slots */
|
||||
selectedSpells: string[];
|
||||
}
|
||||
|
||||
// ─── Computed Design Stats (derived from components) ────────────────────
|
||||
|
||||
export interface ComputedGolemStats {
|
||||
maxRoomDuration: number;
|
||||
totalSummonCost: GolemManaCost[];
|
||||
upkeepCostPerHour: GolemManaCost[];
|
||||
manaCapacity: number;
|
||||
manaRegen: number;
|
||||
baseDamage: number;
|
||||
attackSpeed: number;
|
||||
armorPierce: number;
|
||||
magicAffinity: number;
|
||||
aoeTargets: number;
|
||||
spellSlots: number;
|
||||
availableManaTypes: string[];
|
||||
enchantmentCapacity: number;
|
||||
specialEffect: FrameSpecial;
|
||||
}
|
||||
|
||||
// ─── Runtime Active Golem (in combat) ───────────────────────────────────
|
||||
|
||||
export interface ActiveGolemV2 {
|
||||
/** Reference to the GolemDesign used */
|
||||
designId: string;
|
||||
design: GolemDesign;
|
||||
summonedFloor: number;
|
||||
attackProgress: number;
|
||||
roomsRemaining: number;
|
||||
currentMana: number;
|
||||
/** Index for alternating/cycling spells */
|
||||
spellCastIndex: number;
|
||||
}
|
||||
|
||||
// ─── Legacy Type (kept for backward compat during migration) ────────────
|
||||
|
||||
/** @deprecated Use GolemDesign instead */
|
||||
export interface GolemDef {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
baseManaType: string; // The primary mana type this golem uses
|
||||
summonCost: GolemManaCost[]; // Cost to summon (can be multiple types)
|
||||
maintenanceCost: GolemManaCost[]; // Cost per hour to maintain
|
||||
damage: number; // Base damage per attack
|
||||
attackSpeed: number; // Attacks per hour
|
||||
hp: number; // Golem HP (for display, they don't take damage)
|
||||
armorPierce: number; // Armor piercing (0-1)
|
||||
isAoe: boolean; // Whether golem attacks are AOE
|
||||
aoeTargets: number; // Number of targets for AOE
|
||||
baseManaType: string;
|
||||
summonCost: GolemManaCost[];
|
||||
maintenanceCost: GolemManaCost[];
|
||||
damage: number;
|
||||
attackSpeed: number;
|
||||
hp: number;
|
||||
armorPierce: number;
|
||||
isAoe: boolean;
|
||||
aoeTargets: number;
|
||||
unlockCondition: {
|
||||
type: 'attunement_level' | 'mana_unlocked' | 'dual_attunement';
|
||||
attunement?: string;
|
||||
@@ -38,7 +173,7 @@ export interface GolemDef {
|
||||
attunements?: string[];
|
||||
levels?: number[];
|
||||
};
|
||||
tier: number; // Power tier (1-4)
|
||||
maxRoomDuration: number; // Rooms before golem disappears (spec §9.6)
|
||||
specialAbilities?: { name: string; description: string }[]; // Special abilities
|
||||
tier: number;
|
||||
maxRoomDuration: number;
|
||||
specialAbilities?: { name: string; description: string }[];
|
||||
}
|
||||
|
||||
+189
-174
@@ -1,204 +1,219 @@
|
||||
// ─── Golem Helper Functions ─────────────────────────
|
||||
// ─── Golem Helper Functions ──────────────────────────────────────────────
|
||||
// Component-based construction system utilities.
|
||||
|
||||
import type { GolemDef, GolemManaCost } from './types';
|
||||
import { GOLEMS_DEF } from './golems-data';
|
||||
import type {
|
||||
ComputedGolemStats,
|
||||
GolemDesign,
|
||||
GolemManaCost,
|
||||
GolemUnlockRequirement,
|
||||
ActiveGolemV2,
|
||||
} from './types';
|
||||
import { CORES } from './cores';
|
||||
import { FRAMES } from './frames';
|
||||
import { MIND_CIRCUITS } from './mindCircuits';
|
||||
|
||||
// Get golem slots based on Fabricator attunement level
|
||||
// Level 2 = 1, Level 4 = 2, Level 6 = 3, Level 8 = 4, Level 10 = 5
|
||||
// ─── Golem Slots ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get base golem slots from Fabricator attunement level.
|
||||
* Level 2 = 1, Level 4 = 2, Level 6 = 3, Level 8 = 4, Level 10 = 5
|
||||
*/
|
||||
export function getGolemSlots(fabricatorLevel: number): number {
|
||||
if (fabricatorLevel < 2) return 0;
|
||||
return Math.floor(fabricatorLevel / 2);
|
||||
}
|
||||
|
||||
// Check if a golem is unlocked based on player state
|
||||
export function isGolemUnlocked(
|
||||
golemId: string,
|
||||
// ─── Unlock Checks ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a component is unlocked based on player state.
|
||||
*/
|
||||
export function isComponentUnlocked(
|
||||
requirement: GolemUnlockRequirement,
|
||||
attunements: Record<string, { active: boolean; level: number }>,
|
||||
unlockedElements: string[]
|
||||
unlockedElements: string[],
|
||||
signedGuardianPacts: number[],
|
||||
): boolean {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return false;
|
||||
|
||||
const condition = golem.unlockCondition;
|
||||
|
||||
switch (condition.type) {
|
||||
case 'attunement_level':
|
||||
const attState = attunements[condition.attunement || ''];
|
||||
return attState?.active && (attState.level || 1) >= (condition.level || 1);
|
||||
|
||||
switch (requirement.type) {
|
||||
case 'attunement_level': {
|
||||
const attState = attunements[requirement.attunement || ''];
|
||||
return !!attState?.active && (attState.level || 1) >= (requirement.level || 1);
|
||||
}
|
||||
case 'mana_unlocked':
|
||||
return unlockedElements.includes(condition.manaType || '');
|
||||
|
||||
case 'dual_attunement':
|
||||
if (!condition.attunements || !condition.levels) return false;
|
||||
return condition.attunements.every((attId, idx) => {
|
||||
return unlockedElements.includes(requirement.manaType || '');
|
||||
case 'dual_attunement': {
|
||||
if (!requirement.attunements || !requirement.levels) return false;
|
||||
return requirement.attunements.every((attId, idx) => {
|
||||
const att = attunements[attId];
|
||||
return att?.active && (att.level || 1) >= condition.levels![idx];
|
||||
return att?.active && (att.level || 1) >= requirement.levels![idx];
|
||||
});
|
||||
|
||||
}
|
||||
case 'guardian_pact': {
|
||||
// Requires dual attunement plus at least one guardian pact
|
||||
if (!requirement.attunements || !requirement.levels) return false;
|
||||
const attOk = requirement.attunements.every((attId, idx) => {
|
||||
const att = attunements[attId];
|
||||
return att?.active && (att.level || 1) >= requirement.levels![idx];
|
||||
});
|
||||
return attOk && signedGuardianPacts.length > 0;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all unlocked golems for a player
|
||||
export function getUnlockedGolems(
|
||||
attunements: Record<string, { active: boolean; level: number }>,
|
||||
unlockedElements: string[]
|
||||
): GolemDef[] {
|
||||
return Object.values(GOLEMS_DEF).filter(golem =>
|
||||
isGolemUnlocked(golem.id, attunements, unlockedElements)
|
||||
) as GolemDef[];
|
||||
// ─── Computed Stats ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute all derived stats for a golem design from its components.
|
||||
*/
|
||||
export function computeGolemStats(design: GolemDesign): ComputedGolemStats {
|
||||
const core = design.core;
|
||||
const frame = design.frame;
|
||||
const circuit = design.mindCircuit;
|
||||
const enchantments = design.enchantments;
|
||||
|
||||
// Total summon cost from all components
|
||||
const totalSummonCost: GolemManaCost[] = [
|
||||
...core.summonCost,
|
||||
...frame.summonCost,
|
||||
...circuit.summonCost,
|
||||
...enchantments.flatMap((e) => e.summonCost),
|
||||
];
|
||||
|
||||
// Player upkeep = Core.manaRegen × 2 per hour (spec §13)
|
||||
const upkeepCostPerHour: GolemManaCost[] = [
|
||||
{
|
||||
type: 'element',
|
||||
element: core.primaryManaType,
|
||||
amount: core.manaRegen * 2,
|
||||
},
|
||||
];
|
||||
|
||||
// Enchantment capacity = Frame.MagicAffinity × Core.TierMultiplier
|
||||
const enchantmentCapacity = frame.magicAffinity * core.tierMultiplier;
|
||||
|
||||
return {
|
||||
maxRoomDuration: core.maxRoomDuration,
|
||||
totalSummonCost,
|
||||
upkeepCostPerHour,
|
||||
manaCapacity: core.manaCapacity,
|
||||
manaRegen: core.manaRegen,
|
||||
baseDamage: frame.baseDamage,
|
||||
attackSpeed: frame.attackSpeed,
|
||||
armorPierce: frame.armorPierce,
|
||||
magicAffinity: frame.magicAffinity,
|
||||
aoeTargets: frame.aoeTargets,
|
||||
spellSlots: circuit.spellSlots,
|
||||
availableManaTypes: design.selectedManaTypes.length > 0
|
||||
? design.selectedManaTypes
|
||||
: core.manaTypes,
|
||||
enchantmentCapacity,
|
||||
specialEffect: frame.specialEffect,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate golem damage with skill bonuses
|
||||
// ─── Summoning Cost Checks ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if player can afford to summon a golem design.
|
||||
*/
|
||||
export function canAffordGolemDesign(
|
||||
design: GolemDesign,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
): { canAfford: boolean; missing: string } {
|
||||
const stats = computeGolemStats(design);
|
||||
|
||||
for (const cost of stats.totalSummonCost) {
|
||||
if (cost.type === 'raw') {
|
||||
if (rawMana < cost.amount) {
|
||||
return { canAfford: false, missing: `raw mana (${cost.amount} needed)` };
|
||||
}
|
||||
} else if (cost.element) {
|
||||
const elem = elements[cost.element];
|
||||
if (!elem || !elem.unlocked) {
|
||||
return { canAfford: false, missing: `${cost.element} mana (not unlocked)` };
|
||||
}
|
||||
if (elem.current < cost.amount) {
|
||||
return { canAfford: false, missing: `${cost.element} mana (${cost.amount} needed, have ${elem.current})` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { canAfford: true, missing: '' };
|
||||
}
|
||||
|
||||
// ─── Active Golem V2 Helpers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new ActiveGolemV2 from a GolemDesign for combat.
|
||||
*/
|
||||
export function createActiveGolem(
|
||||
design: GolemDesign,
|
||||
currentFloor: number,
|
||||
): ActiveGolemV2 {
|
||||
return {
|
||||
designId: design.id,
|
||||
design,
|
||||
summonedFloor: currentFloor,
|
||||
attackProgress: 0,
|
||||
roomsRemaining: design.core.maxRoomDuration,
|
||||
currentMana: design.core.manaCapacity, // Starts full
|
||||
spellCastIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Component Lookups ────────────────────────────────────────────────────
|
||||
|
||||
/** Get a CoreDefinition by ID */
|
||||
export function getCore(id: string) {
|
||||
return CORES[id] || null;
|
||||
}
|
||||
|
||||
/** Get a FrameDefinition by ID */
|
||||
export function getFrame(id: string) {
|
||||
return FRAMES[id] || null;
|
||||
}
|
||||
|
||||
/** Get a MindCircuitDefinition by ID */
|
||||
export function getMindCircuit(id: string) {
|
||||
return MIND_CIRCUITS[id] || null;
|
||||
}
|
||||
|
||||
// ─── Legacy Compatibility ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @deprecated Use getGolemSlots instead
|
||||
*/
|
||||
export function getGolemFloorDuration(_skills: Record<string, number>): number {
|
||||
return 3; // Default room duration for legacy calls
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use computeGolemStats instead
|
||||
*/
|
||||
export function getGolemDamage(
|
||||
golemId: string,
|
||||
skills: Record<string, number>
|
||||
_skills: Record<string, number>,
|
||||
): number {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return 0;
|
||||
|
||||
let damage = golem.damage;
|
||||
|
||||
// Golem Mastery skill bonus
|
||||
const masteryBonus = 1 + (skills.golemMastery || 0) * 0.1;
|
||||
damage *= masteryBonus;
|
||||
|
||||
return damage;
|
||||
// Legacy lookup — returns 0 for component-based golems
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate golem attack speed with skill bonuses
|
||||
/**
|
||||
* @deprecated Use computeGolemStats instead
|
||||
*/
|
||||
export function getGolemAttackSpeed(
|
||||
golemId: string,
|
||||
skills: Record<string, number>
|
||||
_skills: Record<string, number>,
|
||||
): number {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return 0;
|
||||
|
||||
let speed = golem.attackSpeed;
|
||||
|
||||
// Golem Efficiency skill bonus
|
||||
const efficiencyBonus = 1 + (skills.golemEfficiency || 0) * 0.05;
|
||||
speed *= efficiencyBonus;
|
||||
|
||||
return speed;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get floors golems can last (base 1, +1 per Golem Longevity skill level)
|
||||
export function getGolemFloorDuration(skills: Record<string, number>): number {
|
||||
return 1 + (skills.golemLongevity || 0);
|
||||
}
|
||||
|
||||
// Get maintenance cost multiplier (Golem Siphon reduces by 10% per level)
|
||||
export function getGolemMaintenanceMultiplier(skills: Record<string, number>): number {
|
||||
return 1 - (skills.golemSiphon || 0) * 0.1;
|
||||
}
|
||||
|
||||
// Check if player can afford golem summon cost
|
||||
export function canAffordGolemSummon(
|
||||
golemId: string,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): boolean {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return false;
|
||||
|
||||
for (const cost of golem.summonCost) {
|
||||
if (cost.type === 'raw') {
|
||||
if (rawMana < cost.amount) return false;
|
||||
} else if (cost.element) {
|
||||
const elem = elements[cost.element];
|
||||
if (!elem || !elem.unlocked || elem.current < cost.amount) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deduct golem summon cost from mana pools
|
||||
export function deductGolemSummonCost(
|
||||
golemId: string,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return { rawMana, elements };
|
||||
|
||||
let newRawMana = rawMana;
|
||||
let newElements = { ...elements };
|
||||
|
||||
for (const cost of golem.summonCost) {
|
||||
if (cost.type === 'raw') {
|
||||
newRawMana -= cost.amount;
|
||||
} else if (cost.element && newElements[cost.element]) {
|
||||
newElements = {
|
||||
...newElements,
|
||||
[cost.element]: {
|
||||
...newElements[cost.element],
|
||||
current: newElements[cost.element].current - cost.amount,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { rawMana: newRawMana, elements: newElements };
|
||||
}
|
||||
|
||||
// Check if player can afford golem maintenance for one tick
|
||||
export function canAffordGolemMaintenance(
|
||||
golemId: string,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
skills: Record<string, number>
|
||||
): boolean {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return false;
|
||||
|
||||
const maintenanceMult = getGolemMaintenanceMultiplier(skills);
|
||||
|
||||
for (const cost of golem.maintenanceCost) {
|
||||
const adjustedAmount = cost.amount * maintenanceMult;
|
||||
if (cost.type === 'raw') {
|
||||
if (rawMana < adjustedAmount) return false;
|
||||
} else if (cost.element) {
|
||||
const elem = elements[cost.element];
|
||||
if (!elem || !elem.unlocked || elem.current < adjustedAmount) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deduct golem maintenance cost for one tick
|
||||
export function deductGolemMaintenance(
|
||||
golemId: string,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
skills: Record<string, number>
|
||||
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return { rawMana, elements };
|
||||
|
||||
const maintenanceMult = getGolemMaintenanceMultiplier(skills);
|
||||
|
||||
let newRawMana = rawMana;
|
||||
let newElements = { ...elements };
|
||||
|
||||
for (const cost of golem.maintenanceCost) {
|
||||
const adjustedAmount = cost.amount * maintenanceMult;
|
||||
if (cost.type === 'raw') {
|
||||
newRawMana -= adjustedAmount;
|
||||
} else if (cost.element && newElements[cost.element]) {
|
||||
newElements = {
|
||||
...newElements,
|
||||
[cost.element]: {
|
||||
...newElements[cost.element],
|
||||
current: newElements[cost.element].current - adjustedAmount,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { rawMana: newRawMana, elements: newElements };
|
||||
/**
|
||||
* @deprecated Component-based system doesn't use skill-based maintenance multiplier
|
||||
*/
|
||||
export function getGolemMaintenanceMultiplier(_skills: Record<string, number>): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 } };
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,10 +50,15 @@ export type {
|
||||
ScheduleBlock,
|
||||
StudyTarget,
|
||||
SummonedGolem,
|
||||
ActiveGolem,
|
||||
GolemancyState,
|
||||
GolemLoadoutEntry,
|
||||
RuntimeActiveGolem,
|
||||
SerializedGolemDesign,
|
||||
GameActionType,
|
||||
ActivityEventType,
|
||||
ActivityLogEntry,
|
||||
ActiveEffect,
|
||||
} from './types/game';
|
||||
|
||||
export type { PrestigeDef } from './types/game';
|
||||
|
||||
+58
-10
@@ -144,25 +144,71 @@ export interface StudyTarget {
|
||||
|
||||
// ─── Golemancy Types ─────────────────────────────────────────────────────────
|
||||
|
||||
/** @deprecated Legacy type for predefined golems. Use GolemDesign instead. */
|
||||
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)
|
||||
golemId: string;
|
||||
summonedFloor: number;
|
||||
attackProgress: number;
|
||||
roomsRemaining: number;
|
||||
}
|
||||
|
||||
/** Runtime state for an active golem in combat (spec §9.7) */
|
||||
/** @deprecated Legacy type. Use ActiveGolemV2 instead. */
|
||||
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 (legacy, kept for golem-tab state)
|
||||
activeGolems: ActiveGolem[]; // Runtime active golems in combat (spec §9)
|
||||
lastSummonFloor: number; // Floor golems were last summoned on
|
||||
/**
|
||||
* Player-designed golem loadout entry.
|
||||
* Each entry is a complete golem design (Core + Frame + Mind Circuit + Enchantments).
|
||||
*/
|
||||
export interface GolemLoadoutEntry {
|
||||
designId: string; // Reference to the GolemDesign
|
||||
/** Golem design (serialized component-based golem) */
|
||||
design: SerializedGolemDesign;
|
||||
enabled: boolean; // Whether this golem is enabled for auto-summon
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime active golem in combat (component-based system).
|
||||
* Tracks combat state per golem instance.
|
||||
*/
|
||||
export interface RuntimeActiveGolem {
|
||||
designId: string; // Reference to the player's GolemDesign
|
||||
summonedFloor: number; // Floor when golem was summoned
|
||||
attackProgress: number; // Progress toward next attack (accumulated)
|
||||
roomsRemaining: number; // Rooms before golem fades
|
||||
currentMana: number; // Current mana in golem's own pool
|
||||
spellCastIndex: number; // For alternating/cycling spell circuits
|
||||
}
|
||||
|
||||
export interface SerializedGolemDesign {
|
||||
id: string;
|
||||
name: string;
|
||||
coreId: string;
|
||||
frameId: string;
|
||||
mindCircuitId: string;
|
||||
enchantmentIds: string[];
|
||||
selectedManaTypes: string[];
|
||||
selectedSpells: string[];
|
||||
}
|
||||
|
||||
export interface GolemancyState {
|
||||
/** Player's saved golem designs indexed by design ID */
|
||||
golemDesigns: Record<string, SerializedGolemDesign>;
|
||||
/** Prioritized loadout of golem designs (persists across rooms, resets per run) */
|
||||
golemLoadout: GolemLoadoutEntry[];
|
||||
/** Runtime active golems in combat */
|
||||
activeGolems: RuntimeActiveGolem[];
|
||||
/** Floor golems were last summoned on */
|
||||
lastSummonFloor: number;
|
||||
// Legacy fields kept for backward compatibility during migration
|
||||
enabledGolems: string[];
|
||||
summonedGolems: SummonedGolem[];
|
||||
/** @deprecated Use activeGolems instead (RuntimeActiveGolem[]) */
|
||||
legacyActiveGolems: ActiveGolem[];
|
||||
}
|
||||
|
||||
|
||||
// ─── Main Game State ─────────────────────────────────────────────────────
|
||||
|
||||
export interface GameState {
|
||||
@@ -245,6 +291,8 @@ export interface GameState {
|
||||
// Golemancy (summoned golems)
|
||||
golemancy: GolemancyState;
|
||||
|
||||
|
||||
|
||||
// Achievements
|
||||
achievements: AchievementState;
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ export type {
|
||||
SummonedGolem,
|
||||
ActiveGolem,
|
||||
GolemancyState,
|
||||
GolemLoadoutEntry,
|
||||
RuntimeActiveGolem,
|
||||
SerializedGolemDesign,
|
||||
GameActionType,
|
||||
ActivityEventType,
|
||||
ActivityLogEntry,
|
||||
|
||||
Reference in New Issue
Block a user