1025 lines
30 KiB
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(),
|
|
}
|
|
} |