You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

833 lines
22 KiB
Go

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
}