eq2go/internal/recipes/manager.go

565 lines
16 KiB
Go

package recipes
import (
"fmt"
"sync"
"eq2emu/internal/database"
)
// RecipeManager provides high-level recipe system management with database integration
type RecipeManager struct {
db *database.DB
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
mu sync.RWMutex
}
// NewRecipeManager creates a new recipe manager with database integration
func NewRecipeManager(db *database.DB) *RecipeManager {
return &RecipeManager{
db: db,
masterRecipeList: NewMasterRecipeList(),
masterRecipeBookList: NewMasterRecipeBookList(),
loadedRecipes: make(map[int32]*Recipe),
loadedRecipeBooks: make(map[int32]*Recipe),
statisticsEnabled: true,
}
}
// 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
func (rm *RecipeManager) containsComponent(components []int32, componentID int32) bool {
for _, comp := range components {
if comp == componentID {
return true
}
}
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
func (rm *RecipeManager) GetRecipe(recipeID int32) *Recipe {
rm.mu.RLock()
defer rm.mu.RUnlock()
return rm.masterRecipeList.GetRecipe(recipeID)
}
// GetRecipeBook retrieves a recipe book by ID
func (rm *RecipeManager) GetRecipeBook(bookID int32) *Recipe {
rm.mu.RLock()
defer rm.mu.RUnlock()
return rm.loadedRecipeBooks[bookID]
}
// GetStatistics returns current recipe system statistics
func (rm *RecipeManager) GetStatistics() RecipeManagerStats {
if !rm.statisticsEnabled {
return RecipeManagerStats{}
}
rm.stats.mu.RLock()
defer rm.stats.mu.RUnlock()
return rm.stats
}
// SetStatisticsEnabled enables or disables statistics collection
func (rm *RecipeManager) SetStatisticsEnabled(enabled bool) {
rm.mu.Lock()
defer rm.mu.Unlock()
rm.statisticsEnabled = enabled
}
// Validate performs comprehensive recipe system validation
func (rm *RecipeManager) Validate() []string {
rm.mu.RLock()
defer rm.mu.RUnlock()
var issues []string
// Validate master recipe list
if rm.masterRecipeList == nil {
issues = append(issues, "master recipe list is nil")
return issues
}
// Validate master recipe book list
if rm.masterRecipeBookList == nil {
issues = append(issues, "master recipe book list is nil")
}
// Check for recipes with invalid data
for _, recipe := range rm.loadedRecipes {
if recipe.ID == 0 {
issues = append(issues, fmt.Sprintf("recipe has invalid ID: %s", recipe.Name))
}
if recipe.Name == "" {
issues = append(issues, fmt.Sprintf("recipe %d has empty name", recipe.ID))
}
if recipe.Level < 0 || recipe.Level > 100 {
issues = append(issues, fmt.Sprintf("recipe %d has invalid level: %d", recipe.ID, recipe.Level))
}
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
func (rm *RecipeManager) Size() (recipes int32, recipeBooks int32) {
rm.mu.RLock()
defer rm.mu.RUnlock()
return int32(len(rm.loadedRecipes)), int32(len(rm.loadedRecipeBooks))
}