package alt_advancement import ( "context" "fmt" "sync" "time" "eq2emu/internal/database" "eq2emu/internal/packets" ) // Logger interface for logging operations type Logger interface { LogInfo(system, format string, args ...interface{}) LogError(system, format string, args ...interface{}) LogDebug(system, format string, args ...interface{}) LogWarning(system, format string, args ...interface{}) } // PlayerManager interface for player operations type PlayerManager interface { GetPlayerLevel(characterID int32) (int8, error) GetPlayerClass(characterID int32) (int8, error) GetPlayerAAPoints(characterID int32) (total, spent, available int32, err error) SpendAAPoints(characterID int32, points int32) error AwardAAPoints(characterID int32, points int32, reason string) error GetPlayerExpansions(characterID int32) (int32, error) GetPlayerName(characterID int32) (string, error) } // AltAdvancement represents an AA node type AltAdvancement struct { // Core identification SpellID int32 `db:"spell_id"` NodeID int32 `db:"nodeid"` SpellCRC int32 `db:"spellcrc"` // Display information Name string `db:"name"` Description string `db:"description"` // Tree organization Group int8 `db:"aa_list_fk"` // AA tab (AA_CLASS, AA_SUBCLASS, etc.) Col int8 `db:"xcoord"` // Column position in tree Row int8 `db:"ycoord"` // Row position in tree // Visual representation Icon int16 `db:"icon_id"` // Primary icon ID Icon2 int16 `db:"icon_backdrop"` // Secondary icon ID // Ranking system RankCost int8 `db:"pointspertier"` // Cost per rank MaxRank int8 `db:"maxtier"` // Maximum achievable rank // Prerequisites MinLevel int8 `db:"minlevel"` // Minimum character level RankPrereqID int32 `db:"firstparentid"` // Prerequisite AA node ID RankPrereq int8 `db:"firstparentrequiredtier"` // Required rank in prerequisite ClassReq int8 `db:"displayedclassification"` // Required class Tier int8 `db:"requiredclassification"` // AA tier ReqPoints int8 `db:"classificationpointsrequired"` // Required points in classification ReqTreePoints int16 `db:"pointsspentintreetounlock"` // Required points in entire tree // Display classification ClassName string `db:"title"` // Class name for display SubclassName string `db:"titlelevel"` // Subclass name for display LineTitle string `db:"title"` // AA line title TitleLevel int8 `db:"titlelevel"` // Title level requirement // Metadata CreatedAt time.Time UpdatedAt time.Time } // TreeNodeData represents class-specific AA tree node configuration type TreeNodeData struct { ClassID int32 `db:"class_id"` // Character class ID TreeID int32 `db:"tree_id"` // Tree node identifier AATreeID int32 `db:"aa_tree_id"` // AA tree classification ID } // PlayerAAData represents a player's AA progression type PlayerAAData struct { CharacterID int32 `db:"character_id"` NodeID int32 `db:"node_id"` CurrentRank int8 `db:"current_rank"` PointsSpent int32 `db:"points_spent"` TemplateID int8 `db:"template_id"` TabID int8 `db:"tab_id"` Order int16 `db:"order"` PurchasedAt time.Time `db:"purchased_at"` UpdatedAt time.Time `db:"updated_at"` } // AAEntry represents a player's AA entry in a template type AAEntry struct { TemplateID int8 `db:"template_id"` TabID int8 `db:"tab_id"` AAID int32 `db:"aa_id"` Order int16 `db:"order"` TreeID int8 `db:"tree_id"` } // AATemplate represents an AA template configuration type AATemplate struct { TemplateID int8 Name string Description string IsPersonal bool IsServer bool IsCurrent bool Entries []*AAEntry CreatedAt time.Time UpdatedAt time.Time } // AATab represents an AA tab with its associated data type AATab struct { TabID int8 Group int8 Name string MaxAA int32 ClassID int32 ExpansionReq int32 PointsSpent int32 PointsAvailable int32 Nodes []*AltAdvancement LastUpdate time.Time } // PlayerAAState represents a player's complete AA state type PlayerAAState struct { CharacterID int32 TotalPoints int32 SpentPoints int32 AvailablePoints int32 BankedPoints int32 ActiveTemplate int8 Templates map[int8]*AATemplate Tabs map[int8]*AATab AAProgress map[int32]*PlayerAAData LastUpdate time.Time NeedsSync bool } // AAConfig holds configuration for the AA system type AAConfig struct { EnableAASystem bool EnableCaching bool EnableValidation bool AAPointsPerLevel int32 MaxBankedPoints int32 EnableAABanking bool CacheSize int32 UpdateInterval time.Duration BatchSize int32 DatabaseEnabled bool AutoSave bool SaveInterval time.Duration } // AAManagerStats holds statistics about the AA system type AAManagerStats struct { TotalAAsLoaded int64 TotalNodesLoaded int64 LastLoadTime time.Time LoadDuration time.Duration ActivePlayers int64 TotalAAPurchases int64 TotalPointsSpent int64 AveragePointsSpent float64 CacheHits int64 CacheMisses int64 DatabaseQueries int64 PacketsSent int64 TabUsage map[int8]int64 PopularAAs map[int32]int64 ValidationErrors int64 DatabaseErrors int64 PacketErrors int64 LastStatsUpdate time.Time } // AAManager manages the complete AA system with centralized orchestration type AAManager struct { mu sync.RWMutex db *database.Database logger Logger config AAConfig // Core AA data (indexed for O(1) lookups) altAdvancements map[int32]*AltAdvancement // NodeID -> AA byGroup map[int8][]*AltAdvancement // Group -> AAs byClass map[int8][]*AltAdvancement // ClassReq -> AAs byLevel map[int8][]*AltAdvancement // MinLevel -> AAs // Tree node configurations treeNodes map[int32]*TreeNodeData // TreeID -> TreeNode treeNodesByClass map[int32][]*TreeNodeData // ClassID -> TreeNodes // Player states (cached) playerStates map[int32]*PlayerAAState // CharacterID -> PlayerState // Statistics and performance stats AAManagerStats // Background processing stopChan chan struct{} saveTimer *time.Timer } // NewAAManager creates a new AA manager with the given database and configuration func NewAAManager(db *database.Database, logger Logger, config AAConfig) *AAManager { return &AAManager{ db: db, logger: logger, config: config, altAdvancements: make(map[int32]*AltAdvancement), byGroup: make(map[int8][]*AltAdvancement), byClass: make(map[int8][]*AltAdvancement), byLevel: make(map[int8][]*AltAdvancement), treeNodes: make(map[int32]*TreeNodeData), treeNodesByClass: make(map[int32][]*TreeNodeData), playerStates: make(map[int32]*PlayerAAState), stats: AAManagerStats{ TabUsage: make(map[int8]int64), PopularAAs: make(map[int32]int64), }, stopChan: make(chan struct{}), } } // Initialize loads all AA data and starts background processes func (am *AAManager) Initialize(ctx context.Context) error { am.mu.Lock() defer am.mu.Unlock() startTime := time.Now() // Load core AA data if err := am.loadAltAdvancements(ctx); err != nil { return fmt.Errorf("failed to load alternate advancements: %v", err) } // Load tree node configurations if err := am.loadTreeNodes(ctx); err != nil { return fmt.Errorf("failed to load tree nodes: %v", err) } // Update statistics am.stats.LastLoadTime = startTime am.stats.LoadDuration = time.Since(startTime) am.stats.TotalAAsLoaded = int64(len(am.altAdvancements)) am.stats.TotalNodesLoaded = int64(len(am.treeNodes)) am.logger.LogInfo("alt_advancement", "Initialized AA system: %d AAs, %d tree nodes (took %v)", am.stats.TotalAAsLoaded, am.stats.TotalNodesLoaded, am.stats.LoadDuration) // Start background processes if am.config.AutoSave && am.config.SaveInterval > 0 { go am.autoSaveLoop() } return nil } // loadAltAdvancements loads all AA data from the database with indexing func (am *AAManager) loadAltAdvancements(ctx context.Context) error { query := `SELECT nodeid, minlevel, spellcrc, name, description, aa_list_fk, icon_id, icon_backdrop, xcoord, ycoord, pointspertier, maxtier, firstparentid, firstparentrequiredtier, displayedclassification, requiredclassification, classificationpointsrequired, pointsspentintreetounlock, title, titlelevel FROM spell_aa_nodelist ORDER BY nodeid` rows, err := am.db.Query(query) if err != nil { return fmt.Errorf("query AA data: %v", err) } defer rows.Close() for rows.Next() { aa := &AltAdvancement{ CreatedAt: time.Now(), UpdatedAt: time.Now(), } var titleLevel interface{} err := rows.Scan( &aa.NodeID, &aa.MinLevel, &aa.SpellCRC, &aa.Name, &aa.Description, &aa.Group, &aa.Icon, &aa.Icon2, &aa.Col, &aa.Row, &aa.RankCost, &aa.MaxRank, &aa.RankPrereqID, &aa.RankPrereq, &aa.ClassReq, &aa.Tier, &aa.ReqPoints, &aa.ReqTreePoints, &aa.ClassName, &titleLevel, ) if err != nil { am.logger.LogError("alt_advancement", "Failed to scan AA row: %v", err) continue } // Handle nullable title level if titleLevel != nil { if tl, ok := titleLevel.(int64); ok { aa.TitleLevel = int8(tl) } } // Add to main index am.altAdvancements[aa.NodeID] = aa // Add to group index am.byGroup[aa.Group] = append(am.byGroup[aa.Group], aa) // Add to class index if class-specific if aa.ClassReq > 0 { am.byClass[aa.ClassReq] = append(am.byClass[aa.ClassReq], aa) } // Add to level index am.byLevel[aa.MinLevel] = append(am.byLevel[aa.MinLevel], aa) } return rows.Err() } // loadTreeNodes loads tree node configurations from database func (am *AAManager) loadTreeNodes(ctx context.Context) error { query := `SELECT class_id, tree_id, aa_tree_id FROM character_aa_tree_nodes ORDER BY tree_id` rows, err := am.db.Query(query) if err != nil { // Tree nodes may not exist in all databases - this is optional am.logger.LogWarning("alt_advancement", "Could not load tree nodes: %v", err) return nil } defer rows.Close() for rows.Next() { node := &TreeNodeData{} err := rows.Scan(&node.ClassID, &node.TreeID, &node.AATreeID) if err != nil { am.logger.LogError("alt_advancement", "Failed to scan tree node row: %v", err) continue } // Add to main index am.treeNodes[node.TreeID] = node // Add to class index am.treeNodesByClass[node.ClassID] = append(am.treeNodesByClass[node.ClassID], node) } return rows.Err() } // GetAltAdvancement retrieves an AA by node ID func (am *AAManager) GetAltAdvancement(nodeID int32) (*AltAdvancement, bool) { am.mu.RLock() defer am.mu.RUnlock() aa, exists := am.altAdvancements[nodeID] return aa, exists } // GetAltAdvancementsByGroup retrieves all AAs for a specific group/tab func (am *AAManager) GetAltAdvancementsByGroup(group int8) []*AltAdvancement { am.mu.RLock() defer am.mu.RUnlock() return am.byGroup[group] } // GetAltAdvancementsByClass retrieves all AAs for a specific class func (am *AAManager) GetAltAdvancementsByClass(classID int8) []*AltAdvancement { am.mu.RLock() defer am.mu.RUnlock() return am.byClass[classID] } // GetAltAdvancementsByLevel retrieves all AAs available at a specific level func (am *AAManager) GetAltAdvancementsByLevel(level int8) []*AltAdvancement { am.mu.RLock() defer am.mu.RUnlock() return am.byLevel[level] } // GetTreeNode retrieves a tree node by tree ID func (am *AAManager) GetTreeNode(treeID int32) (*TreeNodeData, bool) { am.mu.RLock() defer am.mu.RUnlock() node, exists := am.treeNodes[treeID] return node, exists } // GetTreeNodesByClass retrieves all tree nodes for a specific class func (am *AAManager) GetTreeNodesByClass(classID int32) []*TreeNodeData { am.mu.RLock() defer am.mu.RUnlock() return am.treeNodesByClass[classID] } // LoadPlayerAAState loads a player's complete AA state from the database func (am *AAManager) LoadPlayerAAState(ctx context.Context, characterID int32) (*PlayerAAState, error) { am.mu.Lock() defer am.mu.Unlock() // Check cache first if state, exists := am.playerStates[characterID]; exists { return state, nil } // Load from database state := &PlayerAAState{ CharacterID: characterID, ActiveTemplate: AA_TEMPLATE_CURRENT, Templates: make(map[int8]*AATemplate), Tabs: make(map[int8]*AATab), AAProgress: make(map[int32]*PlayerAAData), LastUpdate: time.Now(), } // Load basic AA points err := am.db.QueryRow(`SELECT total_aa_points, spent_aa_points, available_aa_points, banked_aa_points FROM characters WHERE id = ?`, characterID).Scan( &state.TotalPoints, &state.SpentPoints, &state.AvailablePoints, &state.BankedPoints) if err != nil { // Character might not have AA data yet - initialize defaults am.logger.LogDebug("alt_advancement", "No AA data for character %d, using defaults", characterID) } // Load player AA progress if err := am.loadPlayerAAProgress(ctx, characterID, state); err != nil { return nil, fmt.Errorf("failed to load AA progress: %v", err) } // Load templates if err := am.loadPlayerAATemplates(ctx, characterID, state); err != nil { return nil, fmt.Errorf("failed to load AA templates: %v", err) } // Initialize tabs if err := am.initializePlayerTabs(state); err != nil { return nil, fmt.Errorf("failed to initialize tabs: %v", err) } // Cache the loaded state am.playerStates[characterID] = state return state, nil } // loadPlayerAAProgress loads individual AA progress from database func (am *AAManager) loadPlayerAAProgress(ctx context.Context, characterID int32, state *PlayerAAState) error { query := `SELECT node_id, current_rank, points_spent, template_id, tab_id, order_id, purchased_at, updated_at FROM character_aa_progress WHERE character_id = ?` rows, err := am.db.Query(query, characterID) if err != nil { return err } defer rows.Close() for rows.Next() { progress := &PlayerAAData{CharacterID: characterID} err := rows.Scan(&progress.NodeID, &progress.CurrentRank, &progress.PointsSpent, &progress.TemplateID, &progress.TabID, &progress.Order, &progress.PurchasedAt, &progress.UpdatedAt) if err != nil { am.logger.LogError("alt_advancement", "Failed to scan AA progress: %v", err) continue } state.AAProgress[progress.NodeID] = progress } return rows.Err() } // loadPlayerAATemplates loads player's AA templates from database func (am *AAManager) loadPlayerAATemplates(ctx context.Context, characterID int32, state *PlayerAAState) error { // For now, just initialize with default templates for i := int8(1); i <= 8; i++ { template := &AATemplate{ TemplateID: i, Name: GetTemplateName(i), Description: fmt.Sprintf("Template %d", i), IsPersonal: i >= 1 && i <= 3, IsServer: i >= 4 && i <= 6, IsCurrent: i == AA_TEMPLATE_CURRENT, Entries: make([]*AAEntry, 0), CreatedAt: time.Now(), UpdatedAt: time.Now(), } state.Templates[i] = template } return nil } // initializePlayerTabs initializes AA tabs for a player func (am *AAManager) initializePlayerTabs(state *PlayerAAState) error { // Initialize standard tabs tabGroups := []struct { tabID int8 group int8 name string }{ {AA_CLASS, AA_CLASS, "Class"}, {AA_SUBCLASS, AA_SUBCLASS, "Subclass"}, {AA_SHADOW, AA_SHADOW, "Shadows"}, {AA_HEROIC, AA_HEROIC, "Heroic"}, {AA_TRADESKILL, AA_TRADESKILL, "Tradeskill"}, {AA_PRESTIGE, AA_PRESTIGE, "Prestige"}, {AA_TRADESKILL_PRESTIGE, AA_TRADESKILL_PRESTIGE, "Tradeskill Prestige"}, {AA_DRAGON, AA_DRAGON, "Dragon"}, {AA_DRAGONCLASS, AA_DRAGONCLASS, "Dragon Class"}, {AA_FARSEAS, AA_FARSEAS, "Far Seas"}, } for _, tabInfo := range tabGroups { tab := &AATab{ TabID: tabInfo.tabID, Group: tabInfo.group, Name: tabInfo.name, MaxAA: GetMaxAAForTab(tabInfo.group), PointsSpent: 0, PointsAvailable: 0, Nodes: am.byGroup[tabInfo.group], LastUpdate: time.Now(), } // Calculate points spent in this tab for _, progress := range state.AAProgress { if progress.TabID == tabInfo.tabID { tab.PointsSpent += progress.PointsSpent } } tab.PointsAvailable = tab.MaxAA - tab.PointsSpent state.Tabs[tabInfo.tabID] = tab } return nil } // PurchaseAA purchases an AA for a player func (am *AAManager) PurchaseAA(ctx context.Context, characterID int32, nodeID int32, targetRank int8, playerManager PlayerManager) error { am.mu.Lock() defer am.mu.Unlock() // Get AA data aa, exists := am.altAdvancements[nodeID] if !exists { return fmt.Errorf("AA node %d not found", nodeID) } // Load player state state, err := am.LoadPlayerAAState(ctx, characterID) if err != nil { return fmt.Errorf("failed to load player AA state: %v", err) } // Validate purchase if err := am.validateAAPurchase(state, aa, targetRank, playerManager); err != nil { return err } // Calculate cost currentRank := int8(0) if progress, exists := state.AAProgress[nodeID]; exists { currentRank = progress.CurrentRank } cost := int32(0) for rank := currentRank + 1; rank <= targetRank; rank++ { cost += int32(aa.RankCost) } // Spend points if err := playerManager.SpendAAPoints(characterID, cost); err != nil { return fmt.Errorf("failed to spend AA points: %v", err) } // Update progress if progress, exists := state.AAProgress[nodeID]; exists { progress.CurrentRank = targetRank progress.PointsSpent += cost progress.UpdatedAt = time.Now() } else { progress = &PlayerAAData{ CharacterID: characterID, NodeID: nodeID, CurrentRank: targetRank, PointsSpent: cost, TemplateID: state.ActiveTemplate, TabID: aa.Group, PurchasedAt: time.Now(), UpdatedAt: time.Now(), } state.AAProgress[nodeID] = progress } // Update player state state.SpentPoints += cost state.AvailablePoints -= cost state.NeedsSync = true // Save to database if err := am.savePlayerAAProgress(ctx, characterID, state.AAProgress[nodeID]); err != nil { am.logger.LogError("alt_advancement", "Failed to save AA progress: %v", err) return err } // Update statistics am.stats.TotalAAPurchases++ am.stats.TotalPointsSpent += int64(cost) am.stats.PopularAAs[nodeID]++ am.stats.TabUsage[aa.Group]++ am.logger.LogInfo("alt_advancement", "Character %d purchased AA %s rank %d for %d points", characterID, aa.Name, targetRank, cost) return nil } // validateAAPurchase validates if a player can purchase an AA func (am *AAManager) validateAAPurchase(state *PlayerAAState, aa *AltAdvancement, targetRank int8, playerManager PlayerManager) error { // Check if target rank is valid if targetRank > aa.MaxRank || targetRank < 1 { return fmt.Errorf("invalid target rank %d for AA %s (max: %d)", targetRank, aa.Name, aa.MaxRank) } // Check current rank currentRank := int8(0) if progress, exists := state.AAProgress[aa.NodeID]; exists { currentRank = progress.CurrentRank } if targetRank <= currentRank { return fmt.Errorf("AA %s already at or above rank %d (current: %d)", aa.Name, targetRank, currentRank) } // Calculate cost cost := int32(0) for rank := currentRank + 1; rank <= targetRank; rank++ { cost += int32(aa.RankCost) } // Check available points if state.AvailablePoints < cost { return fmt.Errorf("insufficient AA points: need %d, have %d", cost, state.AvailablePoints) } // Check level requirement level, err := playerManager.GetPlayerLevel(state.CharacterID) if err != nil { return fmt.Errorf("failed to get player level: %v", err) } if level < aa.MinLevel { return fmt.Errorf("player level %d below minimum required level %d", level, aa.MinLevel) } // Check class requirement if aa.ClassReq > 0 { playerClass, err := playerManager.GetPlayerClass(state.CharacterID) if err != nil { return fmt.Errorf("failed to get player class: %v", err) } if playerClass != aa.ClassReq { return fmt.Errorf("player class %d does not match required class %d", playerClass, aa.ClassReq) } } // Check prerequisite AA if aa.RankPrereqID > 0 { if prereqProgress, exists := state.AAProgress[aa.RankPrereqID]; exists { if prereqProgress.CurrentRank < aa.RankPrereq { return fmt.Errorf("prerequisite AA %d requires rank %d (current: %d)", aa.RankPrereqID, aa.RankPrereq, prereqProgress.CurrentRank) } } else { return fmt.Errorf("prerequisite AA %d not purchased (requires rank %d)", aa.RankPrereqID, aa.RankPrereq) } } // Check tree points requirement if aa.ReqTreePoints > 0 { treePoints := int16(0) for _, progress := range state.AAProgress { if tabAA, exists := am.altAdvancements[progress.NodeID]; exists && tabAA.Group == aa.Group { treePoints += int16(progress.PointsSpent) } } if treePoints < aa.ReqTreePoints { return fmt.Errorf("insufficient points in tree: need %d, have %d", aa.ReqTreePoints, treePoints) } } return nil } // savePlayerAAProgress saves individual AA progress to database func (am *AAManager) savePlayerAAProgress(ctx context.Context, characterID int32, progress *PlayerAAData) error { query := `INSERT INTO character_aa_progress (character_id, node_id, current_rank, points_spent, template_id, tab_id, order_id, purchased_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE current_rank = VALUES(current_rank), points_spent = VALUES(points_spent), updated_at = VALUES(updated_at)` _, err := am.db.Exec(query, characterID, progress.NodeID, progress.CurrentRank, progress.PointsSpent, progress.TemplateID, progress.TabID, progress.Order, progress.PurchasedAt, progress.UpdatedAt) if err != nil { am.stats.DatabaseErrors++ return fmt.Errorf("failed to save AA progress: %v", err) } am.stats.DatabaseQueries++ return nil } // SavePlayerAAState saves a player's complete AA state to database func (am *AAManager) SavePlayerAAState(ctx context.Context, characterID int32) error { am.mu.RLock() defer am.mu.RUnlock() state, exists := am.playerStates[characterID] if !exists { return fmt.Errorf("player AA state not loaded for character %d", characterID) } if !state.NeedsSync { return nil // No changes to save } // Save basic AA points to characters table _, err := am.db.Exec(`UPDATE characters SET total_aa_points = ?, spent_aa_points = ?, available_aa_points = ?, banked_aa_points = ? WHERE id = ?`, state.TotalPoints, state.SpentPoints, state.AvailablePoints, state.BankedPoints, characterID) if err != nil { am.stats.DatabaseErrors++ return fmt.Errorf("failed to save AA points: %v", err) } // Save all AA progress for _, progress := range state.AAProgress { if err := am.savePlayerAAProgress(ctx, characterID, progress); err != nil { return err } } state.NeedsSync = false state.LastUpdate = time.Now() am.logger.LogDebug("alt_advancement", "Saved AA state for character %d", characterID) return nil } // GetAAListPacket builds and returns an AA list packet for a client (matches C++ API) func (am *AAManager) GetAAListPacket(characterID int32, clientVersion uint32) ([]byte, error) { am.mu.RLock() defer am.mu.RUnlock() // Load player state if not cached state, exists := am.playerStates[characterID] if !exists { return nil, fmt.Errorf("player AA state not loaded for character %d", characterID) } // Get packet structure packet, exists := packets.GetPacket("AdventureList") if !exists { am.stats.PacketErrors++ return nil, fmt.Errorf("failed to get AdventureList packet structure: packet not found") } // Build available AAs list availableAAs := make([]*AltAdvancement, 0) for _, aa := range am.altAdvancements { // Filter based on player's class, level, etc. if am.isAAAvailableToPlayer(aa, state) { availableAAs = append(availableAAs, aa) } } // Build packet data map data := map[string]any{ "num_aa": uint16(len(availableAAs)), "character_id": uint32(characterID), "total_points": uint32(state.TotalPoints), "spent_points": uint32(state.SpentPoints), "available_points": uint32(state.AvailablePoints), } // Add AA entries as an array aaList := make([]map[string]any, len(availableAAs)) for i, aa := range availableAAs { currentRank := int8(0) if progress, exists := state.AAProgress[aa.NodeID]; exists { currentRank = progress.CurrentRank } aaList[i] = map[string]any{ "aa_id": uint32(aa.NodeID), "name": aa.Name, "description": aa.Description, "icon": uint16(aa.Icon), "group": uint8(aa.Group), "current_rank": uint8(currentRank), "max_rank": uint8(aa.MaxRank), "cost": uint8(aa.RankCost), "min_level": uint8(aa.MinLevel), } } data["aa_list"] = aaList // Build packet using the correct interface builder := packets.NewPacketBuilder(packet, clientVersion, 0) packetData, err := builder.Build(data) if err != nil { am.stats.PacketErrors++ return nil, fmt.Errorf("failed to build AA packet: %v", err) } am.stats.PacketsSent++ return packetData, nil } // DisplayAA processes AA template changes and sends updated AA list (matches C++ API) func (am *AAManager) DisplayAA(characterID int32, newTemplate int8, changeMode int8, clientVersion uint32) ([]byte, error) { am.logger.LogDebug("alt_advancement", "DisplayAA called for character %d, template %d, mode %d", characterID, newTemplate, changeMode) // For now, delegate to GetAAListPacket // In the future, this could handle template switching logic return am.GetAAListPacket(characterID, clientVersion) } // SendAAListPacket is a convenience wrapper that calls GetAAListPacket for compatibility func (am *AAManager) SendAAListPacket(characterID int32, clientVersion uint32) ([]byte, error) { return am.GetAAListPacket(characterID, clientVersion) } // isAAAvailableToPlayer checks if an AA is available to a player func (am *AAManager) isAAAvailableToPlayer(aa *AltAdvancement, state *PlayerAAState) bool { // Add logic to filter AAs based on player requirements // For now, return true for all AAs return true } // GetPlayerAAStats returns statistics for a specific player func (am *AAManager) GetPlayerAAStats(characterID int32) map[string]interface{} { am.mu.RLock() defer am.mu.RUnlock() state, exists := am.playerStates[characterID] if !exists { return nil } return map[string]interface{}{ "character_id": characterID, "total_points": state.TotalPoints, "spent_points": state.SpentPoints, "available_points": state.AvailablePoints, "banked_points": state.BankedPoints, "active_template": state.ActiveTemplate, "total_aas_purchased": len(state.AAProgress), "last_update": state.LastUpdate, } } // GetSystemStats returns overall system statistics func (am *AAManager) GetSystemStats() *AAManagerStats { am.mu.RLock() defer am.mu.RUnlock() // Update derived statistics am.stats.ActivePlayers = int64(len(am.playerStates)) if am.stats.TotalAAPurchases > 0 { am.stats.AveragePointsSpent = float64(am.stats.TotalPointsSpent) / float64(am.stats.TotalAAPurchases) } am.stats.LastStatsUpdate = time.Now() return &am.stats } // autoSaveLoop runs in background to periodically save player states func (am *AAManager) autoSaveLoop() { ticker := time.NewTicker(am.config.SaveInterval) defer ticker.Stop() for { select { case <-ticker.C: am.saveAllPlayerStates() case <-am.stopChan: return } } } // saveAllPlayerStates saves all cached player states that need syncing func (am *AAManager) saveAllPlayerStates() { am.mu.RLock() defer am.mu.RUnlock() ctx := context.Background() saved := 0 for characterID, state := range am.playerStates { if state.NeedsSync { if err := am.SavePlayerAAState(ctx, characterID); err != nil { am.logger.LogError("alt_advancement", "Failed to auto-save AA state for character %d: %v", characterID, err) } else { saved++ } } } if saved > 0 { am.logger.LogDebug("alt_advancement", "Auto-saved AA states for %d characters", saved) } } // Shutdown gracefully shuts down the AA manager func (am *AAManager) Shutdown(ctx context.Context) error { am.logger.LogInfo("alt_advancement", "Shutting down AA manager") // Stop background processes close(am.stopChan) // Save all player states am.saveAllPlayerStates() // Clear caches am.mu.Lock() am.playerStates = make(map[int32]*PlayerAAState) am.mu.Unlock() am.logger.LogInfo("alt_advancement", "AA manager shutdown complete") return nil } // DefaultAAConfig returns default configuration for the AA system func DefaultAAConfig() AAConfig { return AAConfig{ EnableAASystem: DEFAULT_ENABLE_AA_SYSTEM, EnableCaching: DEFAULT_ENABLE_AA_CACHING, EnableValidation: DEFAULT_ENABLE_AA_VALIDATION, AAPointsPerLevel: DEFAULT_AA_POINTS_PER_LEVEL, MaxBankedPoints: DEFAULT_AA_MAX_BANKED_POINTS, EnableAABanking: true, CacheSize: AA_CACHE_SIZE, UpdateInterval: time.Duration(AA_UPDATE_INTERVAL) * time.Millisecond, BatchSize: AA_PROCESSING_BATCH_SIZE, DatabaseEnabled: true, AutoSave: true, SaveInterval: 5 * time.Minute, } } // NewPlayerAAState creates a new player AA state func NewPlayerAAState(characterID int32) *PlayerAAState { return &PlayerAAState{ CharacterID: characterID, TotalPoints: 0, SpentPoints: 0, AvailablePoints: 0, BankedPoints: 0, ActiveTemplate: AA_TEMPLATE_CURRENT, Templates: make(map[int8]*AATemplate), Tabs: make(map[int8]*AATab), AAProgress: make(map[int32]*PlayerAAData), LastUpdate: time.Now(), NeedsSync: false, } } // NewAATemplate creates a new AA template func NewAATemplate(templateID int8, name string) *AATemplate { return &AATemplate{ TemplateID: templateID, Name: name, Description: "", IsPersonal: templateID >= 1 && templateID <= 3, IsServer: templateID >= 4 && templateID <= 6, IsCurrent: templateID == AA_TEMPLATE_CURRENT, Entries: make([]*AAEntry, 0), CreatedAt: time.Now(), UpdatedAt: time.Now(), } } // NewAATab creates a new AA tab func NewAATab(tabID, group int8, name string) *AATab { return &AATab{ TabID: tabID, Group: group, Name: name, MaxAA: 0, ClassID: 0, ExpansionReq: EXPANSION_NONE, PointsSpent: 0, PointsAvailable: 0, Nodes: make([]*AltAdvancement, 0), LastUpdate: time.Now(), } }