package achievements import ( "context" "eq2emu/internal/database" "eq2emu/internal/packets" "fmt" "sync" "time" ) // Achievement represents an achievement definition type Achievement struct { mu sync.RWMutex ID uint32 AchievementID uint32 Title string UncompletedText string CompletedText string Category string Expansion string Icon uint16 PointValue uint32 QtyRequired uint32 Hide bool Unknown3A uint32 Unknown3B uint32 MaxVersion uint32 Requirements []Requirement Rewards []Reward } // Requirement represents a requirement for an achievement type Requirement struct { AchievementID uint32 Name string QtyRequired uint32 } // Reward represents a reward for completing an achievement type Reward struct { AchievementID uint32 Reward string } // PlayerAchievement represents a player's progress on an achievement type PlayerAchievement struct { mu sync.RWMutex CharacterID uint32 AchievementID uint32 Progress uint32 CompletedDate time.Time UpdateItems []UpdateItem } // UpdateItem represents progress update data for an achievement type UpdateItem struct { AchievementID uint32 ItemUpdate uint32 } // AchievementManager manages the achievement system type AchievementManager struct { mu sync.RWMutex db *database.Database achievements map[uint32]*Achievement // All achievements by ID categoryIndex map[string][]*Achievement // Achievements by category expansionIndex map[string][]*Achievement // Achievements by expansion playerAchievements map[uint32]map[uint32]*PlayerAchievement // characterID -> achievementID -> PlayerAchievement logger Logger config AchievementConfig } // Logger interface for achievement system logging type Logger interface { LogInfo(system, format string, args ...any) LogError(system, format string, args ...any) LogDebug(system, format string, args ...any) LogWarning(system, format string, args ...any) } // AchievementConfig contains achievement system configuration type AchievementConfig struct { EnablePacketUpdates bool AutoCompleteOnReached bool EnableStatistics bool MaxCachedPlayers int } // NewAchievementManager creates a new achievement manager func NewAchievementManager(db *database.Database, logger Logger, config AchievementConfig) *AchievementManager { return &AchievementManager{ db: db, achievements: make(map[uint32]*Achievement), categoryIndex: make(map[string][]*Achievement), expansionIndex: make(map[string][]*Achievement), playerAchievements: make(map[uint32]map[uint32]*PlayerAchievement), logger: logger, config: config, } } // Initialize loads achievement data and starts background processes func (am *AchievementManager) Initialize(ctx context.Context) error { am.mu.Lock() defer am.mu.Unlock() // If no database, initialize with empty data if am.db == nil { am.logger.LogInfo("achievements", "Loaded %d achievements", len(am.achievements)) return nil } // Load all achievements from database achievements, err := am.loadAchievementsFromDB(ctx) if err != nil { return fmt.Errorf("failed to load achievements: %w", err) } for _, achievement := range achievements { am.achievements[achievement.AchievementID] = achievement // Build category index if achievement.Category != "" { am.categoryIndex[achievement.Category] = append(am.categoryIndex[achievement.Category], achievement) } // Build expansion index if achievement.Expansion != "" { am.expansionIndex[achievement.Expansion] = append(am.expansionIndex[achievement.Expansion], achievement) } } am.logger.LogInfo("achievements", "Loaded %d achievements", len(am.achievements)) return nil } // GetAchievement returns an achievement by ID func (am *AchievementManager) GetAchievement(achievementID uint32) (*Achievement, bool) { am.mu.RLock() defer am.mu.RUnlock() achievement, exists := am.achievements[achievementID] return achievement, exists } // GetAllAchievements returns all achievements func (am *AchievementManager) GetAllAchievements() []*Achievement { am.mu.RLock() defer am.mu.RUnlock() achievements := make([]*Achievement, 0, len(am.achievements)) for _, achievement := range am.achievements { achievements = append(achievements, achievement) } return achievements } // GetAchievementsByCategory returns all achievements in a category func (am *AchievementManager) GetAchievementsByCategory(category string) []*Achievement { am.mu.RLock() defer am.mu.RUnlock() return am.categoryIndex[category] } // GetAchievementsByExpansion returns all achievements in an expansion func (am *AchievementManager) GetAchievementsByExpansion(expansion string) []*Achievement { am.mu.RLock() defer am.mu.RUnlock() return am.expansionIndex[expansion] } // GetCategories returns all unique categories func (am *AchievementManager) GetCategories() []string { am.mu.RLock() defer am.mu.RUnlock() categories := make([]string, 0, len(am.categoryIndex)) for category := range am.categoryIndex { categories = append(categories, category) } return categories } // GetExpansions returns all unique expansions func (am *AchievementManager) GetExpansions() []string { am.mu.RLock() defer am.mu.RUnlock() expansions := make([]string, 0, len(am.expansionIndex)) for expansion := range am.expansionIndex { expansions = append(expansions, expansion) } return expansions } // GetPlayerAchievements returns all achievements for a character func (am *AchievementManager) GetPlayerAchievements(characterID uint32) (map[uint32]*PlayerAchievement, error) { am.mu.RLock() playerAchievements, exists := am.playerAchievements[characterID] am.mu.RUnlock() if !exists { // If no database, return empty map if am.db == nil { return make(map[uint32]*PlayerAchievement), nil } // Load from database playerAchievements, err := am.loadPlayerAchievementsFromDB(context.Background(), characterID) if err != nil { return nil, fmt.Errorf("failed to load player achievements: %w", err) } am.mu.Lock() am.playerAchievements[characterID] = playerAchievements am.mu.Unlock() return playerAchievements, nil } return playerAchievements, nil } // GetPlayerAchievement returns a specific player achievement func (am *AchievementManager) GetPlayerAchievement(characterID, achievementID uint32) (*PlayerAchievement, error) { playerAchievements, err := am.GetPlayerAchievements(characterID) if err != nil { return nil, err } playerAchievement, exists := playerAchievements[achievementID] if !exists { return nil, nil // Not found, but no error } return playerAchievement, nil } // UpdatePlayerProgress updates a player's progress on an achievement func (am *AchievementManager) UpdatePlayerProgress(ctx context.Context, characterID, achievementID, progress uint32) error { achievement, exists := am.GetAchievement(achievementID) if !exists { return fmt.Errorf("achievement %d not found", achievementID) } am.mu.Lock() defer am.mu.Unlock() // Get or create player achievements map if am.playerAchievements[characterID] == nil { am.playerAchievements[characterID] = make(map[uint32]*PlayerAchievement) } // Get or create player achievement playerAchievement := am.playerAchievements[characterID][achievementID] if playerAchievement == nil { playerAchievement = &PlayerAchievement{ CharacterID: characterID, AchievementID: achievementID, Progress: 0, UpdateItems: []UpdateItem{}, } am.playerAchievements[characterID][achievementID] = playerAchievement } // Update progress playerAchievement.mu.Lock() oldProgress := playerAchievement.Progress playerAchievement.Progress = progress // Check if achievement should be completed if am.config.AutoCompleteOnReached && progress >= achievement.QtyRequired && playerAchievement.CompletedDate.IsZero() { playerAchievement.CompletedDate = time.Now() am.logger.LogInfo("achievements", "Character %d completed achievement %d", characterID, achievementID) } playerAchievement.mu.Unlock() // Save to database if available and progress changed if am.db != nil && oldProgress != progress { if err := am.savePlayerAchievementToDBInternal(ctx, playerAchievement); err != nil { return fmt.Errorf("failed to save player achievement progress: %w", err) } } am.logger.LogDebug("achievements", "Updated progress for character %d, achievement %d: %d/%d", characterID, achievementID, progress, achievement.QtyRequired) return nil } // CompletePlayerAchievement marks an achievement as completed for a player func (am *AchievementManager) CompletePlayerAchievement(ctx context.Context, characterID, achievementID uint32) error { _, exists := am.GetAchievement(achievementID) if !exists { return fmt.Errorf("achievement %d not found", achievementID) } am.mu.Lock() defer am.mu.Unlock() // Get or create player achievements map if am.playerAchievements[characterID] == nil { am.playerAchievements[characterID] = make(map[uint32]*PlayerAchievement) } // Get or create player achievement playerAchievement := am.playerAchievements[characterID][achievementID] if playerAchievement == nil { playerAchievement = &PlayerAchievement{ CharacterID: characterID, AchievementID: achievementID, Progress: 0, UpdateItems: []UpdateItem{}, } am.playerAchievements[characterID][achievementID] = playerAchievement } // Mark as completed playerAchievement.mu.Lock() wasCompleted := !playerAchievement.CompletedDate.IsZero() playerAchievement.CompletedDate = time.Now() playerAchievement.mu.Unlock() // Save to database if available and wasn't already completed if am.db != nil && !wasCompleted { if err := am.savePlayerAchievementToDBInternal(ctx, playerAchievement); err != nil { return fmt.Errorf("failed to save player achievement completion: %w", err) } } if !wasCompleted { am.logger.LogInfo("achievements", "Character %d completed achievement %d", characterID, achievementID) } return nil } // IsPlayerAchievementCompleted checks if a player has completed an achievement func (am *AchievementManager) IsPlayerAchievementCompleted(characterID, achievementID uint32) (bool, error) { playerAchievement, err := am.GetPlayerAchievement(characterID, achievementID) if err != nil { return false, err } if playerAchievement == nil { return false, nil } return !playerAchievement.CompletedDate.IsZero(), nil } // GetPlayerAchievementProgress returns a player's progress on an achievement func (am *AchievementManager) GetPlayerAchievementProgress(characterID, achievementID uint32) (uint32, error) { playerAchievement, err := am.GetPlayerAchievement(characterID, achievementID) if err != nil { return 0, err } if playerAchievement == nil { return 0, nil } return playerAchievement.Progress, nil } // SendPlayerAchievementsPacket sends a player's achievement list to client func (am *AchievementManager) SendPlayerAchievementsPacket(characterID uint32, clientVersion int32) error { if !am.config.EnablePacketUpdates { return nil // Packet updates disabled } playerAchievements, err := am.GetPlayerAchievements(characterID) if err != nil { return fmt.Errorf("failed to get player achievements: %w", err) } def, exists := packets.GetPacket("CharacterAchievements") if !exists { return fmt.Errorf("CharacterAchievements packet definition not found") } builder := packets.NewPacketBuilder(def, uint32(clientVersion), 0) // Build achievement array for packet achievementArray := make([]map[string]any, 0, len(playerAchievements)) for achievementID, playerAchievement := range playerAchievements { achievement, exists := am.GetAchievement(achievementID) if !exists { continue // Skip if achievement definition not found } achievementData := map[string]any{ "achievement_id": uint32(achievement.AchievementID), "title": achievement.Title, "completed_text": achievement.CompletedText, "uncompleted_text": achievement.UncompletedText, "category": achievement.Category, "expansion": achievement.Expansion, "icon": uint32(achievement.Icon), "point_value": achievement.PointValue, "progress": playerAchievement.Progress, "qty_required": achievement.QtyRequired, "completed": !playerAchievement.CompletedDate.IsZero(), "completed_date": uint32(playerAchievement.CompletedDate.Unix()), "hide_achievement": achievement.Hide, } achievementArray = append(achievementArray, achievementData) } packetData := map[string]any{ "num_achievements": uint16(len(achievementArray)), "achievement_array": achievementArray, } packet, err := builder.Build(packetData) if err != nil { return fmt.Errorf("failed to build packet: %w", err) } // TODO: Send packet to client when client interface is available _ = packet am.logger.LogDebug("achievements", "Built achievement list packet for character %d (%d achievements)", characterID, len(achievementArray)) return nil } // SendAchievementUpdatePacket sends an achievement update to a client func (am *AchievementManager) SendAchievementUpdatePacket(characterID, achievementID uint32, clientVersion int32) error { if !am.config.EnablePacketUpdates { return nil // Packet updates disabled } playerAchievement, err := am.GetPlayerAchievement(characterID, achievementID) if err != nil { return fmt.Errorf("failed to get player achievement: %w", err) } if playerAchievement == nil { return fmt.Errorf("player achievement not found") } achievement, exists := am.GetAchievement(achievementID) if !exists { return fmt.Errorf("achievement definition not found") } def, exists := packets.GetPacket("AchievementUpdateMsg") if !exists { return fmt.Errorf("AchievementUpdateMsg packet definition not found") } builder := packets.NewPacketBuilder(def, uint32(clientVersion), 0) packetData := map[string]any{ "achievement_id": uint32(achievement.AchievementID), "progress": playerAchievement.Progress, "qty_required": achievement.QtyRequired, "completed": !playerAchievement.CompletedDate.IsZero(), "completed_date": uint32(playerAchievement.CompletedDate.Unix()), } packet, err := builder.Build(packetData) if err != nil { return fmt.Errorf("failed to build packet: %w", err) } // TODO: Send packet to client when client interface is available _ = packet am.logger.LogDebug("achievements", "Built achievement update packet for character %d, achievement %d", characterID, achievementID) return nil } // GetPlayerStatistics returns achievement statistics for a player func (am *AchievementManager) GetPlayerStatistics(characterID uint32) (*PlayerAchievementStatistics, error) { playerAchievements, err := am.GetPlayerAchievements(characterID) if err != nil { return nil, err } stats := &PlayerAchievementStatistics{ CharacterID: characterID, TotalAchievements: uint32(len(am.achievements)), CompletedCount: 0, InProgressCount: 0, TotalPointsEarned: 0, TotalPointsAvailable: 0, CompletedByCategory: make(map[string]uint32), } // Calculate total points available for _, achievement := range am.achievements { stats.TotalPointsAvailable += achievement.PointValue } // Calculate player statistics for achievementID, playerAchievement := range playerAchievements { achievement, exists := am.GetAchievement(achievementID) if !exists { continue } if !playerAchievement.CompletedDate.IsZero() { stats.CompletedCount++ stats.TotalPointsEarned += achievement.PointValue stats.CompletedByCategory[achievement.Category]++ } else if playerAchievement.Progress > 0 { stats.InProgressCount++ } } return stats, nil } // PlayerAchievementStatistics contains achievement statistics for a player type PlayerAchievementStatistics struct { CharacterID uint32 TotalAchievements uint32 CompletedCount uint32 InProgressCount uint32 TotalPointsEarned uint32 TotalPointsAvailable uint32 CompletedByCategory map[string]uint32 } // Database operations (internal) func (am *AchievementManager) loadAchievementsFromDB(ctx context.Context) ([]*Achievement, error) { query := ` SELECT id, achievement_id, title, uncompleted_text, completed_text, category, expansion, icon, point_value, qty_req, hide_achievement, unknown3a, unknown3b, max_version FROM achievements ORDER BY achievement_id ` rows, err := am.db.Query(query) if err != nil { return nil, fmt.Errorf("failed to query achievements: %w", err) } defer rows.Close() achievements := make([]*Achievement, 0) for rows.Next() { achievement := &Achievement{ Requirements: []Requirement{}, Rewards: []Reward{}, } var hideInt int err := rows.Scan( &achievement.ID, &achievement.AchievementID, &achievement.Title, &achievement.UncompletedText, &achievement.CompletedText, &achievement.Category, &achievement.Expansion, &achievement.Icon, &achievement.PointValue, &achievement.QtyRequired, &hideInt, &achievement.Unknown3A, &achievement.Unknown3B, &achievement.MaxVersion, ) if err != nil { return nil, fmt.Errorf("failed to scan achievement: %w", err) } achievement.Hide = hideInt != 0 // Load requirements and rewards if err := am.loadAchievementRequirementsFromDB(ctx, achievement); err != nil { return nil, fmt.Errorf("failed to load requirements for achievement %d: %w", achievement.AchievementID, err) } if err := am.loadAchievementRewardsFromDB(ctx, achievement); err != nil { return nil, fmt.Errorf("failed to load rewards for achievement %d: %w", achievement.AchievementID, err) } achievements = append(achievements, achievement) } return achievements, nil } func (am *AchievementManager) loadAchievementRequirementsFromDB(_ context.Context, achievement *Achievement) error { query := ` SELECT achievement_id, name, qty_req FROM achievements_requirements WHERE achievement_id = ? ` rows, err := am.db.Query(query, achievement.AchievementID) if err != nil { return err } defer rows.Close() for rows.Next() { var req Requirement err := rows.Scan(&req.AchievementID, &req.Name, &req.QtyRequired) if err != nil { return err } achievement.Requirements = append(achievement.Requirements, req) } return rows.Err() } func (am *AchievementManager) loadAchievementRewardsFromDB(_ context.Context, achievement *Achievement) error { query := ` SELECT achievement_id, reward FROM achievements_rewards WHERE achievement_id = ? ` rows, err := am.db.Query(query, achievement.AchievementID) if err != nil { return err } defer rows.Close() for rows.Next() { var reward Reward err := rows.Scan(&reward.AchievementID, &reward.Reward) if err != nil { return err } achievement.Rewards = append(achievement.Rewards, reward) } return rows.Err() } func (am *AchievementManager) loadPlayerAchievementsFromDB(ctx context.Context, characterID uint32) (map[uint32]*PlayerAchievement, error) { query := ` SELECT achievement_id, completed_date FROM character_achievements WHERE char_id = ? ` rows, err := am.db.Query(query, characterID) if err != nil { return nil, fmt.Errorf("failed to query player achievements: %w", err) } defer rows.Close() playerAchievements := make(map[uint32]*PlayerAchievement) for rows.Next() { var achievementID uint32 var completedDate int64 err := rows.Scan(&achievementID, &completedDate) if err != nil { return nil, fmt.Errorf("failed to scan player achievement: %w", err) } playerAchievement := &PlayerAchievement{ CharacterID: characterID, AchievementID: achievementID, Progress: 0, UpdateItems: []UpdateItem{}, } if completedDate > 0 { playerAchievement.CompletedDate = time.Unix(completedDate, 0) } // Load update items if err := am.loadPlayerAchievementUpdateItemsFromDB(ctx, characterID, achievementID, playerAchievement); err != nil { return nil, fmt.Errorf("failed to load update items for character %d, achievement %d: %w", characterID, achievementID, err) } playerAchievements[achievementID] = playerAchievement } return playerAchievements, nil } func (am *AchievementManager) loadPlayerAchievementUpdateItemsFromDB(_ context.Context, characterID, achievementID uint32, playerAchievement *PlayerAchievement) error { query := ` SELECT achievement_id, items FROM character_achievements_items WHERE char_id = ? AND achievement_id = ? ` rows, err := am.db.Query(query, characterID, achievementID) if err != nil { return err } defer rows.Close() var totalProgress uint32 for rows.Next() { var updateItem UpdateItem err := rows.Scan(&updateItem.AchievementID, &updateItem.ItemUpdate) if err != nil { return err } playerAchievement.UpdateItems = append(playerAchievement.UpdateItems, updateItem) totalProgress += updateItem.ItemUpdate } playerAchievement.Progress = totalProgress return rows.Err() } func (am *AchievementManager) savePlayerAchievementToDBInternal(_ context.Context, playerAchievement *PlayerAchievement) error { var completedDate int64 if !playerAchievement.CompletedDate.IsZero() { completedDate = playerAchievement.CompletedDate.Unix() } // Insert or update achievement progress query := ` INSERT OR REPLACE INTO character_achievements (char_id, achievement_id, completed_date) VALUES (?, ?, ?) ` _, err := am.db.Exec(query, playerAchievement.CharacterID, playerAchievement.AchievementID, completedDate) if err != nil { return fmt.Errorf("failed to save player achievement: %w", err) } return nil } // Shutdown gracefully shuts down the achievement manager func (am *AchievementManager) Shutdown(ctx context.Context) error { am.logger.LogInfo("achievements", "Shutting down achievement manager") // Any cleanup would go here return nil }