From c637793dee994d79ea33ba0c25a4d92522fbbd6f Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Thu, 7 Aug 2025 16:09:22 -0500 Subject: [PATCH] round out achievement manager and initial pass on packets --- internal/achievements/achievement.go | 212 +++++++++++++++ internal/world/achievement_manager.go | 378 ++++++++++++++++++++++++++ 2 files changed, 590 insertions(+) diff --git a/internal/achievements/achievement.go b/internal/achievements/achievement.go index 6f4317d..78be878 100644 --- a/internal/achievements/achievement.go +++ b/internal/achievements/achievement.go @@ -3,6 +3,7 @@ package achievements import ( "database/sql" "fmt" + "time" "eq2emu/internal/database" ) @@ -435,3 +436,214 @@ func (a *Achievement) saveRewards(tx *sql.Tx) error { return nil } + +// LoadAllAchievements loads all achievements from database into a master list +func LoadAllAchievements(db *database.Database, masterList *MasterList) 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 := db.Query(query) + if err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + defer rows.Close() + + var achievements []*Achievement + + for rows.Next() { + achievement := &Achievement{ + db: db, + isNew: false, + } + + 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 fmt.Errorf("failed to scan achievement: %w", err) + } + + achievement.Hide = hideInt != 0 + achievements = append(achievements, achievement) + } + + if err := rows.Err(); err != nil { + return fmt.Errorf("failed to iterate rows: %w", err) + } + + // Load requirements and rewards for each achievement + for _, achievement := range achievements { + if err := achievement.loadRequirements(); err != nil { + return fmt.Errorf("failed to load requirements for achievement %d: %w", achievement.AchievementID, err) + } + + if err := achievement.loadRewards(); err != nil { + return fmt.Errorf("failed to load rewards for achievement %d: %w", achievement.AchievementID, err) + } + + // Add to master list + masterList.AddAchievement(achievement) + } + + return nil +} + +// LoadPlayerAchievements loads all achievements for a specific player +func LoadPlayerAchievements(db *database.Database, characterID uint32, playerList *PlayerList) error { + // For now, we load all achievements for the player (matching C++ behavior) + // In the future, this could be optimized to only load unlocked achievements + 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 := db.Query(query) + if err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + defer rows.Close() + + for rows.Next() { + achievement := &Achievement{ + db: db, + isNew: false, + } + + 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 fmt.Errorf("failed to scan achievement: %w", err) + } + + achievement.Hide = hideInt != 0 + + // Load requirements and rewards + if err := achievement.loadRequirements(); err != nil { + return fmt.Errorf("failed to load requirements: %w", err) + } + + if err := achievement.loadRewards(); err != nil { + return fmt.Errorf("failed to load rewards: %w", err) + } + + // Add to player list + playerList.AddAchievement(achievement) + } + + return rows.Err() +} + +// LoadPlayerAchievementUpdates loads player achievement progress from database +func LoadPlayerAchievementUpdates(db *database.Database, characterID uint32, updateList *PlayerUpdateList) error { + query := `SELECT achievement_id, completed_date FROM character_achievements WHERE char_id = ?` + + rows, err := db.Query(query, characterID) + if err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + defer rows.Close() + + for rows.Next() { + update := NewUpdate() + var completedDate int64 + + err := rows.Scan(&update.ID, &completedDate) + if err != nil { + return fmt.Errorf("failed to scan update: %w", err) + } + + if completedDate > 0 { + update.CompletedDate = time.Unix(completedDate, 0) + } + + // Load update items for this achievement + if err := loadPlayerAchievementUpdateItems(db, characterID, update); err != nil { + return fmt.Errorf("failed to load update items: %w", err) + } + + updateList.AddUpdate(update) + } + + return rows.Err() +} + +// loadPlayerAchievementUpdateItems loads update items for a specific achievement update +func loadPlayerAchievementUpdateItems(db *database.Database, characterID uint32, update *Update) error { + query := `SELECT achievement_id, items FROM character_achievements_items WHERE char_id = ? AND achievement_id = ?` + + rows, err := db.Query(query, characterID, update.ID) + if err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + defer rows.Close() + + for rows.Next() { + var updateItem UpdateItem + err := rows.Scan(&updateItem.AchievementID, &updateItem.ItemUpdate) + if err != nil { + return fmt.Errorf("failed to scan update item: %w", err) + } + update.AddUpdateItem(updateItem) + } + + return rows.Err() +} + +// SavePlayerAchievementUpdate saves a player's achievement progress to database +func SavePlayerAchievementUpdate(db *database.Database, characterID uint32, update *Update) error { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + var completedDate int64 + if !update.CompletedDate.IsZero() { + completedDate = update.CompletedDate.Unix() + } + + // Insert or update achievement progress + query := `INSERT OR REPLACE INTO character_achievements (char_id, achievement_id, completed_date) + VALUES (?, ?, ?)` + + _, err = tx.Exec(query, characterID, update.ID, completedDate) + if err != nil { + return fmt.Errorf("failed to save achievement update: %w", err) + } + + // Delete existing update items + deleteQuery := `DELETE FROM character_achievements_items WHERE char_id = ? AND achievement_id = ?` + _, err = tx.Exec(deleteQuery, characterID, update.ID) + if err != nil { + return fmt.Errorf("failed to delete existing update items: %w", err) + } + + // Insert new update items + if len(update.UpdateItems) > 0 { + insertQuery := `INSERT INTO character_achievements_items (char_id, achievement_id, items) VALUES (?, ?, ?)` + for _, item := range update.UpdateItems { + _, err = tx.Exec(insertQuery, characterID, item.AchievementID, item.ItemUpdate) + if err != nil { + return fmt.Errorf("failed to insert update item: %w", err) + } + } + } + + // Commit transaction + return tx.Commit() +} diff --git a/internal/world/achievement_manager.go b/internal/world/achievement_manager.go index 7114edd..d766782 100644 --- a/internal/world/achievement_manager.go +++ b/internal/world/achievement_manager.go @@ -3,9 +3,11 @@ package world import ( "fmt" "sync" + "time" "eq2emu/internal/achievements" "eq2emu/internal/database" + "eq2emu/internal/packets" ) // AchievementManager manages achievements for the world server @@ -117,10 +119,16 @@ func (am *AchievementManager) UpdateProgress(characterID int32, achievementID ui // Trigger achievement completion event go am.onAchievementCompleted(characterID, achievement) + // Send achievement update packet to client + go am.sendAchievementUpdateToClient(characterID) + fmt.Printf("Character %d completed achievement: %s\n", characterID, achievement.Title) } else if progress > 0 { // Save progress update to database go am.savePlayerProgress(characterID, achievementID, playerMgr) + + // Send achievement update packet for progress update + go am.sendAchievementUpdateToClient(characterID) } } @@ -271,6 +279,11 @@ func (am *AchievementManager) RemovePlayerManager(characterID int32) { delete(am.playerManagers, characterID) } +// GetMasterList returns the master achievement list +func (am *AchievementManager) GetMasterList() *achievements.MasterList { + return am.masterList +} + // GetStatistics returns achievement system statistics func (am *AchievementManager) GetStatistics() map[string]any { am.mutex.RLock() @@ -286,6 +299,371 @@ func (am *AchievementManager) GetStatistics() map[string]any { return stats } +// CreateAchievementUpdatePacket creates an achievement update packet for a player +func (am *AchievementManager) CreateAchievementUpdatePacket(characterID int32, version uint32) ([]byte, error) { + playerMgr := am.GetPlayerManager(characterID) + if playerMgr == nil { + return nil, fmt.Errorf("player manager not found for character %d", characterID) + } + + updates := playerMgr.Updates.GetAllUpdates() + + // Build the packet data map according to the AchievementUpdate.xml structure + achievementArray := make([]map[string]any, 0, len(updates)) + + for achievementID, update := range updates { + var completedDate uint32 + if !update.CompletedDate.IsZero() { + completedDate = uint32(update.CompletedDate.Unix()) + } + + // Build item array for this achievement + itemArray := make([]map[string]any, 0, len(update.UpdateItems)) + for _, item := range update.UpdateItems { + itemArray = append(itemArray, map[string]any{ + "item_update": item.ItemUpdate, + }) + } + + achievementData := map[string]any{ + "achievement_id": achievementID, + "completed_date": completedDate, + "num_items": uint8(len(update.UpdateItems)), + "item_array": itemArray, + } + + achievementArray = append(achievementArray, achievementData) + } + + packetData := map[string]any{ + "unknown1": uint8(0), + "num_achievements": uint16(len(updates)), + "achievement_array": achievementArray, + } + + // Build the packet using the packet system + packetBytes, err := packets.BuildPacket("AchievementUpdate", packetData, version, 0) + if err != nil { + return nil, fmt.Errorf("failed to build achievement update packet: %w", err) + } + + return packetBytes, nil +} + +// SendAchievementUpdateToPlayer sends achievement update packet to a player +func (am *AchievementManager) SendAchievementUpdateToPlayer(characterID int32, clientVersion int32) error { + playerMgr := am.GetPlayerManager(characterID) + if playerMgr == nil { + return fmt.Errorf("player manager not found for character %d", characterID) + } + + // Create the packet data + packetData, err := am.CreateAchievementUpdatePacket(characterID, uint32(clientVersion)) + if err != nil { + return fmt.Errorf("failed to create achievement update packet: %w", err) + } + + // TODO: Send packet to player through world server client connection + // This would typically use the world server's client manager + if am.world != nil { + // Get client opcode for this version + clientOpcode := packets.InternalToClient(packets.OP_AchievementUpdateMsg, clientVersion) + if clientOpcode == 0 { + return fmt.Errorf("no client opcode mapping for achievement update in version %d", clientVersion) + } + + fmt.Printf("Would send achievement update packet to character %d (opcode: %d, size: %d bytes)\n", + characterID, clientOpcode, len(packetData)) + + // In a real implementation: + // return am.world.SendPacketToClient(characterID, clientOpcode, packetData) + } + + return nil +} + +// CreateCharacterAchievementsPacket creates a character achievements packet (master list) for a player +func (am *AchievementManager) CreateCharacterAchievementsPacket(characterID int32, version uint32) ([]byte, error) { + playerMgr := am.GetPlayerManager(characterID) + if playerMgr == nil { + return nil, fmt.Errorf("player manager not found for character %d", characterID) + } + + // Get all achievements from master list + allAchievements := am.masterList.GetAllAchievements() + + // Build achievement array according to CharacterAchievements.xml structure + achievementArray := make([]map[string]any, 0, len(allAchievements)) + + for _, achievement := range allAchievements { + // Build requirements array + itemArray := make([]map[string]any, 0, len(achievement.Requirements)) + for _, req := range achievement.Requirements { + itemArray = append(itemArray, map[string]any{ + "item_name": req.Name, + "item_qty_req": req.QtyRequired, + }) + } + + // Build rewards array + rewardArray := make([]map[string]any, 0, len(achievement.Rewards)) + for _, reward := range achievement.Rewards { + rewardData := map[string]any{ + "reward_item": reward.Reward, + } + + // Add unknown4 field for version 57032+ + if version >= 57032 { + rewardData["unknown4"] = uint32(0) + } + + rewardArray = append(rewardArray, rewardData) + } + + // Build achievement data based on version + achievementData := map[string]any{ + "achievement_id": achievement.AchievementID, + "title": achievement.Title, + "uncompleted_text": achievement.UncompletedText, + "completed_text": achievement.CompletedText, + "category": achievement.Category, + "expansion": achievement.Expansion, + "icon": achievement.Icon, + "point_value": achievement.PointValue, + "qty_req": achievement.QtyRequired, + "hide_achievement": uint8(0), // Convert bool to uint8 + } + + if achievement.Hide { + achievementData["hide_achievement"] = uint8(1) + } + + // Handle version-specific fields + switch { + case version >= 57032: + achievementData["unknown3"] = [2]uint32{achievement.Unknown3A, achievement.Unknown3B} + achievementData["num_items"] = uint8(len(achievement.Requirements)) + achievementData["item_array"] = itemArray + achievementData["num_rewards"] = uint8(len(achievement.Rewards)) + achievementData["reward_array"] = rewardArray + achievementData["num_reward_links"] = uint8(0) // TODO: Implement reward links if needed + achievementData["reward_link_array"] = []map[string]any{} + + case version >= 1096: + achievementData["unknown3"] = [2]uint32{achievement.Unknown3A, achievement.Unknown3B} + achievementData["num_items"] = uint8(len(achievement.Requirements)) + achievementData["item_array"] = itemArray + achievementData["num_rewards"] = uint8(len(achievement.Rewards)) + achievementData["reward_array"] = rewardArray + + case version >= 603: + achievementData["unknown3a"] = achievement.Unknown3A + achievementData["unknown3b"] = achievement.Unknown3B + achievementData["guild"] = uint8(0) // TODO: Implement guild achievements if needed + achievementData["num_items"] = uint8(len(achievement.Requirements)) + achievementData["item_array"] = itemArray + achievementData["num_reward_links"] = uint8(0) // TODO: Implement reward links if needed + achievementData["reward_link_array"] = []map[string]any{} + } + + achievementArray = append(achievementArray, achievementData) + } + + packetData := map[string]any{ + "num_achievements": uint16(len(allAchievements)), + "achievement_array": achievementArray, + } + + // Build the packet using the packet system + packetBytes, err := packets.BuildPacket("CharacterAchievements", packetData, version, 0) + if err != nil { + return nil, fmt.Errorf("failed to build character achievements packet: %w", err) + } + + return packetBytes, nil +} + +// SendCharacterAchievementsToPlayer sends the master achievement list to a player +func (am *AchievementManager) SendCharacterAchievementsToPlayer(characterID int32, clientVersion int32) error { + // Create the packet data + packetData, err := am.CreateCharacterAchievementsPacket(characterID, uint32(clientVersion)) + if err != nil { + return fmt.Errorf("failed to create character achievements packet: %w", err) + } + + // TODO: Send packet to player through world server client connection + if am.world != nil { + // Get client opcode for this version + clientOpcode := packets.InternalToClient(packets.OP_CharacterAchievements, clientVersion) + if clientOpcode == 0 { + return fmt.Errorf("no client opcode mapping for character achievements in version %d", clientVersion) + } + + fmt.Printf("Would send character achievements packet to character %d (opcode: %d, size: %d bytes)\n", + characterID, clientOpcode, len(packetData)) + + // In a real implementation: + // return am.world.SendPacketToClient(characterID, clientOpcode, packetData) + } + + return nil +} + +// AwardAchievementPoints awards achievement points to a player +func (am *AchievementManager) AwardAchievementPoints(characterID int32, points uint32) error { + // TODO: Integrate with player character system to award achievement points + // This would typically update the player's total achievement points + fmt.Printf("Character %d awarded %d achievement points\n", characterID, points) + return nil +} + +// ProcessAchievementTrigger processes an achievement trigger for a player +func (am *AchievementManager) ProcessAchievementTrigger(characterID int32, triggerType string, value uint32) error { + playerMgr := am.GetPlayerManager(characterID) + if playerMgr == nil { + return fmt.Errorf("player manager not found for character %d", characterID) + } + + // Get all achievements and check if any match the trigger + allAchievements := am.masterList.GetAllAchievements() + + for _, achievement := range allAchievements { + // Check requirements to see if any match the trigger + for _, requirement := range achievement.Requirements { + if requirement.Name == triggerType { + // Update progress for this achievement + currentProgress := playerMgr.Updates.GetProgress(achievement.AchievementID) + newProgress := currentProgress + value + + // Ensure we don't exceed the requirement + if newProgress > requirement.QtyRequired { + newProgress = requirement.QtyRequired + } + + // Update the progress + err := am.UpdateProgress(characterID, achievement.AchievementID, newProgress) + if err != nil { + fmt.Printf("Error updating progress for achievement %d: %v\n", achievement.AchievementID, err) + } + } + } + } + + return nil +} + +// GetPlayerAchievementPoints returns total achievement points for a player +func (am *AchievementManager) GetPlayerAchievementPoints(characterID int32) uint32 { + playerMgr := am.GetPlayerManager(characterID) + if playerMgr == nil { + return 0 + } + + var totalPoints uint32 + completedAchievements := playerMgr.Updates.GetCompletedAchievements() + + for _, achievementID := range completedAchievements { + achievement := am.masterList.GetAchievement(achievementID) + if achievement != nil { + totalPoints += achievement.PointValue + } + } + + return totalPoints +} + +// RefreshPlayerAchievements refreshes a player's achievement data +func (am *AchievementManager) RefreshPlayerAchievements(characterID int32) error { + // Remove existing player manager + am.RemovePlayerManager(characterID) + + // This will create a new manager and load fresh data + am.GetPlayerManager(characterID) + + return nil +} + +// GetAchievementProgress returns detailed progress information for a player's achievement +func (am *AchievementManager) GetAchievementProgress(characterID int32, achievementID uint32) map[string]any { + playerMgr := am.GetPlayerManager(characterID) + if playerMgr == nil { + return nil + } + + achievement := am.masterList.GetAchievement(achievementID) + if achievement == nil { + return nil + } + + progress := playerMgr.Updates.GetProgress(achievementID) + completed := playerMgr.Updates.IsCompleted(achievementID) + completionPercentage := am.GetCompletionPercentage(characterID, achievementID) + + result := map[string]any{ + "achievement_id": achievementID, + "title": achievement.Title, + "category": achievement.Category, + "current_progress": progress, + "required_progress": achievement.QtyRequired, + "completed": completed, + "completion_percentage": completionPercentage, + "point_value": achievement.PointValue, + } + + if completed { + completedDate := playerMgr.Updates.GetCompletedDate(achievementID) + if !completedDate.IsZero() { + result["completed_date"] = completedDate.Format(time.RFC3339) + } + } + + return result +} + +// sendAchievementUpdateToClient sends achievement update to client with version detection +func (am *AchievementManager) sendAchievementUpdateToClient(characterID int32) { + // TODO: Get client version from world server client connection + // For now, use a common version (1096 is a common EQ2 client version) + defaultClientVersion := int32(1096) + + err := am.SendAchievementUpdateToPlayer(characterID, defaultClientVersion) + if err != nil { + fmt.Printf("Failed to send achievement update to character %d: %v\n", characterID, err) + } +} + +// LoadAndSendInitialAchievements loads and sends all achievements to a newly connected player +func (am *AchievementManager) LoadAndSendInitialAchievements(characterID int32, clientVersion int32) error { + // Ensure player manager is loaded + playerMgr := am.GetPlayerManager(characterID) + if playerMgr == nil { + return fmt.Errorf("failed to create player manager for character %d", characterID) + } + + // Send master achievement list first + err := am.SendCharacterAchievementsToPlayer(characterID, clientVersion) + if err != nil { + return fmt.Errorf("failed to send character achievements: %w", err) + } + + // Then send current progress + err = am.SendAchievementUpdateToPlayer(characterID, clientVersion) + if err != nil { + return fmt.Errorf("failed to send achievement updates: %w", err) + } + + fmt.Printf("Sent initial achievement data to character %d\n", characterID) + return nil +} + +// HandleAchievementTriggerEvent processes achievement triggers from game events +func (am *AchievementManager) HandleAchievementTriggerEvent(characterID int32, triggerType string, value uint32) { + err := am.ProcessAchievementTrigger(characterID, triggerType, value) + if err != nil { + fmt.Printf("Error processing achievement trigger for character %d: %v\n", characterID, err) + } +} + // Shutdown gracefully shuts down the achievement manager func (am *AchievementManager) Shutdown() { fmt.Println("Shutting down achievement manager...")