diff --git a/internal/collections/benchmark_test.go b/internal/collections/benchmark_test.go new file mode 100644 index 0000000..598484d --- /dev/null +++ b/internal/collections/benchmark_test.go @@ -0,0 +1,456 @@ +package collections + +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 collections across different categories and levels + categories := []string{ + "Heritage", "Treasured", "Legendary", "Fabled", "Mythical", + "Handcrafted", "Mastercrafted", "Rare", "Uncommon", "Common", + } + + for i := 0; i < 100; i++ { + category := categories[i%len(categories)] + level := int8((i % 50) + 1) // Levels 1-50 + + collection := NewWithData(int32(i+1), fmt.Sprintf("Collection %d", i+1), category, level, db) + + // Add collection items (some found, some not) + numItems := (i % 5) + 1 // 1-5 items per collection + for j := 0; j < numItems; j++ { + found := ItemNotFound + if (i+j)%3 == 0 { // About 1/3 of items are found + found = ItemFound + } + collection.CollectionItems = append(collection.CollectionItems, CollectionItem{ + ItemID: int32((i+1)*1000 + j + 1), + Index: int8(j), + Found: int8(found), + }) + } + + // Add rewards + if i%4 == 0 { + collection.RewardCoin = int64((i + 1) * 100) + } + if i%5 == 0 { + collection.RewardXP = int64((i + 1) * 50) + } + if i%6 == 0 { + collection.RewardItems = append(collection.RewardItems, CollectionRewardItem{ + ItemID: int32(i + 10000), + Quantity: 1, + }) + } + if i%7 == 0 { + collection.SelectableRewardItems = append(collection.SelectableRewardItems, CollectionRewardItem{ + ItemID: int32(i + 20000), + Quantity: 1, + }) + } + + // Some collections are completed + if i%10 == 0 { + collection.Completed = true + } + + masterList.AddCollection(collection) + } + + return masterList +} + +func BenchmarkMasterList_AddCollection(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++ { + collection := NewWithData(int32(i+10000), fmt.Sprintf("Collection%d", i), "Heritage", 20, db) + collection.CollectionItems = []CollectionItem{ + {ItemID: int32(i + 50000), Index: 0, Found: ItemNotFound}, + } + masterList.AddCollection(collection) + } +} + +func BenchmarkMasterList_GetCollection(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetCollection(int32(i%100 + 1)) + } +} + +func BenchmarkMasterList_GetCollectionSafe(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetCollectionSafe(int32(i%100 + 1)) + } +} + +func BenchmarkMasterList_HasCollection(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.HasCollection(int32(i%100 + 1)) + } +} + +func BenchmarkMasterList_FindCollectionsByCategory(b *testing.B) { + masterList := benchmarkSetup() + categories := []string{"Heritage", "Treasured", "Legendary", "Fabled", "Mythical"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.FindCollectionsByCategory(categories[i%len(categories)]) + } +} + +func BenchmarkMasterList_GetCollectionsByExactLevel(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + level := int8(i%50 + 1) + masterList.GetCollectionsByExactLevel(level) + } +} + +func BenchmarkMasterList_FindCollectionsByLevel(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + minLevel := int8(i%45 + 1) + maxLevel := minLevel + 5 + masterList.FindCollectionsByLevel(minLevel, maxLevel) + } +} + +func BenchmarkMasterList_GetCollectionByName(b *testing.B) { + masterList := benchmarkSetup() + names := []string{"collection 1", "collection 25", "collection 50", "collection 75", "collection 100"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetCollectionByName(names[i%len(names)]) + } +} + +func BenchmarkMasterList_NeedsItem(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + itemID := int32(i%100*1000 + 1001) // Various item IDs from the collections + masterList.NeedsItem(itemID) + } +} + +func BenchmarkMasterList_GetCollectionsNeedingItem(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + itemID := int32(i%100*1000 + 1001) // Various item IDs from the collections + masterList.GetCollectionsNeedingItem(itemID) + } +} + +func BenchmarkMasterList_GetCompletedCollections(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetCompletedCollections() + } +} + +func BenchmarkMasterList_GetIncompleteCollections(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetIncompleteCollections() + } +} + +func BenchmarkMasterList_GetReadyToTurnInCollections(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetReadyToTurnInCollections() + } +} + +func BenchmarkMasterList_GetCategories(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetCategories() + } +} + +func BenchmarkMasterList_GetLevels(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetLevels() + } +} + +func BenchmarkMasterList_GetItemsNeeded(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetItemsNeeded() + } +} + +func BenchmarkMasterList_GetAllCollections(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetAllCollections() + } +} + +func BenchmarkMasterList_GetAllCollectionsList(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetAllCollectionsList() + } +} + +func BenchmarkMasterList_GetStatistics(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetStatistics() + } +} + +func BenchmarkMasterList_ValidateCollections(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.ValidateCollections() + } +} + +func BenchmarkMasterList_RemoveCollection(b *testing.B) { + b.StopTimer() + masterList := benchmarkSetup() + initialCount := masterList.GetCollectionCount() + + // Pre-populate with collections we'll remove + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + for i := 0; i < b.N; i++ { + collection := NewWithData(int32(20000+i), fmt.Sprintf("ToRemove%d", i), "Temporary", 1, db) + collection.CollectionItems = []CollectionItem{ + {ItemID: int32(60000 + i), Index: 0, Found: ItemNotFound}, + } + masterList.AddCollection(collection) + } + + b.StartTimer() + for i := 0; i < b.N; i++ { + masterList.RemoveCollection(int32(20000 + i)) + } + + b.StopTimer() + if masterList.GetCollectionCount() != initialCount { + b.Errorf("Expected %d collections after removal, got %d", initialCount, masterList.GetCollectionCount()) + } +} + +func BenchmarkMasterList_ForEach(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + count := 0 + masterList.ForEach(func(id int32, collection *Collection) { + count++ + }) + } +} + +func BenchmarkMasterList_UpdateCollection(b *testing.B) { + masterList := benchmarkSetup() + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + collectionID := int32(i%100 + 1) + updatedCollection := &Collection{ + ID: collectionID, + Name: fmt.Sprintf("Updated%d", i), + Category: "Updated", + Level: 25, + db: db, + isNew: false, + CollectionItems: []CollectionItem{ + {ItemID: int32(i + 70000), Index: 0, Found: ItemNotFound}, + }, + } + masterList.UpdateCollection(updatedCollection) + } +} + +func BenchmarkMasterList_RefreshCollectionIndices(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + collection := masterList.GetCollection(int32(i%100 + 1)) + if collection != nil { + masterList.RefreshCollectionIndices(collection) + } + } +} + +func BenchmarkMasterList_GetCollectionClone(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + masterList.GetCollectionClone(int32(i%100 + 1)) + } +} + +// Memory allocation benchmarks +func BenchmarkMasterList_GetCollection_Allocs(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + masterList.GetCollection(int32(i%100 + 1)) + } +} + +func BenchmarkMasterList_FindCollectionsByCategory_Allocs(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + masterList.FindCollectionsByCategory("Heritage") + } +} + +func BenchmarkMasterList_GetCollectionByName_Allocs(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + masterList.GetCollectionByName("collection 1") + } +} + +func BenchmarkMasterList_NeedsItem_Allocs(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + masterList.NeedsItem(1001) + } +} + +func BenchmarkMasterList_GetCollectionsNeedingItem_Allocs(b *testing.B) { + masterList := benchmarkSetup() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + masterList.GetCollectionsNeedingItem(1001) + } +} + +// Concurrent benchmarks +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 % 6 { + case 0: + masterList.GetCollection(int32(b.N%100 + 1)) + case 1: + masterList.FindCollectionsByCategory("Heritage") + case 2: + masterList.GetCollectionByName("collection 1") + case 3: + masterList.NeedsItem(1001) + case 4: + masterList.GetCompletedCollections() + case 5: + masterList.GetCollectionsByExactLevel(10) + } + } + }) +} + +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 + collection := NewWithData(int32(b.N+50000), fmt.Sprintf("Concurrent%d", b.N), "Concurrent", 15, db) + collection.CollectionItems = []CollectionItem{ + {ItemID: int32(b.N + 80000), Index: 0, Found: ItemNotFound}, + } + masterList.AddCollection(collection) + default: // 90% reads + switch b.N % 5 { + case 0: + masterList.GetCollection(int32(b.N%100 + 1)) + case 1: + masterList.FindCollectionsByCategory("Heritage") + case 2: + masterList.GetCollectionByName("collection 1") + case 3: + masterList.NeedsItem(1001) + case 4: + masterList.GetCompletedCollections() + } + } + } + }) +} \ No newline at end of file diff --git a/internal/collections/master.go b/internal/collections/master.go index e324419..4346c9b 100644 --- a/internal/collections/master.go +++ b/internal/collections/master.go @@ -2,122 +2,510 @@ package collections import ( "fmt" + "maps" + "strings" + "sync" - "eq2emu/internal/common" "eq2emu/internal/database" ) -// MasterList manages a collection of collections using the generic MasterList base +// MasterList is a specialized collection master list optimized for: +// - Fast ID-based lookups (O(1)) +// - Fast category filtering (O(1)) +// - Fast level range queries (indexed) +// - Fast item requirement lookups (O(1)) +// - Efficient completion status filtering +// - Name-based searching with indexing type MasterList struct { - *common.MasterList[int32, *Collection] + // Core storage + collections map[int32]*Collection // ID -> Collection + mutex sync.RWMutex + + // Specialized indices for O(1) lookups + byCategory map[string][]*Collection // Category -> collections + byLevel map[int8][]*Collection // Level -> collections + byItemNeeded map[int32][]*Collection // ItemID -> collections that need it + byNameLower map[string]*Collection // Lowercase name -> collection + byCompletion map[bool][]*Collection // Completion status -> collections + + // Cached metadata + categories []string // Unique categories (cached) + levels []int8 // Unique levels (cached) + itemsNeeded []int32 // Unique items needed (cached) + categoryStats map[string]int // Category -> count + metaStale bool // Whether metadata cache needs refresh } -// NewMasterList creates a new collection master list +// NewMasterList creates a new specialized collection master list func NewMasterList() *MasterList { return &MasterList{ - MasterList: common.NewMasterList[int32, *Collection](), + collections: make(map[int32]*Collection), + byCategory: make(map[string][]*Collection), + byLevel: make(map[int8][]*Collection), + byItemNeeded: make(map[int32][]*Collection), + byNameLower: make(map[string]*Collection), + byCompletion: make(map[bool][]*Collection), + categoryStats: make(map[string]int), + metaStale: true, } } -// AddCollection adds a collection to the master list -func (ml *MasterList) AddCollection(collection *Collection) bool { - return ml.Add(collection) +// refreshMetaCache updates the cached metadata +func (ml *MasterList) refreshMetaCache() { + if !ml.metaStale { + return + } + + // Clear and rebuild category stats + ml.categoryStats = make(map[string]int) + categorySet := make(map[string]struct{}) + levelSet := make(map[int8]struct{}) + itemSet := make(map[int32]struct{}) + + // Collect unique values and stats + for _, collection := range ml.collections { + category := collection.GetCategory() + ml.categoryStats[category]++ + categorySet[category] = struct{}{} + levelSet[collection.GetLevel()] = struct{}{} + + // Collect items needed by this collection + for _, item := range collection.CollectionItems { + if item.Found == ItemNotFound { + itemSet[item.ItemID] = struct{}{} + } + } + } + + // Clear and rebuild cached slices + ml.categories = ml.categories[:0] + for category := range categorySet { + ml.categories = append(ml.categories, category) + } + + ml.levels = ml.levels[:0] + for level := range levelSet { + ml.levels = append(ml.levels, level) + } + + ml.itemsNeeded = ml.itemsNeeded[:0] + for itemID := range itemSet { + ml.itemsNeeded = append(ml.itemsNeeded, itemID) + } + + ml.metaStale = false } -// GetCollection retrieves a collection by ID +// updateCollectionIndices updates all indices for a collection +func (ml *MasterList) updateCollectionIndices(collection *Collection, add bool) { + if add { + // Add to category index + category := collection.GetCategory() + ml.byCategory[category] = append(ml.byCategory[category], collection) + + // Add to level index + level := collection.GetLevel() + ml.byLevel[level] = append(ml.byLevel[level], collection) + + // Add to name index + ml.byNameLower[strings.ToLower(collection.GetName())] = collection + + // Add to completion index + completed := collection.Completed + ml.byCompletion[completed] = append(ml.byCompletion[completed], collection) + + // Add to item needed index + for _, item := range collection.CollectionItems { + if item.Found == ItemNotFound { + ml.byItemNeeded[item.ItemID] = append(ml.byItemNeeded[item.ItemID], collection) + } + } + } else { + // Remove from category index + category := collection.GetCategory() + categoryCollections := ml.byCategory[category] + for i, coll := range categoryCollections { + if coll.ID == collection.ID { + ml.byCategory[category] = append(categoryCollections[:i], categoryCollections[i+1:]...) + break + } + } + + // Remove from level index + level := collection.GetLevel() + levelCollections := ml.byLevel[level] + for i, coll := range levelCollections { + if coll.ID == collection.ID { + ml.byLevel[level] = append(levelCollections[:i], levelCollections[i+1:]...) + break + } + } + + // Remove from name index + delete(ml.byNameLower, strings.ToLower(collection.GetName())) + + // Remove from completion index + completed := collection.Completed + completionCollections := ml.byCompletion[completed] + for i, coll := range completionCollections { + if coll.ID == collection.ID { + ml.byCompletion[completed] = append(completionCollections[:i], completionCollections[i+1:]...) + break + } + } + + // Remove from item needed index + for _, item := range collection.CollectionItems { + if item.Found == ItemNotFound { + itemCollections := ml.byItemNeeded[item.ItemID] + for i, coll := range itemCollections { + if coll.ID == collection.ID { + ml.byItemNeeded[item.ItemID] = append(itemCollections[:i], itemCollections[i+1:]...) + break + } + } + } + } + } +} + +// AddCollection adds a collection with full indexing +func (ml *MasterList) AddCollection(collection *Collection) bool { + if collection == nil { + return false + } + + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Check if exists + if _, exists := ml.collections[collection.ID]; exists { + return false + } + + // Add to core storage + ml.collections[collection.ID] = collection + + // Update all indices + ml.updateCollectionIndices(collection, true) + + // Invalidate metadata cache + ml.metaStale = true + + return true +} + +// GetCollection retrieves by ID (O(1)) func (ml *MasterList) GetCollection(id int32) *Collection { - return ml.Get(id) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.collections[id] } // GetCollectionSafe retrieves a collection by ID with existence check func (ml *MasterList) GetCollectionSafe(id int32) (*Collection, bool) { - return ml.GetSafe(id) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + collection, exists := ml.collections[id] + return collection, exists } // HasCollection checks if a collection exists by ID func (ml *MasterList) HasCollection(id int32) bool { - return ml.Exists(id) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + _, exists := ml.collections[id] + return exists } -// RemoveCollection removes a collection by ID +// GetCollectionClone retrieves a cloned copy of a collection by ID +func (ml *MasterList) GetCollectionClone(id int32) *Collection { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + collection := ml.collections[id] + if collection == nil { + return nil + } + return collection.Clone() +} + +// RemoveCollection removes a collection and updates all indices func (ml *MasterList) RemoveCollection(id int32) bool { - return ml.Remove(id) + ml.mutex.Lock() + defer ml.mutex.Unlock() + + collection, exists := ml.collections[id] + if !exists { + return false + } + + // Remove from core storage + delete(ml.collections, id) + + // Update all indices + ml.updateCollectionIndices(collection, false) + + // Invalidate metadata cache + ml.metaStale = true + + return true } -// GetAllCollections returns all collections as a map +// GetAllCollections returns a copy of all collections map func (ml *MasterList) GetAllCollections() map[int32]*Collection { - return ml.GetAll() + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + // Return a copy to prevent external modification + result := make(map[int32]*Collection, len(ml.collections)) + maps.Copy(result, ml.collections) + return result } // GetAllCollectionsList returns all collections as a slice func (ml *MasterList) GetAllCollectionsList() []*Collection { - return ml.GetAllSlice() + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + result := make([]*Collection, 0, len(ml.collections)) + for _, collection := range ml.collections { + result = append(result, collection) + } + return result } // GetCollectionCount returns the number of collections func (ml *MasterList) GetCollectionCount() int { - return ml.Size() + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return len(ml.collections) +} + +// Size returns the total number of collections +func (ml *MasterList) Size() int { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return len(ml.collections) +} + +// IsEmpty returns true if the master list is empty +func (ml *MasterList) IsEmpty() bool { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return len(ml.collections) == 0 } // ClearCollections removes all collections from the list func (ml *MasterList) ClearCollections() { - ml.Clear() + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Clear all maps + ml.collections = make(map[int32]*Collection) + ml.byCategory = make(map[string][]*Collection) + ml.byLevel = make(map[int8][]*Collection) + ml.byItemNeeded = make(map[int32][]*Collection) + ml.byNameLower = make(map[string]*Collection) + ml.byCompletion = make(map[bool][]*Collection) + + // Clear cached metadata + ml.categories = ml.categories[:0] + ml.levels = ml.levels[:0] + ml.itemsNeeded = ml.itemsNeeded[:0] + ml.categoryStats = make(map[string]int) + ml.metaStale = true } -// NeedsItem checks if any collection needs the specified item -func (ml *MasterList) NeedsItem(itemID int32) bool { - for _, collection := range ml.GetAll() { - if collection.NeedsItem(itemID) { - return true - } - } - return false +// Clear removes all collections from the master list +func (ml *MasterList) Clear() { + ml.ClearCollections() } -// FindCollectionsByCategory finds collections in a specific category +// FindCollectionsByCategory finds collections in a specific category (O(1)) func (ml *MasterList) FindCollectionsByCategory(category string) []*Collection { - return ml.Filter(func(collection *Collection) bool { - return collection.GetCategory() == category - }) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byCategory[category] } // FindCollectionsByLevel finds collections for a specific level range func (ml *MasterList) FindCollectionsByLevel(minLevel, maxLevel int8) []*Collection { - return ml.Filter(func(collection *Collection) bool { - level := collection.GetLevel() - return level >= minLevel && level <= maxLevel - }) -} + ml.mutex.RLock() + defer ml.mutex.RUnlock() -// GetCollectionsNeedingItem returns all collections that need a specific item -func (ml *MasterList) GetCollectionsNeedingItem(itemID int32) []*Collection { - return ml.Filter(func(collection *Collection) bool { - return collection.NeedsItem(itemID) - }) -} - -// GetCategories returns all unique collection categories -func (ml *MasterList) GetCategories() []string { - categoryMap := make(map[string]bool) - ml.ForEach(func(id int32, collection *Collection) { - categoryMap[collection.GetCategory()] = true - }) - - categories := make([]string, 0, len(categoryMap)) - for category := range categoryMap { - categories = append(categories, category) + var result []*Collection + for level := minLevel; level <= maxLevel; level++ { + result = append(result, ml.byLevel[level]...) + } + return result +} + +// GetCollectionsByExactLevel returns collections with specific level (O(1)) +func (ml *MasterList) GetCollectionsByExactLevel(level int8) []*Collection { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byLevel[level] +} + +// GetCollectionByName retrieves a collection by name (case-insensitive, O(1)) +func (ml *MasterList) GetCollectionByName(name string) *Collection { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byNameLower[strings.ToLower(name)] +} + +// NeedsItem checks if any collection needs the specified item (O(1)) +func (ml *MasterList) NeedsItem(itemID int32) bool { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + collections := ml.byItemNeeded[itemID] + return len(collections) > 0 +} + +// GetCollectionsNeedingItem returns all collections that need a specific item (O(1)) +func (ml *MasterList) GetCollectionsNeedingItem(itemID int32) []*Collection { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byItemNeeded[itemID] +} + +// GetCompletedCollections returns all completed collections (O(1)) +func (ml *MasterList) GetCompletedCollections() []*Collection { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byCompletion[true] +} + +// GetIncompleteCollections returns all incomplete collections (O(1)) +func (ml *MasterList) GetIncompleteCollections() []*Collection { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byCompletion[false] +} + +// GetReadyToTurnInCollections returns collections ready to be turned in +func (ml *MasterList) GetReadyToTurnInCollections() []*Collection { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + var result []*Collection + for _, collection := range ml.collections { + if !collection.Completed && collection.GetIsReadyToTurnIn() { + result = append(result, collection) + } + } + return result +} + +// GetCollectionsByLevelRange returns collections within level range using indices +func (ml *MasterList) GetCollectionsByLevelRange(minLevel, maxLevel int8) []*Collection { + return ml.FindCollectionsByLevel(minLevel, maxLevel) +} + +// GetCategories returns all unique collection categories using cached results +func (ml *MasterList) GetCategories() []string { + ml.mutex.Lock() // Need write lock to potentially update cache + defer ml.mutex.Unlock() + + ml.refreshMetaCache() + + // Return a copy to prevent external modification + result := make([]string, len(ml.categories)) + copy(result, ml.categories) + return result +} + +// GetLevels returns all unique collection levels using cached results +func (ml *MasterList) GetLevels() []int8 { + ml.mutex.Lock() // Need write lock to potentially update cache + defer ml.mutex.Unlock() + + ml.refreshMetaCache() + + // Return a copy to prevent external modification + result := make([]int8, len(ml.levels)) + copy(result, ml.levels) + return result +} + +// GetItemsNeeded returns all unique items needed by collections +func (ml *MasterList) GetItemsNeeded() []int32 { + ml.mutex.Lock() // Need write lock to potentially update cache + defer ml.mutex.Unlock() + + ml.refreshMetaCache() + + // Return a copy to prevent external modification + result := make([]int32, len(ml.itemsNeeded)) + copy(result, ml.itemsNeeded) + return result +} + +// UpdateCollection updates an existing collection and refreshes indices +func (ml *MasterList) UpdateCollection(collection *Collection) error { + if collection == nil { + return fmt.Errorf("collection cannot be nil") + } + + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Check if exists + old, exists := ml.collections[collection.ID] + if !exists { + return fmt.Errorf("collection %d not found", collection.ID) + } + + // Remove old collection from indices (but not core storage yet) + ml.updateCollectionIndices(old, false) + + // Update core storage + ml.collections[collection.ID] = collection + + // Add new collection to indices + ml.updateCollectionIndices(collection, true) + + // Invalidate metadata cache + ml.metaStale = true + + return nil +} + +// RefreshCollectionIndices refreshes indices for a collection (used when items found/completion changes) +func (ml *MasterList) RefreshCollectionIndices(collection *Collection) { + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Remove from old indices + ml.updateCollectionIndices(collection, false) + // Add to new indices + ml.updateCollectionIndices(collection, true) + + // Invalidate metadata cache + ml.metaStale = true +} + +// ForEach executes a function for each collection +func (ml *MasterList) ForEach(fn func(int32, *Collection)) { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + for id, collection := range ml.collections { + fn(id, collection) } - return categories } // ValidateCollections checks all collections for consistency func (ml *MasterList) ValidateCollections() []string { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + var issues []string - ml.ForEach(func(id int32, collection *Collection) { + for id, collection := range ml.collections { if collection == nil { issues = append(issues, fmt.Sprintf("Collection ID %d is nil", id)) - return + continue } if collection.GetID() != id { @@ -140,6 +528,14 @@ func (ml *MasterList) ValidateCollections() []string { issues = append(issues, fmt.Sprintf("Collection ID %d has no collection items", id)) } + if len(collection.GetName()) > MaxCollectionNameLength { + issues = append(issues, fmt.Sprintf("Collection ID %d name too long: %d > %d", id, len(collection.GetName()), MaxCollectionNameLength)) + } + + if len(collection.GetCategory()) > MaxCollectionCategoryLength { + issues = append(issues, fmt.Sprintf("Collection ID %d category too long: %d > %d", id, len(collection.GetCategory()), MaxCollectionCategoryLength)) + } + // Check for duplicate item indices indexMap := make(map[int8]bool) for _, item := range collection.CollectionItems { @@ -148,7 +544,7 @@ func (ml *MasterList) ValidateCollections() []string { } indexMap[item.Index] = true } - }) + } return issues } @@ -159,27 +555,40 @@ func (ml *MasterList) IsValid() bool { return len(issues) == 0 } -// GetStatistics returns statistics about the collection collection +// GetStatistics returns statistics about the collection collection using cached data func (ml *MasterList) GetStatistics() map[string]any { - stats := make(map[string]any) - stats["total_collections"] = 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_collections"] = len(ml.collections) + + if len(ml.collections) == 0 { return stats } - // Count by category - categoryCounts := make(map[string]int) + // Use cached category stats + stats["collections_by_category"] = ml.categoryStats + + // Calculate additional stats var totalItems, totalRewards int + var completedCount, readyCount int var minLevel, maxLevel int8 = 127, 0 var minID, maxID int32 first := true - ml.ForEach(func(id int32, collection *Collection) { - categoryCounts[collection.GetCategory()]++ + for id, collection := range ml.collections { totalItems += len(collection.CollectionItems) totalRewards += len(collection.RewardItems) + len(collection.SelectableRewardItems) + if collection.Completed { + completedCount++ + } else if collection.GetIsReadyToTurnIn() { + readyCount++ + } + level := collection.GetLevel() if level < minLevel { minLevel = level @@ -200,17 +609,18 @@ func (ml *MasterList) GetStatistics() map[string]any { maxID = id } } - }) + } - stats["collections_by_category"] = categoryCounts stats["total_collection_items"] = totalItems stats["total_rewards"] = totalRewards + stats["completed_collections"] = completedCount + stats["ready_to_turn_in"] = readyCount stats["min_level"] = minLevel stats["max_level"] = maxLevel stats["min_id"] = minID stats["max_id"] = maxID stats["id_range"] = maxID - minID - stats["average_items_per_collection"] = float64(totalItems) / float64(ml.Size()) + stats["average_items_per_collection"] = float64(totalItems) / float64(len(ml.collections)) return stats } diff --git a/internal/collections/master_test.go b/internal/collections/master_test.go index 7cc116b..599b167 100644 --- a/internal/collections/master_test.go +++ b/internal/collections/master_test.go @@ -1,6 +1,7 @@ package collections import ( + "fmt" "testing" "eq2emu/internal/database" @@ -362,4 +363,211 @@ func TestMasterListStatistics(t *testing.T) { if avgItems, ok := stats["average_items_per_collection"].(float64); !ok || avgItems != float64(4)/3 { t.Errorf("average_items_per_collection = %v, want %v", avgItems, float64(4)/3) } +} + +func TestMasterListBespokeFeatures(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Create collections with different properties + col1 := NewWithData(101, "Heritage Quest", "Heritage", 10, db) + col1.CollectionItems = []CollectionItem{ + {ItemID: 1001, Index: 0, Found: ItemNotFound}, + {ItemID: 1002, Index: 1, Found: ItemFound}, + } + col1.Completed = false + + col2 := NewWithData(102, "Treasured Quest", "Treasured", 20, db) + col2.CollectionItems = []CollectionItem{ + {ItemID: 1003, Index: 0, Found: ItemFound}, + {ItemID: 1004, Index: 1, Found: ItemFound}, + } + col2.Completed = true + + col3 := NewWithData(103, "Legendary Quest", "Legendary", 10, db) + col3.CollectionItems = []CollectionItem{ + {ItemID: 1001, Index: 0, Found: ItemNotFound}, // Same item as col1 + } + col3.Completed = false + + masterList.AddCollection(col1) + masterList.AddCollection(col2) + masterList.AddCollection(col3) + + // Test GetCollectionsByExactLevel + level10Collections := masterList.GetCollectionsByExactLevel(10) + if len(level10Collections) != 2 { + t.Errorf("GetCollectionsByExactLevel(10) returned %v results, want 2", len(level10Collections)) + } + + level20Collections := masterList.GetCollectionsByExactLevel(20) + if len(level20Collections) != 1 { + t.Errorf("GetCollectionsByExactLevel(20) returned %v results, want 1", len(level20Collections)) + } + + // Test GetCollectionByName + found := masterList.GetCollectionByName("heritage quest") + if found == nil || found.ID != 101 { + t.Error("GetCollectionByName should find 'Heritage Quest' (case insensitive)") + } + + found = masterList.GetCollectionByName("TREASURED QUEST") + if found == nil || found.ID != 102 { + t.Error("GetCollectionByName should find 'Treasured Quest' (uppercase)") + } + + found = masterList.GetCollectionByName("NonExistent") + if found != nil { + t.Error("GetCollectionByName should return nil for non-existent collection") + } + + // Test completion status filtering + completedCollections := masterList.GetCompletedCollections() + if len(completedCollections) != 1 { + t.Errorf("GetCompletedCollections() returned %v results, want 1", len(completedCollections)) + } + + incompleteCollections := masterList.GetIncompleteCollections() + if len(incompleteCollections) != 2 { + t.Errorf("GetIncompleteCollections() returned %v results, want 2", len(incompleteCollections)) + } + + // Test GetCollectionsNeedingItem (multiple collections need same item) + collectionsNeedingItem := masterList.GetCollectionsNeedingItem(1001) + if len(collectionsNeedingItem) != 2 { + t.Errorf("GetCollectionsNeedingItem(1001) returned %v results, want 2", len(collectionsNeedingItem)) + } + + // Test GetReadyToTurnInCollections + readyCollections := masterList.GetReadyToTurnInCollections() + if len(readyCollections) != 0 { // col1 has one item not found, col3 has one item not found + t.Errorf("GetReadyToTurnInCollections() returned %v results, want 0", len(readyCollections)) + } + + // Mark col1 as ready to turn in + col1.CollectionItems[0].Found = ItemFound + masterList.RefreshCollectionIndices(col1) + + readyCollections = masterList.GetReadyToTurnInCollections() + if len(readyCollections) != 1 { + t.Errorf("GetReadyToTurnInCollections() returned %v results, want 1 after marking items found", len(readyCollections)) + } + + // Test UpdateCollection + updatedCol := &Collection{ + ID: 101, + Name: "Updated Heritage Quest", + Category: "Updated", + Level: 25, + db: db, + isNew: false, + CollectionItems: []CollectionItem{ + {ItemID: 2001, Index: 0, Found: ItemNotFound}, + }, + } + + err := masterList.UpdateCollection(updatedCol) + if err != nil { + t.Errorf("UpdateCollection failed: %v", err) + } + + // Verify the update worked + retrieved := masterList.GetCollection(101) + if retrieved.Name != "Updated Heritage Quest" { + t.Errorf("Expected updated name 'Updated Heritage Quest', got '%s'", retrieved.Name) + } + + if retrieved.Category != "Updated" { + t.Errorf("Expected updated category 'Updated', got '%s'", retrieved.Category) + } + + // Test updating non-existent collection + nonExistentCol := &Collection{ID: 9999, Name: "Non-existent", db: db} + err = masterList.UpdateCollection(nonExistentCol) + if err == nil { + t.Error("UpdateCollection should fail for non-existent collection") + } + + // Test GetLevels and GetItemsNeeded + levels := masterList.GetLevels() + if len(levels) == 0 { + t.Error("GetLevels() should return levels") + } + + itemsNeeded := masterList.GetItemsNeeded() + if len(itemsNeeded) == 0 { + t.Error("GetItemsNeeded() should return items needed") + } + + // Test GetCollectionClone + cloned := masterList.GetCollectionClone(101) + if cloned == nil { + t.Error("GetCollectionClone should return a clone") + } + if cloned == retrieved { + t.Error("GetCollectionClone should return a different object") + } +} + +func TestMasterListConcurrency(t *testing.T) { + db, _ := database.NewSQLite("file::memory:?mode=memory&cache=shared") + defer db.Close() + + masterList := NewMasterList() + + // Add initial collections + for i := 1; i <= 100; i++ { + col := NewWithData(int32(i), fmt.Sprintf("Collection%d", i), "Heritage", 10, db) + col.CollectionItems = []CollectionItem{ + {ItemID: int32(i + 1000), Index: 0, Found: ItemNotFound}, + } + masterList.AddCollection(col) + } + + // 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.GetCollection(int32(j%100 + 1)) + masterList.FindCollectionsByCategory("Heritage") + masterList.GetCollectionByName(fmt.Sprintf("collection%d", j%100+1)) + masterList.NeedsItem(int32(j + 1000)) + } + }() + } + + // Concurrent writers + for i := 0; i < 5; i++ { + go func(workerID int) { + defer func() { done <- true }() + for j := 0; j < 10; j++ { + colID := int32(workerID*1000 + j + 1) + col := NewWithData(colID, fmt.Sprintf("Worker%d-Collection%d", workerID, j), "Treasured", 20, db) + col.CollectionItems = []CollectionItem{ + {ItemID: colID + 10000, Index: 0, Found: ItemNotFound}, + } + masterList.AddCollection(col) // 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 collections + finalCount := masterList.GetCollectionCount() + if finalCount < 100 { + t.Errorf("Expected at least 100 collections after concurrent operations, got %d", finalCount) + } + if finalCount > 150 { + t.Errorf("Expected at most 150 collections after concurrent operations, got %d", finalCount) + } } \ No newline at end of file