eq2go/internal/achievements/achievements.go

724 lines
22 KiB
Go

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
}