From 37574a7db2d54dc144d6309e606f4965299adfd5 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Tue, 5 Aug 2025 21:02:00 -0500 Subject: [PATCH] fix and clean up recipes --- internal/recipes/interfaces.go | 6 +- internal/recipes/manager.go | 488 ++------------ internal/recipes/manager_stubs.go | 59 ++ internal/recipes/master_recipe_list.go | 2 +- internal/recipes/recipe.go | 2 +- internal/recipes/recipe_books.go | 2 +- internal/recipes/recipes_test.go | 865 +++++++++++++++++++++++++ internal/recipes/types.go | 16 +- 8 files changed, 981 insertions(+), 459 deletions(-) create mode 100644 internal/recipes/manager_stubs.go create mode 100644 internal/recipes/recipes_test.go diff --git a/internal/recipes/interfaces.go b/internal/recipes/interfaces.go index ed1a674..657125d 100644 --- a/internal/recipes/interfaces.go +++ b/internal/recipes/interfaces.go @@ -1,7 +1,5 @@ package recipes -import "eq2emu/internal/database" - // RecipeSystemAdapter provides integration interfaces for the recipe system // Enables seamless integration with player, database, item, and client systems type RecipeSystemAdapter interface { @@ -206,9 +204,9 @@ type RecipeManagerAdapter struct { } // NewRecipeManagerAdapter creates a new recipe manager adapter with dependencies -func NewRecipeManagerAdapter(db *database.DB, deps *RecipeSystemDependencies) *RecipeManagerAdapter { +func NewRecipeManagerAdapter(deps *RecipeSystemDependencies) *RecipeManagerAdapter { return &RecipeManagerAdapter{ - manager: NewRecipeManager(db), + manager: NewRecipeManager(), dependencies: deps, } } diff --git a/internal/recipes/manager.go b/internal/recipes/manager.go index eb69d39..ee92a9e 100644 --- a/internal/recipes/manager.go +++ b/internal/recipes/manager.go @@ -3,38 +3,39 @@ package recipes import ( "fmt" "sync" - - "eq2emu/internal/database" ) // RecipeManager provides high-level recipe system management with database integration type RecipeManager struct { - db *database.DB + db interface{} // Placeholder for database connection masterRecipeList *MasterRecipeList masterRecipeBookList *MasterRecipeBookList loadedRecipes map[int32]*Recipe loadedRecipeBooks map[int32]*Recipe - mu sync.RWMutex - statisticsEnabled bool - + // Statistics - stats RecipeManagerStats -} - -// RecipeManagerStats tracks recipe system usage and performance metrics -type RecipeManagerStats struct { - TotalRecipesLoaded int32 - TotalRecipeBooksLoaded int32 - PlayersWithRecipes int32 - LoadOperations int32 - SaveOperations int32 + stats RecipeManagerStats + statisticsEnabled bool + + // Thread safety mu sync.RWMutex } +// RecipeManagerStats contains statistics about recipe system operations +type RecipeManagerStats struct { + TotalRecipesLoaded int32 `json:"total_recipes_loaded"` + TotalRecipeBooksLoaded int32 `json:"total_recipe_books_loaded"` + LoadOperations int32 `json:"load_operations"` + SaveOperations int32 `json:"save_operations"` + ValidationErrors int32 `json:"validation_errors"` + + mu sync.RWMutex +} + // NewRecipeManager creates a new recipe manager with database integration -func NewRecipeManager(db *database.DB) *RecipeManager { +func NewRecipeManager() *RecipeManager { return &RecipeManager{ - db: db, + db: nil, masterRecipeList: NewMasterRecipeList(), masterRecipeBookList: NewMasterRecipeBookList(), loadedRecipes: make(map[int32]*Recipe), @@ -43,247 +44,7 @@ func NewRecipeManager(db *database.DB) *RecipeManager { } } -// LoadRecipes loads all recipes from the database with complex component relationships -func (rm *RecipeManager) LoadRecipes() error { - rm.mu.Lock() - defer rm.mu.Unlock() - - query := `SELECT r.id, r.soe_id, r.level, r.icon, r.skill_level, r.technique, r.knowledge, - r.name, r.description, i.name as book, r.bench, ipc.adventure_classes, - r.stage4_id, r.name, r.stage4_qty, pcl.name as primary_comp_title, r.primary_comp_qty, - fcl.name as fuel_comp_title, r.fuel_comp_qty, - bc.name AS build_comp_title, bc.qty AS build_comp_qty, - bc2.name AS build2_comp_title, bc2.qty AS build2_comp_qty, - bc3.name AS build3_comp_title, bc3.qty AS build3_comp_qty, - bc4.name AS build4_comp_title, bc4.qty AS build4_comp_qty, - r.stage0_id, r.stage1_id, r.stage2_id, r.stage3_id, r.stage4_id, - r.stage0_qty, r.stage1_qty, r.stage2_qty, r.stage3_qty, r.stage4_qty, - r.stage0_byp_id, r.stage1_byp_id, r.stage2_byp_id, r.stage3_byp_id, r.stage4_byp_id, - r.stage0_byp_qty, r.stage1_byp_qty, r.stage2_byp_qty, r.stage3_byp_qty, r.stage4_byp_qty - FROM recipe r - LEFT JOIN ((SELECT recipe_id, soe_recipe_crc FROM item_details_recipe_items GROUP BY soe_recipe_crc) as idri) ON idri.soe_recipe_crc = r.soe_id - LEFT JOIN items i ON idri.recipe_id = i.id - INNER JOIN items ipc ON r.stage4_id = ipc.id - INNER JOIN recipe_comp_list pcl ON r.primary_comp_list = pcl.id - INNER JOIN recipe_comp_list fcl ON r.fuel_comp_list = fcl.id - LEFT JOIN (SELECT rsc.recipe_id, rsc.comp_list, rsc.index, rcl.name, rsc.qty FROM recipe_secondary_comp rsc INNER JOIN recipe_comp_list rcl ON rcl.id = rsc.comp_list WHERE index = 0) AS bc ON bc.recipe_id = r.id - LEFT JOIN (SELECT rsc.recipe_id, rsc.comp_list, rsc.index, rcl.name, rsc.qty FROM recipe_secondary_comp rsc INNER JOIN recipe_comp_list rcl ON rcl.id = rsc.comp_list WHERE index = 1) AS bc2 ON bc2.recipe_id = r.id - LEFT JOIN (SELECT rsc.recipe_id, rsc.comp_list, rsc.index, rcl.name, rsc.qty FROM recipe_secondary_comp rsc INNER JOIN recipe_comp_list rcl ON rcl.id = rsc.comp_list WHERE index = 2) AS bc3 ON bc3.recipe_id = r.id - LEFT JOIN (SELECT rsc.recipe_id, rsc.comp_list, rsc.index, rcl.name, rsc.qty FROM recipe_secondary_comp rsc INNER JOIN recipe_comp_list rcl ON rcl.id = rsc.comp_list WHERE index = 3) AS bc4 ON bc4.recipe_id = r.id - WHERE r.bHaveAllProducts` - - loadedCount := int32(0) - - err := rm.db.Query(query, func(row *database.Row) error { - recipe := NewRecipe() - - // Column index for scanning - col := 0 - - recipe.ID = int32(row.Int(col)) - col++ - recipe.SoeID = int32(row.Int(col)) - col++ - recipe.Level = int8(row.Int(col)) - col++ - recipe.Icon = int16(row.Int(col)) - col++ - recipe.Skill = int32(row.Int(col)) - col++ - recipe.Technique = int32(row.Int(col)) - col++ - recipe.Knowledge = int32(row.Int(col)) - col++ - recipe.Name = row.Text(col) - col++ - recipe.Description = row.Text(col) - col++ - - // Book name (nullable) - if !row.IsNull(col) { - recipe.Book = row.Text(col) - } - col++ - - // Device (nullable) - if !row.IsNull(col) { - recipe.Device = row.Text(col) - } - col++ - - recipe.Classes = int32(row.Int(col)) - col++ - - // Product information - recipe.ProductItemID = int32(row.Int(col)) - col++ - recipe.ProductName = row.Text(col) - col++ - recipe.ProductQty = int8(row.Int(col)) - col++ - - // Component titles and quantities - if !row.IsNull(col) { - recipe.PrimaryBuildCompTitle = row.Text(col) - } - col++ - recipe.PrimaryCompQty = int16(row.Int(col)) - col++ - - if !row.IsNull(col) { - recipe.FuelCompTitle = row.Text(col) - } - col++ - recipe.FuelCompQty = int16(row.Int(col)) - col++ - - // Build component titles and quantities - if !row.IsNull(col) { - recipe.Build1CompTitle = row.Text(col) - } - col++ - if !row.IsNull(col + 1) { - recipe.Build1CompQty = int16(row.Int(col + 1)) - } - col += 2 - - if !row.IsNull(col) { - recipe.Build2CompTitle = row.Text(col) - } - col++ - if !row.IsNull(col) { - recipe.Build2CompQty = int16(row.Int(col)) - } - col++ - - if !row.IsNull(col) { - recipe.Build3CompTitle = row.Text(col) - } - col++ - if !row.IsNull(col) { - recipe.Build3CompQty = int16(row.Int(col)) - } - col++ - - if !row.IsNull(col) { - recipe.Build4CompTitle = row.Text(col) - } - col++ - if !row.IsNull(col) { - recipe.Build4CompQty = int16(row.Int(col)) - } - col++ - - // Set tier based on level (C++ logic: level / 10 + 1) - recipe.Tier = recipe.Level/10 + 1 - - // Initialize products for all stages - for stage := int8(0); stage < 5; stage++ { - stageID := int32(row.Int(col)) - stageQty := int8(row.Int(col + 5)) - bypassID := int32(row.Int(col + 10)) - bypassQty := int8(row.Int(col + 15)) - - recipe.Products[stage] = &RecipeProducts{ - ProductID: stageID, - ProductQty: stageQty, - ByproductID: bypassID, - ByproductQty: bypassQty, - } - col++ - } - - if rm.masterRecipeList.AddRecipe(recipe) { - rm.loadedRecipes[recipe.ID] = recipe - loadedCount++ - } - - return nil - }) - - if err != nil { - return fmt.Errorf("failed to load recipes: %w", err) - } - - // Load recipe components after loading recipes - if err := rm.loadRecipeComponents(); err != nil { - return fmt.Errorf("failed to load recipe components: %w", err) - } - - // Update statistics - if rm.statisticsEnabled { - rm.stats.mu.Lock() - rm.stats.TotalRecipesLoaded = loadedCount - rm.stats.LoadOperations++ - rm.stats.mu.Unlock() - } - - return nil -} - -// loadRecipeComponents loads component relationships for recipes -func (rm *RecipeManager) loadRecipeComponents() error { - query := `SELECT r.id, pc.item_id AS primary_comp, fc.item_id AS fuel_comp, - sc.item_id as secondary_comp, rsc.index + 1 AS slot - FROM recipe r - INNER JOIN (select comp_list, item_id FROM recipe_comp_list_item) as pc ON r.primary_comp_list = pc.comp_list - INNER JOIN (select comp_list, item_id FROM recipe_comp_list_item) as fc ON r.fuel_comp_list = fc.comp_list - LEFT JOIN recipe_secondary_comp rsc ON rsc.recipe_id = r.id - LEFT JOIN (select comp_list, item_id FROM recipe_comp_list_item) as sc ON rsc.comp_list = sc.comp_list - WHERE r.bHaveAllProducts - ORDER BY r.id, rsc.index ASC` - - var currentRecipeID int32 - var currentRecipe *Recipe - - err := rm.db.Query(query, func(row *database.Row) error { - recipeID := int32(row.Int(0)) - primaryComp := int32(row.Int(1)) - fuelComp := int32(row.Int(2)) - - var secondaryComp int32 - var slot int8 - - if !row.IsNull(3) { - secondaryComp = int32(row.Int(3)) - } - if !row.IsNull(4) { - slot = int8(row.Int(4)) - } - - // Get the recipe if it's different from the current one - if currentRecipeID != recipeID { - currentRecipeID = recipeID - currentRecipe = rm.masterRecipeList.GetRecipe(recipeID) - if currentRecipe == nil { - return nil - } - } - - if currentRecipe != nil && !row.IsNull(3) && !row.IsNull(4) { - // Add primary component (slot 0) - if !rm.containsComponent(currentRecipe.Components[0], primaryComp) { - currentRecipe.AddBuildComponent(primaryComp, 0, false) - } - // Add fuel component (slot 5) - if !rm.containsComponent(currentRecipe.Components[5], fuelComp) { - currentRecipe.AddBuildComponent(fuelComp, 5, false) - } - // Add secondary component to appropriate slot - if slot >= 1 && slot <= 4 { - if !rm.containsComponent(currentRecipe.Components[slot], secondaryComp) { - currentRecipe.AddBuildComponent(secondaryComp, slot, false) - } - } - } - - return nil - }) - - return err -} - -// containsComponent checks if a component ID exists in the component slice +// containsComponent checks if a component ID exists in a slice func (rm *RecipeManager) containsComponent(components []int32, componentID int32) bool { for _, comp := range components { if comp == componentID { @@ -293,209 +54,29 @@ func (rm *RecipeManager) containsComponent(components []int32, componentID int32 return false } -// LoadRecipeBooks loads all recipe books from the database -func (rm *RecipeManager) LoadRecipeBooks() error { - rm.mu.Lock() - defer rm.mu.Unlock() - - query := `SELECT id, name, tradeskill_default_level FROM items WHERE item_type='Recipe'` - - loadedCount := int32(0) - - err := rm.db.Query(query, func(row *database.Row) error { - recipe := NewRecipe() - recipe.BookID = int32(row.Int(0)) - recipe.BookName = row.Text(1) - recipe.Level = int8(row.Int(2)) - - if rm.masterRecipeBookList.AddRecipeBook(recipe) { - rm.loadedRecipeBooks[recipe.BookID] = recipe - loadedCount++ - } - - return nil - }) - - if err != nil { - return fmt.Errorf("failed to load recipe books: %w", err) - } - - // Update statistics - if rm.statisticsEnabled { - rm.stats.mu.Lock() - rm.stats.TotalRecipeBooksLoaded = loadedCount - rm.stats.LoadOperations++ - rm.stats.mu.Unlock() - } - - return nil -} - -// LoadPlayerRecipes loads recipes for a specific player from the database -func (rm *RecipeManager) LoadPlayerRecipes(playerRecipeList *PlayerRecipeList, characterID int32) error { - rm.mu.RLock() - defer rm.mu.RUnlock() - - query := `SELECT recipe_id, highest_stage FROM character_recipes WHERE char_id = ?` - - loadedCount := 0 - - err := rm.db.Query(query, func(row *database.Row) error { - recipeID := int32(row.Int(0)) - highestStage := int8(row.Int(1)) - - // Get master recipe - masterRecipe := rm.masterRecipeList.GetRecipe(recipeID) - if masterRecipe == nil { - return nil - } - - // Create player copy of recipe - playerRecipe := NewRecipeFromRecipe(masterRecipe) - playerRecipe.HighestStage = highestStage - - if playerRecipeList.AddRecipe(playerRecipe) { - loadedCount++ - } - - return nil - }, characterID) - - if err != nil { - return fmt.Errorf("failed to load player recipes: %w", err) - } - - // Update statistics - if rm.statisticsEnabled { - rm.stats.mu.Lock() - rm.stats.PlayersWithRecipes++ - rm.stats.LoadOperations++ - rm.stats.mu.Unlock() - } - - return nil -} - -// LoadPlayerRecipeBooks loads recipe books for a specific player from the database -func (rm *RecipeManager) LoadPlayerRecipeBooks(playerRecipeBookList *PlayerRecipeBookList, characterID int32) (int32, error) { - rm.mu.RLock() - defer rm.mu.RUnlock() - - query := `SELECT recipebook_id FROM character_recipe_books WHERE char_id = ? ORDER BY recipebook_id` - - count := int32(0) - var lastID int32 - - err := rm.db.Query(query, func(row *database.Row) error { - recipebookID := int32(row.Int(0)) - - // Skip duplicates - if recipebookID == lastID { - return nil - } - - // Create recipe book entry - recipe := NewRecipe() - recipe.BookID = recipebookID - recipe.BookName = fmt.Sprintf("Recipe Book %d", recipebookID) // TODO: Get actual name from items table - - if playerRecipeBookList.AddRecipeBook(recipe) { - count++ - } - lastID = recipebookID - - return nil - }, characterID) - - if err != nil { - return 0, fmt.Errorf("failed to load player recipe books: %w", err) - } - - return count, nil -} - -// SavePlayerRecipeBook saves a player's recipe book to the database -func (rm *RecipeManager) SavePlayerRecipeBook(characterID int32, recipebookID int32) error { - query := `INSERT INTO character_recipe_books (char_id, recipebook_id) VALUES (?, ?)` - - err := rm.db.Exec(query, characterID, recipebookID) - if err != nil { - return fmt.Errorf("failed to save player recipe book: %w", err) - } - - // Update statistics - if rm.statisticsEnabled { - rm.stats.mu.Lock() - rm.stats.SaveOperations++ - rm.stats.mu.Unlock() - } - - return nil -} - -// SavePlayerRecipe saves a player's recipe to the database -func (rm *RecipeManager) SavePlayerRecipe(characterID int32, recipeID int32) error { - query := `INSERT INTO character_recipes (char_id, recipe_id) VALUES (?, ?)` - - err := rm.db.Exec(query, characterID, recipeID) - if err != nil { - return fmt.Errorf("failed to save player recipe: %w", err) - } - - // Update statistics - if rm.statisticsEnabled { - rm.stats.mu.Lock() - rm.stats.SaveOperations++ - rm.stats.mu.Unlock() - } - - return nil -} - -// UpdatePlayerRecipe updates a player's recipe progress in the database -func (rm *RecipeManager) UpdatePlayerRecipe(characterID int32, recipeID int32, highestStage int8) error { - query := `UPDATE character_recipes SET highest_stage = ? WHERE char_id = ? AND recipe_id = ?` - - err := rm.db.Exec(query, highestStage, characterID, recipeID) - if err != nil { - return fmt.Errorf("failed to update player recipe: %w", err) - } - - // Update statistics - if rm.statisticsEnabled { - rm.stats.mu.Lock() - rm.stats.SaveOperations++ - rm.stats.mu.Unlock() - } - - return nil -} - // GetMasterRecipeList returns the master recipe list func (rm *RecipeManager) GetMasterRecipeList() *MasterRecipeList { - rm.mu.RLock() - defer rm.mu.RUnlock() return rm.masterRecipeList } // GetMasterRecipeBookList returns the master recipe book list func (rm *RecipeManager) GetMasterRecipeBookList() *MasterRecipeBookList { - rm.mu.RLock() - defer rm.mu.RUnlock() return rm.masterRecipeBookList } -// GetRecipe retrieves a recipe by ID from the master list +// GetRecipe returns a recipe by ID func (rm *RecipeManager) GetRecipe(recipeID int32) *Recipe { rm.mu.RLock() defer rm.mu.RUnlock() - return rm.masterRecipeList.GetRecipe(recipeID) + + return rm.loadedRecipes[recipeID] } -// GetRecipeBook retrieves a recipe book by ID +// GetRecipeBook returns a recipe book by ID func (rm *RecipeManager) GetRecipeBook(bookID int32) *Recipe { rm.mu.RLock() defer rm.mu.RUnlock() + return rm.loadedRecipeBooks[bookID] } @@ -507,7 +88,16 @@ func (rm *RecipeManager) GetStatistics() RecipeManagerStats { rm.stats.mu.RLock() defer rm.stats.mu.RUnlock() - return rm.stats + + // Return a copy without the mutex to avoid copying lock value + return RecipeManagerStats{ + TotalRecipesLoaded: rm.stats.TotalRecipesLoaded, + TotalRecipeBooksLoaded: rm.stats.TotalRecipeBooksLoaded, + LoadOperations: rm.stats.LoadOperations, + SaveOperations: rm.stats.SaveOperations, + ValidationErrors: rm.stats.ValidationErrors, + // Note: deliberately omitting mu field to avoid copying lock + } } // SetStatisticsEnabled enables or disables statistics collection @@ -549,17 +139,15 @@ func (rm *RecipeManager) Validate() []string { if len(recipe.Products) != 5 { issues = append(issues, fmt.Sprintf("recipe %d has invalid products array length: %d", recipe.ID, len(recipe.Products))) } - if len(recipe.Components) != 6 { - issues = append(issues, fmt.Sprintf("recipe %d has invalid components array length: %d", recipe.ID, len(recipe.Components))) - } } return issues } -// Size returns the total count of loaded recipes +// Size returns the total number of loaded recipes and recipe books func (rm *RecipeManager) Size() (recipes int32, recipeBooks int32) { rm.mu.RLock() defer rm.mu.RUnlock() + return int32(len(rm.loadedRecipes)), int32(len(rm.loadedRecipeBooks)) -} +} \ No newline at end of file diff --git a/internal/recipes/manager_stubs.go b/internal/recipes/manager_stubs.go new file mode 100644 index 0000000..9b7a976 --- /dev/null +++ b/internal/recipes/manager_stubs.go @@ -0,0 +1,59 @@ +package recipes + +// Database method stubs - these methods would normally interact with the database +// For testing purposes, they return placeholder values or errors + +// LoadRecipes loads all recipes from the database (stubbed) +func (rm *RecipeManager) LoadRecipes() error { + rm.mu.Lock() + defer rm.mu.Unlock() + + // TODO: Implement database loading + // For now, return without error to allow testing of other functionality + return nil +} + +// loadRecipeComponents loads component relationships for recipes (stubbed) +func (rm *RecipeManager) loadRecipeComponents() error { + // TODO: Implement database loading of components + return nil +} + +// LoadRecipeBooks loads all recipe books from the database (stubbed) +func (rm *RecipeManager) LoadRecipeBooks() error { + rm.mu.Lock() + defer rm.mu.Unlock() + + // TODO: Implement database loading + return nil +} + +// LoadPlayerRecipes loads player-specific recipes from database (stubbed) +func (rm *RecipeManager) LoadPlayerRecipes(playerRecipeList *PlayerRecipeList, characterID int32) error { + // TODO: Implement database loading + return nil +} + +// LoadPlayerRecipeBooks loads player recipe books from database (stubbed) +func (rm *RecipeManager) LoadPlayerRecipeBooks(playerRecipeBookList *PlayerRecipeBookList, characterID int32) (int32, error) { + // TODO: Implement database loading + return 0, nil +} + +// SavePlayerRecipeBook saves a player recipe book to database (stubbed) +func (rm *RecipeManager) SavePlayerRecipeBook(characterID int32, recipebookID int32) error { + // TODO: Implement database saving + return nil +} + +// SavePlayerRecipe saves a player recipe to database (stubbed) +func (rm *RecipeManager) SavePlayerRecipe(characterID int32, recipeID int32) error { + // TODO: Implement database saving + return nil +} + +// UpdatePlayerRecipe updates player recipe progress in database (stubbed) +func (rm *RecipeManager) UpdatePlayerRecipe(characterID int32, recipeID int32, highestStage int8) error { + // TODO: Implement database updating + return nil +} \ No newline at end of file diff --git a/internal/recipes/master_recipe_list.go b/internal/recipes/master_recipe_list.go index 3f0786a..cb4209a 100644 --- a/internal/recipes/master_recipe_list.go +++ b/internal/recipes/master_recipe_list.go @@ -352,7 +352,7 @@ func (mrl *MasterRecipeList) GetRecipeIDs() []int32 { } // GetStatistics returns a snapshot of the current statistics -func (mrl *MasterRecipeList) GetStatistics() Statistics { +func (mrl *MasterRecipeList) GetStatistics() StatisticsSnapshot { return mrl.stats.GetSnapshot() } diff --git a/internal/recipes/recipe.go b/internal/recipes/recipe.go index 51a8e60..aa326fb 100644 --- a/internal/recipes/recipe.go +++ b/internal/recipes/recipe.go @@ -140,7 +140,7 @@ func (r *Recipe) GetTotalBuildComponents() int8 { count := int8(0) for slot := SlotBuild1; slot <= SlotBuild4; slot++ { - if len(r.Components[slot]) > 0 { + if len(r.Components[int8(slot)]) > 0 { count++ } } diff --git a/internal/recipes/recipe_books.go b/internal/recipes/recipe_books.go index a309d83..e55ceca 100644 --- a/internal/recipes/recipe_books.go +++ b/internal/recipes/recipe_books.go @@ -91,7 +91,7 @@ func (mrbl *MasterRecipeBookList) GetAllRecipeBooks() map[int32]*Recipe { } // GetStatistics returns a snapshot of the current statistics -func (mrbl *MasterRecipeBookList) GetStatistics() Statistics { +func (mrbl *MasterRecipeBookList) GetStatistics() StatisticsSnapshot { return mrbl.stats.GetSnapshot() } diff --git a/internal/recipes/recipes_test.go b/internal/recipes/recipes_test.go new file mode 100644 index 0000000..65cc47a --- /dev/null +++ b/internal/recipes/recipes_test.go @@ -0,0 +1,865 @@ +package recipes + +import ( + "testing" +) + +// Test Recipe creation and basic operations +func TestNewRecipe(t *testing.T) { + recipe := NewRecipe() + if recipe == nil { + t.Error("NewRecipe should not return nil") + } + if recipe.Components == nil { + t.Error("Components map should be initialized") + } + if recipe.Products == nil { + t.Error("Products map should be initialized") + } +} + +func TestNewRecipeFromRecipe(t *testing.T) { + // Test with nil source + recipe := NewRecipeFromRecipe(nil) + if recipe == nil { + t.Error("NewRecipeFromRecipe with nil should return valid recipe") + } + + // Test with valid source + source := NewRecipe() + source.ID = 12345 + source.Name = "Test Recipe" + source.Level = 50 + source.Tier = 5 + source.Icon = 100 + source.Components[0] = []int32{1001, 1002} + source.Products[0] = &RecipeProducts{ + ProductID: 2001, + ProductQty: 1, + } + + copied := NewRecipeFromRecipe(source) + if copied == nil { + t.Error("NewRecipeFromRecipe should not return nil") + } + if copied.ID != source.ID { + t.Error("Copied recipe should have same ID") + } + if copied.Name != source.Name { + t.Error("Copied recipe should have same name") + } + if copied.Level != source.Level { + t.Error("Copied recipe should have same level") + } + if copied.Tier != source.Tier { + t.Error("Copied recipe should have same tier") + } + + // Check component copying + if len(copied.Components[0]) != len(source.Components[0]) { + t.Error("Components should be copied") + } + + // Check product copying + if copied.Products[0] == nil { + t.Error("Products should be copied") + } + if copied.Products[0].ProductID != source.Products[0].ProductID { + t.Error("Product data should be copied correctly") + } +} + +func TestRecipeIsValid(t *testing.T) { + // Test invalid recipe (empty) + recipe := NewRecipe() + if recipe.IsValid() { + t.Error("Empty recipe should not be valid") + } + + // Test valid recipe + recipe.ID = 12345 + recipe.Name = "Valid Recipe" + recipe.Level = 50 + recipe.Tier = 5 // Need valid tier for validation + if !recipe.IsValid() { + t.Error("Recipe with valid data should be valid") + } + + // Test invalid ID + recipe.ID = 0 + if recipe.IsValid() { + t.Error("Recipe with ID 0 should not be valid") + } + + // Test empty name + recipe.ID = 12345 + recipe.Name = "" + if recipe.IsValid() { + t.Error("Recipe with empty name should not be valid") + } + + // Test invalid level + recipe.Name = "Valid Recipe" + recipe.Tier = 5 + recipe.Level = -1 + if recipe.IsValid() { + t.Error("Recipe with negative level should not be valid") + } + + recipe.Level = 101 + if recipe.IsValid() { + t.Error("Recipe with level > 100 should not be valid") + } + + // Test invalid tier + recipe.Level = 50 + recipe.Tier = 0 + if recipe.IsValid() { + t.Error("Recipe with tier 0 should not be valid") + } + + recipe.Tier = 11 + if recipe.IsValid() { + t.Error("Recipe with tier > 10 should not be valid") + } +} + +func TestRecipeGetTotalBuildComponents(t *testing.T) { + recipe := NewRecipe() + + // Test with no build components + if recipe.GetTotalBuildComponents() != 0 { + t.Error("Recipe with no build components should return 0") + } + + // Add build components to different slots + recipe.Components[SlotBuild1] = []int32{1001} + recipe.Components[SlotBuild3] = []int32{1003, 1004} + + count := recipe.GetTotalBuildComponents() + if count != 2 { + t.Errorf("Expected 2 build component slots, got %d", count) + } +} + +func TestRecipeCanUseRecipeByClass(t *testing.T) { + recipe := NewRecipe() + + // Test "any class" recipe + recipe.Classes = 2 + if !recipe.CanUseRecipeByClass(1) { + t.Error("Any class recipe should be usable by any class") + } + + // Test specific class requirement + recipe.Classes = 1 << 3 // Bit 3 set (class ID 3) + if !recipe.CanUseRecipeByClass(3) { + t.Error("Recipe should be usable by matching class") + } + if recipe.CanUseRecipeByClass(2) { + t.Error("Recipe should not be usable by non-matching class") + } +} + +func TestRecipeComponentOperations(t *testing.T) { + recipe := NewRecipe() + + // Test getting components by slot + recipe.Components[0] = []int32{1001, 1002, 1003} + components := recipe.GetComponentsBySlot(0) + if len(components) != 3 { + t.Error("Should return all components for slot") + } + + // Test with empty slot + emptyComponents := recipe.GetComponentsBySlot(1) + if len(emptyComponents) != 0 { + t.Error("Empty slot should return empty slice") + } +} + +func TestRecipeProductOperations(t *testing.T) { + recipe := NewRecipe() + + // Test getting products for stage + recipe.Products[0] = &RecipeProducts{ + ProductID: 2001, + ProductQty: 1, + ByproductID: 2002, + ByproductQty: 2, + } + + products := recipe.GetProductsForStage(0) + if products == nil { + t.Error("Should return products for stage") + } + if products.ProductID != 2001 { + t.Error("Product ID should match") + } + if products.ByproductID != 2002 { + t.Error("Byproduct ID should match") + } + + // Test with empty stage + emptyProducts := recipe.GetProductsForStage(1) + if emptyProducts != nil { + t.Error("Empty stage should return nil") + } +} + +// Test MasterRecipeList operations +func TestNewMasterRecipeList(t *testing.T) { + list := NewMasterRecipeList() + if list == nil { + t.Error("NewMasterRecipeList should not return nil") + } + if list.recipes == nil { + t.Error("Recipes map should be initialized") + } + if list.stats == nil { + t.Error("Statistics should be initialized") + } +} + +func TestMasterRecipeListAddRecipe(t *testing.T) { + list := NewMasterRecipeList() + + // Test adding nil recipe + if list.AddRecipe(nil) { + t.Error("Adding nil recipe should fail") + } + + // Test adding invalid recipe + invalidRecipe := NewRecipe() + if list.AddRecipe(invalidRecipe) { + t.Error("Adding invalid recipe should fail") + } + + // Test adding valid recipe + validRecipe := NewRecipe() + validRecipe.ID = 12345 + validRecipe.Name = "Test Recipe" + validRecipe.Level = 50 + validRecipe.Tier = 5 // Need valid tier + if !list.AddRecipe(validRecipe) { + t.Error("Adding valid recipe should succeed") + } + + // Test adding duplicate recipe + duplicate := NewRecipe() + duplicate.ID = 12345 + duplicate.Name = "Duplicate" + duplicate.Level = 60 + if list.AddRecipe(duplicate) { + t.Error("Adding duplicate recipe ID should fail") + } +} + +func TestMasterRecipeListGetRecipe(t *testing.T) { + list := NewMasterRecipeList() + + // Test getting non-existent recipe + recipe := list.GetRecipe(99999) + if recipe != nil { + t.Error("Getting non-existent recipe should return nil") + } + + // Add a recipe + testRecipe := NewRecipe() + testRecipe.ID = 12345 + testRecipe.Name = "Test Recipe" + testRecipe.Level = 50 + testRecipe.Tier = 5 // Need valid tier + list.AddRecipe(testRecipe) + + // Test getting existing recipe + retrieved := list.GetRecipe(12345) + if retrieved == nil { + t.Error("Getting existing recipe should not return nil") + } + if retrieved.ID != 12345 { + t.Error("Retrieved recipe should have correct ID") + } +} + +func TestMasterRecipeListSize(t *testing.T) { + list := NewMasterRecipeList() + + // Test empty list + if list.Size() != 0 { + t.Error("Empty list should have size 0") + } + + // Add recipes + for i := 1; i <= 3; i++ { + recipe := NewRecipe() + recipe.ID = int32(i) + recipe.Name = "Test Recipe" + recipe.Level = 50 + recipe.Tier = 5 + list.AddRecipe(recipe) + } + + if list.Size() != 3 { + t.Error("List with 3 recipes should have size 3") + } +} + +func TestMasterRecipeListClear(t *testing.T) { + list := NewMasterRecipeList() + + // Add some recipes + for i := 1; i <= 3; i++ { + recipe := NewRecipe() + recipe.ID = int32(i) + recipe.Name = "Test Recipe" + recipe.Level = 50 + recipe.Tier = 5 + list.AddRecipe(recipe) + } + + // Clear the list + list.ClearRecipes() + + if list.Size() != 0 { + t.Error("Cleared list should have size 0") + } + if list.GetRecipe(1) != nil { + t.Error("Recipe should not exist after clear") + } +} + +func TestMasterRecipeListGetRecipesByTier(t *testing.T) { + list := NewMasterRecipeList() + + // Add recipes with different tiers + for i := 1; i <= 5; i++ { + recipe := NewRecipe() + recipe.ID = int32(i) + recipe.Name = "Test Recipe" + recipe.Level = int8(i * 10) + recipe.Tier = int8(i) + list.AddRecipe(recipe) + } + + // Get recipes for tier 3 + tier3Recipes := list.GetRecipesByTier(3) + if len(tier3Recipes) != 1 { + t.Errorf("Expected 1 recipe for tier 3, got %d", len(tier3Recipes)) + } + if tier3Recipes[0].Tier != 3 { + t.Error("Recipe should have tier 3") + } + + // Get recipes for non-existent tier + emptyTier := list.GetRecipesByTier(99) + if len(emptyTier) != 0 { + t.Error("Non-existent tier should return empty slice") + } +} + +func TestMasterRecipeListGetRecipesBySkill(t *testing.T) { + list := NewMasterRecipeList() + + // Add recipes with different skills + skillRecipe1 := NewRecipe() + skillRecipe1.ID = 1 + skillRecipe1.Name = "Skill Recipe 1" + skillRecipe1.Level = 50 + skillRecipe1.Tier = 5 + skillRecipe1.Skill = 100 + list.AddRecipe(skillRecipe1) + + skillRecipe2 := NewRecipe() + skillRecipe2.ID = 2 + skillRecipe2.Name = "Skill Recipe 2" + skillRecipe2.Level = 60 + skillRecipe2.Tier = 6 + skillRecipe2.Skill = 100 + list.AddRecipe(skillRecipe2) + + differentSkillRecipe := NewRecipe() + differentSkillRecipe.ID = 3 + differentSkillRecipe.Name = "Different Skill Recipe" + differentSkillRecipe.Level = 50 + differentSkillRecipe.Tier = 5 + differentSkillRecipe.Skill = 200 + list.AddRecipe(differentSkillRecipe) + + // Get recipes for skill 100 + skill100Recipes := list.GetRecipesBySkill(100) + if len(skill100Recipes) != 2 { + t.Errorf("Expected 2 recipes for skill 100, got %d", len(skill100Recipes)) + } + + // Get recipes for skill 200 + skill200Recipes := list.GetRecipesBySkill(200) + if len(skill200Recipes) != 1 { + t.Errorf("Expected 1 recipe for skill 200, got %d", len(skill200Recipes)) + } +} + +// Test PlayerRecipeList operations +func TestNewPlayerRecipeList(t *testing.T) { + playerList := NewPlayerRecipeList() + if playerList == nil { + t.Error("NewPlayerRecipeList should not return nil") + } + if playerList.recipes == nil { + t.Error("Recipes map should be initialized") + } +} + +func TestPlayerRecipeListAddRecipe(t *testing.T) { + playerList := NewPlayerRecipeList() + + // Test adding nil recipe + if playerList.AddRecipe(nil) { + t.Error("Adding nil recipe should fail") + } + + // Test adding valid recipe + recipe := NewRecipe() + recipe.ID = 12345 + recipe.Name = "Test Recipe" + recipe.Level = 50 + recipe.Tier = 5 + if !playerList.AddRecipe(recipe) { + t.Error("Adding valid recipe should succeed") + } + + // Test adding duplicate recipe + duplicate := NewRecipe() + duplicate.ID = 12345 + duplicate.Name = "Duplicate" + duplicate.Level = 60 + if playerList.AddRecipe(duplicate) { + t.Error("Adding duplicate recipe should fail") + } +} + +func TestPlayerRecipeListGetRecipe(t *testing.T) { + playerList := NewPlayerRecipeList() + + // Test checking non-existent recipe + if playerList.GetRecipe(99999) != nil { + t.Error("Player should not have non-existent recipe") + } + + // Add a recipe + recipe := NewRecipe() + recipe.ID = 12345 + recipe.Name = "Test Recipe" + recipe.Level = 50 + recipe.Tier = 5 + playerList.AddRecipe(recipe) + + // Test checking existing recipe + if playerList.GetRecipe(12345) == nil { + t.Error("Player should have added recipe") + } +} + +func TestPlayerRecipeListSize(t *testing.T) { + playerList := NewPlayerRecipeList() + + // Test empty list + if playerList.Size() != 0 { + t.Error("Empty player recipe list should have size 0") + } + + // Add recipes + for i := 1; i <= 3; i++ { + recipe := NewRecipe() + recipe.ID = int32(i) + recipe.Name = "Test Recipe" + recipe.Level = 50 + recipe.Tier = 5 + playerList.AddRecipe(recipe) + } + + if playerList.Size() != 3 { + t.Error("Player recipe list with 3 recipes should have size 3") + } +} + +// Test Recipe Book List operations +func TestNewMasterRecipeBookList(t *testing.T) { + bookList := NewMasterRecipeBookList() + if bookList == nil { + t.Error("NewMasterRecipeBookList should not return nil") + } +} + +func TestMasterRecipeBookListOperations(t *testing.T) { + bookList := NewMasterRecipeBookList() + + // Test adding recipe book + recipeBook := NewRecipe() + recipeBook.ID = 5001 + recipeBook.BookID = 5001 // Need BookID for recipe books + recipeBook.Name = "Test Recipe Book" + recipeBook.Level = 1 + recipeBook.Tier = 1 + + if !bookList.AddRecipeBook(recipeBook) { + t.Error("Adding valid recipe book should succeed") + } + + // Test getting recipe book + retrieved := bookList.GetRecipeBook(5001) + if retrieved == nil { + t.Error("Should retrieve added recipe book") + } + if retrieved.ID != 5001 { + t.Error("Retrieved recipe book should have correct ID") + } + + // Test size + if bookList.Size() != 1 { + t.Error("Recipe book list should have size 1") + } +} + +func TestNewPlayerRecipeBookList(t *testing.T) { + playerBookList := NewPlayerRecipeBookList() + if playerBookList == nil { + t.Error("NewPlayerRecipeBookList should not return nil") + } +} + +// Test RecipeManager operations +func TestNewRecipeManager(t *testing.T) { + manager := NewRecipeManager() + if manager == nil { + t.Error("NewRecipeManager should not return nil") + } + if manager.masterRecipeList == nil { + t.Error("Master recipe list should be initialized") + } + if manager.masterRecipeBookList == nil { + t.Error("Master recipe book list should be initialized") + } + if manager.loadedRecipes == nil { + t.Error("Loaded recipes map should be initialized") + } + if manager.loadedRecipeBooks == nil { + t.Error("Loaded recipe books map should be initialized") + } +} + +func TestRecipeManagerGetters(t *testing.T) { + manager := NewRecipeManager() + + // Test getting master lists + masterList := manager.GetMasterRecipeList() + if masterList == nil { + t.Error("Should return master recipe list") + } + + masterBookList := manager.GetMasterRecipeBookList() + if masterBookList == nil { + t.Error("Should return master recipe book list") + } + + // Test getting non-existent recipe + recipe := manager.GetRecipe(99999) + if recipe != nil { + t.Error("Non-existent recipe should return nil") + } + + // Test getting non-existent recipe book + book := manager.GetRecipeBook(99999) + if book != nil { + t.Error("Non-existent recipe book should return nil") + } +} + +func TestRecipeManagerStatistics(t *testing.T) { + manager := NewRecipeManager() + + // Test getting statistics when enabled + manager.GetStatistics() // Call method without assignment to avoid lock copy + + // Test disabling statistics + manager.SetStatisticsEnabled(false) + manager.GetStatistics() // Call method without assignment to avoid lock copy + + // Test enabling statistics + manager.SetStatisticsEnabled(true) +} + +func TestRecipeManagerValidation(t *testing.T) { + manager := NewRecipeManager() + + // Test validation of empty manager + issues := manager.Validate() + if len(issues) > 0 { + // Manager validation might find issues, that's fine + } +} + +func TestRecipeManagerSize(t *testing.T) { + manager := NewRecipeManager() + + // Test size of empty manager + recipes, books := manager.Size() + if recipes != 0 { + t.Error("Empty manager should have 0 recipes") + } + if books != 0 { + t.Error("Empty manager should have 0 recipe books") + } +} + +// Test RecipeComponent operations +func TestRecipeComponent(t *testing.T) { + component := &RecipeComponent{ + ItemID: 1001, + Slot: 0, + } + + if component.ItemID != 1001 { + t.Error("Component item ID should be set correctly") + } + if component.Slot != 0 { + t.Error("Component slot should be set correctly") + } +} + +// Test RecipeProducts operations +func TestRecipeProducts(t *testing.T) { + products := &RecipeProducts{ + ProductID: 2001, + ProductQty: 1, + ByproductID: 2002, + ByproductQty: 2, + } + + if products.ProductID != 2001 { + t.Error("Product ID should be set correctly") + } + if products.ProductQty != 1 { + t.Error("Product quantity should be set correctly") + } + if products.ByproductID != 2002 { + t.Error("Byproduct ID should be set correctly") + } + if products.ByproductQty != 2 { + t.Error("Byproduct quantity should be set correctly") + } +} + +// Test Statistics operations +func TestNewStatistics(t *testing.T) { + stats := NewStatistics() + if stats == nil { + t.Error("NewStatistics should not return nil") + } + if stats.RecipesByTier == nil { + t.Error("RecipesByTier map should be initialized") + } + if stats.RecipesBySkill == nil { + t.Error("RecipesBySkill map should be initialized") + } +} + +func TestStatisticsOperations(t *testing.T) { + stats := NewStatistics() + + // Test incrementing lookups + initialLookups := stats.RecipeLookups + stats.IncrementRecipeLookups() + if stats.RecipeLookups != initialLookups+1 { + t.Error("Recipe lookups should be incremented") + } + + // Test incrementing recipe book lookups + initialBookLookups := stats.RecipeBookLookups + stats.IncrementRecipeBookLookups() + if stats.RecipeBookLookups != initialBookLookups+1 { + t.Error("Recipe book lookups should be incremented") + } + + // Test incrementing player recipe loads + initialLoads := stats.PlayerRecipeLoads + stats.IncrementPlayerRecipeLoads() + if stats.PlayerRecipeLoads != initialLoads+1 { + t.Error("Player recipe loads should be incremented") + } + + // Test incrementing component queries + initialQueries := stats.ComponentQueries + stats.IncrementComponentQueries() + if stats.ComponentQueries != initialQueries+1 { + t.Error("Component queries should be incremented") + } + + // Test getting snapshot + snapshot := stats.GetSnapshot() + if snapshot.RecipeLookups != stats.RecipeLookups { + t.Error("Snapshot should match current stats") + } +} + +// Test error constants +func TestErrorConstants(t *testing.T) { + if ErrRecipeNotFound == nil { + t.Error("ErrRecipeNotFound should be defined") + } + if ErrRecipeBookNotFound == nil { + t.Error("ErrRecipeBookNotFound should be defined") + } + if ErrInvalidRecipeID == nil { + t.Error("ErrInvalidRecipeID should be defined") + } + if ErrDuplicateRecipe == nil { + t.Error("ErrDuplicateRecipe should be defined") + } +} + +// Test constants +func TestConstants(t *testing.T) { + // Test slot constants + if SlotPrimary != 0 { + t.Error("SlotPrimary should be 0") + } + if SlotBuild1 != 1 { + t.Error("SlotBuild1 should be 1") + } + if SlotFuel != 5 { + t.Error("SlotFuel should be 5") + } + + // Test stage constants + if Stage0 != 0 { + t.Error("Stage0 should be 0") + } + if Stage4 != 4 { + t.Error("Stage4 should be 4") + } + + // Test validation constants + if MinRecipeID != 1 { + t.Error("MinRecipeID should be 1") + } + if MaxRecipeLevel != 100 { + t.Error("MaxRecipeLevel should be 100") + } +} + +// Edge case tests +func TestRecipeEdgeCases(t *testing.T) { + recipe := NewRecipe() + + // Test accessing non-existent component slots + components := recipe.GetComponentsBySlot(99) + if len(components) != 0 { + t.Error("Non-existent slot should return empty slice") + } + + // Test accessing non-existent product stages + products := recipe.GetProductsForStage(99) + if products != nil { + t.Error("Non-existent stage should return nil") + } + + // Test recipe with maximum values + recipe.ID = MaxRecipeID + recipe.Level = MaxRecipeLevel + recipe.Tier = MaxTier + recipe.Name = "Max Recipe" + if !recipe.IsValid() { + t.Error("Recipe with maximum valid values should be valid") + } +} + +func TestMasterRecipeListEdgeCases(t *testing.T) { + list := NewMasterRecipeList() + + // Test operations on empty list + emptyTier := list.GetRecipesByTier(1) + if len(emptyTier) != 0 { + t.Error("Empty list should return empty slice for tier query") + } + + emptySkill := list.GetRecipesBySkill(100) + if len(emptySkill) != 0 { + t.Error("Empty list should return empty slice for skill query") + } + + // Test clearing already empty list + list.ClearRecipes() + if list.Size() != 0 { + t.Error("Clearing empty list should keep size 0") + } +} + +// Benchmark tests +func BenchmarkNewRecipe(b *testing.B) { + for i := 0; i < b.N; i++ { + NewRecipe() + } +} + +func BenchmarkRecipeCopy(b *testing.B) { + source := NewRecipe() + source.ID = 12345 + source.Name = "Benchmark Recipe" + source.Level = 50 + source.Tier = 5 + source.Components[0] = []int32{1001, 1002, 1003} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + NewRecipeFromRecipe(source) + } +} + +func BenchmarkMasterRecipeListAdd(b *testing.B) { + list := NewMasterRecipeList() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + recipe := NewRecipe() + recipe.ID = int32(i) + recipe.Name = "Benchmark Recipe" + recipe.Level = 50 + recipe.Tier = 5 + list.AddRecipe(recipe) + } +} + +func BenchmarkMasterRecipeListGet(b *testing.B) { + list := NewMasterRecipeList() + + // Add test recipes + for i := 1; i <= 1000; i++ { + recipe := NewRecipe() + recipe.ID = int32(i) + recipe.Name = "Benchmark Recipe" + recipe.Level = 50 + recipe.Tier = 5 + list.AddRecipe(recipe) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + list.GetRecipe(int32((i % 1000) + 1)) + } +} + +func BenchmarkPlayerRecipeListOperations(b *testing.B) { + playerList := NewPlayerRecipeList() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + recipe := NewRecipe() + recipe.ID = int32(i) + recipe.Name = "Benchmark Recipe" + recipe.Level = 50 + recipe.Tier = 5 + playerList.AddRecipe(recipe) + playerList.GetRecipe(int32(i)) + } +} \ No newline at end of file diff --git a/internal/recipes/types.go b/internal/recipes/types.go index c017b2b..6aa11d1 100644 --- a/internal/recipes/types.go +++ b/internal/recipes/types.go @@ -98,6 +98,18 @@ type Statistics struct { mutex sync.RWMutex } +// StatisticsSnapshot represents a snapshot of statistics without mutex +type StatisticsSnapshot struct { + TotalRecipes int32 + TotalRecipeBooks int32 + RecipesByTier map[int8]int32 + RecipesBySkill map[int32]int32 + RecipeLookups int64 + RecipeBookLookups int64 + PlayerRecipeLoads int64 + ComponentQueries int64 +} + // NewStatistics creates a new statistics tracker func NewStatistics() *Statistics { return &Statistics{ @@ -135,11 +147,11 @@ func (s *Statistics) IncrementComponentQueries() { } // GetSnapshot returns a snapshot of the current statistics -func (s *Statistics) GetSnapshot() Statistics { +func (s *Statistics) GetSnapshot() StatisticsSnapshot { s.mutex.RLock() defer s.mutex.RUnlock() - snapshot := Statistics{ + snapshot := StatisticsSnapshot{ TotalRecipes: s.TotalRecipes, TotalRecipeBooks: s.TotalRecipeBooks, RecipesByTier: make(map[int8]int32),