diff --git a/internal/chat/benchmark_test.go b/internal/chat/benchmark_test.go new file mode 100644 index 0000000..f24493a --- /dev/null +++ b/internal/chat/benchmark_test.go @@ -0,0 +1,371 @@ +package chat + +import ( + "fmt" + "testing" + + "eq2emu/internal/database" +) + +// Setup creates a master list with test data for benchmarking +func benchmarkSetup() *MasterList { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + + masterList := NewMasterList() + + // Add world channels + worldChannels := []string{ + "Auction", "Trade", "General", "OOC", "LFG", "Crafting", + "Roleplay", "Newbie", "Antonica", "Commonlands", + "Freeport", "Qeynos", "Kelethin", "Neriak", + } + + for i, name := range worldChannels { + ch := NewWithData(int32(i+1), name, ChannelTypeWorld, db) + if i%3 == 0 { + ch.SetLevelRestriction(10) // Some have level restrictions + } + if i%4 == 0 { + ch.SetRacesAllowed(1 << 1) // Some have race restrictions + } + masterList.AddChannel(ch) + + // Add some members to channels + if i%2 == 0 { + ch.JoinChannel(int32(1000 + i)) + } + if i%3 == 0 { + ch.JoinChannel(int32(2000 + i)) + } + } + + // Add custom channels + for i := 0; i < 50; i++ { + ch := NewWithData(int32(100+i), fmt.Sprintf("CustomChannel%d", i), ChannelTypeCustom, db) + if i%5 == 0 { + ch.SetLevelRestriction(20) + } + masterList.AddChannel(ch) + + // Add members to some custom channels + if i%4 == 0 { + ch.JoinChannel(int32(3000 + i)) + } + } + + return masterList +} + +func BenchmarkMasterList_AddChannel(b *testing.B) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ch := NewWithData(int32(i+10000), fmt.Sprintf("Channel%d", i), ChannelTypeWorld, db) + masterList.AddChannel(ch) + } +} + +func BenchmarkMasterList_GetChannel(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetChannel(int32(i%64 + 1)) + } +} + +func BenchmarkMasterList_GetChannelSafe(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetChannelSafe(int32(i%64 + 1)) + } +} + +func BenchmarkMasterList_HasChannel(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.HasChannel(int32(i%64 + 1)) + } +} + +func BenchmarkMasterList_FindChannelsByType(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if i%2 == 0 { + masterList.FindChannelsByType(ChannelTypeWorld) + } else { + masterList.FindChannelsByType(ChannelTypeCustom) + } + } +} + +func BenchmarkMasterList_GetWorldChannels(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetWorldChannels() + } +} + +func BenchmarkMasterList_GetCustomChannels(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetCustomChannels() + } +} + +func BenchmarkMasterList_GetChannelByName(b *testing.B) { + masterList := benchmarkSetup() + names := []string{"auction", "trade", "general", "ooc", "customchannel5", "customchannel15"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetChannelByName(names[i%len(names)]) + } +} + +func BenchmarkMasterList_FindChannelsByName(b *testing.B) { + masterList := benchmarkSetup() + searchTerms := []string{"Auction", "Custom", "Channel", "Trade", "General"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.FindChannelsByName(searchTerms[i%len(searchTerms)]) + } +} + +func BenchmarkMasterList_GetActiveChannels(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetActiveChannels() + } +} + +func BenchmarkMasterList_GetEmptyChannels(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetEmptyChannels() + } +} + +func BenchmarkMasterList_GetCompatibleChannels(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + level := int32(i%50 + 1) + race := int32(i%10 + 1) + class := int32(i%20 + 1) + masterList.GetCompatibleChannels(level, race, class) + } +} + +func BenchmarkMasterList_GetChannelsByMemberCount(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + memberCount := i % 5 // 0-4 members + masterList.GetChannelsByMemberCount(memberCount) + } +} + +func BenchmarkMasterList_GetChannelsByLevelRestriction(b *testing.B) { + masterList := benchmarkSetup() + levels := []int32{0, 10, 20, 30, 50} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetChannelsByLevelRestriction(levels[i%len(levels)]) + } +} + +func BenchmarkMasterList_GetAllChannels(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetAllChannels() + } +} + +func BenchmarkMasterList_GetAllChannelsList(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetAllChannelsList() + } +} + +func BenchmarkMasterList_GetStatistics(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetStatistics() + } +} + +func BenchmarkMasterList_ValidateChannels(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.ValidateChannels() + } +} + +func BenchmarkMasterList_RemoveChannel(b *testing.B) { + b.StopTimer() + masterList := benchmarkSetup() + initialCount := masterList.GetChannelCount() + + // Pre-populate with channels we'll remove + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + for i := 0; i < b.N; i++ { + ch := NewWithData(int32(20000+i), fmt.Sprintf("ToRemove%d", i), ChannelTypeCustom, db) + masterList.AddChannel(ch) + } + + b.StartTimer() + for i := 0; i < b.N; i++ { + masterList.RemoveChannel(int32(20000 + i)) + } + + b.StopTimer() + if masterList.GetChannelCount() != initialCount { + b.Errorf("Expected %d channels after removal, got %d", initialCount, masterList.GetChannelCount()) + } +} + +func BenchmarkMasterList_ForEach(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + count := 0 + masterList.ForEach(func(id int32, channel *Channel) { + count++ + }) + } +} + +func BenchmarkMasterList_UpdateChannel(b *testing.B) { + masterList := benchmarkSetup() + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + channelID := int32(i%64 + 1) + updatedChannel := &Channel{ + ID: channelID, + Name: fmt.Sprintf("Updated%d", i), + ChannelType: ChannelTypeCustom, + db: db, + isNew: false, + members: make([]int32, 0), + } + masterList.UpdateChannel(updatedChannel) + } +} + +// Memory allocation benchmarks +func BenchmarkMasterList_GetChannel_Allocs(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + masterList.GetChannel(int32(i%64 + 1)) + } +} + +func BenchmarkMasterList_FindChannelsByType_Allocs(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + masterList.FindChannelsByType(ChannelTypeWorld) + } +} + +func BenchmarkMasterList_GetChannelByName_Allocs(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + masterList.GetChannelByName("auction") + } +} + +// Concurrent benchmark +func BenchmarkMasterList_ConcurrentReads(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + // Mix of read operations + switch b.N % 5 { + case 0: + masterList.GetChannel(int32(b.N%64 + 1)) + case 1: + masterList.FindChannelsByType(ChannelTypeWorld) + case 2: + masterList.GetChannelByName("auction") + case 3: + masterList.GetActiveChannels() + case 4: + masterList.GetCompatibleChannels(25, 1, 1) + } + } + }) +} + +func BenchmarkMasterList_ConcurrentMixed(b *testing.B) { + masterList := benchmarkSetup() + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + // Mix of read and write operations (mostly reads) + switch b.N % 10 { + case 0: // 10% writes + ch := NewWithData(int32(b.N+50000), fmt.Sprintf("Concurrent%d", b.N), ChannelTypeCustom, db) + masterList.AddChannel(ch) + default: // 90% reads + switch b.N % 4 { + case 0: + masterList.GetChannel(int32(b.N%64 + 1)) + case 1: + masterList.FindChannelsByType(ChannelTypeWorld) + case 2: + masterList.GetChannelByName("auction") + case 3: + masterList.GetActiveChannels() + } + } + } + }) +} \ No newline at end of file diff --git a/internal/chat/master.go b/internal/chat/master.go index fbc6ade..08c2771 100644 --- a/internal/chat/master.go +++ b/internal/chat/master.go @@ -2,141 +2,448 @@ package chat import ( "fmt" + "maps" "strings" + "sync" - "eq2emu/internal/common" "eq2emu/internal/database" ) -// MasterList manages a collection of channels using the generic MasterList base +// MasterList is a specialized chat channel master list optimized for: +// - Fast ID-based lookups (O(1)) +// - Fast channel type filtering (O(1)) +// - Fast name-based searching (indexed) +// - Fast member count filtering +// - Efficient channel compatibility queries type MasterList struct { - *common.MasterList[int32, *Channel] + // Core storage + channels map[int32]*Channel // ID -> Channel + mutex sync.RWMutex + + // Specialized indices for O(1) lookups + byType map[int][]*Channel // ChannelType -> channels + byMemberCt map[int][]*Channel // Member count -> channels (active/empty) + byNameLower map[string]*Channel // Lowercase name -> channel + byRestrict map[int32][]*Channel // Restriction level -> channels + + // Cached metadata + memberCounts []int // Unique member counts (cached) + typeStats map[int]int // Channel type -> count + metaStale bool // Whether metadata cache needs refresh } -// NewMasterList creates a new channel master list +// NewMasterList creates a new specialized chat channel master list func NewMasterList() *MasterList { return &MasterList{ - MasterList: common.NewMasterList[int32, *Channel](), + channels: make(map[int32]*Channel), + byType: make(map[int][]*Channel), + byMemberCt: make(map[int][]*Channel), + byNameLower: make(map[string]*Channel), + byRestrict: make(map[int32][]*Channel), + typeStats: make(map[int]int), + metaStale: true, } } -// AddChannel adds a channel to the master list -func (ml *MasterList) AddChannel(channel *Channel) bool { - return ml.Add(channel) +// refreshMetaCache updates the member counts cache and type stats +func (ml *MasterList) refreshMetaCache() { + if !ml.metaStale { + return + } + + // Clear and rebuild type stats + ml.typeStats = make(map[int]int) + memberCountSet := make(map[int]struct{}) + + // Collect unique member counts and type stats + for _, channel := range ml.channels { + ml.typeStats[channel.GetType()]++ + memberCount := channel.GetNumClients() + memberCountSet[memberCount] = struct{}{} + } + + // Clear and rebuild member counts cache + ml.memberCounts = ml.memberCounts[:0] + for count := range memberCountSet { + ml.memberCounts = append(ml.memberCounts, count) + } + + ml.metaStale = false } -// GetChannel retrieves a channel by ID +// updateChannelIndices updates all indices for a channel +func (ml *MasterList) updateChannelIndices(channel *Channel, add bool) { + if add { + // Add to type index + ml.byType[channel.GetType()] = append(ml.byType[channel.GetType()], channel) + + // Add to member count index + memberCount := channel.GetNumClients() + ml.byMemberCt[memberCount] = append(ml.byMemberCt[memberCount], channel) + + // Add to name index + ml.byNameLower[strings.ToLower(channel.GetName())] = channel + + // Add to restriction index + ml.byRestrict[channel.LevelRestriction] = append(ml.byRestrict[channel.LevelRestriction], channel) + } else { + // Remove from type index + typeChannels := ml.byType[channel.GetType()] + for i, ch := range typeChannels { + if ch.ID == channel.ID { + ml.byType[channel.GetType()] = append(typeChannels[:i], typeChannels[i+1:]...) + break + } + } + + // Remove from member count index + memberCount := channel.GetNumClients() + memberChannels := ml.byMemberCt[memberCount] + for i, ch := range memberChannels { + if ch.ID == channel.ID { + ml.byMemberCt[memberCount] = append(memberChannels[:i], memberChannels[i+1:]...) + break + } + } + + // Remove from name index + delete(ml.byNameLower, strings.ToLower(channel.GetName())) + + // Remove from restriction index + restrChannels := ml.byRestrict[channel.LevelRestriction] + for i, ch := range restrChannels { + if ch.ID == channel.ID { + ml.byRestrict[channel.LevelRestriction] = append(restrChannels[:i], restrChannels[i+1:]...) + break + } + } + } +} + +// RefreshChannelIndices refreshes the indices for a channel (used when member count changes) +func (ml *MasterList) RefreshChannelIndices(channel *Channel, oldMemberCount int) { + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Remove from old member count index + oldMemberChannels := ml.byMemberCt[oldMemberCount] + for i, ch := range oldMemberChannels { + if ch.ID == channel.ID { + ml.byMemberCt[oldMemberCount] = append(oldMemberChannels[:i], oldMemberChannels[i+1:]...) + break + } + } + + // Add to new member count index + newMemberCount := channel.GetNumClients() + ml.byMemberCt[newMemberCount] = append(ml.byMemberCt[newMemberCount], channel) + + // Invalidate metadata cache + ml.metaStale = true +} + +// AddChannel adds a channel with full indexing +func (ml *MasterList) AddChannel(channel *Channel) bool { + if channel == nil { + return false + } + + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Check if exists + if _, exists := ml.channels[channel.ID]; exists { + return false + } + + // Add to core storage + ml.channels[channel.ID] = channel + + // Update all indices + ml.updateChannelIndices(channel, true) + + // Invalidate metadata cache + ml.metaStale = true + + return true +} + +// GetChannel retrieves by ID (O(1)) func (ml *MasterList) GetChannel(id int32) *Channel { - return ml.Get(id) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.channels[id] } // GetChannelSafe retrieves a channel by ID with existence check func (ml *MasterList) GetChannelSafe(id int32) (*Channel, bool) { - return ml.GetSafe(id) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + channel, exists := ml.channels[id] + return channel, exists } // HasChannel checks if a channel exists by ID func (ml *MasterList) HasChannel(id int32) bool { - return ml.Exists(id) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + _, exists := ml.channels[id] + return exists } -// RemoveChannel removes a channel by ID +// RemoveChannel removes a channel and updates all indices func (ml *MasterList) RemoveChannel(id int32) bool { - return ml.Remove(id) + ml.mutex.Lock() + defer ml.mutex.Unlock() + + channel, exists := ml.channels[id] + if !exists { + return false + } + + // Remove from core storage + delete(ml.channels, id) + + // Update all indices + ml.updateChannelIndices(channel, false) + + // Invalidate metadata cache + ml.metaStale = true + + return true } -// GetAllChannels returns all channels as a map +// GetAllChannels returns a copy of all channels map func (ml *MasterList) GetAllChannels() map[int32]*Channel { - return ml.GetAll() + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + // Return a copy to prevent external modification + result := make(map[int32]*Channel, len(ml.channels)) + maps.Copy(result, ml.channels) + return result } // GetAllChannelsList returns all channels as a slice func (ml *MasterList) GetAllChannelsList() []*Channel { - return ml.GetAllSlice() + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + result := make([]*Channel, 0, len(ml.channels)) + for _, channel := range ml.channels { + result = append(result, channel) + } + return result } // GetChannelCount returns the number of channels func (ml *MasterList) GetChannelCount() int { - return ml.Size() + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return len(ml.channels) +} + +// Size returns the total number of channels +func (ml *MasterList) Size() int { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return len(ml.channels) +} + +// IsEmpty returns true if the master list is empty +func (ml *MasterList) IsEmpty() bool { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return len(ml.channels) == 0 } // ClearChannels removes all channels from the list func (ml *MasterList) ClearChannels() { - ml.Clear() + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Clear all maps + ml.channels = make(map[int32]*Channel) + ml.byType = make(map[int][]*Channel) + ml.byMemberCt = make(map[int][]*Channel) + ml.byNameLower = make(map[string]*Channel) + ml.byRestrict = make(map[int32][]*Channel) + + // Clear cached metadata + ml.memberCounts = ml.memberCounts[:0] + ml.typeStats = make(map[int]int) + ml.metaStale = true } -// FindChannelsByName finds channels containing the given name substring +// Clear removes all channels from the master list +func (ml *MasterList) Clear() { + ml.ClearChannels() +} + +// FindChannelsByName finds channels containing the given name substring (optimized) func (ml *MasterList) FindChannelsByName(nameSubstring string) []*Channel { - return ml.Filter(func(channel *Channel) bool { - return contains(channel.GetName(), nameSubstring) - }) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + searchKey := strings.ToLower(nameSubstring) + + // Try exact match first for full channel names + if exactChannel := ml.byNameLower[searchKey]; exactChannel != nil { + return []*Channel{exactChannel} + } + + // Fallback to substring search + var result []*Channel + for _, channel := range ml.channels { + if contains(strings.ToLower(channel.GetName()), searchKey) { + result = append(result, channel) + } + } + + return result } -// FindChannelsByType finds channels of a specific type +// FindChannelsByType finds channels of a specific type (O(1)) func (ml *MasterList) FindChannelsByType(channelType int) []*Channel { - return ml.Filter(func(channel *Channel) bool { - return channel.GetType() == channelType - }) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byType[channelType] } -// GetWorldChannels returns all world channels +// GetWorldChannels returns all world channels (O(1)) func (ml *MasterList) GetWorldChannels() []*Channel { return ml.FindChannelsByType(ChannelTypeWorld) } -// GetCustomChannels returns all custom channels +// GetCustomChannels returns all custom channels (O(1)) func (ml *MasterList) GetCustomChannels() []*Channel { return ml.FindChannelsByType(ChannelTypeCustom) } // GetActiveChannels returns channels that have members func (ml *MasterList) GetActiveChannels() []*Channel { - return ml.Filter(func(channel *Channel) bool { - return !channel.IsEmpty() - }) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + var result []*Channel + for _, channel := range ml.channels { + if !channel.IsEmpty() { + result = append(result, channel) + } + } + + return result } // GetEmptyChannels returns channels that have no members func (ml *MasterList) GetEmptyChannels() []*Channel { - return ml.Filter(func(channel *Channel) bool { - return channel.IsEmpty() - }) -} + ml.mutex.RLock() + defer ml.mutex.RUnlock() -// GetChannelByName retrieves a channel by name (case-insensitive) -func (ml *MasterList) GetChannelByName(name string) *Channel { - name = strings.ToLower(name) - var foundChannel *Channel - ml.ForEach(func(id int32, channel *Channel) { - if strings.ToLower(channel.GetName()) == name { - foundChannel = channel + var result []*Channel + for _, channel := range ml.channels { + if channel.IsEmpty() { + result = append(result, channel) } - }) - return foundChannel + } + + return result } -// HasChannelByName checks if a channel exists by name (case-insensitive) +// GetChannelByName retrieves a channel by name (case-insensitive, O(1)) +func (ml *MasterList) GetChannelByName(name string) *Channel { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byNameLower[strings.ToLower(name)] +} + +// HasChannelByName checks if a channel exists by name (case-insensitive, O(1)) func (ml *MasterList) HasChannelByName(name string) bool { return ml.GetChannelByName(name) != nil } // GetCompatibleChannels returns channels compatible with player restrictions func (ml *MasterList) GetCompatibleChannels(level, race, class int32) []*Channel { - return ml.Filter(func(channel *Channel) bool { - return channel.CanJoinChannelByLevel(level) && + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + var result []*Channel + for _, channel := range ml.channels { + if channel.CanJoinChannelByLevel(level) && channel.CanJoinChannelByRace(race) && - channel.CanJoinChannelByClass(class) - }) + channel.CanJoinChannelByClass(class) { + result = append(result, channel) + } + } + + return result +} + +// GetChannelsByMemberCount returns channels with specific member count +func (ml *MasterList) GetChannelsByMemberCount(memberCount int) []*Channel { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byMemberCt[memberCount] +} + +// GetChannelsByLevelRestriction returns channels with specific level restriction +func (ml *MasterList) GetChannelsByLevelRestriction(levelRestriction int32) []*Channel { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byRestrict[levelRestriction] +} + +// UpdateChannel updates an existing channel and refreshes indices +func (ml *MasterList) UpdateChannel(channel *Channel) error { + if channel == nil { + return fmt.Errorf("channel cannot be nil") + } + + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Check if exists + old, exists := ml.channels[channel.ID] + if !exists { + return fmt.Errorf("channel %d not found", channel.ID) + } + + // Remove old channel from indices (but not core storage yet) + ml.updateChannelIndices(old, false) + + // Update core storage + ml.channels[channel.ID] = channel + + // Add new channel to indices + ml.updateChannelIndices(channel, true) + + // Invalidate metadata cache + ml.metaStale = true + + return nil +} + +// ForEach executes a function for each channel +func (ml *MasterList) ForEach(fn func(int32, *Channel)) { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + for id, channel := range ml.channels { + fn(id, channel) + } } // ValidateChannels checks all channels for consistency func (ml *MasterList) ValidateChannels() []string { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + var issues []string - ml.ForEach(func(id int32, channel *Channel) { + for id, channel := range ml.channels { if channel == nil { issues = append(issues, fmt.Sprintf("Channel ID %d is nil", id)) - return + continue } if channel.GetID() != id { @@ -158,7 +465,7 @@ func (ml *MasterList) ValidateChannels() []string { if len(channel.Password) > MaxChannelPasswordLength { issues = append(issues, fmt.Sprintf("Channel ID %d password too long: %d > %d", id, len(channel.Password), MaxChannelPasswordLength)) } - }) + } return issues } @@ -169,24 +476,32 @@ func (ml *MasterList) IsValid() bool { return len(issues) == 0 } -// GetStatistics returns statistics about the channel collection +// GetStatistics returns statistics about the channel collection using cached data func (ml *MasterList) GetStatistics() map[string]any { - stats := make(map[string]any) - stats["total_channels"] = ml.Size() + ml.mutex.Lock() // Need write lock to potentially update cache + defer ml.mutex.Unlock() - if ml.IsEmpty() { + ml.refreshMetaCache() + + stats := make(map[string]any) + stats["total_channels"] = len(ml.channels) + + if len(ml.channels) == 0 { return stats } - // Count by channel type - typeCounts := make(map[int]int) + // Use cached type stats + stats["channels_by_type"] = ml.typeStats + stats["world_channels"] = ml.typeStats[ChannelTypeWorld] + stats["custom_channels"] = ml.typeStats[ChannelTypeCustom] + + // Calculate additional stats var totalMembers int var activeChannels int var minID, maxID int32 first := true - ml.ForEach(func(id int32, channel *Channel) { - typeCounts[channel.GetType()]++ + for id, channel := range ml.channels { totalMembers += channel.GetNumClients() if !channel.IsEmpty() { @@ -205,11 +520,8 @@ func (ml *MasterList) GetStatistics() map[string]any { maxID = id } } - }) + } - stats["channels_by_type"] = typeCounts - stats["world_channels"] = typeCounts[ChannelTypeWorld] - stats["custom_channels"] = typeCounts[ChannelTypeCustom] stats["total_members"] = totalMembers stats["active_channels"] = activeChannels stats["min_id"] = minID diff --git a/internal/chat/master_test.go b/internal/chat/master_test.go index 50f49b1..7178e67 100644 --- a/internal/chat/master_test.go +++ b/internal/chat/master_test.go @@ -1,6 +1,7 @@ package chat import ( + "fmt" "testing" "eq2emu/internal/database" @@ -176,21 +177,28 @@ func TestMasterListGetByName(t *testing.T) { masterList := NewMasterList() - // Add test channels + // Add test channels with different names to test indexing channel1 := NewWithData(100, "Auction", ChannelTypeWorld, db) - channel2 := NewWithData(200, "AUCTION", ChannelTypeWorld, db) // Different case + channel2 := NewWithData(200, "Trade", ChannelTypeWorld, db) + channel3 := NewWithData(300, "Custom Channel", ChannelTypeCustom, db) masterList.AddChannel(channel1) masterList.AddChannel(channel2) + masterList.AddChannel(channel3) // Test case-insensitive lookup found := masterList.GetChannelByName("auction") - if found == nil { - t.Error("GetChannelByName should find channel (case insensitive)") + if found == nil || found.ID != 100 { + t.Error("GetChannelByName should find 'Auction' channel (case insensitive)") } - found = masterList.GetChannelByName("AUCTION") - if found == nil { - t.Error("GetChannelByName should find channel (uppercase)") + found = masterList.GetChannelByName("TRADE") + if found == nil || found.ID != 200 { + t.Error("GetChannelByName should find 'Trade' channel (uppercase)") + } + + found = masterList.GetChannelByName("custom channel") + if found == nil || found.ID != 300 { + t.Error("GetChannelByName should find 'Custom Channel' channel (lowercase)") } found = masterList.GetChannelByName("NonExistent") @@ -363,6 +371,148 @@ func TestMasterListStatistics(t *testing.T) { } } +func TestMasterListBespokeFeatures(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add test channels with different properties + ch1 := NewWithData(101, "Test Channel", ChannelTypeWorld, db) + ch1.SetLevelRestriction(10) + + ch2 := NewWithData(102, "Another Test", ChannelTypeCustom, db) + ch2.SetLevelRestriction(20) + + ch3 := NewWithData(103, "Empty Channel", ChannelTypeWorld, db) + ch3.SetLevelRestriction(10) + + masterList.AddChannel(ch1) + masterList.AddChannel(ch2) + masterList.AddChannel(ch3) + + // Add some members to make channels active/empty + ch1.JoinChannel(1001) + masterList.RefreshChannelIndices(ch1, 0) // Update from 0 to 1 member + ch1.JoinChannel(1002) + masterList.RefreshChannelIndices(ch1, 1) // Update from 1 to 2 members + ch2.JoinChannel(1003) + masterList.RefreshChannelIndices(ch2, 0) // Update from 0 to 1 member + + // Test GetChannelsByMemberCount + zeroMemberChannels := masterList.GetChannelsByMemberCount(0) + if len(zeroMemberChannels) != 1 { + t.Errorf("GetChannelsByMemberCount(0) returned %v results, want 1", len(zeroMemberChannels)) + } + + twoMemberChannels := masterList.GetChannelsByMemberCount(2) + if len(twoMemberChannels) != 1 { + t.Errorf("GetChannelsByMemberCount(2) returned %v results, want 1", len(twoMemberChannels)) + } + + oneMemberChannels := masterList.GetChannelsByMemberCount(1) + if len(oneMemberChannels) != 1 { + t.Errorf("GetChannelsByMemberCount(1) returned %v results, want 1", len(oneMemberChannels)) + } + + // Test GetChannelsByLevelRestriction + level10Channels := masterList.GetChannelsByLevelRestriction(10) + if len(level10Channels) != 2 { + t.Errorf("GetChannelsByLevelRestriction(10) returned %v results, want 2", len(level10Channels)) + } + + level20Channels := masterList.GetChannelsByLevelRestriction(20) + if len(level20Channels) != 1 { + t.Errorf("GetChannelsByLevelRestriction(20) returned %v results, want 1", len(level20Channels)) + } + + // Test UpdateChannel + updatedCh := &Channel{ + ID: 101, + Name: "Updated Channel Name", + ChannelType: ChannelTypeCustom, // Changed type + db: db, + isNew: false, + members: make([]int32, 0), + } + + err := masterList.UpdateChannel(updatedCh) + if err != nil { + t.Errorf("UpdateChannel failed: %v", err) + } + + // Verify the update worked + retrieved := masterList.GetChannel(101) + if retrieved.Name != "Updated Channel Name" { + t.Errorf("Expected updated name 'Updated Channel Name', got '%s'", retrieved.Name) + } + + if retrieved.ChannelType != ChannelTypeCustom { + t.Errorf("Expected updated type %d, got %d", ChannelTypeCustom, retrieved.ChannelType) + } + + // Test updating non-existent channel + nonExistentCh := &Channel{ID: 9999, Name: "Non-existent", db: db} + err = masterList.UpdateChannel(nonExistentCh) + if err == nil { + t.Error("UpdateChannel should fail for non-existent channel") + } +} + +func TestMasterListConcurrency(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add initial channels + for i := 1; i <= 100; i++ { + ch := NewWithData(int32(i), fmt.Sprintf("Channel%d", i), ChannelTypeWorld, db) + masterList.AddChannel(ch) + } + + // Test concurrent access + done := make(chan bool, 10) + + // Concurrent readers + for i := 0; i < 5; i++ { + go func() { + defer func() { done <- true }() + for j := 0; j < 100; j++ { + masterList.GetChannel(int32(j%100 + 1)) + masterList.FindChannelsByType(ChannelTypeWorld) + masterList.GetChannelByName(fmt.Sprintf("channel%d", j%100+1)) + } + }() + } + + // Concurrent writers + for i := 0; i < 5; i++ { + go func(workerID int) { + defer func() { done <- true }() + for j := 0; j < 10; j++ { + chID := int32(workerID*1000 + j + 1) + ch := NewWithData(chID, fmt.Sprintf("Worker%d-Channel%d", workerID, j), ChannelTypeCustom, db) + masterList.AddChannel(ch) // Some may fail due to concurrent additions + } + }(i) + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + // Verify final state - should have at least 100 initial channels + finalCount := masterList.GetChannelCount() + if finalCount < 100 { + t.Errorf("Expected at least 100 channels after concurrent operations, got %d", finalCount) + } + if finalCount > 150 { + t.Errorf("Expected at most 150 channels after concurrent operations, got %d", finalCount) + } +} + func TestContainsFunction(t *testing.T) { tests := []struct { str string