diff --git a/Workshoppa/GameData/Ingredient.cs b/Workshoppa/GameData/Ingredient.cs new file mode 100644 index 0000000..6a49505 --- /dev/null +++ b/Workshoppa/GameData/Ingredient.cs @@ -0,0 +1,18 @@ +namespace Workshoppa.GameData; + +public class Ingredient +{ + public required uint ItemId { get; init; } + public uint IconId { get; init; } + public required string Name { get; init; } + public required int TotalQuantity { get; set; } + public required EType Type { get; init; } + + public enum EType + { + Craftable, + Gatherable, + Other, + ShopItem, + } +} diff --git a/Workshoppa/GameData/RecipeTree.cs b/Workshoppa/GameData/RecipeTree.cs new file mode 100644 index 0000000..67cde3e --- /dev/null +++ b/Workshoppa/GameData/RecipeTree.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Plugin.Services; +using Lumina.Excel.GeneratedSheets; + +namespace Workshoppa.GameData; + +public 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 List ResolveRecipes(List 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(); + + // 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) + { + var craftable = completeList.Where(x => + !sortedList.Contains(x) && x.DependsOn.All(y => sortedList.Any(z => y == z.ItemId))) + .ToList(); + if (craftable.Count == 0) + throw new Exception("Unable to sort items"); + + sortedList.AddRange(craftable.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; + + foreach (var ingredient in recipe.UnkData5.Take(8)) + { + if (ingredient == null || ingredient.ItemIngredient == 0) + continue; + + Item? item = _dataManager.GetExcelSheet()!.GetRow((uint)ingredient.ItemIngredient); + if (item == null) + continue; + + Recipe? ingredientRecipe = GetFirstRecipeForItem((uint)ingredient.ItemIngredient); + + _pluginLog.Information($"Adding {item.Name}"); + ingredients.Add(new RecipeInfo + { + ItemId = (uint)ingredient.ItemIngredient, + Name = item.Name, + TotalQuantity = material.TotalQuantity * ingredient.AmountIngredient, + Type = + _shopItemsOnly.Contains((uint)ingredient.ItemIngredient) ? Ingredient.EType.ShopItem : + ingredientRecipe != null ? Ingredient.EType.Craftable : + GetGatheringItem((uint)ingredient.ItemIngredient) != null ? Ingredient.EType.Gatherable : + GetVentureItem((uint)ingredient.ItemIngredient) != null ? Ingredient.EType.Gatherable : + Ingredient.EType.Other, + + AmountCrafted = ingredientRecipe?.AmountResult ?? 1, + DependsOn = ingredientRecipe?.UnkData5.Take(8).Where(x => x != null && x.ItemIngredient != 0) + .Select(x => (uint)x.ItemIngredient) + .ToList() + ?? new(), + }); + } + } + + return ingredients; + } + + private List ExtendWithAmountCrafted(List 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.UnkData5.Take(8).Where(y => y != null && y.ItemIngredient != 0) + .Select(y => (uint)y.ItemIngredient) + .ToList(), + }) + .ToList(); + } + + public Recipe? GetFirstRecipeForItem(uint itemId) + { + return _dataManager.GetExcelSheet()!.FirstOrDefault(x => x.RowId > 0 && x.ItemResult.Row == itemId); + } + + public GatheringItem? GetGatheringItem(uint itemId) + { + return _dataManager.GetExcelSheet()!.FirstOrDefault(x => x.RowId > 0 && (uint)x.Item == itemId); + } + + public RetainerTaskNormal? GetVentureItem(uint itemId) + { + return _dataManager.GetExcelSheet()! + .FirstOrDefault(x => x.RowId > 0 && x.Item.Row == itemId); + } + + private sealed class RecipeInfo : Ingredient + { + public required uint AmountCrafted { get; init; } + public required List DependsOn { get; init; } + } +} diff --git a/Workshoppa/Windows/MainWindow.cs b/Workshoppa/Windows/MainWindow.cs index 8837747..e30c4ac 100644 --- a/Workshoppa/Windows/MainWindow.cs +++ b/Workshoppa/Windows/MainWindow.cs @@ -29,6 +29,7 @@ internal sealed class MainWindow : LImGui.LWindow private readonly WorkshopCache _workshopCache; private readonly IconCache _iconCache; private readonly IChatGui _chatGui; + private readonly RecipeTree _recipeTree; private readonly IPluginLog _pluginLog; private string _searchString = string.Empty; @@ -37,7 +38,7 @@ internal sealed class MainWindow : LImGui.LWindow public MainWindow(WorkshopPlugin plugin, DalamudPluginInterface pluginInterface, IClientState clientState, Configuration configuration, WorkshopCache workshopCache, IconCache iconCache, IChatGui chatGui, - IPluginLog pluginLog) + RecipeTree recipeTree, IPluginLog pluginLog) : base("Workshoppa###WorkshoppaMainWindow") { _plugin = plugin; @@ -47,6 +48,7 @@ internal sealed class MainWindow : LImGui.LWindow _workshopCache = workshopCache; _iconCache = iconCache; _chatGui = chatGui; + _recipeTree = recipeTree; _pluginLog = pluginLog; Position = new Vector2(100, 100); @@ -459,6 +461,22 @@ internal sealed class MainWindow : LImGui.LWindow _chatGui.Print("Copied queue content to clipboard."); } + if (ImGui.MenuItem("Export Material List to Clipboard")) + { + var toClipboardItems = _recipeTree.ResolveRecipes(GetMaterialList()).Where(x => x.Type == Ingredient.EType.Craftable); + ImGui.SetClipboardText(string.Join(Environment.NewLine, toClipboardItems.Select(x => $"{x.TotalQuantity}x {x.Name}"))); + + _chatGui.Print("Copied material list to clipboard."); + } + + if (ImGui.MenuItem("Export Gathered/Venture materials to Clipboard")) + { + var toClipboardItems = _recipeTree.ResolveRecipes(GetMaterialList()).Where(x => x.Type == Ingredient.EType.Gatherable); + ImGui.SetClipboardText(string.Join(Environment.NewLine, toClipboardItems.Select(x => $"{x.TotalQuantity}x {x.Name}"))); + + _chatGui.Print("Copied material list to clipboard."); + } + ImGui.EndDisabled(); ImGui.EndMenu(); @@ -504,7 +522,32 @@ internal sealed class MainWindow : LImGui.LWindow private unsafe void CheckMaterial() { ImGui.Text("Items needed for all crafts in queue:"); + var items = GetMaterialList(); + ImGui.Indent(20); + InventoryManager* inventoryManager = InventoryManager.Instance(); + foreach (var item in items) + { + int inInventory = inventoryManager->GetInventoryItemCount(item.ItemId, true, false, false) + + inventoryManager->GetInventoryItemCount(item.ItemId, false, false, false); + + IDalamudTextureWrap? icon = _iconCache.GetIcon(item.IconId); + if (icon != null) + { + ImGui.Image(icon.ImGuiHandle, new Vector2(23, 23)); + ImGui.SameLine(0, 3); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 3); + } + + ImGui.TextColored(inInventory >= item.TotalQuantity ? ImGuiColors.HealerGreen : ImGuiColors.DalamudRed, + $"{item.Name} ({inInventory} / {item.TotalQuantity})"); + } + + ImGui.Unindent(20); + } + + private List GetMaterialList() + { List workshopItemIds = _configuration.ItemQueue .SelectMany(x => Enumerable.Range(0, x.Quantity).Select(_ => x.WorkshopItemId)) .ToList(); @@ -529,41 +572,22 @@ internal sealed class MainWindow : LImGui.LWindow } } - var items = workshopItemIds.Select(x => _workshopCache.Crafts.Single(y => y.WorkshopItemId == x)) + return workshopItemIds.Select(x => _workshopCache.Crafts.Single(y => y.WorkshopItemId == x)) .SelectMany(x => x.Phases) .SelectMany(x => x.Items) .GroupBy(x => new { x.Name, x.ItemId, x.IconId }) .OrderBy(x => x.Key.Name) - .Select(x => new + .Select(x => new Ingredient { - x.Key.ItemId, - x.Key.IconId, - x.Key.Name, + ItemId = x.Key.ItemId, + IconId = x.Key.IconId, + Name = x.Key.Name, TotalQuantity = completedForCurrentCraft.TryGetValue(x.Key.ItemId, out var completed) ? x.Sum(y => y.TotalQuantity) - completed : x.Sum(y => y.TotalQuantity), - }); - - ImGui.Indent(20); - InventoryManager* inventoryManager = InventoryManager.Instance(); - foreach (var item in items) - { - int inInventory = inventoryManager->GetInventoryItemCount(item.ItemId, true, false, false) + - inventoryManager->GetInventoryItemCount(item.ItemId, false, false, false); - - IDalamudTextureWrap? icon = _iconCache.GetIcon(item.IconId); - if (icon != null) - { - ImGui.Image(icon.ImGuiHandle, new Vector2(23, 23)); - ImGui.SameLine(0, 3); - ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 3); - } - - ImGui.TextColored(inInventory >= item.TotalQuantity ? ImGuiColors.HealerGreen : ImGuiColors.DalamudRed, - $"{item.Name} ({inInventory} / {item.TotalQuantity})"); - } - - ImGui.Unindent(20); + Type = Ingredient.EType.Craftable, + }) + .ToList(); } private void AddMaterial(Dictionary completedForCurrentCraft, uint itemId, int quantity) diff --git a/Workshoppa/WorkshopPlugin.cs b/Workshoppa/WorkshopPlugin.cs index 84f085f..deadf50 100644 --- a/Workshoppa/WorkshopPlugin.cs +++ b/Workshoppa/WorkshopPlugin.cs @@ -71,7 +71,7 @@ public sealed partial class WorkshopPlugin : IDalamudPlugin _gameStrings = new(dataManager, _pluginLog); _mainWindow = new(this, _pluginInterface, _clientState, _configuration, _workshopCache, - new IconCache(textureProvider), _chatGui, _pluginLog); + new IconCache(textureProvider), _chatGui, new RecipeTree(dataManager, _pluginLog), _pluginLog); _windowSystem.AddWindow(_mainWindow); _configWindow = new(_pluginInterface, _configuration); _windowSystem.AddWindow(_configWindow); diff --git a/Workshoppa/Workshoppa.csproj b/Workshoppa/Workshoppa.csproj index 7b0dc2b..03f629f 100644 --- a/Workshoppa/Workshoppa.csproj +++ b/Workshoppa/Workshoppa.csproj @@ -1,7 +1,7 @@ net7.0-windows - 4.1 + 4.2 11.0 enable true