eq2go/internal/alt_advancement/alt_advancement.go

1025 lines
30 KiB
Go

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(),
}
}