using System; using System.Collections.Generic; using System.Linq; using Dalamud.Plugin.Services; using Lumina.Excel.GeneratedSheets2; namespace Workshoppa.GameData; internal sealed class RecipeTree { private readonly IDataManager _dataManager; private readonly IPluginLog _pluginLog; private readonly IReadOnlyList _shopItemsOnly; public RecipeTree(IDataManager dataManager, IPluginLog pluginLog) { _dataManager = dataManager; _pluginLog = pluginLog; // probably incomplete, e.g. different housing districts have different shop types var shopVendorIds = new uint[] { 262461, // Purchase Items (Lumber, Metal, Stone, Bone, Leather) 262462, // Purchase Items (Cloth, Reagents) 262463, // Purchase Items (Gardening, Dyes) 262471, // Purchase Items (Catalysts) 262472, // Purchase (Cooking Ingredients) 262692, // Amalj'aa 262422, // Housing District Merchant 262211, // Z'ranmaia, upper decks }; _shopItemsOnly = _dataManager.GetExcelSheet()! .Where(x => shopVendorIds.Contains(x.RowId)) .Select(x => x.Item.Row) .Where(x => x > 0) .Distinct() .ToList() .AsReadOnly(); } public IReadOnlyList ResolveRecipes(IReadOnlyList materials) { // look up recipes recursively int limit = 10; List nextStep = ExtendWithAmountCrafted(materials); List completeList = new(nextStep); while (--limit > 0 && nextStep.Any(x => x.Type == Ingredient.EType.Craftable)) { nextStep = GetIngredients(nextStep); completeList.AddRange(nextStep); } // sum up all recipes completeList = completeList.GroupBy(x => x.ItemId) .Select(x => new RecipeInfo { ItemId = x.Key, Name = x.First().Name, TotalQuantity = x.Sum(y => y.TotalQuantity), Type = x.First().Type, DependsOn = x.First().DependsOn, AmountCrafted = x.First().AmountCrafted, }) .ToList(); _pluginLog.Verbose("Complete craft list:"); foreach (var item in completeList) _pluginLog.Verbose($" {item.TotalQuantity}x {item.Name}"); // if a recipe has a specific amount crafted, divide the gathered amount by it foreach (var ingredient in completeList.Where(x => x is { AmountCrafted: > 1 })) { //_pluginLog.Information($"Fudging {ingredient.Name}"); foreach (var part in completeList.Where(x => ingredient.DependsOn.Contains(x.ItemId))) { //_pluginLog.Information($" → {part.Name}"); int unmodifiedQuantity = part.TotalQuantity; int roundedQuantity = (int)((unmodifiedQuantity + ingredient.AmountCrafted - 1) / ingredient.AmountCrafted); part.TotalQuantity = part.TotalQuantity - unmodifiedQuantity + roundedQuantity; } } // figure out the correct order for items to be crafted foreach (var item in completeList.Where(x => x.Type == Ingredient.EType.ShopItem)) item.DependsOn.Clear(); List sortedList = new List(); while (sortedList.Count < completeList.Count) { _pluginLog.Verbose("Sort round"); var canBeCrafted = completeList.Where(x => !sortedList.Contains(x) && x.DependsOn.All(y => sortedList.Any(z => y == z.ItemId))) .ToList(); foreach (var item in canBeCrafted) _pluginLog.Verbose($" can craft: {item.TotalQuantity}x {item.Name}"); if (canBeCrafted.Count == 0) { foreach (var item in completeList.Where(x => !sortedList.Contains(x))) _pluginLog.Warning($" can't craft: {item.TotalQuantity}x {item.Name} → ({string.Join(", ", item.DependsOn.Where(y => sortedList.All(z => y != z.ItemId)))})"); throw new InvalidOperationException("Unable to sort items"); } sortedList.AddRange(canBeCrafted.OrderBy(x => x.Name)); } return sortedList.Cast().ToList(); } private List GetIngredients(List materials) { List ingredients = new(); foreach (var material in materials.Where(x => x.Type == Ingredient.EType.Craftable)) { //_pluginLog.Information($"Looking up recipe for {material.Name}"); var recipe = GetFirstRecipeForItem(material.ItemId); if (recipe == null) continue; for (int i = 0; i < 8; ++ i) { var ingredient = recipe.Ingredient[i]; if (ingredient == null || ingredient.Row == 0) continue; Item? item = ingredient.Value; if (item == null || !IsValidItem(item.RowId)) continue; Recipe? ingredientRecipe = GetFirstRecipeForItem(ingredient.Row); //_pluginLog.Information($"Adding {item.Name}"); ingredients.Add(new RecipeInfo { ItemId = ingredient.Row, Name = item.Name, TotalQuantity = material.TotalQuantity * recipe.AmountIngredient[i], Type = _shopItemsOnly.Contains(ingredient.Row) ? Ingredient.EType.ShopItem : ingredientRecipe != null ? Ingredient.EType.Craftable : GetGatheringItem(ingredient.Row) != null ? Ingredient.EType.Gatherable : GetVentureItem(ingredient.Row) != null ? Ingredient.EType.Gatherable : Ingredient.EType.Other, AmountCrafted = ingredientRecipe?.AmountResult ?? 1, DependsOn = ingredientRecipe?.Ingredient.Where(x => x != null && IsValidItem(x.Row)) .Select(x => x.Row) .ToList() ?? new(), }); } } return ingredients; } private List ExtendWithAmountCrafted(IEnumerable materials) { return materials.Select(x => new { Ingredient = x, Recipe = GetFirstRecipeForItem(x.ItemId) }) .Where(x => x.Recipe != null) .Select(x => new RecipeInfo { ItemId = x.Ingredient.ItemId, Name = x.Ingredient.Name, TotalQuantity = x.Ingredient.TotalQuantity, Type = _shopItemsOnly.Contains(x.Ingredient.ItemId) ? Ingredient.EType.ShopItem : x.Ingredient.Type, AmountCrafted = x.Recipe!.AmountResult, DependsOn = x.Recipe.Ingredient.Where(y => y != null && IsValidItem(y.Row)) .Select(y => y.Row) .ToList(), }) .ToList(); } private Recipe? GetFirstRecipeForItem(uint itemId) { return _dataManager.GetExcelSheet()!.FirstOrDefault(x => x.RowId > 0 && x.ItemResult.Row == itemId); } private GatheringItem? GetGatheringItem(uint itemId) { return _dataManager.GetExcelSheet()!.FirstOrDefault(x => x.RowId > 0 && x.Item.Row == itemId); } private RetainerTaskNormal? GetVentureItem(uint itemId) { return _dataManager.GetExcelSheet()! .FirstOrDefault(x => x.RowId > 0 && x.Item.Row == itemId); } private static bool IsValidItem(uint itemId) { return itemId > 19 && itemId != uint.MaxValue; } private sealed class RecipeInfo : Ingredient { public required uint AmountCrafted { get; init; } public required List DependsOn { get; init; } } }