eq2go/internal/npc/ai/brains.go
2025-08-29 17:48:39 -05:00

664 lines
17 KiB
Go

package ai
import (
"fmt"
"time"
)
// BaseBrain implements the basic AI brain functionality
type BaseBrain struct {
npc NPC
logger Logger
brainType int8
state *BrainState
hateList *HateList
encounter *EncounterList
statistics *BrainStatistics
}
// NewBaseBrain creates a new base brain
func NewBaseBrain(npc NPC, logger Logger) *BaseBrain {
return &BaseBrain{
npc: npc,
logger: logger,
brainType: BrainTypeDefault,
state: NewBrainState(),
hateList: NewHateList(),
encounter: NewEncounterList(),
statistics: NewBrainStatistics(),
}
}
// Think implements the main AI logic
func (bb *BaseBrain) Think() error {
if bb.npc == nil {
return fmt.Errorf("brain has no body")
}
// Update last think time
currentTime := time.Now().UnixMilli()
bb.state.SetLastThink(currentTime)
bb.statistics.ThinkCycles++
// Skip thinking if mezzzed, stunned, feared, or dazed
if bb.npc.IsMezzedOrStunned() || bb.npc.IsFeared() || bb.npc.IsDazed() {
return nil
}
// Check if we need to runback
if bb.npc.IsRunningBack() || bb.npc.ShouldCallRunback() {
return bb.handleRunback()
}
// Get most hated target
mostHatedID := bb.hateList.GetMostHated()
var target Entity
if mostHatedID > 0 {
target = bb.getEntityByID(mostHatedID)
if target == nil {
// Target no longer exists, remove from hate list
bb.hateList.RemoveHate(mostHatedID)
mostHatedID = bb.hateList.GetMostHated()
if mostHatedID > 0 {
target = bb.getEntityByID(mostHatedID)
}
}
}
// Handle combat state
if target != nil && bb.npc.GetInCombat() {
return bb.handleCombat(target)
} else if bb.npc.GetInCombat() {
// No target but still in combat - exit combat
bb.npc.InCombat(false)
bb.npc.SetTarget(nil)
bb.state.SetState(AIStateIdle)
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelBasic {
bb.logger.LogDebug("NPC %s exiting combat - no targets", bb.npc.GetName())
}
return nil
}
// Handle idle state
return bb.handleIdle()
}
// handleCombat handles combat AI logic
func (bb *BaseBrain) handleCombat(target Entity) error {
if target == nil {
return nil
}
// Set target if not already set
if bb.npc.GetTarget() != target {
bb.npc.SetTarget(target)
bb.npc.FaceTarget(target, false)
}
distance := bb.npc.GetDistance(target)
// Check if target is too far away
maxRange := bb.getMaxCombatRange()
if distance > maxRange {
// Try to move closer or runback if too far
if distance > MaxChaseDistance {
if bb.npc.ShouldCallRunback() {
bb.npc.Runback(bb.npc.GetRunbackDistance())
return nil
}
} else {
bb.MoveCloser(target)
}
return nil
}
bb.state.SetState(AIStateCombat)
// Try to cast spells first if we have line of sight
if bb.npc.CheckLoS(target) && !bb.npc.IsCasting() {
if bb.HasRecovered() && bb.ProcessSpell(target, distance) {
return nil
}
}
// Try melee combat
bb.ProcessMelee(target, distance)
return nil
}
// handleIdle handles idle AI logic
func (bb *BaseBrain) handleIdle() error {
bb.state.SetState(AIStateIdle)
// Set slower think tick when idle
bb.state.SetThinkTick(SlowThinkTick)
return nil
}
// handleRunback handles runback logic
func (bb *BaseBrain) handleRunback() error {
// Clear hate and encounter when running back
bb.hateList.Clear()
bb.encounter.Clear()
// Set target to nil
bb.npc.SetTarget(nil)
bb.npc.InCombat(false)
bb.state.SetState(AIStateRunback)
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
bb.logger.LogDebug("NPC %s running back", bb.npc.GetName())
}
return nil
}
// ProcessSpell processes spell casting for the brain
func (bb *BaseBrain) ProcessSpell(target Entity, distance float32) bool {
if bb.npc == nil || target == nil {
return false
}
// Check if we can cast
if bb.npc.IsCasting() || !bb.HasRecovered() {
return false
}
// Get next spell to cast
spell := bb.npc.GetNextSpell(target, distance)
if spell == nil {
return false
}
// Check range
if distance < spell.GetMinRange() || distance > spell.GetRange() {
return false
}
// Cast the spell
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
bb.logger.LogDebug("NPC %s casting spell %s on %s",
bb.npc.GetName(), spell.GetName(), target.GetName())
}
// Set spell recovery time
recoveryTime := time.Now().UnixMilli() + int64(spell.GetRecoveryTime()*RecoveryTimeMultiple) + int64(SpellRecoveryBuffer)
bb.state.SetSpellRecovery(recoveryTime)
bb.state.SetState(AIStateCasting)
bb.statistics.SpellsCast++
return true
}
// ProcessMelee processes melee combat for the brain
func (bb *BaseBrain) ProcessMelee(target Entity, distance float32) {
if bb.npc == nil || target == nil {
return
}
// Check if we can attack
if !bb.npc.AttackAllowed(target) {
return
}
// Try primary weapon
if bb.npc.PrimaryWeaponReady() {
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
bb.logger.LogDebug("NPC %s melee attacking %s with primary weapon",
bb.npc.GetName(), target.GetName())
}
bb.npc.MeleeAttack(target, distance, true)
bb.npc.SetPrimaryLastAttackTime(time.Now().UnixMilli())
bb.statistics.MeleeAttacks++
return
}
// Try secondary weapon
if bb.npc.SecondaryWeaponReady() {
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
bb.logger.LogDebug("NPC %s melee attacking %s with secondary weapon",
bb.npc.GetName(), target.GetName())
}
bb.npc.MeleeAttack(target, distance, false)
bb.npc.SetSecondaryLastAttackTime(time.Now().UnixMilli())
bb.statistics.MeleeAttacks++
}
}
// MoveCloser moves the NPC closer to the target
func (bb *BaseBrain) MoveCloser(target Entity) {
if bb.npc == nil || target == nil {
return
}
// Face the target
bb.npc.FaceTarget(target, false)
// Calculate running location towards target
bb.npc.CalculateRunningLocation(true)
bb.state.SetState(AIStateMoving)
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
bb.logger.LogDebug("NPC %s moving closer to %s", bb.npc.GetName(), target.GetName())
}
}
// Brain interface implementation
// GetBrainType returns the brain type
func (bb *BaseBrain) GetBrainType() int8 {
return bb.brainType
}
// IsActive returns whether the brain is active
func (bb *BaseBrain) IsActive() bool {
return bb.state.IsActive()
}
// SetActive sets the brain's active state
func (bb *BaseBrain) SetActive(active bool) {
bb.state.SetActive(active)
}
// GetLastThink returns the timestamp of the last think cycle
func (bb *BaseBrain) GetLastThink() int64 {
return bb.state.GetLastThink()
}
// SetLastThink sets the timestamp of the last think cycle
func (bb *BaseBrain) SetLastThink(timestamp int64) {
bb.state.SetLastThink(timestamp)
}
// GetThinkTick returns the think tick interval
func (bb *BaseBrain) GetThinkTick() int32 {
return bb.state.GetThinkTick()
}
// SetThinkTick sets the think tick interval
func (bb *BaseBrain) SetThinkTick(tick int32) {
bb.state.SetThinkTick(tick)
}
// Hate management
// AddHate adds hate for an entity
func (bb *BaseBrain) AddHate(entityID, hate int32) {
bb.hateList.AddHate(entityID, hate)
bb.statistics.HateEvents++
// Enter combat if not already
if !bb.npc.GetInCombat() {
bb.npc.InCombat(true)
bb.state.SetThinkTick(FastThinkTick)
}
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
bb.logger.LogDebug("NPC %s gained %d hate for entity %d", bb.npc.GetName(), hate, entityID)
}
}
// GetHate returns hate for an entity
func (bb *BaseBrain) GetHate(entityID int32) int32 {
return bb.hateList.GetHate(entityID)
}
// GetMostHated returns the most hated entity ID
func (bb *BaseBrain) GetMostHated() int32 {
return bb.hateList.GetMostHated()
}
// ClearHate clears all hate
func (bb *BaseBrain) ClearHate() {
bb.hateList.Clear()
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelBasic {
bb.logger.LogDebug("NPC %s cleared all hate", bb.npc.GetName())
}
}
// ClearHateForEntity clears hate for a specific entity
func (bb *BaseBrain) ClearHateForEntity(entityID int32) {
bb.hateList.RemoveHate(entityID)
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
bb.logger.LogDebug("NPC %s cleared hate for entity %d", bb.npc.GetName(), entityID)
}
}
// Encounter management
// AddToEncounter adds an entity to the encounter
func (bb *BaseBrain) AddToEncounter(entityID, characterID int32, isPlayer, isBot bool) bool {
success := bb.encounter.AddEntity(entityID, characterID, isPlayer, isBot)
if success {
bb.statistics.EncounterEvents++
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
bb.logger.LogDebug("NPC %s added entity %d to encounter", bb.npc.GetName(), entityID)
}
}
return success
}
// RemoveFromEncounter removes an entity from the encounter
func (bb *BaseBrain) RemoveFromEncounter(entityID int32) {
bb.encounter.RemoveEntity(entityID)
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelDetailed {
bb.logger.LogDebug("NPC %s removed entity %d from encounter", bb.npc.GetName(), entityID)
}
}
// IsInEncounter checks if an entity is in the encounter
func (bb *BaseBrain) IsInEncounter(entityID int32) bool {
return bb.encounter.IsEntityInEncounter(entityID)
}
// ClearEncounter clears the encounter
func (bb *BaseBrain) ClearEncounter() {
bb.encounter.Clear()
if bb.logger != nil && bb.state.GetDebugLevel() >= DebugLevelBasic {
bb.logger.LogDebug("NPC %s cleared encounter", bb.npc.GetName())
}
}
// GetEncounterSize returns the encounter size
func (bb *BaseBrain) GetEncounterSize() int {
return bb.encounter.Size()
}
// State management
// GetState returns the current AI state
func (bb *BaseBrain) GetState() int32 {
return bb.state.GetState()
}
// SetState sets the current AI state
func (bb *BaseBrain) SetState(state int32) {
bb.state.SetState(state)
}
// HasRecovered checks if the brain has recovered from spell casting
func (bb *BaseBrain) HasRecovered() bool {
return bb.state.HasRecovered()
}
// SetSpellRecovery sets the spell recovery time
func (bb *BaseBrain) SetSpellRecovery(timestamp int64) {
bb.state.SetSpellRecovery(timestamp)
}
// GetStatistics returns brain statistics
func (bb *BaseBrain) GetStatistics() *BrainStatistics {
return bb.statistics
}
// GetDebugLevel returns the debug level
func (bb *BaseBrain) GetDebugLevel() int8 {
return bb.state.GetDebugLevel()
}
// SetDebugLevel sets the debug level
func (bb *BaseBrain) SetDebugLevel(level int8) {
bb.state.SetDebugLevel(level)
}
// Helper methods
// getEntityByID gets an entity by ID (placeholder - would be implemented by zone)
func (bb *BaseBrain) getEntityByID(entityID int32) Entity {
// This would typically call into the zone system to get the entity
// For now, return nil - concrete implementations should override this
return nil
}
// getMaxCombatRange returns the maximum combat range
func (bb *BaseBrain) getMaxCombatRange() float32 {
return MaxCombatRange
}
// Pet Brain Implementations
// CombatPetBrain extends the base brain for combat pets
type CombatPetBrain struct {
*BaseBrain
}
// NewCombatPetBrain creates a new combat pet brain
func NewCombatPetBrain(npc NPC, logger Logger) *CombatPetBrain {
brain := &CombatPetBrain{
BaseBrain: NewBaseBrain(npc, logger),
}
brain.brainType = BrainTypeCombatPet
return brain
}
// Think implements pet-specific AI logic
func (cpb *CombatPetBrain) Think() error {
// Call parent Think() for default combat behavior
if err := cpb.BaseBrain.Think(); err != nil {
return err
}
// Additional pet-specific logic
if cpb.npc.GetInCombat() || !cpb.npc.IsPet() || cpb.npc.IsMezzedOrStunned() {
return nil
}
if cpb.logger != nil && cpb.state.GetDebugLevel() >= DebugLevelDetailed {
cpb.logger.LogDebug("Combat pet AI for %s", cpb.npc.GetName())
}
// Check if owner has stay command set
owner := cpb.npc.GetOwner()
if owner != nil && owner.IsPlayer() {
// TODO: Check player's pet movement setting
// if player.GetInfoStruct().GetPetMovement() == PetMovementStay {
// return nil
// }
}
// Follow owner
if owner != nil {
cpb.npc.SetTarget(owner)
distance := cpb.npc.GetDistance(owner)
maxRange := cpb.getMaxCombatRange()
if distance > maxRange {
cpb.MoveCloser(owner)
}
}
return nil
}
// NonCombatPetBrain handles non-combat pets (cosmetic pets)
type NonCombatPetBrain struct {
*BaseBrain
}
// NewNonCombatPetBrain creates a new non-combat pet brain
func NewNonCombatPetBrain(npc NPC, logger Logger) *NonCombatPetBrain {
brain := &NonCombatPetBrain{
BaseBrain: NewBaseBrain(npc, logger),
}
brain.brainType = BrainTypeNonCombatPet
return brain
}
// Think implements non-combat pet AI (just following)
func (ncpb *NonCombatPetBrain) Think() error {
// Non-combat pets don't do combat AI
if !ncpb.npc.IsPet() || ncpb.npc.IsMezzedOrStunned() {
return nil
}
if ncpb.logger != nil && ncpb.state.GetDebugLevel() >= DebugLevelDetailed {
ncpb.logger.LogDebug("Non-combat pet AI for %s", ncpb.npc.GetName())
}
// Just follow owner
owner := ncpb.npc.GetOwner()
if owner != nil {
ncpb.npc.SetTarget(owner)
distance := ncpb.npc.GetDistance(owner)
maxRange := ncpb.getMaxCombatRange()
if distance > maxRange {
ncpb.MoveCloser(owner)
}
}
return nil
}
// BlankBrain provides a minimal AI that does nothing
type BlankBrain struct {
*BaseBrain
}
// NewBlankBrain creates a new blank brain
func NewBlankBrain(npc NPC, logger Logger) *BlankBrain {
brain := &BlankBrain{
BaseBrain: NewBaseBrain(npc, logger),
}
brain.brainType = BrainTypeBlank
brain.SetThinkTick(BlankBrainTick) // Very slow tick
return brain
}
// Think does nothing for blank brains
func (bb *BlankBrain) Think() error {
// Blank brain does nothing
return nil
}
// DumbFirePetBrain handles dumbfire pets (temporary combat pets)
type DumbFirePetBrain struct {
*BaseBrain
expireTime int64
}
// NewDumbFirePetBrain creates a new dumbfire pet brain
func NewDumbFirePetBrain(npc NPC, target Entity, expireTimeMS int32, logger Logger) *DumbFirePetBrain {
brain := &DumbFirePetBrain{
BaseBrain: NewBaseBrain(npc, logger),
expireTime: time.Now().UnixMilli() + int64(expireTimeMS),
}
brain.brainType = BrainTypeDumbFire
// Add maximum hate for the target
if target != nil {
brain.AddHate(target.GetID(), MaxHateValue)
}
return brain
}
// AddHate only allows hate for the initial target
func (dfpb *DumbFirePetBrain) AddHate(entityID int32, hate int32) {
// Only add hate if we don't already have a target
if dfpb.GetMostHated() == 0 {
dfpb.BaseBrain.AddHate(entityID, hate)
}
}
// Think implements dumbfire pet AI
func (dfpb *DumbFirePetBrain) Think() error {
// Check if expired
if time.Now().UnixMilli() > dfpb.expireTime {
if dfpb.npc != nil && dfpb.npc.GetHP() > 0 {
if dfpb.logger != nil {
dfpb.logger.LogDebug("Dumbfire pet %s expired", dfpb.npc.GetName())
}
dfpb.npc.KillSpawn(dfpb.npc)
}
return nil
}
// Get target
targetID := dfpb.GetMostHated()
if targetID == 0 {
// No target, kill self
if dfpb.npc != nil && dfpb.npc.GetHP() > 0 {
if dfpb.logger != nil {
dfpb.logger.LogDebug("Dumbfire pet %s has no target", dfpb.npc.GetName())
}
dfpb.npc.KillSpawn(dfpb.npc)
}
return nil
}
target := dfpb.getEntityByID(targetID)
if target == nil {
// Target no longer exists, kill self
if dfpb.npc != nil && dfpb.npc.GetHP() > 0 {
dfpb.npc.KillSpawn(dfpb.npc)
}
return nil
}
// Skip if mezzed or stunned
if dfpb.npc.IsMezzedOrStunned() {
return nil
}
// Set target if not already set
if dfpb.npc.GetTarget() != target {
dfpb.npc.SetTarget(target)
dfpb.npc.FaceTarget(target, false)
}
// Enter combat if not already
if !dfpb.npc.GetInCombat() {
dfpb.npc.CalculateRunningLocation(true)
dfpb.npc.InCombat(true)
}
distance := dfpb.npc.GetDistance(target)
// Try to cast spells if we have line of sight
if dfpb.npc.CheckLoS(target) && !dfpb.npc.IsCasting() &&
(!dfpb.HasRecovered() || !dfpb.ProcessSpell(target, distance)) {
if dfpb.logger != nil && dfpb.state.GetDebugLevel() >= DebugLevelDetailed {
dfpb.logger.LogDebug("Dumbfire pet %s attempting melee on %s",
dfpb.npc.GetName(), target.GetName())
}
dfpb.npc.FaceTarget(target, false)
dfpb.ProcessMelee(target, distance)
}
return nil
}
// GetExpireTime returns when this dumbfire pet will expire
func (dfpb *DumbFirePetBrain) GetExpireTime() int64 {
return dfpb.expireTime
}
// SetExpireTime sets when this dumbfire pet will expire
func (dfpb *DumbFirePetBrain) SetExpireTime(expireTime int64) {
dfpb.expireTime = expireTime
}
// IsExpired checks if the dumbfire pet has expired
func (dfpb *DumbFirePetBrain) IsExpired() bool {
return time.Now().UnixMilli() > dfpb.expireTime
}
// ExtendExpireTime extends the expire time by the given duration
func (dfpb *DumbFirePetBrain) ExtendExpireTime(durationMS int32) {
dfpb.expireTime += int64(durationMS)
}