565 lines
16 KiB
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))
|
|
} |