eq2go/internal/npc/manager.go

766 lines
19 KiB
Go

package npc
import (
"fmt"
"math/rand"
"strings"
"sync"
)
// Manager provides high-level management of the NPC system
type Manager struct {
npcs map[int32]*NPC // NPCs indexed by ID
npcsByZone map[int32][]*NPC // NPCs indexed by zone ID
npcsByAppearance map[int32][]*NPC // NPCs indexed by appearance ID
database Database // Database interface
logger Logger // Logger interface
spellManager SpellManager // Spell system interface
skillManager SkillManager // Skill system interface
appearanceManager AppearanceManager // Appearance system interface
mutex sync.RWMutex // Thread safety
// Statistics
totalNPCs int64
npcsInCombat int64
spellCastCount int64
skillUsageCount int64
runbackCount int64
aiStrategyCounts map[int8]int64
// Configuration
maxNPCs int32
defaultAggroRadius float32
enableAI bool
}
// NewManager creates a new NPC manager
func NewManager(database Database, logger Logger) *Manager {
return &Manager{
npcs: make(map[int32]*NPC),
npcsByZone: make(map[int32][]*NPC),
npcsByAppearance: make(map[int32][]*NPC),
database: database,
logger: logger,
aiStrategyCounts: make(map[int8]int64),
maxNPCs: 10000, // Default limit
defaultAggroRadius: DefaultAggroRadius,
enableAI: true,
}
}
// Initialize loads NPCs from database and sets up the system
func (m *Manager) Initialize() error {
if m.logger != nil {
m.logger.LogInfo("Initializing NPC manager...")
}
if m.database == nil {
if m.logger != nil {
m.logger.LogWarning("No database provided, starting with empty NPC list")
}
return nil
}
// Load NPCs from database
npcs, err := m.database.LoadAllNPCs()
if err != nil {
return fmt.Errorf("failed to load NPCs from database: %w", err)
}
for _, npc := range npcs {
if err := m.addNPCInternal(npc); err != nil {
if m.logger != nil {
m.logger.LogError("Failed to add NPC %d: %v", npc.GetNPCID(), err)
}
}
}
if m.logger != nil {
m.logger.LogInfo("Loaded %d NPCs from database", len(npcs))
}
return nil
}
// AddNPC adds a new NPC to the system
func (m *Manager) AddNPC(npc *NPC) error {
if npc == nil {
return fmt.Errorf("NPC cannot be nil")
}
if !npc.IsValid() {
return fmt.Errorf("NPC is not valid: %s", npc.String())
}
m.mutex.Lock()
defer m.mutex.Unlock()
if len(m.npcs) >= int(m.maxNPCs) {
return fmt.Errorf("maximum NPC limit reached (%d)", m.maxNPCs)
}
return m.addNPCInternal(npc)
}
// addNPCInternal adds an NPC without locking (internal use)
func (m *Manager) addNPCInternal(npc *NPC) error {
npcID := npc.GetNPCID()
// Check for duplicate ID
if _, exists := m.npcs[npcID]; exists {
return fmt.Errorf("NPC with ID %d already exists", npcID)
}
// Add to main index
m.npcs[npcID] = npc
// Add to zone index
// TODO: Add zone support when Entity.GetZoneID() is available
// if npc.Entity != nil {
// zoneID := npc.Entity.GetZoneID()
// m.npcsByZone[zoneID] = append(m.npcsByZone[zoneID], npc)
// }
// Add to appearance index
appearanceID := npc.GetAppearanceID()
m.npcsByAppearance[appearanceID] = append(m.npcsByAppearance[appearanceID], npc)
// Update statistics
m.totalNPCs++
strategy := npc.GetAIStrategy()
m.aiStrategyCounts[strategy]++
// Save to database if available
if m.database != nil {
if err := m.database.SaveNPC(npc); err != nil {
// Remove from indexes if database save failed
delete(m.npcs, npcID)
m.removeFromZoneIndex(npc)
m.removeFromAppearanceIndex(npc)
m.totalNPCs--
m.aiStrategyCounts[strategy]--
return fmt.Errorf("failed to save NPC to database: %w", err)
}
}
if m.logger != nil {
m.logger.LogInfo("Added NPC %d: %s", npcID, npc.String())
}
return nil
}
// GetNPC retrieves an NPC by ID
func (m *Manager) GetNPC(id int32) *NPC {
m.mutex.RLock()
defer m.mutex.RUnlock()
return m.npcs[id]
}
// GetNPCsByZone retrieves all NPCs in a specific zone
func (m *Manager) GetNPCsByZone(zoneID int32) []*NPC {
m.mutex.RLock()
defer m.mutex.RUnlock()
npcs := m.npcsByZone[zoneID]
result := make([]*NPC, len(npcs))
copy(result, npcs)
return result
}
// GetNPCsByAppearance retrieves all NPCs with a specific appearance
func (m *Manager) GetNPCsByAppearance(appearanceID int32) []*NPC {
m.mutex.RLock()
defer m.mutex.RUnlock()
npcs := m.npcsByAppearance[appearanceID]
result := make([]*NPC, len(npcs))
copy(result, npcs)
return result
}
// RemoveNPC removes an NPC from the system
func (m *Manager) RemoveNPC(id int32) error {
m.mutex.Lock()
defer m.mutex.Unlock()
npc, exists := m.npcs[id]
if !exists {
return fmt.Errorf("NPC with ID %d does not exist", id)
}
// Remove from database first if available
if m.database != nil {
if err := m.database.DeleteNPC(id); err != nil {
return fmt.Errorf("failed to delete NPC from database: %w", err)
}
}
// Remove from all indexes
delete(m.npcs, id)
m.removeFromZoneIndex(npc)
m.removeFromAppearanceIndex(npc)
// Update statistics
m.totalNPCs--
strategy := npc.GetAIStrategy()
if count := m.aiStrategyCounts[strategy]; count > 0 {
m.aiStrategyCounts[strategy]--
}
if m.logger != nil {
m.logger.LogInfo("Removed NPC %d", id)
}
return nil
}
// UpdateNPC updates an existing NPC
func (m *Manager) UpdateNPC(npc *NPC) error {
if npc == nil {
return fmt.Errorf("NPC cannot be nil")
}
if !npc.IsValid() {
return fmt.Errorf("NPC is not valid: %s", npc.String())
}
m.mutex.Lock()
defer m.mutex.Unlock()
npcID := npc.GetNPCID()
oldNPC, exists := m.npcs[npcID]
if !exists {
return fmt.Errorf("NPC with ID %d does not exist", npcID)
}
// Update indexes if zone or appearance changed
// TODO: Add zone support when Entity.GetZoneID() is available
// if npc.Entity != nil && oldNPC.Entity != nil {
// if npc.Entity.GetZoneID() != oldNPC.Entity.GetZoneID() {
// m.removeFromZoneIndex(oldNPC)
// zoneID := npc.Entity.GetZoneID()
// m.npcsByZone[zoneID] = append(m.npcsByZone[zoneID], npc)
// }
// }
if npc.GetAppearanceID() != oldNPC.GetAppearanceID() {
m.removeFromAppearanceIndex(oldNPC)
appearanceID := npc.GetAppearanceID()
m.npcsByAppearance[appearanceID] = append(m.npcsByAppearance[appearanceID], npc)
}
// Update AI strategy statistics
oldStrategy := oldNPC.GetAIStrategy()
newStrategy := npc.GetAIStrategy()
if oldStrategy != newStrategy {
if count := m.aiStrategyCounts[oldStrategy]; count > 0 {
m.aiStrategyCounts[oldStrategy]--
}
m.aiStrategyCounts[newStrategy]++
}
// Update main index
m.npcs[npcID] = npc
// Save to database if available
if m.database != nil {
if err := m.database.SaveNPC(npc); err != nil {
return fmt.Errorf("failed to save NPC to database: %w", err)
}
}
if m.logger != nil {
m.logger.LogInfo("Updated NPC %d: %s", npcID, npc.String())
}
return nil
}
// CreateNPCFromTemplate creates a new NPC from an existing template
func (m *Manager) CreateNPCFromTemplate(templateID, newID int32) (*NPC, error) {
template := m.GetNPC(templateID)
if template == nil {
return nil, fmt.Errorf("template NPC with ID %d not found", templateID)
}
// Create new NPC from template
newNPC := NewNPCFromExisting(template)
newNPC.SetNPCID(newID)
// Add to system
if err := m.AddNPC(newNPC); err != nil {
return nil, fmt.Errorf("failed to add new NPC: %w", err)
}
return newNPC, nil
}
// GetRandomNPCByAppearance returns a random NPC with the specified appearance
func (m *Manager) GetRandomNPCByAppearance(appearanceID int32) *NPC {
npcs := m.GetNPCsByAppearance(appearanceID)
if len(npcs) == 0 {
return nil
}
return npcs[rand.Intn(len(npcs))]
}
// ProcessCombat handles combat processing for all NPCs
func (m *Manager) ProcessCombat() {
m.mutex.RLock()
npcs := make([]*NPC, 0, len(m.npcs))
for _, npc := range m.npcs {
// TODO: Add combat status check when GetInCombat() is available
// if npc.Entity != nil && npc.Entity.GetInCombat() {
if npc.Entity != nil {
npcs = append(npcs, npc)
}
}
m.mutex.RUnlock()
// Process combat for each NPC in combat
for _, npc := range npcs {
npc.ProcessCombat()
}
// Update combat statistics
m.mutex.Lock()
m.npcsInCombat = int64(len(npcs))
m.mutex.Unlock()
}
// ProcessAI handles AI processing for all NPCs
func (m *Manager) ProcessAI() {
if !m.enableAI {
return
}
m.mutex.RLock()
npcs := make([]*NPC, 0, len(m.npcs))
for _, npc := range m.npcs {
npcs = append(npcs, npc)
}
m.mutex.RUnlock()
// Process AI for each NPC
for _, npc := range npcs {
if brain := npc.GetBrain(); brain != nil && brain.IsActive() {
if err := brain.Think(); err != nil && m.logger != nil {
m.logger.LogError("AI brain error for NPC %d: %v", npc.GetNPCID(), err)
}
}
}
}
// ProcessMovement handles movement processing for all NPCs
func (m *Manager) ProcessMovement() {
m.mutex.RLock()
npcs := make([]*NPC, 0, len(m.npcs))
for _, npc := range m.npcs {
npcs = append(npcs, npc)
}
m.mutex.RUnlock()
// Process movement for each NPC
for _, npc := range npcs {
// Check pause timer
if npc.IsPauseMovementTimerActive() {
continue
}
// Handle runback if needed
if npc.callRunback && npc.GetRunbackLocation() != nil {
npc.callRunback = false
npc.Runback(0, true)
m.mutex.Lock()
m.runbackCount++
m.mutex.Unlock()
}
}
}
// GetStatistics returns NPC system statistics
func (m *Manager) GetStatistics() *NPCStatistics {
m.mutex.RLock()
defer m.mutex.RUnlock()
// Create AI strategy counts by name
aiCounts := make(map[string]int)
for strategy, count := range m.aiStrategyCounts {
switch strategy {
case AIStrategyBalanced:
aiCounts["balanced"] = int(count)
case AIStrategyOffensive:
aiCounts["offensive"] = int(count)
case AIStrategyDefensive:
aiCounts["defensive"] = int(count)
default:
aiCounts[fmt.Sprintf("unknown_%d", strategy)] = int(count)
}
}
// Calculate average aggro radius
var totalAggro float32
npcCount := 0
for _, npc := range m.npcs {
totalAggro += npc.GetAggroRadius()
npcCount++
}
var avgAggro float32
if npcCount > 0 {
avgAggro = totalAggro / float32(npcCount)
}
// Count NPCs with spells and skills
npcsWithSpells := 0
npcsWithSkills := 0
for _, npc := range m.npcs {
if npc.HasSpells() {
npcsWithSpells++
}
if len(npc.skills) > 0 {
npcsWithSkills++
}
}
return &NPCStatistics{
TotalNPCs: int(m.totalNPCs),
NPCsInCombat: int(m.npcsInCombat),
NPCsWithSpells: npcsWithSpells,
NPCsWithSkills: npcsWithSkills,
AIStrategyCounts: aiCounts,
SpellCastCount: m.spellCastCount,
SkillUsageCount: m.skillUsageCount,
RunbackCount: m.runbackCount,
AverageAggroRadius: avgAggro,
}
}
// ValidateAllNPCs validates all NPCs in the system
func (m *Manager) ValidateAllNPCs() []string {
m.mutex.RLock()
npcs := make([]*NPC, 0, len(m.npcs))
for _, npc := range m.npcs {
npcs = append(npcs, npc)
}
m.mutex.RUnlock()
var issues []string
for _, npc := range npcs {
if !npc.IsValid() {
issues = append(issues, fmt.Sprintf("NPC %d is invalid: %s", npc.GetNPCID(), npc.String()))
}
}
return issues
}
// ProcessCommand handles NPC-related commands
func (m *Manager) ProcessCommand(command string, args []string) (string, error) {
switch command {
case "stats":
return m.handleStatsCommand(args)
case "validate":
return m.handleValidateCommand(args)
case "list":
return m.handleListCommand(args)
case "info":
return m.handleInfoCommand(args)
case "create":
return m.handleCreateCommand(args)
case "remove":
return m.handleRemoveCommand(args)
case "search":
return m.handleSearchCommand(args)
case "combat":
return m.handleCombatCommand(args)
default:
return "", fmt.Errorf("unknown NPC command: %s", command)
}
}
// Command handlers
func (m *Manager) handleStatsCommand(args []string) (string, error) {
stats := m.GetStatistics()
result := "NPC System Statistics:\n"
result += fmt.Sprintf("Total NPCs: %d\n", stats.TotalNPCs)
result += fmt.Sprintf("NPCs in Combat: %d\n", stats.NPCsInCombat)
result += fmt.Sprintf("NPCs with Spells: %d\n", stats.NPCsWithSpells)
result += fmt.Sprintf("NPCs with Skills: %d\n", stats.NPCsWithSkills)
result += fmt.Sprintf("Average Aggro Radius: %.2f\n", stats.AverageAggroRadius)
result += fmt.Sprintf("Spell Casts: %d\n", stats.SpellCastCount)
result += fmt.Sprintf("Skill Uses: %d\n", stats.SkillUsageCount)
result += fmt.Sprintf("Runbacks: %d\n", stats.RunbackCount)
if len(stats.AIStrategyCounts) > 0 {
result += "\nAI Strategy Distribution:\n"
for strategy, count := range stats.AIStrategyCounts {
result += fmt.Sprintf(" %s: %d\n", strategy, count)
}
}
return result, nil
}
func (m *Manager) handleValidateCommand(args []string) (string, error) {
issues := m.ValidateAllNPCs()
if len(issues) == 0 {
return "All NPCs are valid.", nil
}
result := fmt.Sprintf("Found %d issues with NPCs:\n", len(issues))
for i, issue := range issues {
if i >= 10 { // Limit output
result += "... (and more)\n"
break
}
result += fmt.Sprintf("%d. %s\n", i+1, issue)
}
return result, nil
}
func (m *Manager) handleListCommand(args []string) (string, error) {
m.mutex.RLock()
npcs := make([]*NPC, 0, len(m.npcs))
for _, npc := range m.npcs {
npcs = append(npcs, npc)
}
m.mutex.RUnlock()
if len(npcs) == 0 {
return "No NPCs loaded.", nil
}
result := fmt.Sprintf("NPCs (%d):\n", len(npcs))
count := 0
for _, npc := range npcs {
if count >= 20 { // Limit output
result += "... (and more)\n"
break
}
result += fmt.Sprintf(" %d: %s\n", npc.GetNPCID(), npc.String())
count++
}
return result, nil
}
func (m *Manager) handleInfoCommand(args []string) (string, error) {
if len(args) == 0 {
return "", fmt.Errorf("NPC ID required")
}
var npcID int32
if _, err := fmt.Sscanf(args[0], "%d", &npcID); err != nil {
return "", fmt.Errorf("invalid NPC ID: %s", args[0])
}
npc := m.GetNPC(npcID)
if npc == nil {
return fmt.Sprintf("NPC %d not found.", npcID), nil
}
result := fmt.Sprintf("NPC Information:\n")
result += fmt.Sprintf("ID: %d\n", npc.GetNPCID())
result += fmt.Sprintf("Appearance ID: %d\n", npc.GetAppearanceID())
result += fmt.Sprintf("AI Strategy: %d\n", npc.GetAIStrategy())
result += fmt.Sprintf("Cast Percentage: %d%%\n", npc.GetCastPercentage())
result += fmt.Sprintf("Aggro Radius: %.2f\n", npc.GetAggroRadius())
result += fmt.Sprintf("Has Spells: %v\n", npc.HasSpells())
result += fmt.Sprintf("Running Back: %v\n", npc.IsRunningBack())
if npc.Entity != nil {
result += fmt.Sprintf("Name: %s\n", npc.Entity.GetName())
result += fmt.Sprintf("Level: %d\n", npc.Entity.GetLevel())
// TODO: Add zone and combat status when methods are available
// result += fmt.Sprintf("Zone: %d\n", npc.Entity.GetZoneID())
// result += fmt.Sprintf("In Combat: %v\n", npc.Entity.GetInCombat())
}
return result, nil
}
func (m *Manager) handleCreateCommand(args []string) (string, error) {
if len(args) < 2 {
return "", fmt.Errorf("usage: create <template_id> <new_id>")
}
var templateID, newID int32
if _, err := fmt.Sscanf(args[0], "%d", &templateID); err != nil {
return "", fmt.Errorf("invalid template ID: %s", args[0])
}
if _, err := fmt.Sscanf(args[1], "%d", &newID); err != nil {
return "", fmt.Errorf("invalid new ID: %s", args[1])
}
_, err := m.CreateNPCFromTemplate(templateID, newID)
if err != nil {
return "", fmt.Errorf("failed to create NPC: %w", err)
}
return fmt.Sprintf("Successfully created NPC %d from template %d", newID, templateID), nil
}
func (m *Manager) handleRemoveCommand(args []string) (string, error) {
if len(args) == 0 {
return "", fmt.Errorf("NPC ID required")
}
var npcID int32
if _, err := fmt.Sscanf(args[0], "%d", &npcID); err != nil {
return "", fmt.Errorf("invalid NPC ID: %s", args[0])
}
if err := m.RemoveNPC(npcID); err != nil {
return "", fmt.Errorf("failed to remove NPC: %w", err)
}
return fmt.Sprintf("Successfully removed NPC %d", npcID), nil
}
func (m *Manager) handleSearchCommand(args []string) (string, error) {
if len(args) == 0 {
return "", fmt.Errorf("search term required")
}
searchTerm := strings.ToLower(args[0])
m.mutex.RLock()
var results []*NPC
for _, npc := range m.npcs {
if npc.Entity != nil {
name := strings.ToLower(npc.Entity.GetName())
if strings.Contains(name, searchTerm) {
results = append(results, npc)
}
}
}
m.mutex.RUnlock()
if len(results) == 0 {
return fmt.Sprintf("No NPCs found matching '%s'.", args[0]), nil
}
result := fmt.Sprintf("Found %d NPCs matching '%s':\n", len(results), args[0])
for i, npc := range results {
if i >= 20 { // Limit output
result += "... (and more)\n"
break
}
result += fmt.Sprintf(" %d: %s\n", npc.GetNPCID(), npc.String())
}
return result, nil
}
func (m *Manager) handleCombatCommand(args []string) (string, error) {
result := "Combat Processing Status:\n"
result += fmt.Sprintf("NPCs in Combat: %d\n", m.npcsInCombat)
result += fmt.Sprintf("Total Spell Casts: %d\n", m.spellCastCount)
result += fmt.Sprintf("Total Runbacks: %d\n", m.runbackCount)
return result, nil
}
// Helper methods
func (m *Manager) removeFromZoneIndex(npc *NPC) {
if npc.Entity == nil {
return
}
// TODO: Implement zone index removal when Entity.GetZoneID() is available
// zoneID := npc.Entity.GetZoneID()
// npcs := m.npcsByZone[zoneID]
// for i, n := range npcs {
// if n == npc {
// // Remove from slice
// m.npcsByZone[zoneID] = append(npcs[:i], npcs[i+1:]...)
// break
// }
// }
// // Clean up empty slices
// if len(m.npcsByZone[zoneID]) == 0 {
// delete(m.npcsByZone, zoneID)
// }
}
func (m *Manager) removeFromAppearanceIndex(npc *NPC) {
appearanceID := npc.GetAppearanceID()
npcs := m.npcsByAppearance[appearanceID]
for i, n := range npcs {
if n == npc {
// Remove from slice
m.npcsByAppearance[appearanceID] = append(npcs[:i], npcs[i+1:]...)
break
}
}
// Clean up empty slices
if len(m.npcsByAppearance[appearanceID]) == 0 {
delete(m.npcsByAppearance, appearanceID)
}
}
// SetManagers sets the external system managers
func (m *Manager) SetManagers(spellMgr SpellManager, skillMgr SkillManager, appearanceMgr AppearanceManager) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.spellManager = spellMgr
m.skillManager = skillMgr
m.appearanceManager = appearanceMgr
}
// Configuration methods
func (m *Manager) SetMaxNPCs(max int32) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.maxNPCs = max
}
func (m *Manager) SetDefaultAggroRadius(radius float32) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.defaultAggroRadius = radius
}
func (m *Manager) SetAIEnabled(enabled bool) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.enableAI = enabled
}
// GetNPCCount returns the total number of NPCs
func (m *Manager) GetNPCCount() int32 {
m.mutex.RLock()
defer m.mutex.RUnlock()
return int32(len(m.npcs))
}
// Shutdown gracefully shuts down the manager
func (m *Manager) Shutdown() {
if m.logger != nil {
m.logger.LogInfo("Shutting down NPC manager...")
}
// Stop all AI brains
m.mutex.Lock()
for _, npc := range m.npcs {
if brain := npc.GetBrain(); brain != nil {
brain.SetActive(false)
}
}
// Clear all data
m.npcs = make(map[int32]*NPC)
m.npcsByZone = make(map[int32][]*NPC)
m.npcsByAppearance = make(map[int32][]*NPC)
m.mutex.Unlock()
}