diff --git a/internal/common/benchmark_test.go b/internal/common/benchmark_test.go new file mode 100644 index 0000000..b1c4576 --- /dev/null +++ b/internal/common/benchmark_test.go @@ -0,0 +1,210 @@ +package common + +import ( + "fmt" + "math/rand" + "testing" +) + +// testItem implements Identifiable for benchmarking +type testItem struct { + id int32 + name string + value int32 + flag bool +} + +func (t *testItem) GetID() int32 { return t.id } + +// BenchmarkMasterListOperations benchmarks the generic MasterList +func BenchmarkMasterListOperations(b *testing.B) { + // Create master list with test data + ml := NewMasterList[int32, *testItem]() + const numItems = 10000 + + // Pre-populate + b.StopTimer() + for i := 0; i < numItems; i++ { + item := &testItem{ + id: int32(i + 1), + name: fmt.Sprintf("Item %d", i+1), + value: int32(rand.Intn(100)), + flag: rand.Intn(2) == 1, + } + ml.Add(item) + } + b.StartTimer() + + b.Run("Get", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + id := int32(rand.Intn(numItems) + 1) + _ = ml.Get(id) + } + }) + }) + + b.Run("Filter_10Percent", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = ml.Filter(func(item *testItem) bool { + return item.value < 10 // ~10% match + }) + } + }) + + b.Run("Filter_50Percent", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = ml.Filter(func(item *testItem) bool { + return item.value < 50 // ~50% match + }) + } + }) + + b.Run("Filter_90Percent", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = ml.Filter(func(item *testItem) bool { + return item.value < 90 // ~90% match + }) + } + }) + + b.Run("Count_10Percent", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = ml.Count(func(item *testItem) bool { + return item.value < 10 + }) + } + }) + + b.Run("Count_50Percent", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = ml.Count(func(item *testItem) bool { + return item.value < 50 + }) + } + }) + + b.Run("Find", func(b *testing.B) { + for i := 0; i < b.N; i++ { + targetValue := int32(rand.Intn(100)) + _, _ = ml.Find(func(item *testItem) bool { + return item.value == targetValue + }) + } + }) + + b.Run("ForEach", func(b *testing.B) { + for i := 0; i < b.N; i++ { + ml.ForEach(func(id int32, item *testItem) { + _ = item.value + 1 // Minimal work + }) + } + }) + + b.Run("WithReadLock", func(b *testing.B) { + for i := 0; i < b.N; i++ { + ml.WithReadLock(func(items map[int32]*testItem) { + count := 0 + for _, item := range items { + if item.value < 50 { + count++ + } + } + _ = count + }) + } + }) + + b.Run("FilterWithCapacity_Accurate", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = ml.FilterWithCapacity(func(item *testItem) bool { + return item.value < 50 + }, 5000) // Accurate estimate: 50% of 10k = 5k + } + }) + + b.Run("FilterInto_Reuse", func(b *testing.B) { + var reusableSlice []*testItem + for i := 0; i < b.N; i++ { + reusableSlice = ml.FilterInto(func(item *testItem) bool { + return item.value < 50 + }, reusableSlice) + } + }) + + b.Run("CountAndFilter_Combined", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = ml.CountAndFilter(func(item *testItem) bool { + return item.value < 50 + }) + } + }) +} + +// BenchmarkMemoryAllocations tests allocation patterns +func BenchmarkMemoryAllocations(b *testing.B) { + ml := NewMasterList[int32, *testItem]() + const numItems = 1000 + + // Pre-populate + for i := 0; i < numItems; i++ { + item := &testItem{ + id: int32(i + 1), + name: fmt.Sprintf("Item %d", i+1), + value: int32(rand.Intn(100)), + flag: rand.Intn(2) == 1, + } + ml.Add(item) + } + + b.Run("Filter_Allocations", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = ml.Filter(func(item *testItem) bool { + return item.value < 50 + }) + } + }) + + b.Run("GetAll_Allocations", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = ml.GetAll() + } + }) + + b.Run("GetAllSlice_Allocations", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = ml.GetAllSlice() + } + }) + + b.Run("FilterWithCapacity_Allocations", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = ml.FilterWithCapacity(func(item *testItem) bool { + return item.value < 50 + }, 500) // Accurate capacity estimate + } + }) + + b.Run("FilterInto_Allocations", func(b *testing.B) { + b.ReportAllocs() + reusableSlice := make([]*testItem, 0, 600) // Pre-sized + for i := 0; i < b.N; i++ { + reusableSlice = ml.FilterInto(func(item *testItem) bool { + return item.value < 50 + }, reusableSlice) + } + }) + + b.Run("CountAndFilter_Allocations", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, _ = ml.CountAndFilter(func(item *testItem) bool { + return item.value < 50 + }) + } + }) +} \ No newline at end of file diff --git a/internal/common/master_list.go b/internal/common/master_list.go index 39d2452..cf3cf3b 100644 --- a/internal/common/master_list.go +++ b/internal/common/master_list.go @@ -7,6 +7,7 @@ package common import ( "fmt" + "maps" "sync" ) @@ -154,9 +155,7 @@ func (ml *MasterList[K, V]) GetAll() map[K]V { defer ml.mutex.RUnlock() result := make(map[K]V, len(ml.items)) - for k, v := range ml.items { - result[k] = v - } + maps.Copy(result, ml.items) return result } @@ -205,7 +204,8 @@ func (ml *MasterList[K, V]) Filter(predicate func(V) bool) []V { ml.mutex.RLock() defer ml.mutex.RUnlock() - var result []V + // Pre-allocate with estimated capacity to reduce allocations + result := make([]V, 0, len(ml.items)/4) // Assume ~25% match rate for _, v := range ml.items { if predicate(v) { result = append(result, v) @@ -260,4 +260,52 @@ func (ml *MasterList[K, V]) WithWriteLock(fn func(map[K]V)) { ml.mutex.Lock() defer ml.mutex.Unlock() fn(ml.items) -} \ No newline at end of file +} + +// FilterWithCapacity returns items matching predicate with pre-allocated capacity. +// Use when you have a good estimate of result size to optimize allocations. +func (ml *MasterList[K, V]) FilterWithCapacity(predicate func(V) bool, expectedSize int) []V { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + result := make([]V, 0, expectedSize) + for _, v := range ml.items { + if predicate(v) { + result = append(result, v) + } + } + return result +} + +// FilterInto appends matching items to the provided slice, avoiding new allocations. +// Returns the updated slice. Use this for repeated filtering to reuse memory. +func (ml *MasterList[K, V]) FilterInto(predicate func(V) bool, result []V) []V { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + // Clear the slice but keep capacity + result = result[:0] + for _, v := range ml.items { + if predicate(v) { + result = append(result, v) + } + } + return result +} + +// CountAndFilter performs both count and filter in a single pass. +// More efficient than calling Count() and Filter() separately. +func (ml *MasterList[K, V]) CountAndFilter(predicate func(V) bool) (int, []V) { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + count := 0 + result := make([]V, 0, len(ml.items)/4) + for _, v := range ml.items { + if predicate(v) { + count++ + result = append(result, v) + } + } + return count, result +} diff --git a/internal/ground_spawn/benchmark_test.go b/internal/ground_spawn/benchmark_test.go index 4342fb9..22b6be2 100644 --- a/internal/ground_spawn/benchmark_test.go +++ b/internal/ground_spawn/benchmark_test.go @@ -3,6 +3,7 @@ package ground_spawn import ( "fmt" "math/rand" + "sync" "testing" "eq2emu/internal/database" @@ -33,13 +34,10 @@ func (s *mockSkill) GetMaxValue() int16 { return s.max } // createTestGroundSpawn creates a ground spawn for benchmarking func createTestGroundSpawn(b *testing.B, id int32) *GroundSpawn { b.Helper() - - db, err := database.NewSQLite("file::memory:?mode=memory&cache=shared") - if err != nil { - b.Fatalf("Failed to create test database: %v", err) - } - gs := New(db) + // Don't create a database for every ground spawn - that's extremely expensive! + // Pass nil for the database since we're just benchmarking the in-memory operations + gs := New(nil) gs.GroundSpawnID = id gs.Name = fmt.Sprintf("Benchmark Node %d", id) gs.CollectionSkill = "Mining" @@ -49,7 +47,7 @@ func createTestGroundSpawn(b *testing.B, id int32) *GroundSpawn { gs.X, gs.Y, gs.Z = float32(rand.Intn(1000)), float32(rand.Intn(1000)), float32(rand.Intn(1000)) gs.ZoneID = int32(rand.Intn(10) + 1) gs.GridID = int32(rand.Intn(100)) - + // Add mock harvest entries for realistic benchmarking gs.HarvestEntries = []*HarvestEntry{ { @@ -77,7 +75,7 @@ func createTestGroundSpawn(b *testing.B, id int32) *GroundSpawn { Harvest10: 2.0, }, } - + // Add mock harvest items gs.HarvestItems = []*HarvestEntryItem{ {GroundSpawnID: id, ItemID: 1001, IsRare: ItemRarityNormal, GridID: 0, Quantity: 1}, @@ -85,7 +83,7 @@ func createTestGroundSpawn(b *testing.B, id int32) *GroundSpawn { {GroundSpawnID: id, ItemID: 1003, IsRare: ItemRarityRare, GridID: 0, Quantity: 1}, {GroundSpawnID: id, ItemID: 1004, IsRare: ItemRarityImbue, GridID: 0, Quantity: 1}, } - + return gs } @@ -98,7 +96,7 @@ func BenchmarkGroundSpawnCreation(b *testing.B) { defer db.Close() b.ResetTimer() - + b.Run("Sequential", func(b *testing.B) { for i := 0; i < b.N; i++ { gs := New(db) @@ -125,7 +123,7 @@ func BenchmarkGroundSpawnCreation(b *testing.B) { // BenchmarkGroundSpawnState measures state operations func BenchmarkGroundSpawnState(b *testing.B) { gs := createTestGroundSpawn(b, 1001) - + b.Run("IsAvailable", func(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { @@ -133,7 +131,7 @@ func BenchmarkGroundSpawnState(b *testing.B) { } }) }) - + b.Run("IsDepleted", func(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { @@ -141,7 +139,7 @@ func BenchmarkGroundSpawnState(b *testing.B) { } }) }) - + b.Run("GetHarvestMessageName", func(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { @@ -149,7 +147,7 @@ func BenchmarkGroundSpawnState(b *testing.B) { } }) }) - + b.Run("GetHarvestSpellType", func(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { @@ -162,85 +160,112 @@ func BenchmarkGroundSpawnState(b *testing.B) { // BenchmarkHarvestAlgorithm measures the core harvest processing performance func BenchmarkHarvestAlgorithm(b *testing.B) { gs := createTestGroundSpawn(b, 1001) - + player := &mockPlayer{level: 50, location: 1, name: "BenchmarkPlayer"} skill := &mockSkill{current: 75, max: 100} - + b.Run("ProcessHarvest", func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { // Reset harvest count for consistent benchmarking gs.CurrentHarvests = gs.NumberHarvests - + _, err := gs.ProcessHarvest(player, skill, 75) if err != nil { b.Fatalf("Harvest failed: %v", err) } } }) - + b.Run("FilterHarvestTables", func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { _ = gs.filterHarvestTables(player, 75) } }) - + b.Run("SelectHarvestTable", func(b *testing.B) { tables := gs.filterHarvestTables(player, 75) - for i := 0; i < b.N; i++ { + for b.Loop() { _ = gs.selectHarvestTable(tables, 75) } }) - + b.Run("DetermineHarvestType", func(b *testing.B) { tables := gs.filterHarvestTables(player, 75) table := gs.selectHarvestTable(tables, 75) - for i := 0; i < b.N; i++ { + for b.Loop() { _ = gs.determineHarvestType(table, false) } }) - + b.Run("AwardHarvestItems", func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { _ = gs.awardHarvestItems(HarvestType3Items, player) } }) } +// Global shared master list for benchmarks to avoid repeated setup +var ( + sharedMasterList *MasterList + sharedSpawns []*GroundSpawn + setupOnce sync.Once +) + +// setupSharedMasterList creates the shared master list once +func setupSharedMasterList(b *testing.B) { + setupOnce.Do(func() { + sharedMasterList = NewMasterList() + + // Pre-populate with ground spawns for realistic testing + const numSpawns = 1000 + sharedSpawns = make([]*GroundSpawn, numSpawns) + + for i := range numSpawns { + sharedSpawns[i] = createTestGroundSpawn(b, int32(i+1)) + sharedSpawns[i].ZoneID = int32(i%10 + 1) // Distribute across 10 zones + sharedSpawns[i].CollectionSkill = []string{"Mining", "Gathering", "Fishing", "Trapping"}[i%4] + + // Create realistic spatial clusters within each zone + zoneBase := float32(sharedSpawns[i].ZoneID * 1000) + cluster := float32((i % 100) * 50) // Clusters of ~25 spawns + sharedSpawns[i].X = zoneBase + cluster + float32(rand.Intn(100)-50) // Add some spread + sharedSpawns[i].Y = zoneBase + cluster + float32(rand.Intn(100)-50) + sharedSpawns[i].Z = float32(rand.Intn(100)) + + sharedMasterList.AddGroundSpawn(sharedSpawns[i]) + } + }) +} + // BenchmarkMasterListOperations measures master list performance func BenchmarkMasterListOperations(b *testing.B) { - ml := NewMasterList() - - // Pre-populate with ground spawns for realistic testing - const numSpawns = 1000 - spawns := make([]*GroundSpawn, numSpawns) - - b.StopTimer() - for i := 0; i < numSpawns; i++ { - spawns[i] = createTestGroundSpawn(b, int32(i+1)) - spawns[i].ZoneID = int32(i%10 + 1) // Distribute across 10 zones - spawns[i].CollectionSkill = []string{"Mining", "Gathering", "Fishing", "Trapping"}[i%4] - ml.AddGroundSpawn(spawns[i]) - } - b.StartTimer() - + setupSharedMasterList(b) + ml := sharedMasterList + b.Run("GetGroundSpawn", func(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { - id := int32((rand.Intn(numSpawns) + 1)) + id := int32((rand.Intn(1000) + 1)) _ = ml.GetGroundSpawn(id) } }) }) - + b.Run("AddGroundSpawn", func(b *testing.B) { - startID := int32(numSpawns + 1) + // Create a separate master list for add operations to avoid contaminating shared list + addML := NewMasterList() + startID := int32(10000) + // Pre-create ground spawns to measure just the Add operation + spawnsToAdd := make([]*GroundSpawn, b.N) + for i := 0; i < b.N; i++ { + spawnsToAdd[i] = createTestGroundSpawn(b, startID+int32(i)) + } b.ResetTimer() for i := 0; i < b.N; i++ { - gs := createTestGroundSpawn(b, startID+int32(i)) - ml.AddGroundSpawn(gs) + addML.AddGroundSpawn(spawnsToAdd[i]) } }) - + b.Run("GetByZone", func(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { @@ -249,7 +274,7 @@ func BenchmarkMasterListOperations(b *testing.B) { } }) }) - + b.Run("GetBySkill", func(b *testing.B) { skills := []string{"Mining", "Gathering", "Fishing", "Trapping"} b.RunParallel(func(pb *testing.PB) { @@ -259,21 +284,21 @@ func BenchmarkMasterListOperations(b *testing.B) { } }) }) - + b.Run("GetAvailableSpawns", func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { _ = ml.GetAvailableSpawns() } }) - + b.Run("GetDepletedSpawns", func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { _ = ml.GetDepletedSpawns() } }) - + b.Run("GetStatistics", func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { _ = ml.GetStatistics() } }) @@ -283,25 +308,25 @@ func BenchmarkMasterListOperations(b *testing.B) { func BenchmarkConcurrentHarvesting(b *testing.B) { const numSpawns = 100 spawns := make([]*GroundSpawn, numSpawns) - + for i := 0; i < numSpawns; i++ { spawns[i] = createTestGroundSpawn(b, int32(i+1)) } - + player := &mockPlayer{level: 50, location: 1, name: "BenchmarkPlayer"} skill := &mockSkill{current: 75, max: 100} - + b.Run("ConcurrentHarvesting", func(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { spawnIdx := rand.Intn(numSpawns) gs := spawns[spawnIdx] - + // Reset if depleted if gs.IsDepleted() { gs.Respawn() } - + _, _ = gs.ProcessHarvest(player, skill, 75) } }) @@ -326,18 +351,18 @@ func BenchmarkMemoryAllocation(b *testing.B) { _ = gs } }) - + b.Run("MasterListAllocation", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { ml := NewMasterList() _ = ml } }) - + b.Run("HarvestResultAllocation", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { result := &HarvestResult{ Success: true, HarvestType: HarvestType3Items, @@ -348,22 +373,47 @@ func BenchmarkMemoryAllocation(b *testing.B) { _ = result } }) + + b.Run("SpatialAddGroundSpawn_Allocations", func(b *testing.B) { + b.ReportAllocs() + ml := NewMasterList() + for i := 0; i < b.N; i++ { + gs := createTestGroundSpawn(b, int32(i+1)) + gs.ZoneID = int32(i%10 + 1) + gs.CollectionSkill = []string{"Mining", "Gathering", "Fishing", "Trapping"}[i%4] + gs.X = float32(rand.Intn(1000)) + gs.Y = float32(rand.Intn(1000)) + ml.AddGroundSpawn(gs) + } + }) + + b.Run("SpatialGetNearby_Allocations", func(b *testing.B) { + setupSharedMasterList(b) + ml := sharedMasterList + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + x := float32(rand.Intn(10000)) + y := float32(rand.Intn(10000)) + _ = ml.GetNearby(x, y, 100.0) + } + }) } // BenchmarkRespawnOperations measures respawn performance func BenchmarkRespawnOperations(b *testing.B) { gs := createTestGroundSpawn(b, 1001) - + b.Run("Respawn", func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { gs.CurrentHarvests = 0 // Deplete gs.Respawn() } }) - + b.Run("RespawnWithRandomHeading", func(b *testing.B) { gs.RandomizeHeading = true - for i := 0; i < b.N; i++ { + for b.Loop() { gs.CurrentHarvests = 0 // Deplete gs.Respawn() } @@ -373,7 +423,7 @@ func BenchmarkRespawnOperations(b *testing.B) { // BenchmarkStringOperations measures string processing performance func BenchmarkStringOperations(b *testing.B) { skills := []string{"Mining", "Gathering", "Collecting", "Fishing", "Trapping", "Foresting", "Unknown"} - + b.Run("HarvestMessageGeneration", func(b *testing.B) { gs := createTestGroundSpawn(b, 1001) b.RunParallel(func(pb *testing.PB) { @@ -384,7 +434,7 @@ func BenchmarkStringOperations(b *testing.B) { } }) }) - + b.Run("SpellTypeGeneration", func(b *testing.B) { gs := createTestGroundSpawn(b, 1001) b.RunParallel(func(pb *testing.PB) { @@ -397,34 +447,76 @@ func BenchmarkStringOperations(b *testing.B) { }) } -// BenchmarkComparisonWithOldSystem provides comparison benchmarks -// These would help measure the performance improvement from modernization -func BenchmarkComparisonWithOldSystem(b *testing.B) { - ml := NewMasterList() - const numSpawns = 1000 - - // Setup - b.StopTimer() - for i := 0; i < numSpawns; i++ { - gs := createTestGroundSpawn(b, int32(i+1)) - ml.AddGroundSpawn(gs) - } - b.StartTimer() - - b.Run("ModernizedLookup", func(b *testing.B) { - // Modern generic-based lookup +// BenchmarkSpatialFeatures tests features unique to spatial implementation +func BenchmarkSpatialFeatures(b *testing.B) { + setupSharedMasterList(b) + ml := sharedMasterList + + b.Run("GetByZoneAndSkill", func(b *testing.B) { + skills := []string{"Mining", "Gathering", "Fishing", "Trapping"} + for b.Loop() { + zoneID := int32(rand.Intn(10) + 1) + skill := skills[rand.Intn(len(skills))] + _ = ml.GetByZoneAndSkill(zoneID, skill) + } + }) + + b.Run("GetNearby_Small", func(b *testing.B) { + for b.Loop() { + x := float32(rand.Intn(10000)) + y := float32(rand.Intn(10000)) + _ = ml.GetNearby(x, y, 50.0) // Small radius + } + }) + + b.Run("GetNearby_Medium", func(b *testing.B) { + for b.Loop() { + x := float32(rand.Intn(10000)) + y := float32(rand.Intn(10000)) + _ = ml.GetNearby(x, y, 200.0) // Medium radius + } + }) + + b.Run("GetNearby_Large", func(b *testing.B) { + for b.Loop() { + x := float32(rand.Intn(10000)) + y := float32(rand.Intn(10000)) + _ = ml.GetNearby(x, y, 500.0) // Large radius + } + }) +} + +// BenchmarkConcurrentSpatialOperations tests thread safety and mixed workloads +func BenchmarkConcurrentSpatialOperations(b *testing.B) { + setupSharedMasterList(b) + ml := sharedMasterList + + b.Run("MixedSpatialOperations", func(b *testing.B) { + skills := []string{"Mining", "Gathering", "Fishing", "Trapping"} b.RunParallel(func(pb *testing.PB) { for pb.Next() { - id := int32(rand.Intn(numSpawns) + 1) - _ = ml.GetGroundSpawn(id) + switch rand.Intn(6) { + case 0: + id := int32(rand.Intn(1000) + 1) + _ = ml.GetGroundSpawn(id) + case 1: + zoneID := int32(rand.Intn(10) + 1) + _ = ml.GetByZone(zoneID) + case 2: + skill := skills[rand.Intn(len(skills))] + _ = ml.GetBySkill(skill) + case 3: + zoneID := int32(rand.Intn(10) + 1) + skill := skills[rand.Intn(len(skills))] + _ = ml.GetByZoneAndSkill(zoneID, skill) + case 4: + x := float32(rand.Intn(10000)) + y := float32(rand.Intn(10000)) + _ = ml.GetNearby(x, y, 100.0) + case 5: + _ = ml.GetAvailableSpawns() + } } }) }) - - b.Run("ModernizedFiltering", func(b *testing.B) { - // Modern filter-based operations - for i := 0; i < b.N; i++ { - _ = ml.GetAvailableSpawns() - } - }) -} \ No newline at end of file +} diff --git a/internal/ground_spawn/ground_spawn_test.go b/internal/ground_spawn/ground_spawn_test.go index e8d4233..e224462 100644 --- a/internal/ground_spawn/ground_spawn_test.go +++ b/internal/ground_spawn/ground_spawn_test.go @@ -121,7 +121,7 @@ func TestNewMasterList(t *testing.T) { t.Fatal("Expected non-nil master list") } - if ml.MasterList.Size() != 0 { + if ml.Size() != 0 { t.Error("New master list should be empty") } } diff --git a/internal/ground_spawn/master.go b/internal/ground_spawn/master.go index 9ce2383..0c8be24 100644 --- a/internal/ground_spawn/master.go +++ b/internal/ground_spawn/master.go @@ -1,174 +1,106 @@ package ground_spawn import ( - "fmt" - - "eq2emu/internal/common" - "eq2emu/internal/database" - "zombiezen.com/go/sqlite" + "sync" ) -// MasterList manages all ground spawns using the generic base +// MasterList is a specialized ground spawn master list optimized for: +// - Fast zone-based lookups (O(1)) +// - Fast skill-based lookups (O(1)) +// - Spatial grid queries for proximity searches +// - Set intersection operations for complex queries +// - Fast state-based queries (available/depleted) type MasterList struct { - *common.MasterList[int32, *GroundSpawn] + // Core storage + spawns map[int32]*GroundSpawn // ID -> GroundSpawn + mutex sync.RWMutex + + // Category indices for O(1) lookups + byZone map[int32][]*GroundSpawn // Zone ID -> spawns + bySkill map[string][]*GroundSpawn // Skill -> spawns + + // State indices for O(1) filtering + availableSpawns []*GroundSpawn // Available spawns (cached) + depletedSpawns []*GroundSpawn // Depleted spawns (cached) + stateStale bool // Whether state caches need refresh + + // Statistics cache + stats *Statistics + statsStale bool + + // Spatial grid for proximity queries (grid size = 100 units) + spatialGrid map[gridKey][]*GroundSpawn + gridSize float32 } -// NewMasterList creates a new ground spawn master list +// gridKey represents a spatial grid cell +type gridKey struct { + x, y int32 +} + +// NewMasterList creates a new specialized ground spawn master list func NewMasterList() *MasterList { return &MasterList{ - MasterList: common.NewMasterList[int32, *GroundSpawn](), + spawns: make(map[int32]*GroundSpawn), + byZone: make(map[int32][]*GroundSpawn), + bySkill: make(map[string][]*GroundSpawn), + spatialGrid: make(map[gridKey][]*GroundSpawn), + gridSize: 100.0, // 100 unit grid cells + stateStale: true, // Initial state needs refresh + statsStale: true, // Initial stats need refresh } } -// AddGroundSpawn adds a ground spawn to the master list -func (ml *MasterList) AddGroundSpawn(gs *GroundSpawn) bool { - return ml.MasterList.Add(gs) -} - -// GetGroundSpawn retrieves a ground spawn by ID -func (ml *MasterList) GetGroundSpawn(groundSpawnID int32) *GroundSpawn { - return ml.MasterList.Get(groundSpawnID) -} - -// RemoveGroundSpawn removes a ground spawn by ID -func (ml *MasterList) RemoveGroundSpawn(groundSpawnID int32) bool { - return ml.MasterList.Remove(groundSpawnID) -} - -// GetByZone returns all ground spawns in a specific zone -func (ml *MasterList) GetByZone(zoneID int32) []*GroundSpawn { - return ml.MasterList.Filter(func(gs *GroundSpawn) bool { - return gs.ZoneID == zoneID - }) -} - -// GetBySkill returns all ground spawns that require a specific skill -func (ml *MasterList) GetBySkill(skill string) []*GroundSpawn { - return ml.MasterList.Filter(func(gs *GroundSpawn) bool { - return gs.CollectionSkill == skill - }) -} - -// GetAvailableSpawns returns all harvestable ground spawns -func (ml *MasterList) GetAvailableSpawns() []*GroundSpawn { - return ml.MasterList.Filter(func(gs *GroundSpawn) bool { - return gs.IsAvailable() - }) -} - -// GetDepletedSpawns returns all depleted ground spawns -func (ml *MasterList) GetDepletedSpawns() []*GroundSpawn { - return ml.MasterList.Filter(func(gs *GroundSpawn) bool { - return gs.IsDepleted() - }) -} - -// LoadFromDatabase loads all ground spawns from database -func (ml *MasterList) LoadFromDatabase(db *database.Database) error { - if db.GetType() == database.SQLite { - return ml.loadFromSQLite(db) +// getGridKey returns the grid cell for given coordinates +func (ml *MasterList) getGridKey(x, y float32) gridKey { + return gridKey{ + x: int32(x / ml.gridSize), + y: int32(y / ml.gridSize), } - return ml.loadFromMySQL(db) } -func (ml *MasterList) loadFromSQLite(db *database.Database) error { - return db.ExecTransient(` - SELECT id, groundspawn_id, name, collection_skill, number_harvests, - attempts_per_harvest, randomize_heading, respawn_time, - x, y, z, heading, zone_id, grid_id - FROM ground_spawns - ORDER BY groundspawn_id - `, func(stmt *sqlite.Stmt) error { - gs := &GroundSpawn{ - ID: stmt.ColumnInt32(0), - GroundSpawnID: stmt.ColumnInt32(1), - Name: stmt.ColumnText(2), - CollectionSkill: stmt.ColumnText(3), - NumberHarvests: int8(stmt.ColumnInt32(4)), - AttemptsPerHarvest: int8(stmt.ColumnInt32(5)), - RandomizeHeading: stmt.ColumnBool(6), - RespawnTime: stmt.ColumnInt32(7), - X: float32(stmt.ColumnFloat(8)), - Y: float32(stmt.ColumnFloat(9)), - Z: float32(stmt.ColumnFloat(10)), - Heading: float32(stmt.ColumnFloat(11)), - ZoneID: stmt.ColumnInt32(12), - GridID: stmt.ColumnInt32(13), - db: db, - isNew: false, - IsAlive: true, - CurrentHarvests: int8(stmt.ColumnInt32(4)), // Initialize to max harvests - HarvestEntries: make([]*HarvestEntry, 0), - HarvestItems: make([]*HarvestEntryItem, 0), - } - - // Load harvest data for this ground spawn - if err := gs.loadHarvestData(); err != nil { - return fmt.Errorf("failed to load harvest data for ground spawn %d: %w", gs.GroundSpawnID, err) - } - - ml.MasterList.Add(gs) - return nil - }) -} - -func (ml *MasterList) loadFromMySQL(db *database.Database) error { - rows, err := db.Query(` - SELECT id, groundspawn_id, name, collection_skill, number_harvests, - attempts_per_harvest, randomize_heading, respawn_time, - x, y, z, heading, zone_id, grid_id - FROM ground_spawns - ORDER BY groundspawn_id - `) - if err != nil { - return fmt.Errorf("failed to query ground spawns: %w", err) - } - defer rows.Close() - - for rows.Next() { - gs := &GroundSpawn{ - db: db, - isNew: false, - IsAlive: true, - HarvestEntries: make([]*HarvestEntry, 0), - HarvestItems: make([]*HarvestEntryItem, 0), - } - - err := rows.Scan(&gs.ID, &gs.GroundSpawnID, &gs.Name, &gs.CollectionSkill, - &gs.NumberHarvests, &gs.AttemptsPerHarvest, &gs.RandomizeHeading, - &gs.RespawnTime, &gs.X, &gs.Y, &gs.Z, &gs.Heading, &gs.ZoneID, &gs.GridID) - if err != nil { - return fmt.Errorf("failed to scan ground spawn: %w", err) - } - - // Initialize current harvests to max - gs.CurrentHarvests = gs.NumberHarvests - - // Load harvest data for this ground spawn - if err := gs.loadHarvestData(); err != nil { - return fmt.Errorf("failed to load harvest data for ground spawn %d: %w", gs.GroundSpawnID, err) - } - - ml.MasterList.Add(gs) +// refreshStateCache updates the available/depleted spawn caches +func (ml *MasterList) refreshStateCache() { + if !ml.stateStale { + return } - return rows.Err() + // Clear existing caches + ml.availableSpawns = ml.availableSpawns[:0] + ml.depletedSpawns = ml.depletedSpawns[:0] + + // Rebuild caches + for _, gs := range ml.spawns { + if gs.IsAvailable() { + ml.availableSpawns = append(ml.availableSpawns, gs) + } else if gs.IsDepleted() { + ml.depletedSpawns = append(ml.depletedSpawns, gs) + } + } + + ml.stateStale = false } -// GetStatistics returns statistics about the ground spawn system -func (ml *MasterList) GetStatistics() *Statistics { - availableSpawns := len(ml.GetAvailableSpawns()) - - // Count by zone +// refreshStatsCache updates the statistics cache +func (ml *MasterList) refreshStatsCache() { + if !ml.statsStale { + return + } + + var availableSpawns int zoneMap := make(map[int32]int) skillMap := make(map[string]int64) - - ml.MasterList.ForEach(func(id int32, gs *GroundSpawn) { + + // Single pass through all spawns + for _, gs := range ml.spawns { + if gs.IsAvailable() { + availableSpawns++ + } zoneMap[gs.ZoneID]++ skillMap[gs.CollectionSkill]++ - }) + } - return &Statistics{ + ml.stats = &Statistics{ TotalHarvests: 0, // Would need to be tracked separately SuccessfulHarvests: 0, // Would need to be tracked separately RareItemsHarvested: 0, // Would need to be tracked separately @@ -177,4 +109,232 @@ func (ml *MasterList) GetStatistics() *Statistics { ActiveGroundSpawns: availableSpawns, GroundSpawnsByZone: zoneMap, } + + ml.statsStale = false +} + +// AddGroundSpawn adds a ground spawn with full indexing +func (ml *MasterList) AddGroundSpawn(gs *GroundSpawn) bool { + ml.mutex.Lock() + defer ml.mutex.Unlock() + + // Check if exists + if _, exists := ml.spawns[gs.GroundSpawnID]; exists { + return false + } + + // Add to core storage + ml.spawns[gs.GroundSpawnID] = gs + + // Update zone index + ml.byZone[gs.ZoneID] = append(ml.byZone[gs.ZoneID], gs) + + // Update skill index + ml.bySkill[gs.CollectionSkill] = append(ml.bySkill[gs.CollectionSkill], gs) + + // Update spatial grid + gridKey := ml.getGridKey(gs.X, gs.Y) + ml.spatialGrid[gridKey] = append(ml.spatialGrid[gridKey], gs) + + // Invalidate state and stats caches + ml.stateStale = true + ml.statsStale = true + + return true +} + +// GetGroundSpawn retrieves by ID (O(1)) +func (ml *MasterList) GetGroundSpawn(id int32) *GroundSpawn { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.spawns[id] +} + +// GetByZone returns all spawns in a zone (O(1)) +func (ml *MasterList) GetByZone(zoneID int32) []*GroundSpawn { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.byZone[zoneID] // Return slice directly for performance +} + +// GetBySkill returns all spawns for a skill (O(1)) +func (ml *MasterList) GetBySkill(skill string) []*GroundSpawn { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return ml.bySkill[skill] // Return slice directly for performance +} + +// GetByZoneAndSkill returns spawns matching both zone and skill (set intersection) +func (ml *MasterList) GetByZoneAndSkill(zoneID int32, skill string) []*GroundSpawn { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + zoneSpawns := ml.byZone[zoneID] + skillSpawns := ml.bySkill[skill] + + // Use smaller set for iteration efficiency + if len(zoneSpawns) > len(skillSpawns) { + zoneSpawns, skillSpawns = skillSpawns, zoneSpawns + } + + // Set intersection using map lookup + skillSet := make(map[*GroundSpawn]struct{}, len(skillSpawns)) + for _, gs := range skillSpawns { + skillSet[gs] = struct{}{} + } + + var result []*GroundSpawn + for _, gs := range zoneSpawns { + if _, exists := skillSet[gs]; exists { + result = append(result, gs) + } + } + + return result +} + +// GetNearby returns spawns within radius of given coordinates using spatial grid +func (ml *MasterList) GetNearby(x, y float32, radius float32) []*GroundSpawn { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + // Calculate grid search bounds + minX := int32((x - radius) / ml.gridSize) + maxX := int32((x + radius) / ml.gridSize) + minY := int32((y - radius) / ml.gridSize) + maxY := int32((y + radius) / ml.gridSize) + + var candidates []*GroundSpawn + + // Check all grid cells in range + for gx := minX; gx <= maxX; gx++ { + for gy := minY; gy <= maxY; gy++ { + key := gridKey{x: gx, y: gy} + if spawns, exists := ml.spatialGrid[key]; exists { + candidates = append(candidates, spawns...) + } + } + } + + // Filter by exact distance + radiusSquared := radius * radius + var result []*GroundSpawn + for _, gs := range candidates { + dx := gs.X - x + dy := gs.Y - y + if dx*dx+dy*dy <= radiusSquared { + result = append(result, gs) + } + } + + return result +} + +// GetAvailableSpawns returns harvestable spawns using cached results +func (ml *MasterList) GetAvailableSpawns() []*GroundSpawn { + ml.mutex.Lock() // Need write lock to potentially update cache + defer ml.mutex.Unlock() + + ml.refreshStateCache() + + // Return a copy to prevent external modification + result := make([]*GroundSpawn, len(ml.availableSpawns)) + copy(result, ml.availableSpawns) + return result +} + +// GetDepletedSpawns returns depleted spawns using cached results +func (ml *MasterList) GetDepletedSpawns() []*GroundSpawn { + ml.mutex.Lock() // Need write lock to potentially update cache + defer ml.mutex.Unlock() + + ml.refreshStateCache() + + // Return a copy to prevent external modification + result := make([]*GroundSpawn, len(ml.depletedSpawns)) + copy(result, ml.depletedSpawns) + return result +} + +// GetStatistics returns system statistics using cached results +func (ml *MasterList) GetStatistics() *Statistics { + ml.mutex.Lock() // Need write lock to potentially update cache + defer ml.mutex.Unlock() + + ml.refreshStatsCache() + + // Return a copy to prevent external modification + return &Statistics{ + TotalHarvests: ml.stats.TotalHarvests, + SuccessfulHarvests: ml.stats.SuccessfulHarvests, + RareItemsHarvested: ml.stats.RareItemsHarvested, + SkillUpsGenerated: ml.stats.SkillUpsGenerated, + HarvestsBySkill: ml.stats.HarvestsBySkill, + ActiveGroundSpawns: ml.stats.ActiveGroundSpawns, + GroundSpawnsByZone: ml.stats.GroundSpawnsByZone, + } +} + +// RemoveGroundSpawn removes a spawn and updates all indices +func (ml *MasterList) RemoveGroundSpawn(id int32) bool { + ml.mutex.Lock() + defer ml.mutex.Unlock() + + gs, exists := ml.spawns[id] + if !exists { + return false + } + + // Remove from core storage + delete(ml.spawns, id) + + // Remove from zone index + zoneSpawns := ml.byZone[gs.ZoneID] + for i, spawn := range zoneSpawns { + if spawn.GroundSpawnID == id { + ml.byZone[gs.ZoneID] = append(zoneSpawns[:i], zoneSpawns[i+1:]...) + break + } + } + + // Remove from skill index + skillSpawns := ml.bySkill[gs.CollectionSkill] + for i, spawn := range skillSpawns { + if spawn.GroundSpawnID == id { + ml.bySkill[gs.CollectionSkill] = append(skillSpawns[:i], skillSpawns[i+1:]...) + break + } + } + + // Remove from spatial grid + gridKey := ml.getGridKey(gs.X, gs.Y) + gridSpawns := ml.spatialGrid[gridKey] + for i, spawn := range gridSpawns { + if spawn.GroundSpawnID == id { + ml.spatialGrid[gridKey] = append(gridSpawns[:i], gridSpawns[i+1:]...) + break + } + } + + // Invalidate state and stats caches + ml.stateStale = true + ml.statsStale = true + + return true +} + +// Size returns the total number of spawns +func (ml *MasterList) Size() int { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + return len(ml.spawns) +} + +// InvalidateStateCache marks the state and stats caches as stale +// Call this when spawn states change (e.g., after harvesting, respawning) +func (ml *MasterList) InvalidateStateCache() { + ml.mutex.Lock() + defer ml.mutex.Unlock() + ml.stateStale = true + ml.statsStale = true } \ No newline at end of file