package leaderboard import ( "fmt" "sort" "strings" "time" "knowfoolery/backend/shared/types" "knowfoolery/backend/shared/errors" "knowfoolery/backend/services/leaderboard-service/internal/domain/valueobjects" ) // Leaderboard represents a leaderboard aggregate type Leaderboard struct { // Identity id types.LeaderboardID name string description string // Configuration category types.LeaderboardCategory metric types.LeaderboardMetric timeframe types.TimeFrame maxEntries int // Entries entries []*valueobjects.LeaderboardEntry // Status and lifecycle status types.LeaderboardStatus isActive bool isFrozen bool // Time boundaries startTime types.Timestamp endTime *types.Timestamp // Update tracking lastUpdated types.Timestamp lastReset *types.Timestamp // Competition settings allowTies bool resetRules *valueobjects.ResetRules // Display settings displaySettings *valueobjects.DisplaySettings // Statistics statistics *valueobjects.LeaderboardStatistics // Lifecycle createdAt types.Timestamp updatedAt types.Timestamp version int } // NewLeaderboard creates a new leaderboard func NewLeaderboard( name string, description string, category types.LeaderboardCategory, metric types.LeaderboardMetric, timeframe types.TimeFrame, ) (*Leaderboard, error) { // Validate inputs if err := validateLeaderboardName(name); err != nil { return nil, fmt.Errorf("invalid name: %w", err) } if description == "" { return nil, errors.ErrValidationFailed("description", "description cannot be empty") } if len(description) > types.MaxLeaderboardDescriptionLength { return nil, errors.ErrValidationFailed("description", fmt.Sprintf("description too long (max %d characters)", types.MaxLeaderboardDescriptionLength)) } now := types.NewTimestamp() // Create default settings displaySettings := valueobjects.NewDefaultDisplaySettings() resetRules := valueobjects.NewResetRules(timeframe) statistics := valueobjects.NewLeaderboardStatistics() leaderboard := &Leaderboard{ id: types.NewLeaderboardID(), name: strings.TrimSpace(name), description: strings.TrimSpace(description), category: category, metric: metric, timeframe: timeframe, maxEntries: types.DefaultMaxLeaderboardEntries, entries: make([]*valueobjects.LeaderboardEntry, 0), status: types.LeaderboardStatusActive, isActive: true, isFrozen: false, startTime: now, endTime: nil, lastUpdated: now, lastReset: nil, allowTies: true, resetRules: resetRules, displaySettings: displaySettings, statistics: statistics, createdAt: now, updatedAt: now, version: 1, } return leaderboard, nil } // NewLeaderboardWithID creates a leaderboard with specific ID (for loading from persistence) func NewLeaderboardWithID( id types.LeaderboardID, name string, description string, category types.LeaderboardCategory, metric types.LeaderboardMetric, timeframe types.TimeFrame, createdAt types.Timestamp, ) (*Leaderboard, error) { if id.IsEmpty() { return nil, errors.ErrValidationFailed("id", "leaderboard ID cannot be empty") } leaderboard, err := NewLeaderboard(name, description, category, metric, timeframe) if err != nil { return nil, err } leaderboard.id = id leaderboard.createdAt = createdAt leaderboard.updatedAt = createdAt return leaderboard, nil } // Getters // ID returns the leaderboard's unique identifier func (l *Leaderboard) ID() types.LeaderboardID { return l.id } // Name returns the leaderboard name func (l *Leaderboard) Name() string { return l.name } // Description returns the leaderboard description func (l *Leaderboard) Description() string { return l.description } // Category returns the leaderboard category func (l *Leaderboard) Category() types.LeaderboardCategory { return l.category } // Metric returns the leaderboard metric func (l *Leaderboard) Metric() types.LeaderboardMetric { return l.metric } // Timeframe returns the leaderboard timeframe func (l *Leaderboard) Timeframe() types.TimeFrame { return l.timeframe } // MaxEntries returns the maximum number of entries func (l *Leaderboard) MaxEntries() int { return l.maxEntries } // Entries returns all leaderboard entries func (l *Leaderboard) Entries() []*valueobjects.LeaderboardEntry { // Return copy to prevent mutation entries := make([]*valueobjects.LeaderboardEntry, len(l.entries)) copy(entries, l.entries) return entries } // Status returns the leaderboard status func (l *Leaderboard) Status() types.LeaderboardStatus { return l.status } // IsActive returns true if the leaderboard is active func (l *Leaderboard) IsActive() bool { return l.isActive && l.status == types.LeaderboardStatusActive } // IsFrozen returns true if the leaderboard is frozen func (l *Leaderboard) IsFrozen() bool { return l.isFrozen } // StartTime returns when the leaderboard started func (l *Leaderboard) StartTime() types.Timestamp { return l.startTime } // EndTime returns when the leaderboard ended func (l *Leaderboard) EndTime() *types.Timestamp { return l.endTime } // LastUpdated returns when the leaderboard was last updated func (l *Leaderboard) LastUpdated() types.Timestamp { return l.lastUpdated } // LastReset returns when the leaderboard was last reset func (l *Leaderboard) LastReset() *types.Timestamp { return l.lastReset } // AllowTies returns true if ties are allowed func (l *Leaderboard) AllowTies() bool { return l.allowTies } // ResetRules returns the reset rules func (l *Leaderboard) ResetRules() *valueobjects.ResetRules { return l.resetRules } // DisplaySettings returns the display settings func (l *Leaderboard) DisplaySettings() *valueobjects.DisplaySettings { return l.displaySettings } // Statistics returns the leaderboard statistics func (l *Leaderboard) Statistics() *valueobjects.LeaderboardStatistics { return l.statistics } // CreatedAt returns the creation timestamp func (l *Leaderboard) CreatedAt() types.Timestamp { return l.createdAt } // UpdatedAt returns the last update timestamp func (l *Leaderboard) UpdatedAt() types.Timestamp { return l.updatedAt } // Version returns the current version func (l *Leaderboard) Version() int { return l.version } // Business methods // UpdateScore updates or adds a user's score to the leaderboard func (l *Leaderboard) UpdateScore( userID types.UserID, playerName string, score int64, metadata map[string]interface{}, ) error { if !l.IsActive() { return errors.ErrOperationNotAllowed("update score", "leaderboard is not active") } if l.isFrozen { return errors.ErrOperationNotAllowed("update score", "leaderboard is frozen") } if userID.IsEmpty() { return errors.ErrValidationFailed("user_id", "user ID cannot be empty") } // Check if entry already exists existingEntry := l.findEntryByUserID(userID) if existingEntry != nil { // Update existing entry oldScore := existingEntry.Score() err := existingEntry.UpdateScore(score, metadata) if err != nil { return fmt.Errorf("failed to update entry: %w", err) } // Update statistics l.statistics.RecordScoreUpdate(oldScore, score) } else { // Create new entry newEntry, err := valueobjects.NewLeaderboardEntry( userID, playerName, score, metadata) if err != nil { return fmt.Errorf("failed to create entry: %w", err) } l.entries = append(l.entries, newEntry) l.statistics.RecordNewEntry(score) } // Re-sort entries l.sortEntries() // Trim to max entries if needed l.trimToMaxEntries() // Update ranks l.updateRanks() l.lastUpdated = types.NewTimestamp() l.markUpdated() return nil } // RemoveUser removes a user from the leaderboard func (l *Leaderboard) RemoveUser(userID types.UserID) error { if userID.IsEmpty() { return errors.ErrValidationFailed("user_id", "user ID cannot be empty") } for i, entry := range l.entries { if entry.UserID() == userID { // Remove the entry removedScore := entry.Score() l.entries = append(l.entries[:i], l.entries[i+1:]...) // Update statistics l.statistics.RecordEntryRemoval(removedScore) // Update ranks l.updateRanks() l.lastUpdated = types.NewTimestamp() l.markUpdated() return nil } } return errors.ErrNotFound("user", string(userID)) } // GetTopEntries returns the top N entries func (l *Leaderboard) GetTopEntries(count int) []*valueobjects.LeaderboardEntry { if count <= 0 { return []*valueobjects.LeaderboardEntry{} } if count > len(l.entries) { count = len(l.entries) } // Return copy to prevent mutation result := make([]*valueobjects.LeaderboardEntry, count) for i := 0; i < count; i++ { result[i] = l.entries[i] } return result } // GetUserRank returns the rank of a specific user func (l *Leaderboard) GetUserRank(userID types.UserID) (int, *valueobjects.LeaderboardEntry) { for _, entry := range l.entries { if entry.UserID() == userID { return entry.Rank(), entry } } return -1, nil // User not found } // GetEntriesAroundUser returns entries around a specific user's position func (l *Leaderboard) GetEntriesAroundUser( userID types.UserID, contextSize int, ) []*valueobjects.LeaderboardEntry { userRank, _ := l.GetUserRank(userID) if userRank == -1 { return []*valueobjects.LeaderboardEntry{} // User not found } // Calculate range around user (0-based indexing) userIndex := userRank - 1 startIndex := userIndex - contextSize endIndex := userIndex + contextSize // Clamp to valid range if startIndex < 0 { startIndex = 0 } if endIndex >= len(l.entries) { endIndex = len(l.entries) - 1 } // Extract entries result := make([]*valueobjects.LeaderboardEntry, 0, endIndex-startIndex+1) for i := startIndex; i <= endIndex; i++ { result = append(result, l.entries[i]) } return result } // Reset resets the leaderboard func (l *Leaderboard) Reset() error { if !l.IsActive() { return errors.ErrOperationNotAllowed("reset", "leaderboard is not active") } // Clear entries l.entries = make([]*valueobjects.LeaderboardEntry, 0) // Reset statistics l.statistics.Reset() // Update timestamps now := types.NewTimestamp() l.lastReset = &now l.lastUpdated = now l.markUpdated() return nil } // Freeze freezes the leaderboard to prevent further updates func (l *Leaderboard) Freeze() error { if !l.IsActive() { return errors.ErrOperationNotAllowed("freeze", "leaderboard is not active") } if l.isFrozen { return errors.ErrOperationNotAllowed("freeze", "leaderboard is already frozen") } l.isFrozen = true l.markUpdated() return nil } // Unfreeze unfreezes the leaderboard func (l *Leaderboard) Unfreeze() error { if !l.IsActive() { return errors.ErrOperationNotAllowed("unfreeze", "leaderboard is not active") } if !l.isFrozen { return errors.ErrOperationNotAllowed("unfreeze", "leaderboard is not frozen") } l.isFrozen = false l.markUpdated() return nil } // Archive archives the leaderboard func (l *Leaderboard) Archive() error { if l.status == types.LeaderboardStatusArchived { return errors.ErrOperationNotAllowed("archive", "leaderboard is already archived") } l.status = types.LeaderboardStatusArchived l.isActive = false l.isFrozen = true now := types.NewTimestamp() l.endTime = &now l.markUpdated() return nil } // UpdateConfiguration updates leaderboard configuration func (l *Leaderboard) UpdateConfiguration( name string, description string, maxEntries int, allowTies bool, ) error { if err := validateLeaderboardName(name); err != nil { return fmt.Errorf("invalid name: %w", err) } if description == "" { return errors.ErrValidationFailed("description", "description cannot be empty") } if len(description) > types.MaxLeaderboardDescriptionLength { return errors.ErrValidationFailed("description", fmt.Sprintf("description too long (max %d characters)", types.MaxLeaderboardDescriptionLength)) } if maxEntries < types.MinLeaderboardEntries || maxEntries > types.MaxLeaderboardEntries { return errors.ErrValidationFailed("max_entries", fmt.Sprintf("max entries must be between %d and %d", types.MinLeaderboardEntries, types.MaxLeaderboardEntries)) } l.name = strings.TrimSpace(name) l.description = strings.TrimSpace(description) l.maxEntries = maxEntries l.allowTies = allowTies // Trim entries if max entries was reduced if len(l.entries) > maxEntries { l.trimToMaxEntries() l.updateRanks() } l.markUpdated() return nil } // UpdateDisplaySettings updates display settings func (l *Leaderboard) UpdateDisplaySettings(settings *valueobjects.DisplaySettings) error { if settings == nil { return errors.ErrValidationFailed("display_settings", "display settings cannot be nil") } if err := settings.Validate(); err != nil { return fmt.Errorf("invalid display settings: %w", err) } l.displaySettings = settings l.markUpdated() return nil } // UpdateResetRules updates reset rules func (l *Leaderboard) UpdateResetRules(rules *valueobjects.ResetRules) error { if rules == nil { return errors.ErrValidationFailed("reset_rules", "reset rules cannot be nil") } if err := rules.Validate(); err != nil { return fmt.Errorf("invalid reset rules: %w", err) } l.resetRules = rules l.markUpdated() return nil } // Query methods // HasUser returns true if a user is on the leaderboard func (l *Leaderboard) HasUser(userID types.UserID) bool { return l.findEntryByUserID(userID) != nil } // GetEntryCount returns the number of entries func (l *Leaderboard) GetEntryCount() int { return len(l.entries) } // IsEmpty returns true if the leaderboard has no entries func (l *Leaderboard) IsEmpty() bool { return len(l.entries) == 0 } // IsFull returns true if the leaderboard is at maximum capacity func (l *Leaderboard) IsFull() bool { return len(l.entries) >= l.maxEntries } // GetScoreRange returns the score range (min, max) of the leaderboard func (l *Leaderboard) GetScoreRange() (int64, int64) { if len(l.entries) == 0 { return 0, 0 } // Entries are sorted by score (descending), so first is highest, last is lowest highest := l.entries[0].Score() lowest := l.entries[len(l.entries)-1].Score() return lowest, highest } // ShouldReset returns true if the leaderboard should be reset based on its rules func (l *Leaderboard) ShouldReset() bool { if l.resetRules == nil || l.lastReset == nil { return false } return l.resetRules.ShouldReset(*l.lastReset, types.NewTimestamp()) } // GetTimeUntilReset returns time until next reset func (l *Leaderboard) GetTimeUntilReset() *types.Duration { if l.resetRules == nil || l.lastReset == nil { return nil } return l.resetRules.GetTimeUntilReset(*l.lastReset, types.NewTimestamp()) } // Internal methods // findEntryByUserID finds an entry by user ID func (l *Leaderboard) findEntryByUserID(userID types.UserID) *valueobjects.LeaderboardEntry { for _, entry := range l.entries { if entry.UserID() == userID { return entry } } return nil } // sortEntries sorts entries by score (descending) func (l *Leaderboard) sortEntries() { sort.Slice(l.entries, func(i, j int) bool { // Sort by score descending if l.entries[i].Score() != l.entries[j].Score() { return l.entries[i].Score() > l.entries[j].Score() } // If scores are equal, sort by update time (earlier first for tie-breaking) return l.entries[i].UpdatedAt().Before(l.entries[j].UpdatedAt()) }) } // trimToMaxEntries removes excess entries beyond max limit func (l *Leaderboard) trimToMaxEntries() { if len(l.entries) > l.maxEntries { // Remove excess entries from the end (lowest scores) removed := l.entries[l.maxEntries:] l.entries = l.entries[:l.maxEntries] // Update statistics for _, entry := range removed { l.statistics.RecordEntryRemoval(entry.Score()) } } } // updateRanks updates the rank of all entries func (l *Leaderboard) updateRanks() { currentRank := 1 var previousScore *int64 for i, entry := range l.entries { if previousScore != nil && *previousScore != entry.Score() { // Score changed, update rank if l.allowTies { currentRank = i + 1 } else { currentRank++ } } entry.SetRank(currentRank) score := entry.Score() previousScore = &score if !l.allowTies { currentRank++ } } } // Validation // Validate performs comprehensive validation func (l *Leaderboard) Validate() *types.ValidationResult { result := types.NewValidationResult() // Validate ID if l.id.IsEmpty() { result.AddError("leaderboard ID cannot be empty") } // Validate name if err := validateLeaderboardName(l.name); err != nil { result.AddErrorf("invalid name: %v", err) } // Validate description if l.description == "" { result.AddError("description cannot be empty") } if len(l.description) > types.MaxLeaderboardDescriptionLength { result.AddErrorf("description too long (max %d characters)", types.MaxLeaderboardDescriptionLength) } // Validate max entries if l.maxEntries < types.MinLeaderboardEntries || l.maxEntries > types.MaxLeaderboardEntries { result.AddErrorf("max entries must be between %d and %d", types.MinLeaderboardEntries, types.MaxLeaderboardEntries) } // Validate entries count if len(l.entries) > l.maxEntries { result.AddError("entry count exceeds max entries") } // Validate display settings if l.displaySettings != nil { if settingsResult := l.displaySettings.Validate(); !settingsResult.IsValid() { for _, error := range settingsResult.Errors() { result.AddErrorf("display settings validation failed: %s", error) } } } // Validate reset rules if l.resetRules != nil { if rulesResult := l.resetRules.Validate(); !rulesResult.IsValid() { for _, error := range rulesResult.Errors() { result.AddErrorf("reset rules validation failed: %s", error) } } } // Validate statistics if l.statistics != nil { if statsResult := l.statistics.Validate(); !statsResult.IsValid() { for _, error := range statsResult.Errors() { result.AddErrorf("statistics validation failed: %s", error) } } } return result } // Helper methods // markUpdated updates the timestamp and version func (l *Leaderboard) markUpdated() { l.updatedAt = types.NewTimestamp() l.version++ } // ToSnapshot creates a read-only snapshot of the leaderboard func (l *Leaderboard) ToSnapshot() *LeaderboardSnapshot { // Copy entries entriesCopy := make([]*valueobjects.LeaderboardEntrySnapshot, len(l.entries)) for i, entry := range l.entries { entriesCopy[i] = entry.ToSnapshot() } return &LeaderboardSnapshot{ ID: l.id, Name: l.name, Description: l.description, Category: l.category, Metric: l.metric, Timeframe: l.timeframe, MaxEntries: l.maxEntries, Entries: entriesCopy, Status: l.status, IsActive: l.isActive, IsFrozen: l.isFrozen, StartTime: l.startTime, EndTime: l.endTime, LastUpdated: l.lastUpdated, LastReset: l.lastReset, AllowTies: l.allowTies, ResetRules: l.resetRules.ToSnapshot(), DisplaySettings: l.displaySettings.ToSnapshot(), Statistics: l.statistics.ToSnapshot(), CreatedAt: l.createdAt, UpdatedAt: l.updatedAt, Version: l.version, } } // GetSummary returns a human-readable summary func (l *Leaderboard) GetSummary() string { return fmt.Sprintf("%s (%s): %d entries, %s timeframe, %s", l.name, l.category, len(l.entries), l.timeframe, l.status) } // LeaderboardSnapshot represents a read-only view of leaderboard data type LeaderboardSnapshot struct { ID types.LeaderboardID `json:"id"` Name string `json:"name"` Description string `json:"description"` Category types.LeaderboardCategory `json:"category"` Metric types.LeaderboardMetric `json:"metric"` Timeframe types.TimeFrame `json:"timeframe"` MaxEntries int `json:"max_entries"` Entries []*valueobjects.LeaderboardEntrySnapshot `json:"entries"` Status types.LeaderboardStatus `json:"status"` IsActive bool `json:"is_active"` IsFrozen bool `json:"is_frozen"` StartTime types.Timestamp `json:"start_time"` EndTime *types.Timestamp `json:"end_time,omitempty"` LastUpdated types.Timestamp `json:"last_updated"` LastReset *types.Timestamp `json:"last_reset,omitempty"` AllowTies bool `json:"allow_ties"` ResetRules *valueobjects.ResetRulesSnapshot `json:"reset_rules"` DisplaySettings *valueobjects.DisplaySettingsSnapshot `json:"display_settings"` Statistics *valueobjects.LeaderboardStatisticsSnapshot `json:"statistics"` CreatedAt types.Timestamp `json:"created_at"` UpdatedAt types.Timestamp `json:"updated_at"` Version int `json:"version"` } // GetSummary returns a summary of the snapshot func (ls *LeaderboardSnapshot) GetSummary() string { return fmt.Sprintf("%s (%s): %d entries, %s timeframe", ls.Name, ls.Category, len(ls.Entries), ls.Timeframe) } // Validation helper functions func validateLeaderboardName(name string) error { name = strings.TrimSpace(name) if name == "" { return errors.ErrValidationFailed("name", "leaderboard name cannot be empty") } if len(name) < types.MinLeaderboardNameLength { return errors.ErrValidationFailed("name", fmt.Sprintf("leaderboard name too short (min %d characters)", types.MinLeaderboardNameLength)) } if len(name) > types.MaxLeaderboardNameLength { return errors.ErrValidationFailed("name", fmt.Sprintf("leaderboard name too long (max %d characters)", types.MaxLeaderboardNameLength)) } return nil }