From 4595c392d111b993c6fdec4b04589aa8cd2e528a Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Fri, 8 Aug 2025 10:44:31 -0500 Subject: [PATCH] rework appearances again --- internal/appearances/appearance_test.go | 258 +++++++++++ internal/appearances/benchmark_test.go | 548 ++++++++++++++++++++++++ internal/appearances/master.go | 427 +++++++++++++++--- 3 files changed, 1179 insertions(+), 54 deletions(-) create mode 100644 internal/appearances/benchmark_test.go diff --git a/internal/appearances/appearance_test.go b/internal/appearances/appearance_test.go index ecbfa19..0b17060 100644 --- a/internal/appearances/appearance_test.go +++ b/internal/appearances/appearance_test.go @@ -1,6 +1,7 @@ package appearances import ( + "sync" "testing" "eq2emu/internal/database" @@ -219,3 +220,260 @@ func TestGetAppearanceTypeName(t *testing.T) { }) } } + +// TestMasterList tests the bespoke master list implementation +func TestMasterList(t *testing.T) { + masterList := NewMasterList() + + if masterList == nil { + t.Fatal("NewMasterList returned nil") + } + + if masterList.Size() != 0 { + t.Errorf("Expected size 0, got %d", masterList.Size()) + } + + // Create test database + db, err := database.NewSQLite("file::memory:?mode=memory&cache=shared") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer db.Close() + + // Create appearances for testing + app1 := NewWithData(1001, "Human Male", 1096, db) + app2 := NewWithData(1002, "Elf Female", 1200, db) + app3 := NewWithData(1003, "Dwarf Warrior", 1096, db) + + // Test adding + if !masterList.AddAppearance(app1) { + t.Error("Should successfully add app1") + } + + if !masterList.AddAppearance(app2) { + t.Error("Should successfully add app2") + } + + if !masterList.AddAppearance(app3) { + t.Error("Should successfully add app3") + } + + if masterList.Size() != 3 { + t.Errorf("Expected size 3, got %d", masterList.Size()) + } + + // Test duplicate add (should fail) + if masterList.AddAppearance(app1) { + t.Error("Should not add duplicate appearance") + } + + // Test retrieving + retrieved := masterList.GetAppearance(1001) + if retrieved == nil { + t.Error("Should retrieve added appearance") + } + + if retrieved.Name != "Human Male" { + t.Errorf("Expected name 'Human Male', got '%s'", retrieved.Name) + } + + // Test safe retrieval + retrievedSafe, exists := masterList.GetAppearanceSafe(1001) + if !exists { + t.Error("Should find existing appearance") + } + if retrievedSafe.Name != "Human Male" { + t.Errorf("Expected safe name 'Human Male', got '%s'", retrievedSafe.Name) + } + + _, notExists := masterList.GetAppearanceSafe(9999) + if notExists { + t.Error("Should not find non-existent appearance") + } + + // Test client version filtering + version1096 := masterList.FindAppearancesByMinClient(1096) + if len(version1096) != 2 { + t.Errorf("Expected 2 appearances with min client 1096, got %d", len(version1096)) + } + + version1200 := masterList.FindAppearancesByMinClient(1200) + if len(version1200) != 1 { + t.Errorf("Expected 1 appearance with min client 1200, got %d", len(version1200)) + } + + // Test compatible appearances + compatible1200 := masterList.GetCompatibleAppearances(1200) + if len(compatible1200) != 3 { + t.Errorf("Expected 3 appearances compatible with client 1200, got %d", len(compatible1200)) + } + + compatible1100 := masterList.GetCompatibleAppearances(1100) + if len(compatible1100) != 2 { + t.Errorf("Expected 2 appearances compatible with client 1100, got %d", len(compatible1100)) + } + + // Test name searching (case insensitive) + // Names: "Human Male", "Elf Female", "Dwarf Warrior" + humanApps := masterList.FindAppearancesByName("human") + if len(humanApps) != 1 { + t.Errorf("Expected 1 appearance with 'human' in name, got %d", len(humanApps)) + } + + maleApps := masterList.FindAppearancesByName("male") + if len(maleApps) != 1 { // Only "Human Male" contains "male" + t.Errorf("Expected 1 appearance with 'male' in name, got %d", len(maleApps)) + } + + // Test exact name match (indexed lookup) + humanMaleApps := masterList.FindAppearancesByName("human male") + if len(humanMaleApps) != 1 { + t.Errorf("Expected 1 appearance with exact name 'human male', got %d", len(humanMaleApps)) + } + + // Test ID range filtering + rangeApps := masterList.GetAppearancesByIDRange(1001, 1002) + if len(rangeApps) != 2 { + t.Errorf("Expected 2 appearances in range 1001-1002, got %d", len(rangeApps)) + } + + // Test client version range filtering + clientRangeApps := masterList.GetAppearancesByClientRange(1096, 1096) + if len(clientRangeApps) != 2 { + t.Errorf("Expected 2 appearances in client range 1096-1096, got %d", len(clientRangeApps)) + } + + // Test metadata caching + clientVersions := masterList.GetClientVersions() + if len(clientVersions) != 2 { + t.Errorf("Expected 2 unique client versions, got %d", len(clientVersions)) + } + + // Test clone + clone := masterList.GetAppearanceClone(1001) + if clone == nil { + t.Error("Should return cloned appearance") + } + + if clone.Name != "Human Male" { + t.Errorf("Expected cloned name 'Human Male', got '%s'", clone.Name) + } + + // Test GetAllAppearances + allApps := masterList.GetAllAppearances() + if len(allApps) != 3 { + t.Errorf("Expected 3 appearances in GetAll, got %d", len(allApps)) + } + + // Test GetAllAppearancesList + allAppsList := masterList.GetAllAppearancesList() + if len(allAppsList) != 3 { + t.Errorf("Expected 3 appearances in GetAllList, got %d", len(allAppsList)) + } + + // Test update + updatedApp := NewWithData(1001, "Updated Human", 1500, db) + if err := masterList.UpdateAppearance(updatedApp); err != nil { + t.Errorf("Update should succeed: %v", err) + } + + // Verify update worked + retrievedUpdated := masterList.GetAppearance(1001) + if retrievedUpdated.Name != "Updated Human" { + t.Errorf("Expected updated name 'Updated Human', got '%s'", retrievedUpdated.Name) + } + + // Verify client version index updated + version1500 := masterList.FindAppearancesByMinClient(1500) + if len(version1500) != 1 { + t.Errorf("Expected 1 appearance with min client 1500, got %d", len(version1500)) + } + + // Test removal + if !masterList.RemoveAppearance(1001) { + t.Error("Should successfully remove appearance") + } + + if masterList.Size() != 2 { + t.Errorf("Expected size 2 after removal, got %d", masterList.Size()) + } + + // Test validation + issues := masterList.ValidateAppearances() + if len(issues) != 0 { + t.Errorf("Expected no validation issues, got %d", len(issues)) + } + + // Test statistics + stats := masterList.GetStatistics() + if stats["total_appearances"] != 2 { + t.Errorf("Expected statistics total 2, got %v", stats["total_appearances"]) + } + + // Test clear + masterList.Clear() + if masterList.Size() != 0 { + t.Errorf("Expected size 0 after clear, got %d", masterList.Size()) + } +} + +// TestMasterListConcurrency tests thread safety of the master list +func TestMasterListConcurrency(t *testing.T) { + masterList := NewMasterList() + + // Create test database + db, err := database.NewSQLite("file::memory:?mode=memory&cache=shared") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer db.Close() + + const numWorkers = 10 + const appsPerWorker = 100 + var wg sync.WaitGroup + + // Concurrently add appearances + wg.Add(numWorkers) + for i := 0; i < numWorkers; i++ { + go func(workerID int) { + defer wg.Done() + for j := 0; j < appsPerWorker; j++ { + app := NewWithData( + int32(workerID*appsPerWorker+j+1), + "Concurrent Test", + int16(1096+(workerID%3)*100), + db, + ) + masterList.AddAppearance(app) + } + }(i) + } + + // Concurrently read appearances + wg.Add(numWorkers) + for i := 0; i < numWorkers; i++ { + go func() { + defer wg.Done() + for j := 0; j < appsPerWorker; j++ { + // Random reads + _ = masterList.GetAppearance(int32(j + 1)) + _ = masterList.FindAppearancesByMinClient(1096) + _ = masterList.GetCompatibleAppearances(1200) + _ = masterList.Size() + } + }() + } + + wg.Wait() + + // Verify final state + expectedSize := numWorkers * appsPerWorker + if masterList.Size() != expectedSize { + t.Errorf("Expected size %d, got %d", expectedSize, masterList.Size()) + } + + clientVersions := masterList.GetClientVersions() + if len(clientVersions) != 3 { + t.Errorf("Expected 3 client versions, got %d", len(clientVersions)) + } +} diff --git a/internal/appearances/benchmark_test.go b/internal/appearances/benchmark_test.go new file mode 100644 index 0000000..501a9c9 --- /dev/null +++ b/internal/appearances/benchmark_test.go @@ -0,0 +1,548 @@ +package appearances + +import ( + "fmt" + "math/rand" + "sync" + "testing" + + "eq2emu/internal/database" +) + +// Global shared master list for benchmarks to avoid repeated setup +var ( + sharedAppearanceMasterList *MasterList + sharedAppearances []*Appearance + appearanceSetupOnce sync.Once +) + +// setupSharedAppearanceMasterList creates the shared master list once +func setupSharedAppearanceMasterList(b *testing.B) { + appearanceSetupOnce.Do(func() { + // Create test database + db, err := database.NewSQLite("file::memory:?mode=memory&cache=shared") + if err != nil { + b.Fatalf("Failed to create test database: %v", err) + } + + sharedAppearanceMasterList = NewMasterList() + + // Pre-populate with appearances for realistic testing + const numAppearances = 1000 + sharedAppearances = make([]*Appearance, numAppearances) + + clientVersions := []int16{1096, 1200, 1300, 1400, 1500} + nameTemplates := []string{ + "Human %s", + "Elf %s", + "Dwarf %s", + "Halfling %s", + "Barbarian %s", + "Dark Elf %s", + "Wood Elf %s", + "High Elf %s", + "Gnome %s", + "Troll %s", + } + genders := []string{"Male", "Female"} + + for i := range numAppearances { + sharedAppearances[i] = NewWithData( + int32(i+1), + fmt.Sprintf(nameTemplates[i%len(nameTemplates)], genders[i%len(genders)]), + clientVersions[i%len(clientVersions)], + db, + ) + + sharedAppearanceMasterList.AddAppearance(sharedAppearances[i]) + } + }) +} + +// createTestAppearance creates an appearance for benchmarking +func createTestAppearance(b *testing.B, id int32) *Appearance { + b.Helper() + + // Use nil database for benchmarking in-memory operations + clientVersions := []int16{1096, 1200, 1300, 1400, 1500} + nameTemplates := []string{"Human", "Elf", "Dwarf", "Halfling"} + genders := []string{"Male", "Female"} + + app := NewWithData( + id, + fmt.Sprintf("Benchmark %s %s", nameTemplates[id%int32(len(nameTemplates))], genders[id%2]), + clientVersions[id%int32(len(clientVersions))], + nil, + ) + + return app +} + +// BenchmarkAppearanceCreation measures appearance creation performance +func BenchmarkAppearanceCreation(b *testing.B) { + db, err := database.NewSQLite("file::memory:?mode=memory&cache=shared") + if err != nil { + b.Fatalf("Failed to create test database: %v", err) + } + defer db.Close() + + b.ResetTimer() + + b.Run("Sequential", func(b *testing.B) { + for i := 0; i < b.N; i++ { + app := New(db) + app.ID = int32(i) + app.Name = fmt.Sprintf("Appearance %d", i) + app.MinClient = 1096 + _ = app + } + }) + + b.Run("Parallel", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + id := int32(0) + for pb.Next() { + app := New(db) + app.ID = id + app.Name = fmt.Sprintf("Appearance %d", id) + app.MinClient = 1096 + id++ + _ = app + } + }) + }) + + b.Run("NewWithData", func(b *testing.B) { + for i := 0; i < b.N; i++ { + app := NewWithData(int32(i), fmt.Sprintf("Appearance %d", i), 1096, db) + _ = app + } + }) +} + +// BenchmarkAppearanceOperations measures individual appearance operations +func BenchmarkAppearanceOperations(b *testing.B) { + app := createTestAppearance(b, 1001) + + b.Run("GetID", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = app.GetID() + } + }) + }) + + b.Run("GetName", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = app.GetName() + } + }) + }) + + b.Run("GetMinClientVersion", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = app.GetMinClientVersion() + } + }) + }) + + b.Run("IsCompatibleWithClient", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = app.IsCompatibleWithClient(1200) + } + }) + }) + + b.Run("Clone", func(b *testing.B) { + for b.Loop() { + _ = app.Clone() + } + }) + + b.Run("IsNew", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = app.IsNew() + } + }) + }) +} + +// BenchmarkMasterListOperations measures master list performance +func BenchmarkMasterListOperations(b *testing.B) { + setupSharedAppearanceMasterList(b) + ml := sharedAppearanceMasterList + + b.Run("GetAppearance", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + id := int32(rand.Intn(1000) + 1) + _ = ml.GetAppearance(id) + } + }) + }) + + b.Run("AddAppearance", func(b *testing.B) { + // Create a separate master list for add operations + addML := NewMasterList() + startID := int32(10000) + // Pre-create appearances to measure just the Add operation + appsToAdd := make([]*Appearance, b.N) + for i := 0; i < b.N; i++ { + appsToAdd[i] = createTestAppearance(b, startID+int32(i)) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + addML.AddAppearance(appsToAdd[i]) + } + }) + + b.Run("GetAppearanceSafe", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + id := int32(rand.Intn(1000) + 1) + _, _ = ml.GetAppearanceSafe(id) + } + }) + }) + + b.Run("HasAppearance", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + id := int32(rand.Intn(1000) + 1) + _ = ml.HasAppearance(id) + } + }) + }) + + b.Run("FindAppearancesByMinClient", func(b *testing.B) { + clientVersions := []int16{1096, 1200, 1300, 1400, 1500} + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + version := clientVersions[rand.Intn(len(clientVersions))] + _ = ml.FindAppearancesByMinClient(version) + } + }) + }) + + b.Run("GetCompatibleAppearances", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + version := int16(1200 + rand.Intn(300)) + _ = ml.GetCompatibleAppearances(version) + } + }) + }) + + b.Run("FindAppearancesByName", func(b *testing.B) { + searchTerms := []string{"human", "male", "elf", "female", "dwarf"} + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + term := searchTerms[rand.Intn(len(searchTerms))] + _ = ml.FindAppearancesByName(term) + } + }) + }) + + b.Run("GetAppearancesByIDRange", func(b *testing.B) { + for b.Loop() { + start := int32(rand.Intn(900) + 1) + end := start + int32(rand.Intn(100)+10) + _ = ml.GetAppearancesByIDRange(start, end) + } + }) + + b.Run("GetAppearancesByClientRange", func(b *testing.B) { + for b.Loop() { + minVersion := int16(1096 + rand.Intn(200)) + maxVersion := minVersion + int16(rand.Intn(200)) + _ = ml.GetAppearancesByClientRange(minVersion, maxVersion) + } + }) + + b.Run("GetClientVersions", func(b *testing.B) { + for b.Loop() { + _ = ml.GetClientVersions() + } + }) + + b.Run("Size", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = ml.Size() + } + }) + }) + + b.Run("GetAppearanceCount", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = ml.GetAppearanceCount() + } + }) + }) +} + +// BenchmarkConcurrentOperations tests mixed workload performance +func BenchmarkConcurrentOperations(b *testing.B) { + setupSharedAppearanceMasterList(b) + ml := sharedAppearanceMasterList + + b.Run("MixedOperations", func(b *testing.B) { + clientVersions := []int16{1096, 1200, 1300, 1400, 1500} + searchTerms := []string{"human", "male", "elf", "female", "dwarf"} + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + switch rand.Intn(10) { + case 0: + id := int32(rand.Intn(1000) + 1) + _ = ml.GetAppearance(id) + case 1: + id := int32(rand.Intn(1000) + 1) + _, _ = ml.GetAppearanceSafe(id) + case 2: + id := int32(rand.Intn(1000) + 1) + _ = ml.HasAppearance(id) + case 3: + version := clientVersions[rand.Intn(len(clientVersions))] + _ = ml.FindAppearancesByMinClient(version) + case 4: + version := int16(1200 + rand.Intn(300)) + _ = ml.GetCompatibleAppearances(version) + case 5: + term := searchTerms[rand.Intn(len(searchTerms))] + _ = ml.FindAppearancesByName(term) + case 6: + start := int32(rand.Intn(900) + 1) + end := start + int32(rand.Intn(100)+10) + _ = ml.GetAppearancesByIDRange(start, end) + case 7: + minVersion := int16(1096 + rand.Intn(200)) + maxVersion := minVersion + int16(rand.Intn(200)) + _ = ml.GetAppearancesByClientRange(minVersion, maxVersion) + case 8: + _ = ml.GetClientVersions() + case 9: + _ = ml.Size() + } + } + }) + }) +} + +// BenchmarkMemoryAllocation measures memory allocation patterns +func BenchmarkMemoryAllocation(b *testing.B) { + db, err := database.NewSQLite("file::memory:?mode=memory&cache=shared") + if err != nil { + b.Fatalf("Failed to create test database: %v", err) + } + defer db.Close() + + b.Run("AppearanceAllocation", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + app := New(db) + app.ID = int32(i) + app.Name = fmt.Sprintf("Appearance %d", i) + app.MinClient = 1096 + _ = app + } + }) + + b.Run("NewWithDataAllocation", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + app := NewWithData(int32(i), fmt.Sprintf("Appearance %d", i), 1096, db) + _ = app + } + }) + + b.Run("MasterListAllocation", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + ml := NewMasterList() + _ = ml + } + }) + + b.Run("AddAppearance_Allocations", func(b *testing.B) { + b.ReportAllocs() + ml := NewMasterList() + for i := 0; i < b.N; i++ { + app := createTestAppearance(b, int32(i+1)) + ml.AddAppearance(app) + } + }) + + b.Run("FindAppearancesByMinClient_Allocations", func(b *testing.B) { + setupSharedAppearanceMasterList(b) + ml := sharedAppearanceMasterList + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = ml.FindAppearancesByMinClient(1096) + } + }) + + b.Run("FindAppearancesByName_Allocations", func(b *testing.B) { + setupSharedAppearanceMasterList(b) + ml := sharedAppearanceMasterList + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = ml.FindAppearancesByName("human") + } + }) + + b.Run("GetClientVersions_Allocations", func(b *testing.B) { + setupSharedAppearanceMasterList(b) + ml := sharedAppearanceMasterList + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = ml.GetClientVersions() + } + }) +} + +// BenchmarkUpdateOperations measures update performance +func BenchmarkUpdateOperations(b *testing.B) { + setupSharedAppearanceMasterList(b) + ml := sharedAppearanceMasterList + + b.Run("UpdateAppearance", func(b *testing.B) { + // Create appearances to update + updateApps := make([]*Appearance, b.N) + for i := 0; i < b.N; i++ { + updateApps[i] = createTestAppearance(b, int32((i%1000)+1)) + updateApps[i].Name = "Updated Name" + updateApps[i].MinClient = 1600 + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = ml.UpdateAppearance(updateApps[i]) + } + }) + + b.Run("RemoveAppearance", func(b *testing.B) { + // Create a separate master list for removal testing + removeML := NewMasterList() + + // Add appearances to remove + for i := 0; i < b.N; i++ { + app := createTestAppearance(b, int32(i+1)) + removeML.AddAppearance(app) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + removeML.RemoveAppearance(int32(i + 1)) + } + }) +} + +// BenchmarkValidation measures validation performance +func BenchmarkValidation(b *testing.B) { + setupSharedAppearanceMasterList(b) + ml := sharedAppearanceMasterList + + b.Run("ValidateAppearances", func(b *testing.B) { + for b.Loop() { + _ = ml.ValidateAppearances() + } + }) + + b.Run("IsValid", func(b *testing.B) { + for b.Loop() { + _ = ml.IsValid() + } + }) + + b.Run("IndividualValidation", func(b *testing.B) { + app := createTestAppearance(b, 1001) + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = app.IsCompatibleWithClient(1200) + } + }) + }) +} + +// BenchmarkCloneOperations measures cloning performance +func BenchmarkCloneOperations(b *testing.B) { + setupSharedAppearanceMasterList(b) + ml := sharedAppearanceMasterList + + b.Run("GetAppearanceClone", func(b *testing.B) { + for b.Loop() { + id := int32(rand.Intn(1000) + 1) + _ = ml.GetAppearanceClone(id) + } + }) + + b.Run("DirectClone", func(b *testing.B) { + app := createTestAppearance(b, 1001) + for b.Loop() { + _ = app.Clone() + } + }) +} + +// BenchmarkStatistics measures statistics performance +func BenchmarkStatistics(b *testing.B) { + setupSharedAppearanceMasterList(b) + ml := sharedAppearanceMasterList + + b.Run("GetStatistics", func(b *testing.B) { + for b.Loop() { + _ = ml.GetStatistics() + } + }) + + b.Run("GetAllAppearances", func(b *testing.B) { + for b.Loop() { + _ = ml.GetAllAppearances() + } + }) + + b.Run("GetAllAppearancesList", func(b *testing.B) { + for b.Loop() { + _ = ml.GetAllAppearancesList() + } + }) +} + +// BenchmarkStringOperations measures string operations performance +func BenchmarkStringOperations(b *testing.B) { + b.Run("GetAppearanceType", func(b *testing.B) { + typeNames := []string{"hair_color1", "skin_color", "eye_color", "unknown_type"} + for b.Loop() { + typeName := typeNames[rand.Intn(len(typeNames))] + _ = GetAppearanceType(typeName) + } + }) + + b.Run("GetAppearanceTypeName", func(b *testing.B) { + typeConstants := []int8{AppearanceHairColor1, AppearanceSkinColor, AppearanceEyeColor, -1} + for b.Loop() { + typeConst := typeConstants[rand.Intn(len(typeConstants))] + _ = GetAppearanceTypeName(typeConst) + } + }) + + b.Run("ContainsSubstring", func(b *testing.B) { + testStrings := []string{"Human Male Fighter", "Elf Female Mage", "Dwarf Male Warrior"} + searchTerms := []string{"human", "male", "elf", "notfound"} + + for b.Loop() { + str := testStrings[rand.Intn(len(testStrings))] + term := searchTerms[rand.Intn(len(searchTerms))] + _ = contains(str, term) + } + }) +} diff --git a/internal/appearances/master.go b/internal/appearances/master.go index 187b3ba..cb689e4 100644 --- a/internal/appearances/master.go +++ b/internal/appearances/master.go @@ -2,105 +2,421 @@ package appearances import ( "fmt" + "maps" + "strings" + "sync" - "eq2emu/internal/common" "eq2emu/internal/database" ) -// MasterList manages a collection of appearances using the generic MasterList base +// MasterList is a specialized appearance master list optimized for: +// - Fast ID-based lookups (O(1)) +// - Fast name-based searching (indexed) +// - Fast client version filtering (O(1)) +// - Efficient range queries and statistics type MasterList struct { - *common.MasterList[int32, *Appearance] + // Core storage + appearances map[int32]*Appearance // ID -> Appearance + mutex sync.RWMutex + + // Specialized indices for O(1) lookups + byMinClient map[int16][]*Appearance // MinClient -> appearances + byNamePart map[string][]*Appearance // Name substring -> appearances (for common searches) + + // Cached metadata + clientVersions []int16 // Unique client versions (cached) + metaStale bool // Whether metadata cache needs refresh } -// NewMasterList creates a new appearance master list +// NewMasterList creates a new specialized appearance master list func NewMasterList() *MasterList { return &MasterList{ - MasterList: common.NewMasterList[int32, *Appearance](), + appearances: make(map[int32]*Appearance), + byMinClient: make(map[int16][]*Appearance), + byNamePart: make(map[string][]*Appearance), + metaStale: true, } } -// AddAppearance adds an appearance to the master list -func (ml *MasterList) AddAppearance(appearance *Appearance) bool { - return ml.Add(appearance) +// refreshMetaCache updates the client versions cache +func (ml *MasterList) refreshMetaCache() { + if !ml.metaStale { + return + } + + clientVersionSet := make(map[int16]struct{}) + + // Collect unique client versions + for _, appearance := range ml.appearances { + clientVersionSet[appearance.MinClient] = struct{}{} + } + + // Clear existing cache and rebuild + ml.clientVersions = ml.clientVersions[:0] + for version := range clientVersionSet { + ml.clientVersions = append(ml.clientVersions, version) + } + + ml.metaStale = false } -// GetAppearance retrieves an appearance by ID +// updateNameIndices updates name-based indices for an appearance +func (ml *MasterList) updateNameIndices(appearance *Appearance, add bool) { + // Index common name patterns for fast searching + name := strings.ToLower(appearance.Name) + partsSet := make(map[string]struct{}) // Use set to avoid duplicates + + // Add full name + partsSet[name] = struct{}{} + + // Add word-based indices for multi-word names + if strings.Contains(name, " ") { + words := strings.FieldsSeq(name) + for word := range words { + partsSet[word] = struct{}{} + } + } + + // Add prefix indices for common prefixes + if len(name) >= 3 { + partsSet[name[:3]] = struct{}{} + } + if len(name) >= 5 { + partsSet[name[:5]] = struct{}{} + } + + // Convert set to slice + for part := range partsSet { + if add { + ml.byNamePart[part] = append(ml.byNamePart[part], appearance) + } else { + // Remove from name part index + if namePartApps := ml.byNamePart[part]; namePartApps != nil { + for i, app := range namePartApps { + if app.ID == appearance.ID { + ml.byNamePart[part] = append(namePartApps[:i], namePartApps[i+1:]...) + break + } + } + } + } + } +} + +// AddAppearance adds an appearance with full indexing +func (ml *MasterList) AddAppearance(appearance *Appearance) bool { + if appearance == nil { + return false + } + + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Check if exists + if _, exists := ml.appearances[appearance.ID]; exists { + return false + } + + // Add to core storage + ml.appearances[appearance.ID] = appearance + + // Update client version index + ml.byMinClient[appearance.MinClient] = append(ml.byMinClient[appearance.MinClient], appearance) + + // Update name indices + ml.updateNameIndices(appearance, true) + + // Invalidate metadata cache + ml.metaStale = true + + return true +} + +// GetAppearance retrieves by ID (O(1)) func (ml *MasterList) GetAppearance(id int32) *Appearance { - return ml.Get(id) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.appearances[id] } // GetAppearanceSafe retrieves an appearance by ID with existence check func (ml *MasterList) GetAppearanceSafe(id int32) (*Appearance, bool) { - return ml.GetSafe(id) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + appearance, exists := ml.appearances[id] + return appearance, exists } // HasAppearance checks if an appearance exists by ID func (ml *MasterList) HasAppearance(id int32) bool { - return ml.Exists(id) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + _, exists := ml.appearances[id] + return exists } -// RemoveAppearance removes an appearance by ID -func (ml *MasterList) RemoveAppearance(id int32) bool { - return ml.Remove(id) +// GetAppearanceClone retrieves a cloned copy of an appearance by ID +func (ml *MasterList) GetAppearanceClone(id int32) *Appearance { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + appearance := ml.appearances[id] + if appearance == nil { + return nil + } + return appearance.Clone() } -// GetAllAppearances returns all appearances as a map +// GetAllAppearances returns a copy of all appearances map func (ml *MasterList) GetAllAppearances() map[int32]*Appearance { - return ml.GetAll() + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + // Return a copy to prevent external modification + result := make(map[int32]*Appearance, len(ml.appearances)) + maps.Copy(result, ml.appearances) + return result } // GetAllAppearancesList returns all appearances as a slice func (ml *MasterList) GetAllAppearancesList() []*Appearance { - return ml.GetAllSlice() + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + result := make([]*Appearance, 0, len(ml.appearances)) + for _, appearance := range ml.appearances { + result = append(result, appearance) + } + return result } -// GetAppearanceCount returns the number of appearances -func (ml *MasterList) GetAppearanceCount() int { - return ml.Size() -} - -// ClearAppearances removes all appearances from the list -func (ml *MasterList) ClearAppearances() { - ml.Clear() -} - -// FindAppearancesByName finds appearances containing the given name substring -func (ml *MasterList) FindAppearancesByName(nameSubstring string) []*Appearance { - return ml.Filter(func(appearance *Appearance) bool { - return contains(appearance.GetName(), nameSubstring) - }) -} - -// FindAppearancesByMinClient finds appearances with specific minimum client version +// FindAppearancesByMinClient finds appearances with specific minimum client version (O(1)) func (ml *MasterList) FindAppearancesByMinClient(minClient int16) []*Appearance { - return ml.Filter(func(appearance *Appearance) bool { - return appearance.GetMinClientVersion() == minClient - }) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byMinClient[minClient] } // GetCompatibleAppearances returns appearances compatible with the given client version func (ml *MasterList) GetCompatibleAppearances(clientVersion int16) []*Appearance { - return ml.Filter(func(appearance *Appearance) bool { - return appearance.IsCompatibleWithClient(clientVersion) - }) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + var result []*Appearance + + // Collect all appearances with MinClient <= clientVersion + for minClient, appearances := range ml.byMinClient { + if minClient <= clientVersion { + result = append(result, appearances...) + } + } + + return result +} + +// FindAppearancesByName finds appearances containing the given name substring (optimized) +func (ml *MasterList) FindAppearancesByName(nameSubstring string) []*Appearance { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + searchKey := strings.ToLower(nameSubstring) + + // Try indexed lookup first for exact matches + if indexedApps := ml.byNamePart[searchKey]; indexedApps != nil { + // Return a copy to prevent external modification + result := make([]*Appearance, len(indexedApps)) + copy(result, indexedApps) + return result + } + + // Fallback to full scan for partial matches + var result []*Appearance + for _, appearance := range ml.appearances { + if contains(strings.ToLower(appearance.Name), searchKey) { + result = append(result, appearance) + } + } + + return result } // GetAppearancesByIDRange returns appearances within the given ID range (inclusive) func (ml *MasterList) GetAppearancesByIDRange(minID, maxID int32) []*Appearance { - return ml.Filter(func(appearance *Appearance) bool { - id := appearance.GetID() - return id >= minID && id <= maxID - }) + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + var result []*Appearance + for id, appearance := range ml.appearances { + if id >= minID && id <= maxID { + result = append(result, appearance) + } + } + + return result +} + +// GetAppearancesByClientRange returns appearances compatible within client version range +func (ml *MasterList) GetAppearancesByClientRange(minClientVersion, maxClientVersion int16) []*Appearance { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + var result []*Appearance + + // Collect all appearances with MinClient in range + for minClient, appearances := range ml.byMinClient { + if minClient >= minClientVersion && minClient <= maxClientVersion { + result = append(result, appearances...) + } + } + + return result +} + +// GetClientVersions returns all unique client versions using cached results +func (ml *MasterList) GetClientVersions() []int16 { + 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([]int16, len(ml.clientVersions)) + copy(result, ml.clientVersions) + return result +} + +// RemoveAppearance removes an appearance and updates all indices +func (ml *MasterList) RemoveAppearance(id int32) bool { + ml.mutex.Lock() + defer ml.mutex.Unlock() + + appearance, exists := ml.appearances[id] + if !exists { + return false + } + + // Remove from core storage + delete(ml.appearances, id) + + // Remove from client version index + clientApps := ml.byMinClient[appearance.MinClient] + for i, app := range clientApps { + if app.ID == id { + ml.byMinClient[appearance.MinClient] = append(clientApps[:i], clientApps[i+1:]...) + break + } + } + + // Remove from name indices + ml.updateNameIndices(appearance, false) + + // Invalidate metadata cache + ml.metaStale = true + + return true +} + +// UpdateAppearance updates an existing appearance +func (ml *MasterList) UpdateAppearance(appearance *Appearance) error { + if appearance == nil { + return fmt.Errorf("appearance cannot be nil") + } + + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Check if exists + old, exists := ml.appearances[appearance.ID] + if !exists { + return fmt.Errorf("appearance %d not found", appearance.ID) + } + + // Remove old appearance from indices (but not core storage yet) + clientApps := ml.byMinClient[old.MinClient] + for i, app := range clientApps { + if app.ID == appearance.ID { + ml.byMinClient[old.MinClient] = append(clientApps[:i], clientApps[i+1:]...) + break + } + } + + // Remove from name indices + ml.updateNameIndices(old, false) + + // Update core storage + ml.appearances[appearance.ID] = appearance + + // Add new appearance to indices + ml.byMinClient[appearance.MinClient] = append(ml.byMinClient[appearance.MinClient], appearance) + ml.updateNameIndices(appearance, true) + + // Invalidate metadata cache + ml.metaStale = true + + return nil +} + +// GetAppearanceCount returns the number of appearances +func (ml *MasterList) GetAppearanceCount() int { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return len(ml.appearances) +} + +// Size returns the total number of appearances +func (ml *MasterList) Size() int { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return len(ml.appearances) +} + +// IsEmpty returns true if the master list is empty +func (ml *MasterList) IsEmpty() bool { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return len(ml.appearances) == 0 +} + +// ClearAppearances removes all appearances from the list +func (ml *MasterList) ClearAppearances() { + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Clear all maps + ml.appearances = make(map[int32]*Appearance) + ml.byMinClient = make(map[int16][]*Appearance) + ml.byNamePart = make(map[string][]*Appearance) + + // Clear cached metadata + ml.clientVersions = ml.clientVersions[:0] + ml.metaStale = true +} + +// Clear removes all appearances from the master list +func (ml *MasterList) Clear() { + ml.ClearAppearances() +} + +// ForEach executes a function for each appearance +func (ml *MasterList) ForEach(fn func(int32, *Appearance)) { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + for id, appearance := range ml.appearances { + fn(id, appearance) + } } // ValidateAppearances checks all appearances for consistency func (ml *MasterList) ValidateAppearances() []string { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + var issues []string - ml.ForEach(func(id int32, appearance *Appearance) { + for id, appearance := range ml.appearances { if appearance == nil { issues = append(issues, fmt.Sprintf("Appearance ID %d is nil", id)) - return + continue } if appearance.GetID() != id { @@ -114,7 +430,7 @@ func (ml *MasterList) ValidateAppearances() []string { if appearance.GetMinClientVersion() < 0 { issues = append(issues, fmt.Sprintf("Appearance ID %d has negative min client version: %d", id, appearance.GetMinClientVersion())) } - }) + } return issues } @@ -127,10 +443,13 @@ func (ml *MasterList) IsValid() bool { // GetStatistics returns statistics about the appearance collection func (ml *MasterList) GetStatistics() map[string]any { - stats := make(map[string]any) - stats["total_appearances"] = ml.Size() + ml.mutex.RLock() + defer ml.mutex.RUnlock() - if ml.IsEmpty() { + stats := make(map[string]any) + stats["total_appearances"] = len(ml.appearances) + + if len(ml.appearances) == 0 { return stats } @@ -139,7 +458,7 @@ func (ml *MasterList) GetStatistics() map[string]any { var minID, maxID int32 first := true - ml.ForEach(func(id int32, appearance *Appearance) { + for id, appearance := range ml.appearances { versionCounts[appearance.GetMinClientVersion()]++ if first { @@ -154,7 +473,7 @@ func (ml *MasterList) GetStatistics() map[string]any { maxID = id } } - }) + } stats["appearances_by_min_client"] = versionCounts stats["min_id"] = minID