diff --git a/Craftimizer/Configuration.cs b/Craftimizer/Configuration.cs index c37a89e..f03b0bc 100644 --- a/Craftimizer/Configuration.cs +++ b/Craftimizer/Configuration.cs @@ -1,7 +1,7 @@ -using Craftimizer.Simulator; using Craftimizer.Simulator.Actions; using Craftimizer.Solver; using Dalamud.Configuration; +using Newtonsoft.Json; using System; using System.Collections.Generic; @@ -10,8 +10,64 @@ namespace Craftimizer.Plugin; [Serializable] public class Macro { + public static event Action? OnMacroChanged; + public string Name { get; set; } = string.Empty; - public List Actions { get; set; } = new(); + [JsonProperty(PropertyName = "Actions")] + private List actions { get; set; } = new(); + [JsonIgnore] + public IReadOnlyList Actions + { + get => actions; + set => ActionEnumerable = value; + } + [JsonIgnore] + public IEnumerable ActionEnumerable + { + set + { + actions = new(value); + OnMacroChanged?.Invoke(this); + } + } +} + +[Serializable] +public class MacroCopyConfiguration +{ + public enum CopyType + { + OpenWindow, // useful for big macros + CopyToMacro, // (add option for down or right) (max macro count; open copy-paste window if too much) + CopyToClipboard, + } + + public CopyType Type { get; set; } = CopyType.OpenWindow; + + // CopyToMacro + public bool CopyDown { get; set; } + public bool SharedMacro { get; set; } + public int StartMacroIdx { get; set; } = 1; + public int MaxMacroCount { get; set; } = 5; + + // Add /nextmacro [down] + public bool UseNextMacro { get; set; } + + // Add /mlock + public bool UseMacroLock { get; set; } + + public bool AddNotification { get; set; } = true; + + // Requires AddNotification + public bool AddNotificationSound { get; set; } = true; + public int IntermediateNotificationSound { get; set; } = 10; + public int EndNotificationSound { get; set; } = 6; + + // For SND + public bool RemoveWaitTimes { get; set; } + + // For SND; Cannot use CopyToMacro + public bool CombineMacro { get; set; } } [Serializable] @@ -19,9 +75,12 @@ public class Configuration : IPluginConfiguration { public int Version { get; set; } = 1; - public bool OverrideUncraftability { get; set; } = true; - public bool HideUnlearnedActions { get; set; } = true; - public List Macros { get; set; } = new(); + public static event Action? OnMacroListChanged; + + [JsonProperty(PropertyName = "Macros")] + private List macros { get; set; } = new(); + [JsonIgnore] + public IReadOnlyList Macros => macros; public bool ConditionRandomness { get; set; } = true; public SolverConfig SimulatorSolverConfig { get; set; } = SolverConfig.SimulatorDefault; public SolverConfig SynthHelperSolverConfig { get; set; } = SolverConfig.SynthHelperDefault; @@ -29,10 +88,23 @@ public class Configuration : IPluginConfiguration public bool ShowOptimalMacroStat { get; set; } = true; public int SynthHelperStepCount { get; set; } = 5; - public Simulator.Simulator CreateSimulator(SimulationState state) => - ConditionRandomness ? - new Simulator.Simulator(state) : - new SimulatorNoRandom(state); + public MacroCopyConfiguration MacroCopy { get; set; } = new(); + + public void AddMacro(Macro macro) + { + macros.Add(macro); + Save(); + OnMacroListChanged?.Invoke(); + } + + public void RemoveMacro(Macro macro) + { + if (macros.Remove(macro)) + { + Save(); + OnMacroListChanged?.Invoke(); + } + } public void Save() => Service.PluginInterface.SavePluginConfig(this); diff --git a/Craftimizer/Craftimizer.csproj b/Craftimizer/Craftimizer.csproj index d4f8fb8..3397a43 100644 --- a/Craftimizer/Craftimizer.csproj +++ b/Craftimizer/Craftimizer.csproj @@ -2,7 +2,7 @@ Asriel Camora - 1.2.0.0 + 1.9.0.0 https://github.com/WorkingRobot/craftimizer.git diff --git a/Craftimizer/ImGuiUtils.cs b/Craftimizer/ImGuiUtils.cs index 24132e2..88afbb8 100644 --- a/Craftimizer/ImGuiUtils.cs +++ b/Craftimizer/ImGuiUtils.cs @@ -1,130 +1,149 @@ +using Craftimizer.Utils; using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; using ImGuiNET; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; +using System.Linq; using System.Numerics; +using System.Threading; +using System.Threading.Tasks; namespace Craftimizer.Plugin; internal static class ImGuiUtils { - private static readonly Stack<(Vector2 Min, Vector2 Max)> GroupPanelLabelStack = new(); + private static readonly Stack<(Vector2 Min, Vector2 Max, float TopPadding)> GroupPanelLabelStack = new(); // Adapted from https://github.com/ocornut/imgui/issues/1496#issuecomment-655048353 - public static void BeginGroupPanel(float width = -1, bool addPadding = true) + // width = -1 -> size to parent + // width = 0 -> size to content + // returns available width (better since it accounts for the right side padding) + // ^ only useful if width = -1 + public static float BeginGroupPanel(string name, float width) { + // container group ImGui.BeginGroup(); var itemSpacing = ImGui.GetStyle().ItemSpacing; - ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, Vector2.Zero); - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - var frameHeight = ImGui.GetFrameHeight(); + width = width < 0 ? ImGui.GetContentRegionAvail().X - (2 * itemSpacing.X) : width; + var fullWidth = width > 0 ? width + (2 * itemSpacing.X) : 0; + { + using var noPadding = ImRaii.PushStyle(ImGuiStyleVar.FramePadding, Vector2.Zero); + using var noSpacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - ImGui.BeginGroup(); - ImGui.Dummy(new Vector2(width < 0 ? ImGui.GetContentRegionAvail().X : width, 0)); - ImGui.Dummy(new Vector2(frameHeight * 0.5f, 0)); - ImGui.SameLine(0, 0); + // inner group + ImGui.BeginGroup(); + ImGui.Dummy(new Vector2(fullWidth, 0)); + ImGui.Dummy(new Vector2(itemSpacing.X, 0)); // shifts next group by is.x + ImGui.SameLine(0, 0); - ImGui.BeginGroup(); - ImGui.Dummy(new Vector2(frameHeight * 0.5f, 0)); - GroupPanelLabelStack.Push((ImGui.GetItemRectMin(), ImGui.GetItemRectMax())); - ImGui.SameLine(0, 0); - ImGui.Dummy(new Vector2(0f, frameHeight * (addPadding ? 1 : .5f) + itemSpacing.Y)); + // label group + ImGui.BeginGroup(); + ImGui.Dummy(new Vector2(frameHeight / 2, 0)); // shifts text by fh/2 + ImGui.SameLine(0, 0); + var textFrameHeight = ImGui.GetFrameHeight(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(name); + GroupPanelLabelStack.Push((ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), textFrameHeight / 2f)); // push rect to stack + ImGui.SameLine(0, 0); + ImGui.Dummy(new Vector2(0f, textFrameHeight + itemSpacing.Y)); // shifts content by fh + is.y - ImGui.BeginGroup(); + // content group + ImGui.BeginGroup(); + } - ImGui.PopStyleVar(2); - - ImGui.PushItemWidth(MathF.Max(0, ImGui.CalcItemWidth() - frameHeight)); - } - - public static void BeginGroupPanel(string name, float width = -1, bool addPadding = true) - { - ImGui.BeginGroup(); - - var itemSpacing = ImGui.GetStyle().ItemSpacing; - ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, Vector2.Zero); - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - - var frameHeight = ImGui.GetFrameHeight(); - - ImGui.BeginGroup(); - ImGui.Dummy(new Vector2(width < 0 ? ImGui.GetContentRegionAvail().X : width, 0)); - ImGui.Dummy(new Vector2(frameHeight * 0.5f, 0)); - ImGui.SameLine(0, 0); - - ImGui.BeginGroup(); - ImGui.Dummy(new Vector2(frameHeight * 0.5f, 0)); - ImGui.SameLine(0, 0); - ImGui.TextUnformatted(name); - GroupPanelLabelStack.Push((ImGui.GetItemRectMin(), ImGui.GetItemRectMax())); - ImGui.SameLine(0, 0); - ImGui.Dummy(new Vector2(0f, frameHeight * (addPadding ? 1 : .5f) + itemSpacing.Y)); - - ImGui.BeginGroup(); - - ImGui.PopStyleVar(2); - - ImGui.PushItemWidth(MathF.Max(0, ImGui.CalcItemWidth() - frameHeight)); + return width; } public static void EndGroupPanel() { - ImGui.PopItemWidth(); - var itemSpacing = ImGui.GetStyle().ItemSpacing; - - ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, Vector2.Zero); - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - - var frameHeight = ImGui.GetFrameHeight(); - - ImGui.EndGroup(); - - ImGui.EndGroup(); - - ImGui.SameLine(0, 0); - ImGui.Dummy(new Vector2(frameHeight * 0.5f, 0)); - ImGui.Dummy(new Vector2(0f, frameHeight * 0.5f - itemSpacing.Y)); - - ImGui.EndGroup(); - - var itemMin = ImGui.GetItemRectMin(); - var itemMax = ImGui.GetItemRectMax(); - var labelRect = GroupPanelLabelStack.Pop(); - - var halfFrame = new Vector2(frameHeight * 0.25f, frameHeight) * 0.5f; - (Vector2 Min, Vector2 Max) frameRect = (itemMin + halfFrame, itemMax - new Vector2(halfFrame.X, 0)); - labelRect.Min.X -= itemSpacing.X; - labelRect.Max.X += itemSpacing.X; - for (var i = 0; i < 4; ++i) { - var (minClip, maxClip) = i switch - { - 0 => (new Vector2(float.NegativeInfinity), new Vector2(labelRect.Min.X, float.PositiveInfinity)), - 1 => (new Vector2(labelRect.Max.X, float.NegativeInfinity), new Vector2(float.PositiveInfinity)), - 2 => (new Vector2(labelRect.Min.X, float.NegativeInfinity), new Vector2(labelRect.Max.X, labelRect.Min.Y)), - 3 => (new Vector2(labelRect.Min.X, labelRect.Max.Y), new Vector2(labelRect.Max.X, float.PositiveInfinity)), - _ => (Vector2.Zero, Vector2.Zero) - }; + using var noPadding = ImRaii.PushStyle(ImGuiStyleVar.FramePadding, Vector2.Zero); + using var noSpacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - ImGui.PushClipRect(minClip, maxClip, true); - ImGui.GetWindowDrawList().AddRect( - frameRect.Min, frameRect.Max, - ImGui.GetColorU32(ImGuiCol.Border), - halfFrame.X); - ImGui.PopClipRect(); + // content group + ImGui.EndGroup(); + + // label group + ImGui.EndGroup(); + + ImGui.SameLine(0, 0); + // shifts full size by is (for rect placement) + ImGui.Dummy(new(itemSpacing.X, 0)); + ImGui.Dummy(new(0, itemSpacing.Y * 2)); // * 2 for some reason (otherwise the bottom is too skinny) + + // inner group + ImGui.EndGroup(); + + var labelRect = GroupPanelLabelStack.Pop(); + var innerMin = ImGui.GetItemRectMin() + new Vector2(0, labelRect.TopPadding); + var innerMax = ImGui.GetItemRectMax(); + + (Vector2 Min, Vector2 Max) frameRect = (innerMin, innerMax); + // add itemspacing padding on the label's sides + labelRect.Min.X -= itemSpacing.X / 2; + labelRect.Max.X += itemSpacing.X / 2; + for (var i = 0; i < 4; ++i) + { + var (minClip, maxClip) = i switch + { + 0 => (new Vector2(float.NegativeInfinity), new Vector2(labelRect.Min.X, float.PositiveInfinity)), + 1 => (new Vector2(labelRect.Max.X, float.NegativeInfinity), new Vector2(float.PositiveInfinity)), + 2 => (new Vector2(labelRect.Min.X, float.NegativeInfinity), new Vector2(labelRect.Max.X, labelRect.Min.Y)), + 3 => (new Vector2(labelRect.Min.X, labelRect.Max.Y), new Vector2(labelRect.Max.X, float.PositiveInfinity)), + _ => (Vector2.Zero, Vector2.Zero) + }; + + ImGui.PushClipRect(minClip, maxClip, true); + ImGui.GetWindowDrawList().AddRect( + frameRect.Min, frameRect.Max, + ImGui.GetColorU32(ImGuiCol.Border), + itemSpacing.X); + ImGui.PopClipRect(); + } + + ImGui.Dummy(Vector2.Zero); } - ImGui.PopStyleVar(2); - - ImGui.Dummy(Vector2.Zero); - ImGui.EndGroup(); } + private struct EndUnconditionally : ImRaii.IEndObject, IDisposable + { + private Action EndAction { get; } + + public bool Success { get; } + + public bool Disposed { get; private set; } + + public EndUnconditionally(Action endAction, bool success) + { + EndAction = endAction; + Success = success; + Disposed = false; + } + + public void Dispose() + { + if (!Disposed) + { + EndAction(); + Disposed = true; + } + } + } + + public static ImRaii.IEndObject GroupPanel(string name, float width, out float internalWidth) + { + internalWidth = BeginGroupPanel(name, width); + return new EndUnconditionally(EndGroupPanel, true); + } + private static Vector2 UnitCircle(float theta) { var (s, c) = MathF.SinCos(theta); @@ -156,7 +175,7 @@ internal static class ImGuiUtils var offset = ImGui.GetCursorScreenPos() + new Vector2(radius); - var segments = ImGui.GetWindowDrawList()._CalcCircleAutoSegmentCount(radius * 2); + var segments = ImGui.GetWindowDrawList()._CalcCircleAutoSegmentCount(radius); var incrementAngle = MathF.Tau / segments; var isFullCircle = (endAngle - startAngle) % MathF.Tau == 0; @@ -215,6 +234,216 @@ internal static class ImGuiUtils Arc(MathF.PI / 2, MathF.PI / 2 - MathF.Tau * Math.Clamp(value, 0, 1), radiusInner, radiusOuter, backgroundColor, filledColor); } + private sealed class SearchableComboData where T : class + { + public readonly ImmutableArray items; + public List filteredItems; + public T selectedItem; + public string input; + public bool wasTextActive; + public bool wasPopupActive; + public CancellationTokenSource? cts; + public Task? task; + + private readonly Func getString; + + public SearchableComboData(IEnumerable items, T selectedItem, Func getString) + { + this.items = items.ToImmutableArray(); + filteredItems = new() { selectedItem }; + this.selectedItem = selectedItem; + this.getString = getString; + input = GetString(selectedItem); + } + + public void SetItem(T selectedItem) + { + if (this.selectedItem != selectedItem) + { + input = GetString(selectedItem); + this.selectedItem = selectedItem; + } + } + + public string GetString(T item) => getString(item); + + public void Filter() + { + cts?.Cancel(); + var inp = input; + cts = new(); + var token = cts.Token; + task = Task.Run(() => FilterTask(inp, token), token) + .ContinueWith(t => + { + if (cts.IsCancellationRequested) + return; + + try + { + t.Exception!.Flatten().Handle(ex => ex is TaskCanceledException or OperationCanceledException); + } + catch (Exception e) + { + Log.Error(e, "Filtering recipes failed"); + } + }, TaskContinuationOptions.OnlyOnFaulted); + } + + private void FilterTask(string input, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(input)) + { + filteredItems = items.ToList(); + return; + } + var matcher = new FuzzyMatcher(input.ToLowerInvariant(), MatchMode.FuzzyParts); + var query = items.AsParallel().Select(i => (Item: i, Score: matcher.Matches(getString(i).ToLowerInvariant()))) + .Where(t => t.Score > 0) + .OrderByDescending(t => t.Score) + .Select(t => t.Item); + token.ThrowIfCancellationRequested(); + filteredItems = query.ToList(); + } + } + private static readonly Dictionary ComboData = new(); + + private static SearchableComboData GetComboData(uint comboKey, IEnumerable items, T selectedItem, Func getString) where T : class => + (SearchableComboData)( + ComboData.TryGetValue(comboKey, out var data) + ? data + : ComboData[comboKey] = new SearchableComboData(items, selectedItem, getString)); + + // https://github.com/ocornut/imgui/issues/718#issuecomment-1563162222 + public static bool SearchableCombo(string id, ref T selectedItem, IEnumerable items, ImFontPtr selectableFont, float width, Func getString, Func getId, Action draw) where T : class + { + var comboKey = ImGui.GetID(id); + var data = GetComboData(comboKey, items, selectedItem, getString); + data.SetItem(selectedItem); + + using var pushId = ImRaii.PushId(id); + + width = width == 0 ? ImGui.GetContentRegionAvail().X : width; + var availableSpace = Math.Min(ImGui.GetContentRegionAvail().X, width); + ImGui.SetNextItemWidth(availableSpace); + var isInputTextEnterPressed = ImGui.InputText("##input", ref data.input, 256, ImGuiInputTextFlags.EnterReturnsTrue); + var min = ImGui.GetItemRectMin(); + var size = ImGui.GetItemRectSize(); + size.X = Math.Min(size.X, availableSpace); + + var isInputTextActivated = ImGui.IsItemActivated(); + + if (isInputTextActivated) + { + ImGui.SetNextWindowPos(min - ImGui.GetStyle().WindowPadding); + ImGui.OpenPopup("##popup"); + data.wasTextActive = false; + } + + using (var popup = ImRaii.Popup("##popup", ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoSavedSettings)) + { + if (popup) + { + data.wasPopupActive = true; + + if (isInputTextActivated) + { + ImGui.SetKeyboardFocusHere(0); + data.Filter(); + } + ImGui.SetNextItemWidth(size.X); + if (ImGui.InputText("##input_popup", ref data.input, 256)) + data.Filter(); + var isActive = ImGui.IsItemActive(); + if (!isActive && data.wasTextActive && ImGui.IsKeyPressed(ImGuiKey.Enter)) + isInputTextEnterPressed = true; + data.wasTextActive = isActive; + + using (var scrollingRegion = ImRaii.Child("scrollingRegion", new Vector2(size.X, size.Y * 10), false, ImGuiWindowFlags.HorizontalScrollbar)) + { + T? _selectedItem = default; + var height = ImGui.GetTextLineHeight(); + var r = ListClip(data.filteredItems, height, t => + { + var name = getString(t); + using (var selectFont = ImRaii.PushFont(selectableFont)) + { + if (ImGui.Selectable($"##{getId(t)}")) + { + _selectedItem = t; + return true; + } + } + ImGui.SameLine(0, ImGui.GetStyle().ItemSpacing.X / 2f); + draw(t); + return false; + }); + if (r) + { + selectedItem = _selectedItem!; + data.SetItem(selectedItem); + ImGui.CloseCurrentPopup(); + return true; + } + } + + if (isInputTextEnterPressed || ImGui.IsKeyPressed(ImGuiKey.Escape)) + { + if (isInputTextEnterPressed && data.filteredItems.Count > 0) + { + selectedItem = data.filteredItems[0]; + data.SetItem(selectedItem); + } + ImGui.CloseCurrentPopup(); + return true; + } + } + else + { + if (data.wasPopupActive) + { + data.wasPopupActive = false; + data.input = getString(selectedItem); + } + } + } + + return false; + } + + private static bool ListClip(IReadOnlyList data, float lineHeight, Predicate func) + { + ImGuiListClipperPtr imGuiListClipperPtr; + unsafe + { + imGuiListClipperPtr = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); + } + try + { + imGuiListClipperPtr.Begin(data.Count, lineHeight); + while (imGuiListClipperPtr.Step()) + { + for (var i = imGuiListClipperPtr.DisplayStart; i <= imGuiListClipperPtr.DisplayEnd; i++) + { + if (i >= data.Count) + return false; + + if (i >= 0) + { + if (func(data[i])) + return true; + } + } + } + return false; + } + finally + { + imGuiListClipperPtr.End(); + imGuiListClipperPtr.Destroy(); + } + } + public static bool IconButtonSized(FontAwesomeIcon icon, Vector2 size) { ImGui.PushFont(UiBuilder.IconFont); @@ -254,6 +483,14 @@ internal static class ImGuiUtils ImGui.SetCursorPosX(ImGui.GetCursorPos().X + (availWidth - width) / 2); } + public static void AlignRight(float width, float availWidth = default) + { + if (availWidth == default) + availWidth = ImGui.GetContentRegionAvail().X; + if (availWidth > width) + ImGui.SetCursorPosX(ImGui.GetCursorPos().X + availWidth - width); + } + public static void AlignMiddle(Vector2 size, Vector2 availSize = default) { if (availSize == default) @@ -271,9 +508,9 @@ internal static class ImGuiUtils ImGui.TextUnformatted(text); } - public static void TextMiddle(string text, Vector2 availSize = default) + public static void TextRight(string text, float availWidth = default) { - AlignMiddle(ImGui.CalcTextSize(text), availSize); + AlignRight(ImGui.CalcTextSize(text).X, availWidth); ImGui.TextUnformatted(text); } diff --git a/Craftimizer/Plugin.cs b/Craftimizer/Plugin.cs index cbb5534..dea4338 100644 --- a/Craftimizer/Plugin.cs +++ b/Craftimizer/Plugin.cs @@ -1,21 +1,22 @@ +using Craftimizer.Plugin.Utils; using Craftimizer.Plugin.Windows; using Craftimizer.Simulator; +using Craftimizer.Simulator.Actions; using Craftimizer.Utils; using Craftimizer.Windows; +using Dalamud.Game.Command; using Dalamud.Interface.Internal; using Dalamud.Interface.Windowing; using Dalamud.IoC; using Dalamud.Plugin; -using ImGuiScene; -using Lumina.Excel.GeneratedSheets; +using System; +using System.Collections.Generic; using System.Reflection; -using ClassJob = Craftimizer.Simulator.ClassJob; namespace Craftimizer.Plugin; public sealed class Plugin : IDalamudPlugin { - public string Name => "Craftimizer"; public string Version { get; } public string Author { get; } public string BuildConfiguration { get; } @@ -23,23 +24,23 @@ public sealed class Plugin : IDalamudPlugin public WindowSystem WindowSystem { get; } public Settings SettingsWindow { get; } - public Craftimizer.Windows.RecipeNote RecipeNoteWindow { get; } - public Craft SynthesisWindow { get; } + public RecipeNote RecipeNoteWindow { get; } + public MacroList ListWindow { get; private set; } + public MacroEditor? EditorWindow { get; private set; } + public MacroClipboard? ClipboardWindow { get; private set; } public Configuration Configuration { get; } public Hooks Hooks { get; } - public Craftimizer.Utils.RecipeNote RecipeNote { get; } public IconManager IconManager { get; } public Plugin([RequiredVersion("1.0")] DalamudPluginInterface pluginInterface) { Service.Initialize(this, pluginInterface); + WindowSystem = new("Craftimizer"); Configuration = pluginInterface.GetPluginConfig() as Configuration ?? new(); Hooks = new(); - RecipeNote = new(); IconManager = new(); - WindowSystem = new(Name); var assembly = Assembly.GetExecutingAssembly(); Version = assembly.GetCustomAttribute()!.InformationalVersion; @@ -49,16 +50,41 @@ public sealed class Plugin : IDalamudPlugin SettingsWindow = new(); RecipeNoteWindow = new(); - SynthesisWindow = new(); + ListWindow = new(); + + // Trigger static constructors so a huge hitch doesn't occur on first RecipeNote frame. + FoodStatus.Initialize(); + Gearsets.Initialize(); + ActionUtils.Initialize(); Service.PluginInterface.UiBuilder.Draw += WindowSystem.Draw; Service.PluginInterface.UiBuilder.OpenConfigUi += OpenSettingsWindow; + Service.PluginInterface.UiBuilder.OpenMainUi += OpenCraftingLog; + + Service.CommandManager.AddHandler("/craftimizer", new CommandInfo((_, _) => OpenSettingsWindow()) + { + HelpMessage = "Open the settings window.", + }); + Service.CommandManager.AddHandler("/craftmacros", new CommandInfo((_, _) => OpenMacroListWindow()) + { + HelpMessage = "Open the crafting macros window.", + }); + Service.CommandManager.AddHandler("/crafteditor", new CommandInfo((_, _) => OpenSettingsWindow()) + { + HelpMessage = "Open the crafting macro editor.", + }); + } + + public void OpenMacroEditor(CharacterStats characterStats, RecipeData recipeData, MacroEditor.CrafterBuffs buffs, IEnumerable actions, Action>? setter) + { + EditorWindow?.Dispose(); + EditorWindow = new(characterStats, recipeData, buffs, actions, setter); } public void OpenSettingsWindow() { - SettingsWindow.IsOpen = true; - SettingsWindow.BringToFront(); + if (SettingsWindow.IsOpen ^= true) + SettingsWindow.BringToFront(); } public void OpenSettingsTab(string selectedTabLabel) @@ -67,11 +93,36 @@ public sealed class Plugin : IDalamudPlugin SettingsWindow.SelectTab(selectedTabLabel); } + public void OpenMacroListWindow() + { + ListWindow.IsOpen = true; + ListWindow.BringToFront(); + } + + public void OpenCraftingLog() + { + Chat.SendMessage("/craftinglog"); + } + + public void OpenMacroClipboard(List macros) + { + ClipboardWindow?.Dispose(); + ClipboardWindow = new(macros); + } + + public void CopyMacro(IReadOnlyList actions) => + MacroCopy.Copy(actions); + public void Dispose() { - SimulatorWindow?.Dispose(); - SynthesisWindow.Dispose(); - RecipeNote.Dispose(); + Service.CommandManager.RemoveHandler("/craftimizer"); + Service.CommandManager.RemoveHandler("/craftmacros"); + Service.CommandManager.RemoveHandler("/crafteditor"); + SettingsWindow.Dispose(); + RecipeNoteWindow.Dispose(); + ListWindow.Dispose(); + EditorWindow?.Dispose(); + ClipboardWindow?.Dispose(); Hooks.Dispose(); IconManager.Dispose(); } diff --git a/Craftimizer/Service.cs b/Craftimizer/Service.cs index 3f41681..91a4a98 100644 --- a/Craftimizer/Service.cs +++ b/Craftimizer/Service.cs @@ -1,6 +1,5 @@ using Craftimizer.Utils; using Dalamud.Game; -using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Objects; using Dalamud.Interface.Windowing; using Dalamud.IoC; diff --git a/Craftimizer/SimulatorUtils.cs b/Craftimizer/SimulatorUtils.cs index 4ab3dd5..30d1221 100644 --- a/Craftimizer/SimulatorUtils.cs +++ b/Craftimizer/SimulatorUtils.cs @@ -6,7 +6,6 @@ using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.Object; using FFXIVClientStructs.FFXIV.Client.Game.UI; -using ImGuiScene; using Lumina.Excel.GeneratedSheets; using System; using System.Globalization; @@ -64,6 +63,8 @@ internal static class ActionUtils } } + public static void Initialize() { } + public static (CraftAction? CraftAction, Action? Action) GetActionRow(this ActionType me, ClassJob classJob) => ActionRows[(int)me, (int)classJob]; @@ -309,11 +310,14 @@ internal static class EffectUtils EffectType.FinalAppraisal => 2190, EffectType.WasteNot2 => 257, EffectType.MuscleMemory => 2191, - EffectType.Manipulation => 258, + EffectType.Manipulation => 1164, EffectType.HeartAndSoul => 2665, - _ => 3412, + _ => throw new ArgumentOutOfRangeException(nameof(me)), }; + public static bool IsIndefinite(this EffectType me) => + me is EffectType.InnerQuiet or EffectType.HeartAndSoul; + public static Status Status(this EffectType me) => LuminaSheets.StatusSheet.GetRow(me.StatusId())!; diff --git a/Craftimizer/Utils/Chat.cs b/Craftimizer/Utils/Chat.cs index 1098841..3efdd0a 100644 --- a/Craftimizer/Utils/Chat.cs +++ b/Craftimizer/Utils/Chat.cs @@ -1,6 +1,7 @@ using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Client.System.Memory; using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI; using System; using System.Runtime.InteropServices; using System.Text; @@ -8,7 +9,7 @@ using System.Text; namespace Craftimizer.Plugin.Utils; // https://github.com/Caraxi/SimpleTweaksPlugin/blob/0973b93931cdf8a1b01153984d62f76d998747ff/Utility/ChatHelper.cs#L17 -public static class Chat +public static unsafe class Chat { private static class Signatures { @@ -16,11 +17,11 @@ public static class Chat internal const string SanitiseString = "E8 ?? ?? ?? ?? EB 0A 48 8D 4C 24 ?? E8 ?? ?? ?? ?? 48 8D 8D"; } - private delegate void ProcessChatBoxDelegate(IntPtr uiModule, IntPtr message, IntPtr unused, byte a4); + private delegate void ProcessChatBoxDelegate(UIModule* uiModule, IntPtr message, IntPtr unused, byte a4); private static ProcessChatBoxDelegate? ProcessChatBox { get; } - private static readonly unsafe delegate* unmanaged _sanitiseString = null!; + private static readonly unsafe delegate* unmanaged SanitiseString = null!; static Chat() { @@ -33,7 +34,7 @@ public static class Chat { if (Service.SigScanner.TryScanText(Signatures.SanitiseString, out var sanitisePtr)) { - _sanitiseString = (delegate* unmanaged)sanitisePtr; + SanitiseString = (delegate* unmanaged)sanitisePtr; } } } @@ -58,7 +59,7 @@ public static class Chat throw new InvalidOperationException("Could not find signature for chat sending"); } - var uiModule = (IntPtr)Framework.Instance()->GetUiModule(); + var uiModule = Framework.Instance()->GetUiModule(); using var payload = new ChatPayload(message); var mem1 = Marshal.AllocHGlobal(400); @@ -118,14 +119,14 @@ public static class Chat /// If the signature for this function could not be found public static unsafe string SanitiseText(string text) { - if (_sanitiseString == null) + if (SanitiseString == null) { throw new InvalidOperationException("Could not find signature for chat sanitisation"); } var uText = Utf8String.FromString(text); - _sanitiseString(uText, 0x27F, IntPtr.Zero); + SanitiseString(uText, 0x27F, IntPtr.Zero); var sanitised = uText->ToString(); uText->Dtor(); @@ -151,19 +152,19 @@ public static class Chat internal ChatPayload(byte[] stringBytes) { - this.textPtr = Marshal.AllocHGlobal(stringBytes.Length + 30); - Marshal.Copy(stringBytes, 0, this.textPtr, stringBytes.Length); - Marshal.WriteByte(this.textPtr + stringBytes.Length, 0); + textPtr = Marshal.AllocHGlobal(stringBytes.Length + 30); + Marshal.Copy(stringBytes, 0, textPtr, stringBytes.Length); + Marshal.WriteByte(textPtr + stringBytes.Length, 0); - this.textLen = (ulong)(stringBytes.Length + 1); + textLen = (ulong)(stringBytes.Length + 1); - this.unk1 = 64; - this.unk2 = 0; + unk1 = 64; + unk2 = 0; } public void Dispose() { - Marshal.FreeHGlobal(this.textPtr); + Marshal.FreeHGlobal(textPtr); } } } diff --git a/Craftimizer/Utils/Colors.cs b/Craftimizer/Utils/Colors.cs new file mode 100644 index 0000000..20ade66 --- /dev/null +++ b/Craftimizer/Utils/Colors.cs @@ -0,0 +1,12 @@ +using System.Numerics; + +namespace Craftimizer.Utils; + +public static class Colors +{ + public static readonly Vector4 Progress = new(0.44f, 0.65f, 0.18f, 1f); + public static readonly Vector4 Quality = new(0.26f, 0.71f, 0.69f, 1f); + public static readonly Vector4 Durability = new(0.13f, 0.52f, 0.93f, 1f); + public static readonly Vector4 HQ = new(0.592f, 0.863f, 0.376f, 1f); + public static readonly Vector4 CP = new(0.63f, 0.37f, 0.75f, 1f); +} diff --git a/Craftimizer/Utils/FoodStatus.cs b/Craftimizer/Utils/FoodStatus.cs new file mode 100644 index 0000000..6336c41 --- /dev/null +++ b/Craftimizer/Utils/FoodStatus.cs @@ -0,0 +1,121 @@ +using Craftimizer.Plugin; +using Craftimizer.Plugin.Utils; +using Lumina.Excel.GeneratedSheets; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Craftimizer.Utils; + +public static class FoodStatus +{ + private static readonly ReadOnlyDictionary ItemFoodToItemLUT; + private static readonly ReadOnlyDictionary FoodItems; + private static readonly ReadOnlyDictionary MedicineItems; + private static readonly ImmutableArray FoodOrder; + private static readonly ImmutableArray MedicineOrder; + + public readonly record struct FoodStat(bool IsRelative, int Value, int Max, int ValueHQ, int MaxHQ); + public readonly record struct Food(Item Item, FoodStat? Craftsmanship, FoodStat? Control, FoodStat? CP); + + static FoodStatus() + { + var lut = new Dictionary(); + foreach (var item in LuminaSheets.ItemSheet) + { + var isFood = item.ItemUICategory.Row == 46; + var isMedicine = item.ItemUICategory.Row == 44; + if (!isFood && !isMedicine) + continue; + + if (item.ItemAction.Value == null) + continue; + + if (!(item.ItemAction.Value.Type is 844 or 845 or 846)) + continue; + + var itemFood = LuminaSheets.ItemFoodSheet.GetRow(item.ItemAction.Value.Data[1]); + if (itemFood == null) + continue; + + lut.TryAdd(itemFood.RowId, item.RowId); + } + ItemFoodToItemLUT = lut.AsReadOnly(); + + var foods = new Dictionary(); + var medicines = new Dictionary(); + foreach (var item in LuminaSheets.ItemSheet) + { + var isFood = item.ItemUICategory.Row == 46; + var isMedicine = item.ItemUICategory.Row == 44; + if (!isFood && !isMedicine) + continue; + + if (item.ItemAction.Value == null) + continue; + + if (!(item.ItemAction.Value.Type is 844 or 845 or 846)) + continue; + + var itemFood = LuminaSheets.ItemFoodSheet.GetRow(item.ItemAction.Value.Data[1]); + if (itemFood == null) + continue; + + FoodStat? craftsmanship = null, control = null, cp = null; + foreach (var stat in itemFood.UnkData1) + { + if (stat.BaseParam == 0) + continue; + var foodStat = new FoodStat(stat.IsRelative, stat.Value, stat.Max, stat.ValueHQ, stat.MaxHQ); + switch (stat.BaseParam) + { + case Gearsets.ParamCraftsmanship: craftsmanship = foodStat; break; + case Gearsets.ParamControl: control = foodStat; break; + case Gearsets.ParamCP: cp = foodStat; break; + default: continue; + } + } + + if (craftsmanship != null || control != null || cp != null) + { + var food = new Food(item, craftsmanship, control, cp); + if (isFood) + foods.Add(item.RowId, food); + if (isMedicine) + medicines.Add(item.RowId, food); + } + } + + FoodItems = foods.AsReadOnly(); + MedicineItems = medicines.AsReadOnly(); + + FoodOrder = FoodItems.OrderByDescending(a => a.Value.Item.LevelItem.Row).Select(a => a.Key).ToImmutableArray(); + MedicineOrder = MedicineItems.OrderByDescending(a => a.Value.Item.LevelItem.Row).Select(a => a.Key).ToImmutableArray(); + } + + public static void Initialize() { } + + public static IEnumerable OrderedFoods => FoodOrder.Select(id => FoodItems[id]); + public static IEnumerable OrderedMedicines => MedicineOrder.Select(id => MedicineItems[id]); + + public static (uint ItemId, bool IsHQ)? ResolveFoodParam(ushort param) + { + var isHq = param > 10000; + param -= 10000; + + if (!ItemFoodToItemLUT.TryGetValue(param, out var itemId)) + return null; + + return (itemId, isHq); + } + + public static Food? TryGetFood(uint itemId) + { + if (FoodItems.TryGetValue(itemId, out var food)) + return food; + if (MedicineItems.TryGetValue(itemId, out food)) + return food; + return null; + } +} diff --git a/Craftimizer/Utils/FuzzyMatcher.cs b/Craftimizer/Utils/FuzzyMatcher.cs new file mode 100644 index 0000000..0c1cb35 --- /dev/null +++ b/Craftimizer/Utils/FuzzyMatcher.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Craftimizer.Utils; + +internal readonly struct FuzzyMatcher +{ + private const bool IsBorderMatching = true; + private static readonly (int, int)[] EmptySegArray = Array.Empty<(int, int)>(); + + private readonly string needleString = string.Empty; + private readonly int needleFinalPosition = -1; + private readonly (int Start, int End)[] needleSegments = EmptySegArray; + private readonly MatchMode mode = MatchMode.Simple; + + public FuzzyMatcher(string term, MatchMode matchMode) + { + needleString = term; + needleFinalPosition = needleString.Length - 1; + mode = matchMode; + + needleSegments = matchMode switch + { + MatchMode.FuzzyParts => FindNeedleSegments(needleString), + MatchMode.Fuzzy or MatchMode.Simple => EmptySegArray, + _ => throw new ArgumentOutOfRangeException(nameof(matchMode), matchMode, "Invalid match mode"), + }; + } + + private static (int Start, int End)[] FindNeedleSegments(ReadOnlySpan span) + { + var segments = new List<(int, int)>(); + var wordStart = -1; + + for (var i = 0; i < span.Length; i++) + { + if (span[i] is not ' ' and not '\u3000') + { + if (wordStart < 0) + wordStart = i; + } + else if (wordStart >= 0) + { + segments.Add((wordStart, i - 1)); + wordStart = -1; + } + } + + if (wordStart >= 0) + segments.Add((wordStart, span.Length - 1)); + + return segments.ToArray(); + } + + public int Matches(string value) + { + if (needleFinalPosition < 0) + return 0; + + if (mode == MatchMode.Simple) + return value.Contains(needleString, StringComparison.InvariantCultureIgnoreCase) ? 1 : 0; + + if (mode == MatchMode.Fuzzy) + return GetRawScore(value, 0, needleFinalPosition); + + if (mode == MatchMode.FuzzyParts) + { + if (needleSegments.Length < 2) + return GetRawScore(value, 0, needleFinalPosition); + + var total = 0; + for (var i = 0; i < needleSegments.Length; i++) + { + var (start, end) = needleSegments[i]; + var cur = GetRawScore(value, start, end); + if (cur == 0) + return 0; + + total += cur; + } + + return total; + } + + return 8; + } + + public int MatchesAny(params string[] values) + { + var max = 0; + for (var i = 0; i < values.Length; i++) + { + var cur = Matches(values[i]); + if (cur > max) + max = cur; + } + + return max; + } + + private int GetRawScore(ReadOnlySpan haystack, int needleStart, int needleEnd) + { + var (startPos, gaps, consecutive, borderMatches, endPos) = FindForward(haystack, needleStart, needleEnd); + if (startPos < 0) + return 0; + + var needleSize = needleEnd - needleStart + 1; + + var score = CalculateRawScore(needleSize, startPos, gaps, consecutive, borderMatches); + + (startPos, gaps, consecutive, borderMatches) = FindReverse(haystack, endPos, needleStart, needleEnd); + var revScore = CalculateRawScore(needleSize, startPos, gaps, consecutive, borderMatches); + + return int.Max(score, revScore); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int CalculateRawScore(int needleSize, int startPos, int gaps, int consecutive, int borderMatches) + { + var score = 100 + + needleSize * 3 + + borderMatches * 3 + + consecutive * 5 + - startPos + - gaps * 2; + if (startPos == 0) + score += 5; + return score < 1 ? 1 : score; + } + + private (int StartPos, int Gaps, int Consecutive, int BorderMatches, int HaystackIndex) FindForward( + ReadOnlySpan haystack, int needleStart, int needleEnd) + { + var needleIndex = needleStart; + var lastMatchIndex = -10; + + var startPos = 0; + var gaps = 0; + var consecutive = 0; + var borderMatches = 0; + + for (var haystackIndex = 0; haystackIndex < haystack.Length; haystackIndex++) + { + if (haystack[haystackIndex] == needleString[needleIndex]) + { + if (IsBorderMatching) + { + if (haystackIndex > 0) + { + if (!char.IsLetterOrDigit(haystack[haystackIndex - 1])) + borderMatches++; + } + } + + needleIndex++; + + if (haystackIndex == lastMatchIndex + 1) + consecutive++; + + if (needleIndex > needleEnd) + return (startPos, gaps, consecutive, borderMatches, haystackIndex); + + lastMatchIndex = haystackIndex; + } + else + { + if (needleIndex > needleStart) + gaps++; + else + startPos++; + } + } + + return (-1, 0, 0, 0, 0); + } + + private (int StartPos, int Gaps, int Consecutive, int BorderMatches) FindReverse( + ReadOnlySpan haystack, int haystackLastMatchIndex, int needleStart, int needleEnd) + { + var needleIndex = needleEnd; + var revLastMatchIndex = haystack.Length + 10; + + var gaps = 0; + var consecutive = 0; + var borderMatches = 0; + + for (var haystackIndex = haystackLastMatchIndex; haystackIndex >= 0; haystackIndex--) + { + if (haystack[haystackIndex] == needleString[needleIndex]) + { + if (IsBorderMatching) + { + if (haystackIndex > 0) + { + if (!char.IsLetterOrDigit(haystack[haystackIndex - 1])) + borderMatches++; + } + } + + needleIndex--; + + if (haystackIndex == revLastMatchIndex - 1) + consecutive++; + + if (needleIndex < needleStart) + return (haystackIndex, gaps, consecutive, borderMatches); + + revLastMatchIndex = haystackIndex; + } + else + gaps++; + } + + return (-1, 0, 0, 0); + } +} + +internal enum MatchMode +{ + Simple, + Fuzzy, + FuzzyParts, +} diff --git a/Craftimizer/Utils/Gearsets.cs b/Craftimizer/Utils/Gearsets.cs index 20293f8..b3bc64e 100644 --- a/Craftimizer/Utils/Gearsets.cs +++ b/Craftimizer/Utils/Gearsets.cs @@ -20,6 +20,24 @@ public static unsafe class Gearsets public const int ParamCraftsmanship = 70; public const int ParamControl = 71; + private static readonly int[] LevelToCLvlLUT; + + static Gearsets() + { + LevelToCLvlLUT = new int[90]; + for (uint i = 0; i < 80; ++i) { + var level = i + 1; + LevelToCLvlLUT[i] = LuminaSheets.ParamGrowSheet.GetRow(level)!.CraftingLevel; + } + for (var i = 80; i < 90; ++i) + { + var level = i + 1; + LevelToCLvlLUT[i] = (int)LuminaSheets.RecipeLevelTableSheet.First(r => r.ClassJobLevel == level).RowId; + } + } + + public static void Initialize() { } + public static GearsetItem[] GetGearsetItems(InventoryContainer* container) { var items = new GearsetItem[(int)container->Size]; @@ -128,10 +146,10 @@ public static unsafe class Gearsets public static bool IsSplendorousTool(GearsetItem item) => LuminaSheets.ItemSheetEnglish.GetRow(item.itemId)!.Description.ToDalamudString().TextValue.Contains("Increases to quality are 1.75 times higher than normal when material condition is Good.", StringComparison.Ordinal); - public static int CalculateCLvl(int characterLevel) => - characterLevel <= 80 - ? LuminaSheets.ParamGrowSheet.GetRow((uint)characterLevel)!.CraftingLevel - : (int)LuminaSheets.RecipeLevelTableSheet.First(r => r.ClassJobLevel == characterLevel).RowId; + public static int CalculateCLvl(int level) => + (level > 0 && level <= 90) ? + LevelToCLvlLUT[level - 1] : + throw new ArgumentOutOfRangeException(nameof(level), level, "Level is out of range."); // https://github.com/ffxiv-teamcraft/ffxiv-teamcraft/blob/24d0db2d9676f264edf53651b21005305267c84c/apps/client/src/app/modules/gearsets/materia.service.ts#L265 private static int CalculateParamCap(Item item, int paramId) diff --git a/Craftimizer/Utils/IconManager.cs b/Craftimizer/Utils/IconManager.cs index c6a93da..9300e15 100644 --- a/Craftimizer/Utils/IconManager.cs +++ b/Craftimizer/Utils/IconManager.cs @@ -1,6 +1,6 @@ using Craftimizer.Plugin; using Dalamud.Interface.Internal; -using ImGuiScene; +using Dalamud.Plugin.Services; using System; using System.Collections.Generic; using System.IO; @@ -11,6 +11,7 @@ namespace Craftimizer.Utils; public sealed class IconManager : IDisposable { private readonly Dictionary iconCache = new(); + private readonly Dictionary hqIconCache = new(); private readonly Dictionary textureCache = new(); private readonly Dictionary assemblyCache = new(); @@ -22,6 +23,16 @@ public sealed class IconManager : IDisposable return ret; } + public IDalamudTextureWrap GetHqIcon(uint id, bool isHq = true) + { + if (!isHq) + return GetIcon(id); + if (!hqIconCache.TryGetValue(id, out var ret)) + hqIconCache.Add(id, ret = Service.TextureProvider.GetIcon(id, ITextureProvider.IconFlags.HiRes | ITextureProvider.IconFlags.ItemHighQuality) ?? + throw new ArgumentException($"Invalid hq icon id {id}", nameof(id))); + return ret; + } + public IDalamudTextureWrap GetTexture(string path) { if (!textureCache.TryGetValue(path, out var ret)) @@ -55,6 +66,10 @@ public sealed class IconManager : IDisposable image.Dispose(); iconCache.Clear(); + foreach (var image in hqIconCache.Values) + image.Dispose(); + hqIconCache.Clear(); + foreach (var image in textureCache.Values) image.Dispose(); textureCache.Clear(); diff --git a/Craftimizer/Utils/MacroCopy.cs b/Craftimizer/Utils/MacroCopy.cs new file mode 100644 index 0000000..849a3ef --- /dev/null +++ b/Craftimizer/Utils/MacroCopy.cs @@ -0,0 +1,152 @@ +using Craftimizer.Plugin; +using Craftimizer.Simulator; +using Craftimizer.Simulator.Actions; +using Dalamud.Interface.Internal.Notifications; +using FFXIVClientStructs.FFXIV.Client.System.Memory; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using ImGuiNET; +using System; +using System.Collections.Generic; + +namespace Craftimizer.Utils; + +public static class MacroCopy +{ + private const ClassJob DefaultJob = ClassJob.Carpenter; + private const int MacroSize = 15; + + public static void Copy(IReadOnlyList actions) + { + if (actions.Count == 0) + { + Service.PluginInterface.UiBuilder.AddNotification("Could not copy macro. It's empty!", "Craftimizer Macro Not Copied", NotificationType.Error); + return; + } + + var config = Service.Configuration.MacroCopy; + var macros = new List(); + var s = new List(); + foreach(var action in actions) + { + if (s.Count == 0) + { + if (config.UseMacroLock) + s.Add("/mlock"); + } + + s.Add(GetActionCommand(action, config)); + + if (config.Type == MacroCopyConfiguration.CopyType.CopyToMacro || !config.CombineMacro) + { + if (s.Count == MacroSize - 1) + { + if (GetEndCommand(macros.Count, true, config) is { } endCommand) + s.Add(endCommand); + } + if (s.Count == MacroSize) + { + macros.Add(string.Join("\n", s)); + s.Clear(); + } + } + } + if (s.Count > 0) + { + if (GetEndCommand(macros.Count, true, config) is { } endCommand) + s.Add(endCommand); + macros.Add(string.Join("\n", s)); + } + + switch (config.Type) + { + case MacroCopyConfiguration.CopyType.OpenWindow: + Service.Plugin.OpenMacroClipboard(macros); + break; + case MacroCopyConfiguration.CopyType.CopyToMacro: + CopyToMacro(macros, config); + break; + case MacroCopyConfiguration.CopyType.CopyToClipboard: + CopyToClipboard(macros, config); + break; + } + } + + private static string GetActionCommand(ActionType action, MacroCopyConfiguration config) + { + var actionBase = action.Base(); + if (actionBase is BaseComboAction) + throw new ArgumentException("Combo actions are not supported", nameof(action)); + if (config.Type != MacroCopyConfiguration.CopyType.CopyToMacro && config.RemoveWaitTimes) + return $"/ac \"{action.GetName(DefaultJob)}\""; + else + return $"/ac \"{action.GetName(DefaultJob)}\" "; + } + + private static string? GetEndCommand(int macroIdx, bool isEnd, MacroCopyConfiguration config) + { + if (config.UseNextMacro && !isEnd) + { + if (config.Type == MacroCopyConfiguration.CopyType.CopyToMacro && config.CopyDown) + return $"/nextmacro down"; + else + return $"/nextmacro"; + } + + if (config.AddNotification) + { + if (isEnd) + { + if (config.AddNotificationSound) + return $"/echo Craft complete! "; + else + return $"/echo Craft complete!"; + } + else + { + if (config.AddNotificationSound) + return $"/echo Macro #{macroIdx + 1} complete! "; + else + return $"/echo Macro #{macroIdx + 1} complete!"; + } + } + return null; + } + + private static void CopyToMacro(List macros, MacroCopyConfiguration config) + { + int i, macroIdx; + for ( + i = 0, macroIdx = config.StartMacroIdx; + i < macros.Count && i < config.MaxMacroCount && macroIdx < 100; + i++, macroIdx += config.CopyDown ? 10 : 1) + SetMacro(macroIdx, config.SharedMacro, macros[i]); + + Service.PluginInterface.UiBuilder.AddNotification(i > 1 ? "Copied macro to User Macros." : $"Copied {i} macros to User Macros.", "Craftimizer Macro Copied", NotificationType.Success); + if (i < macros.Count) + { + Service.Plugin.OpenMacroClipboard(macros); + var rest = macros.Count - i; + Service.PluginInterface.UiBuilder.AddNotification($"Couldn't copy {rest} macro{(rest == 1 ? "" : "s")}, so a window was opened with all of them.", "Craftimizer Macro Copied", NotificationType.Info); + } + } + + private static unsafe void SetMacro(int idx, bool isShared, string macroText) + { + if (idx >= 100 || idx < 0) + throw new ArgumentOutOfRangeException(nameof(idx), "Macro index must be between 0 and 99"); + + var module = RaptureMacroModule.Instance(); + var macro = module->GetMacro(isShared ? 1u : 0u, (uint)idx); + var text = Utf8String.FromString(macroText); + module->ReplaceMacroLines(macro, text); + text->Dtor(); + IMemorySpace.Free(text); + } + + private static void CopyToClipboard(List macros, MacroCopyConfiguration config) + { + ImGui.SetClipboardText(string.Join("\n\n", macros)); + Service.PluginInterface.UiBuilder.AddNotification(macros.Count > 1 ? "Copied macro to clipboard." : $"Copied {macros.Count} macros to clipboard.", "Craftimizer Macro Copied", NotificationType.Success); + } +} diff --git a/Craftimizer/Utils/RecipeData.cs b/Craftimizer/Utils/RecipeData.cs new file mode 100644 index 0000000..71d32b4 --- /dev/null +++ b/Craftimizer/Utils/RecipeData.cs @@ -0,0 +1,82 @@ +using Craftimizer.Plugin; +using Craftimizer.Simulator; +using Lumina.Excel.GeneratedSheets; +using System; +using System.Collections.Generic; +using System.Linq; +using ClassJob = Craftimizer.Simulator.ClassJob; + +namespace Craftimizer.Utils; + +public sealed record RecipeData +{ + public ushort RecipeId { get; } + + public Recipe Recipe { get; } + public RecipeLevelTable Table { get; } + + public ClassJob ClassJob { get; } + public RecipeInfo RecipeInfo { get; } + public IReadOnlyList<(Item Item, int Amount)> Ingredients { get; } + public int MaxStartingQuality { get; } + private int TotalHqILvls { get; } + + public RecipeData(ushort recipeId) + { + RecipeId = recipeId; + + Recipe = LuminaSheets.RecipeSheet.GetRow(recipeId) ?? + throw new ArgumentException($"Invalid recipe id {recipeId}", nameof(recipeId)); + + Table = Recipe.RecipeLevelTable.Value!; + ClassJob = (ClassJob)Recipe.CraftType.Row; + RecipeInfo = new() + { + IsExpert = Recipe.IsExpert, + ClassJobLevel = Table.ClassJobLevel, + RLvl = (int)Table.RowId, + ConditionsFlag = Table.ConditionsFlag, + MaxDurability = Table.Durability * Recipe.DurabilityFactor / 100, + MaxQuality = (Recipe.CanHq || Recipe.IsExpert) ? (int)Table.Quality * Recipe.QualityFactor / 100 : 0, + MaxProgress = Table.Difficulty * Recipe.DifficultyFactor / 100, + QualityModifier = Table.QualityModifier, + QualityDivider = Table.QualityDivider, + ProgressModifier = Table.ProgressModifier, + ProgressDivider = Table.ProgressDivider, + }; + + Ingredients = Recipe.UnkData5.Take(6) + .Where(i => i != null && i.ItemIngredient != 0) + .Select(i => (LuminaSheets.ItemSheet.GetRow((uint)i.ItemIngredient)!, (int)i.AmountIngredient)) + .Where(i => i.Item1 != null).ToList(); + MaxStartingQuality = (int)Math.Floor(Recipe.MaterialQualityFactor * RecipeInfo.MaxQuality / 100f); + + TotalHqILvls = (int)Ingredients.Where(i => i.Item.CanBeHq).Sum(i => i.Item.LevelItem.Row * i.Amount); + } + + public int CalculateItemStartingQuality(int itemIdx, int amount) + { + if (itemIdx >= Ingredients.Count) + throw new ArgumentOutOfRangeException(nameof(itemIdx)); + + var ingredient = Ingredients[itemIdx]; + if (amount > ingredient.Amount) + throw new ArgumentOutOfRangeException(nameof(amount)); + + if (!ingredient.Item.CanBeHq) + return 0; + + var iLvls = ingredient.Item.LevelItem.Row * amount; + return (int)Math.Floor((float)iLvls / TotalHqILvls * MaxStartingQuality); + } + + public int CalculateStartingQuality(IEnumerable hqQuantities) + { + if (TotalHqILvls == 0) + return 0; + + var iLvls = Ingredients.Zip(hqQuantities).Sum(i => i.First.Item.LevelItem.Row * i.Second); + + return (int)Math.Floor((float)iLvls / TotalHqILvls * MaxStartingQuality); + } +} diff --git a/Craftimizer/Utils/RecipeNote.cs b/Craftimizer/Utils/RecipeNote.cs deleted file mode 100644 index 6d4f4b3..0000000 --- a/Craftimizer/Utils/RecipeNote.cs +++ /dev/null @@ -1,203 +0,0 @@ -using Craftimizer.Plugin; -using Craftimizer.Simulator; -using Dalamud.Game; -using Dalamud.Logging; -using Dalamud.Plugin.Services; -using FFXIVClientStructs.FFXIV.Client.Game; -using FFXIVClientStructs.FFXIV.Client.Game.Object; -using FFXIVClientStructs.FFXIV.Client.Game.UI; -using FFXIVClientStructs.FFXIV.Client.UI; -using FFXIVClientStructs.FFXIV.Client.UI.Agent; -using Lumina.Excel.GeneratedSheets; -using System; -using System.Linq; -using ActionType = Craftimizer.Simulator.Actions.ActionType; -using ClassJob = Craftimizer.Simulator.ClassJob; -using CSRecipeNote = FFXIVClientStructs.FFXIV.Client.Game.UI.RecipeNote; - -namespace Craftimizer.Utils; - -public record RecipeData -{ - public ushort RecipeId { get; } - - public Recipe Recipe { get; } - public RecipeLevelTable Table { get; } - - public ClassJob ClassJob { get; } - public RecipeInfo RecipeInfo { get; } - public int HQIngredientCount { get; } - public int MaxStartingQuality { get; } - - public RecipeData(ushort recipeId) - { - RecipeId = recipeId; - - Recipe = LuminaSheets.RecipeSheet.GetRow(recipeId) ?? - throw new ArgumentException($"Invalid recipe id {recipeId}", nameof(recipeId)); - - Table = Recipe.RecipeLevelTable.Value!; - ClassJob = (ClassJob)Recipe.CraftType.Row; - RecipeInfo = new() - { - IsExpert = Recipe.IsExpert, - ClassJobLevel = Table.ClassJobLevel, - RLvl = (int)Table.RowId, - ConditionsFlag = Table.ConditionsFlag, - MaxDurability = Table.Durability * Recipe.DurabilityFactor / 100, - MaxQuality = (Recipe.CanHq || Recipe.IsExpert) ? (int)Table.Quality * Recipe.QualityFactor / 100 : 0, - MaxProgress = Table.Difficulty * Recipe.DifficultyFactor / 100, - QualityModifier = Table.QualityModifier, - QualityDivider = Table.QualityDivider, - ProgressModifier = Table.ProgressModifier, - ProgressDivider = Table.ProgressDivider, - }; - - HQIngredientCount = Recipe.UnkData5 - .Where(i => - i != null && - i.ItemIngredient != 0 && - (LuminaSheets.ItemSheet.GetRow((uint)i.ItemIngredient)?.CanBeHq ?? false) - ).Sum(i => i.AmountIngredient); - MaxStartingQuality = (int)Math.Floor(Recipe.MaterialQualityFactor * RecipeInfo.MaxQuality / 100f); - } -} - -public sealed unsafe class RecipeNote : IDisposable -{ - public AddonRecipeNote* AddonRecipe { get; private set; } - public AddonSynthesis* AddonSynthesis { get; private set; } - public bool IsCrafting { get; private set; } - public ushort RecipeId { get; private set; } - public Recipe Recipe { get; private set; } = null!; - public bool HasValidRecipe { get; private set; } - - public RecipeLevelTable Table { get; private set; } = null!; - public RecipeInfo Info { get; private set; } = null!; - public ClassJob ClassJob { get; private set; } - public short CharacterLevel { get; private set; } - public bool CanUseManipulation { get; private set; } - public int HQIngredientCount { get; private set; } - public int MaxStartingQuality { get; private set; } - - public RecipeNote() - { - Service.Framework.Update += FrameworkUpdate; - } - - private void FrameworkUpdate(IFramework f) - { - HasValidRecipe = false; - try - { - HasValidRecipe = Update(); - } - catch (Exception e) - { - Log.Error(e, "RecipeNote framework update failed"); - } - } - - public bool Update() - { - if (Service.ClientState.LocalPlayer == null) - return false; - - AddonRecipe = (AddonRecipeNote*)Service.GameGui.GetAddonByName("RecipeNote"); - AddonSynthesis = (AddonSynthesis*)Service.GameGui.GetAddonByName("Synthesis"); - - var recipeId = GetRecipeIdFromList(); - if (recipeId == null) - { - recipeId = GetRecipeIdFromAgent(); - if (recipeId == null) - return false; - else - IsCrafting = true; - } - else - IsCrafting = false; - - var isNewRecipe = RecipeId != recipeId.Value; - - RecipeId = recipeId.Value; - - var recipe = LuminaSheets.RecipeSheet.GetRow(RecipeId); - - if (recipe == null) - return false; - - Recipe = recipe; - - if (isNewRecipe) - CalculateStats(); - - return true; - } - - private static ushort? GetRecipeIdFromList() - { - var instance = CSRecipeNote.Instance(); - - var list = instance->RecipeList; - - if (list == null) - return null; - - var recipeEntry = list->SelectedRecipe; - - if (recipeEntry == null) - return null; - - return recipeEntry->RecipeId; - } - - private static ushort? GetRecipeIdFromAgent() - { - var instance = AgentRecipeNote.Instance(); - - var recipeId = instance->ActiveCraftRecipeId; - - if (recipeId == 0) - return null; - - return (ushort)recipeId; - } - - private void CalculateStats() - { - Table = Recipe.RecipeLevelTable.Value!; - Info = CreateInfo(); - ClassJob = (ClassJob)Recipe.CraftType.Row; - CharacterLevel = PlayerState.Instance()->ClassJobLevelArray[ClassJob.GetExpArrayIdx()]; - CanUseManipulation = ActionManager.CanUseActionOnTarget(ActionType.Manipulation.GetId(ClassJob), (GameObject*)Service.ClientState.LocalPlayer!.Address); - HQIngredientCount = Recipe.UnkData5 - .Where(i => - i != null && - i.ItemIngredient != 0 && - (LuminaSheets.ItemSheet.GetRow((uint)i.ItemIngredient)?.CanBeHq ?? false) - ).Sum(i => i.AmountIngredient); - MaxStartingQuality = (int)Math.Floor(Recipe.MaterialQualityFactor * Info.MaxQuality / 100f); - } - - private RecipeInfo CreateInfo() => - new() - { - IsExpert = Recipe.IsExpert, - ClassJobLevel = Table.ClassJobLevel, - RLvl = (int)Table.RowId, - ConditionsFlag = Table.ConditionsFlag, - MaxDurability = Table.Durability * Recipe.DurabilityFactor / 100, - MaxQuality = (int)Table.Quality * Recipe.QualityFactor / 100, - MaxProgress = Table.Difficulty * Recipe.DifficultyFactor / 100, - QualityModifier = Table.QualityModifier, - QualityDivider = Table.QualityDivider, - ProgressModifier = Table.ProgressModifier, - ProgressDivider = Table.ProgressDivider, - }; - - public void Dispose() - { - Service.Framework.Update -= FrameworkUpdate; - } -} diff --git a/Craftimizer/Utils/SqText.cs b/Craftimizer/Utils/SqText.cs index b8ab740..86db04f 100644 --- a/Craftimizer/Utils/SqText.cs +++ b/Craftimizer/Utils/SqText.cs @@ -1,15 +1,16 @@ -using Dalamud.Game.Text; +using Dalamud.Game.Text; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Numerics; -using System.Runtime.CompilerServices; namespace Craftimizer.Utils; public static class SqText { - private static ReadOnlyDictionary levelNumReplacements = new(new Dictionary + public static SeIconChar LevelPrefix => SeIconChar.LevelEn; + + public static readonly ReadOnlyDictionary LevelNumReplacements = new(new Dictionary { ['0'] = SeIconChar.Number0, ['1'] = SeIconChar.Number1, @@ -26,8 +27,15 @@ public static class SqText public static string ToLevelString(T value) where T : IBinaryInteger { var str = value.ToString() ?? throw new FormatException("Failed to format value"); - foreach(var (k, v) in levelNumReplacements) + foreach(var (k, v) in LevelNumReplacements) str = str.Replace(k, v.ToIconChar()); - return SeIconChar.LevelEn.ToIconChar() + str; + return str; + } + + public static bool TryParseLevelString(string str, out int result) + { + foreach(var (k, v) in LevelNumReplacements) + str = str.Replace(v.ToIconChar(), k); + return int.TryParse(str, out result); } } diff --git a/Craftimizer/Windows/Craft.cs b/Craftimizer/Windows/Craft.cs deleted file mode 100644 index b3462f1..0000000 --- a/Craftimizer/Windows/Craft.cs +++ /dev/null @@ -1,226 +0,0 @@ -using Craftimizer.Plugin.Utils; -using Craftimizer.Utils; -using Dalamud.Interface; -using Dalamud.Interface.Components; -using Dalamud.Interface.Utility; -using Dalamud.Interface.Windowing; -using FFXIVClientStructs.FFXIV.Client.Game; -using ImGuiNET; -using System; -using System.Numerics; - -namespace Craftimizer.Plugin.Windows; - -public sealed unsafe partial class Craft : Window, IDisposable -{ - private const ImGuiWindowFlags WindowFlags = ImGuiWindowFlags.NoDecoration - | ImGuiWindowFlags.AlwaysAutoResize - | ImGuiWindowFlags.NoSavedSettings - | ImGuiWindowFlags.NoFocusOnAppearing - | ImGuiWindowFlags.NoNavFocus; - - private const float WindowWidth = 300; - private const int ActionsPerRow = 5; - private static readonly Vector2 CraftProgressBarSize = new(WindowWidth, 15); - - private static Configuration Config => Service.Configuration; - - private static Random Random { get; } = new(); - private static RecipeNote RecipeUtils => Service.Plugin.RecipeNote; - - private bool WasOpen { get; set; } - - public Craft() : base("Craftimizer SynthesisHelper", WindowFlags, true) - { - Service.WindowSystem.AddWindow(this); - Service.Plugin.Hooks.OnActionUsed += OnActionUsed; - - IsOpen = true; - } - - public override void Draw() - { - SolveTick(); - DequeueSolver(); - - DrawActions(); - - ImGui.SameLine(0, 0); - ImGui.Dummy(default); - - ImGuiHelpers.ScaledDummy(5); - - Simulator.DrawAllProgressBars(SolverLatestState, CraftProgressBarSize); - - ImGuiHelpers.ScaledDummy(5); - - ImGui.PushFont(UiBuilder.IconFont); - var cogWidth = ImGui.CalcTextSize(FontAwesomeIcon.Cog.ToIconString()).X + (ImGui.GetStyle().FramePadding.X * 2); - ImGui.PopFont(); - - DrawSolveButton(new(WindowWidth - ImGui.GetStyle().ItemSpacing.X - cogWidth, ImGui.GetFrameHeight())); - - ImGui.SameLine(); - if (ImGuiComponents.IconButton("synthSettingsButton", FontAwesomeIcon.Cog)) - Service.Plugin.OpenSettingsTab(Settings.TabSynthHelper); - } - - private void DrawActions() - { - var actionSize = new Vector2((WindowWidth / ActionsPerRow) - (ImGui.GetStyle().ItemSpacing.X * ((ActionsPerRow - 1f) / ActionsPerRow))); - ImGui.PushStyleColor(ImGuiCol.Button, Vector4.Zero); - ImGui.PushStyleColor(ImGuiCol.ButtonActive, Vector4.Zero); - ImGui.PushStyleColor(ImGuiCol.ButtonHovered, Vector4.Zero); - - ImGui.Dummy(new(0, actionSize.Y)); - ImGui.SameLine(0, 0); - for (var i = 0; i < SolverActions.Count; ++i) - { - var (action, tooltip, state) = SolverActions[i]; - ImGui.PushID(i); - if (ImGui.ImageButton(action.GetIcon(RecipeUtils.ClassJob).ImGuiHandle, actionSize, Vector2.Zero, Vector2.One, 0)) - { - if (i == 0) - Chat.SendMessage($"/ac \"{action.GetName(RecipeUtils.ClassJob)}\""); - } - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text($"{action.GetName(RecipeUtils.ClassJob)}\n{tooltip}"); - Simulator.DrawAllProgressTooltips(state); - if (i == 0) - ImGui.Text("Click to Execute"); - ImGui.EndTooltip(); - } - ImGui.PopID(); - if (i % ActionsPerRow != (ActionsPerRow - 1)) - ImGui.SameLine(); - } - - ImGui.PopStyleColor(3); - } - - private void DrawSolveButton(Vector2 buttonSize) - { - string buttonText; - string tooltipText; - bool isEnabled; - var taskCompleted = SolverTask?.IsCompleted ?? true; - var taskCancelled = SolverTaskToken?.IsCancellationRequested ?? false; - if (!taskCompleted) - { - if (taskCancelled) - { - buttonText = "Cancelling..."; - tooltipText = "Cancelling action generation. This shouldn't take long."; - isEnabled = false; - } - else - { - buttonText = "Cancel"; - tooltipText = "Cancel action generation"; - isEnabled = true; - } - } - else - { - buttonText = "Retry"; - tooltipText = "Retry and regenerate a new set of recommended actions to finish the craft."; - isEnabled = true; - } - ImGui.BeginDisabled(!isEnabled); - if (ImGui.Button(buttonText, buttonSize)) - { - if (!taskCompleted) - { - if (!taskCancelled) - SolverTaskToken?.Cancel(); - } - else - QueueSolve(GetNextState()!.Value); - } - ImGui.EndDisabled(); - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) - ImGui.SetTooltip(tooltipText); - } - - public override void PreDraw() - { - var addon = RecipeUtils.AddonSynthesis; - ref var unit = ref addon->AtkUnitBase; - var scale = unit.Scale; - var pos = new Vector2(unit.X, unit.Y); - var size = new Vector2(unit.WindowNode->AtkResNode.Width, unit.WindowNode->AtkResNode.Height) * scale; - - var node = unit.GetNodeById(79); - - Position = pos + new Vector2(size.X, node->Y * scale); - SizeConstraints = new WindowSizeConstraints - { - MinimumSize = new(-1), - MaximumSize = new(10000, 10000) - }; - - if (Input == null) - return; - - base.PreDraw(); - } - - private bool DrawConditionsInner() - { - if (!RecipeUtils.HasValidRecipe) - return false; - - if (!RecipeUtils.IsCrafting) - return false; - - if (RecipeUtils.AddonSynthesis == null) - return false; - - // Check if Synthesis addon is visible - if (RecipeUtils.AddonSynthesis->AtkUnitBase.WindowNode == null) - return false; - - if (RecipeUtils.AddonSynthesis->AtkUnitBase.GetNodeById(79) == null) - return false; - - return base.DrawConditions(); - } - - public override bool DrawConditions() - { - if (!Config.EnableSynthHelper) - return false; - - var ret = DrawConditionsInner(); - if (ret && !WasOpen) - ResetSimulation(); - - WasOpen = ret; - return ret; - } - - private void ResetSimulation() - { - var container = InventoryManager.Instance()->GetInventoryContainer(InventoryType.EquippedItems); - if (container == null) - return; - - CharacterStats = Gearsets.CalculateCharacterStats(Gearsets.CalculateGearsetCurrentStats(), Gearsets.GetGearsetItems(container), RecipeUtils.CharacterLevel, RecipeUtils.CanUseManipulation); - Input = new(CharacterStats, RecipeUtils.Info, 0, Random); - ActionCount = 0; - ActionStates = new(); - } - - public void Dispose() - { - StopSolve(); - SolverTaskToken?.Cancel(); - SolverTask?.TryWait(); - SolverTask?.Dispose(); - SolverTaskToken?.Dispose(); - - Service.Plugin.Hooks.OnActionUsed -= OnActionUsed; - } -} diff --git a/Craftimizer/Windows/CraftAddon.cs b/Craftimizer/Windows/CraftAddon.cs deleted file mode 100644 index 57cd5be..0000000 --- a/Craftimizer/Windows/CraftAddon.cs +++ /dev/null @@ -1,157 +0,0 @@ -using Craftimizer.Simulator; -using Dalamud.Game.Text.SeStringHandling; -using Dalamud.Interface.Windowing; -using Dalamud.Memory; -using FFXIVClientStructs.FFXIV.Client.Game.Character; -using FFXIVClientStructs.FFXIV.Client.UI; -using FFXIVClientStructs.FFXIV.Component.GUI; -using System; -using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType; - -namespace Craftimizer.Plugin.Windows; - -public sealed unsafe partial class Craft : Window, IDisposable -{ - // State variables, manually kept track of outside of the addon - private CharacterStats CharacterStats = null!; - private SimulationInput Input = null!; - private int ActionCount; - private ActionStates ActionStates; - - private sealed class AddonValues - { - public AddonSynthesis* Addon { get; } - public AtkValue* Values => Addon->AtkUnitBase.AtkValues; - public ushort ValueCount => Addon->AtkUnitBase.AtkValuesCount; - - public AddonValues(AddonSynthesis* addon) - { - Addon = addon; - if (ValueCount != 26) - throw new ArgumentException("AddonSynthesis must have 26 AtkValues", nameof(addon)); - } - - public unsafe AtkValue* this[int i] => Values + i; - - // Always 0? - private uint Unk0 => GetUInt(0); - // Always true? - private bool Unk1 => GetBool(1); - - public SeString ItemName => GetString(2); - public uint ItemIconId => GetUInt(3); - public uint ItemCount => GetUInt(4); - public uint Progress => GetUInt(5); - public uint MaxProgress => GetUInt(6); - public uint Durability => GetUInt(7); - public uint MaxDurability => GetUInt(8); - public uint Quality => GetUInt(9); - public uint HQChance => GetUInt(10); - private uint IsShowingCollectibleInfoValue => GetUInt(11); - private uint ConditionValue => GetUInt(12); - public SeString ConditionName => GetString(13); - public SeString ConditionNameAndTooltip => GetString(14); - public uint StepCount => GetUInt(15); - public uint ResultItemId => GetUInt(16); - public uint MaxQuality => GetUInt(17); - public uint RequiredQuality => GetUInt(18); - private uint IsCollectibleValue => GetUInt(19); - public uint Collectability => GetUInt(20); - public uint MaxCollectability => GetUInt(21); - public uint CollectabilityCheckpoint1 => GetUInt(22); - public uint CollectabilityCheckpoint2 => GetUInt(23); - public uint CollectabilityCheckpoint3 => GetUInt(24); - public bool IsExpertRecipe => GetBool(25); - - public bool IsShowingCollectibleInfo => IsShowingCollectibleInfoValue != 0; - public Condition Condition => (Condition)(1 << (int)ConditionValue); - public bool IsCollectible => IsCollectibleValue != 0; - - private uint GetUInt(int i) - { - var value = this[i]; - return value->Type == ValueType.UInt ? - value->UInt : - throw new ArgumentException($"Value {i} is not a uint", nameof(i)); - } - - private bool GetBool(int i) - { - var value = this[i]; - return value->Type == ValueType.Bool ? - value->Byte != 0 : - throw new ArgumentException($"Value {i} is not a boolean", nameof(i)); - } - - private SeString GetString(int i) - { - var value = this[i]; - return value->Type switch - { - ValueType.AllocatedString or - ValueType.String => - MemoryHelper.ReadSeStringNullTerminated((nint)value->String), - _ => throw new ArgumentException($"Value {i} is not a string", nameof(i)) - }; - } - } - - private const ushort StatusInnerQuiet = 251; - private const ushort StatusWasteNot = 252; - private const ushort StatusVeneration = 2226; - private const ushort StatusGreatStrides = 254; - private const ushort StatusInnovation = 2189; - private const ushort StatusFinalAppraisal = 2190; - private const ushort StatusWasteNot2 = 257; - private const ushort StatusMuscleMemory = 2191; - private const ushort StatusManipulation = 1164; - private const ushort StatusHeartAndSoul = 2665; - - private SimulationState GetAddonSimulationState() - { - var player = Service.ClientState.LocalPlayer!; - var values = new AddonValues(RecipeUtils.AddonSynthesis); - var statusManager = ((Character*)player.Address)->GetStatusManager(); - - byte GetEffectStack(ushort id) - { - foreach (var status in statusManager->StatusSpan) - if (status.StatusID == id) - return status.StackCount; - return 0; - } - bool HasEffect(ushort id) - { - foreach (var status in statusManager->StatusSpan) - if (status.StatusID == id) - return true; - return false; - } - - return new(Input) - { - ActionCount = ActionCount, - StepCount = (int)values.StepCount - 1, - Progress = (int)values.Progress, - Quality = (int)values.Quality, - Durability = (int)values.Durability, - CP = (int)player.CurrentCp, - Condition = values.Condition, - ActiveEffects = new() - { - InnerQuiet = GetEffectStack(StatusInnerQuiet), - WasteNot = GetEffectStack(StatusWasteNot), - Veneration = GetEffectStack(StatusVeneration), - GreatStrides = GetEffectStack(StatusGreatStrides), - Innovation = GetEffectStack(StatusInnovation), - FinalAppraisal = GetEffectStack(StatusFinalAppraisal), - WasteNot2 = GetEffectStack(StatusWasteNot2), - MuscleMemory = GetEffectStack(StatusMuscleMemory), - Manipulation = GetEffectStack(StatusManipulation), - HeartAndSoul = HasEffect(StatusHeartAndSoul), - }, - ActionStates = ActionStates - }; - } -} - diff --git a/Craftimizer/Windows/CraftSolver.cs b/Craftimizer/Windows/CraftSolver.cs deleted file mode 100644 index a2f9881..0000000 --- a/Craftimizer/Windows/CraftSolver.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Craftimizer.Simulator; -using Craftimizer.Simulator.Actions; -using Craftimizer.Utils; -using Dalamud.Interface.Windowing; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; - -namespace Craftimizer.Plugin.Windows; - -public sealed unsafe partial class Craft : Window, IDisposable -{ - private SimulationState? SolverState { get; set; } - private Solver.Solver? SolverTask { get; set; } - private CancellationTokenSource? SolverTaskToken { get; set; } - private ConcurrentQueue SolverActionQueue { get; } = new(); - - // State is the state of the simulation *after* its corresponding action is executed. - private List<(ActionType Action, string Tooltip, SimulationState State)> SolverActions { get; } = new(); - private SimulatorNoRandom SolverSim { get; set; } = null!; - private SimulationState SolverLatestState => SolverActions.Count == 0 ? SolverState!.Value : SolverActions[^1].State; - - private void StopSolve() - { - if (SolverTask == null || SolverTaskToken == null) - return; - - if (!SolverTask.IsCompleted) - SolverTaskToken.Cancel(); - else - { - SolverTaskToken.Dispose(); - SolverTask.Dispose(); - - SolverTask = null; - SolverTaskToken = null; - } - } - - private void QueueSolve(SimulationState state) - { - StopSolve(); - - SolverActionQueue.Clear(); - SolverActions.Clear(); - SolverState = state; - SolverSim = new(state); - - SolverTaskToken = new(); - SolverTask = new(Config.SynthHelperSolverConfig, state) { Token = SolverTaskToken.Token }; - SolverTask.OnLog += s => Log.Debug(s); - SolverTask.OnNewAction += SolverActionQueue.Enqueue; - SolverTask.Start(); - } - - private void SolveTick() - { - var newState = GetNextState(); - if (SolverState == newState) - return; - - if (newState == null) - StopSolve(); - else - QueueSolve(newState.Value); - } - - private void DequeueSolver() - { - while (SolverActionQueue.TryDequeue(out var poppedAction)) - AppendSolverAction(poppedAction); - } - - private void AppendSolverAction(ActionType action) - { - var actionBase = action.Base(); - if (actionBase is BaseComboAction comboActionBase) - { - AppendSolverAction(comboActionBase.ActionTypeA); - AppendSolverAction(comboActionBase.ActionTypeB); - } - else - { - if (SolverActions.Count >= Config.SynthHelperStepCount) - { - StopSolve(); - return; - } - - var tooltip = actionBase.GetTooltip(SolverSim, false); - var (_, state) = SolverSim.Execute(SolverLatestState, action); - SolverActions.Add((action, tooltip, state)); - - if (SolverActions.Count >= Config.SynthHelperStepCount) - StopSolve(); - } - } -} diff --git a/Craftimizer/Windows/CraftState.cs b/Craftimizer/Windows/CraftState.cs deleted file mode 100644 index 3ba53ce..0000000 --- a/Craftimizer/Windows/CraftState.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Craftimizer.Simulator; -using Craftimizer.Simulator.Actions; -using Dalamud.Interface.Windowing; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; - -namespace Craftimizer.Plugin.Windows; - -public sealed unsafe partial class Craft : Window, IDisposable -{ - private ConcurrentQueue UsedActionQueue { get; set; } = new(); - private IEnumerator? StateTicker { get; set; } - - private SimulationState? GetNextState() - { - if (RecipeUtils.IsCrafting && StateTicker == null) - StateTicker = TickState(); - if (!RecipeUtils.IsCrafting && StateTicker != null) - StateTicker = null; - - if (StateTicker == null) - return null; - StateTicker.MoveNext(); - return StateTicker.Current; - } - - private IEnumerator TickState() - { - while (true) - { - SimulationState state; - - // Dequeue used actions - var sim = new SimulatorNoRandom(new()); - while (true) - { - state = GetAddonSimulationState(); - - var dequeued = false; - while (UsedActionQueue.TryDequeue(out var action)) - { - dequeued = true; - (_, state) = sim.Execute(state, action); - ActionCount++; - ActionStates.MutateState(action.Base()); - } - if (dequeued) - break; - - // If nothing is dequeued and executed, just return the addon state - yield return state; - } - - // Intermediate state, wait for addon change - var intermediateState = GetAddonSimulationState(); - while (true) - { - yield return state; - var newState = GetAddonSimulationState(); - if (!IsStateInIntermediate(newState, intermediateState)) - break; - } - } - } - - private static bool IsStateInIntermediate(SimulationState a, SimulationState b) - { - b.CP = a.CP; - b.ActiveEffects = a.ActiveEffects; - return a == b; - } - - private void OnActionUsed(ActionType action) - { - if (!RecipeUtils.IsCrafting || RecipeUtils.AddonSynthesis == null) - return; - - UsedActionQueue.Enqueue(action); - } -} diff --git a/Craftimizer/Windows/MacroClipboard.cs b/Craftimizer/Windows/MacroClipboard.cs new file mode 100644 index 0000000..54f7cb3 --- /dev/null +++ b/Craftimizer/Windows/MacroClipboard.cs @@ -0,0 +1,79 @@ +using Craftimizer.Plugin; +using Dalamud.Interface; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing; +using ImGuiNET; +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Linq; + +namespace Craftimizer.Windows; + +public sealed class MacroClipboard : Window, IDisposable +{ + private const ImGuiWindowFlags WindowFlags = ImGuiWindowFlags.None; + + private List Macros { get; } + + public MacroClipboard(IEnumerable macros) : base("Macro Clipboard", WindowFlags) + { + Macros = new(macros); + + IsOpen = true; + + Service.WindowSystem.AddWindow(this); + } + + public override void Draw() + { + var idx = 0; + foreach(var macro in Macros) + DrawMacro(idx++, macro); + } + + private void DrawMacro(int idx, string macro) + { + using var id = ImRaii.PushId(idx); + using var panel = ImGuiUtils.GroupPanel($"Macro {idx + 1}", -1, out var availWidth); + + var cursor = ImGui.GetCursorPos(); + + ImGuiUtils.AlignRight(ImGui.GetFrameHeight(), availWidth); + var buttonCursor = ImGui.GetCursorPos(); + ImGui.InvisibleButton("##copyInvButton", new(ImGui.GetFrameHeight())); + var buttonHovered = ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenOverlapped | ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); + var buttonActive = buttonHovered && ImGui.GetIO().MouseDown[(int)ImGuiMouseButton.Left]; + var buttonClicked = buttonHovered && ImGui.GetIO().MouseReleased[(int)ImGuiMouseButton.Left]; + ImGui.SetCursorPos(buttonCursor); + { + using var color = ImRaii.PushColor(ImGuiCol.Button, ImGui.GetColorU32(buttonActive ? ImGuiCol.ButtonActive : ImGuiCol.ButtonHovered), buttonHovered); + ImGuiUtils.IconButtonSized(FontAwesomeIcon.Copy, new(ImGui.GetFrameHeight())); + if (buttonClicked) + { + ImGui.SetClipboardText(macro); + Service.PluginInterface.UiBuilder.AddNotification($"Macro {idx + 1} copied to clipboard.", "Craftimizer Macro Copied", NotificationType.Success); + } + } + if (buttonHovered) + ImGui.SetTooltip("Copy to Clipboard"); + + ImGui.SetCursorPos(cursor); + { + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + using var padding = ImRaii.PushStyle(ImGuiStyleVar.FramePadding, Vector2.Zero); + using var bg = ImRaii.PushColor(ImGuiCol.FrameBg, Vector4.Zero); + var lineCount = macro.Count(c => c == '\n') + 1; + ImGui.InputTextMultiline("", ref macro, (uint)macro.Length + 1, new(availWidth, ImGui.GetTextLineHeight() * Math.Max(15, lineCount) + ImGui.GetStyle().FramePadding.Y), ImGuiInputTextFlags.ReadOnly | ImGuiInputTextFlags.AutoSelectAll); + } + + if (buttonHovered) + ImGui.SetMouseCursor(ImGuiMouseCursor.Arrow); + } + + public void Dispose() + { + Service.WindowSystem.RemoveWindow(this); + } +} diff --git a/Craftimizer/Windows/MacroEditor.cs b/Craftimizer/Windows/MacroEditor.cs new file mode 100644 index 0000000..91aafd2 --- /dev/null +++ b/Craftimizer/Windows/MacroEditor.cs @@ -0,0 +1,1372 @@ +using Craftimizer.Plugin; +using Craftimizer.Plugin.Utils; +using Craftimizer.Simulator; +using Craftimizer.Simulator.Actions; +using Craftimizer.Utils; +using Dalamud.Game.ClientState.Statuses; +using Dalamud.Game.Text; +using Dalamud.Interface; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing; +using Dalamud.Utility; +using ImGuiNET; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Sim = Craftimizer.Simulator.SimulatorNoRandom; + +namespace Craftimizer.Windows; + +public sealed class MacroEditor : Window, IDisposable +{ + private const ImGuiWindowFlags WindowFlags = ImGuiWindowFlags.None; + + private CharacterStats characterStats = null!; + public CharacterStats CharacterStats + { + get => characterStats; + private set + { + characterStats = value with + { + Craftsmanship = Math.Clamp(value.Craftsmanship, 0, 9000), + Control = Math.Clamp(value.Control, 0, 9000), + CP = Math.Clamp(value.CP, 180, 1000), + Level = Math.Clamp(value.Level, 1, 90), + CLvl = Gearsets.CalculateCLvl(value.Level), + }; + } + } + public RecipeData RecipeData { get; private set; } + + public record CrafterBuffs + { + public (int Craftsmanship, int Control) FC { get; init; } + public (uint ItemId, bool IsHQ) Food { get; init; } + public (uint ItemId, bool IsHQ) Medicine { get; init; } + + public CrafterBuffs(StatusList? statuses) + { + if (statuses == null) + return; + + foreach (var status in statuses) + { + if (status.StatusId == 48) + Food = FoodStatus.ResolveFoodParam(status.Param) ?? default; + else if (status.StatusId == 49) + Medicine = FoodStatus.ResolveFoodParam(status.Param) ?? default; + else if (status.StatusId == 356) + FC = FC with { Craftsmanship = status.Param / 5 }; + else if (status.StatusId == 357) + FC = FC with { Control = status.Param / 5 }; + } + } + } + public CrafterBuffs Buffs { get; set; } + + private List HQIngredientCounts { get; set; } + private int StartingQuality => RecipeData.CalculateStartingQuality(HQIngredientCounts); + + private sealed record SimulatedActionStep + { + public ActionType Action { get; init; } + // State *after* executing the action + public ActionResponse Response { get; set; } + public SimulationState State { get; set; } + }; + private List Macro { get; set; } = new(); + private SimulationState InitialState { get; set; } + private SimulationState State => Macro.Count > 0 ? Macro[^1].State : InitialState; + private ActionType[] DefaultActions { get; } + private Action>? MacroSetter { get; set; } + + private CancellationTokenSource? SolverTokenSource { get; set; } + private Exception? SolverException { get; set; } + private int? SolverStartStepCount { get; set; } + private bool SolverRunning => SolverTokenSource != null; + + private IDalamudTextureWrap ExpertBadge { get; } + private IDalamudTextureWrap CollectibleBadge { get; } + private IDalamudTextureWrap SplendorousBadge { get; } + private IDalamudTextureWrap SpecialistBadge { get; } + private IDalamudTextureWrap NoManipulationBadge { get; } + private IDalamudTextureWrap ManipulationBadge { get; } + private IDalamudTextureWrap WellFedBadge { get; } + private IDalamudTextureWrap MedicatedBadge { get; } + private IDalamudTextureWrap InControlBadge { get; } + private IDalamudTextureWrap EatFromTheHandBadge { get; } + private GameFontHandle AxisFont { get; } + + public MacroEditor(CharacterStats characterStats, RecipeData recipeData, CrafterBuffs buffs, IEnumerable actions, Action>? setter) : base("Craftimizer Macro Editor", WindowFlags, false) + { + CharacterStats = characterStats; + RecipeData = recipeData; + Buffs = buffs; + MacroSetter = setter; + DefaultActions = actions.ToArray(); + + HQIngredientCounts = new(); + HQIngredientCounts.AddRange(Enumerable.Repeat(0, RecipeData.Ingredients.Count)); + + RecalculateState(); + foreach (var action in DefaultActions) + AddStep(action); + + ExpertBadge = Service.IconManager.GetAssemblyTexture("Graphics.expert_badge.png"); + CollectibleBadge = Service.IconManager.GetAssemblyTexture("Graphics.collectible_badge.png"); + SplendorousBadge = Service.IconManager.GetAssemblyTexture("Graphics.splendorous.png"); + SpecialistBadge = Service.IconManager.GetAssemblyTexture("Graphics.specialist.png"); + NoManipulationBadge = Service.IconManager.GetAssemblyTexture("Graphics.no_manip.png"); + ManipulationBadge = ActionType.Manipulation.GetIcon(RecipeData.ClassJob); + WellFedBadge = Service.IconManager.GetIcon(LuminaSheets.StatusSheet.GetRow(48)!.Icon); + MedicatedBadge = Service.IconManager.GetIcon(LuminaSheets.StatusSheet.GetRow(49)!.Icon); + InControlBadge = Service.IconManager.GetIcon(LuminaSheets.StatusSheet.GetRow(356)!.Icon); + EatFromTheHandBadge = Service.IconManager.GetIcon(LuminaSheets.StatusSheet.GetRow(357)!.Icon); + AxisFont = Service.PluginInterface.UiBuilder.GetGameFontHandle(new(GameFontFamilyAndSize.Axis14)); + + IsOpen = true; + + CollapsedCondition = ImGuiCond.Appearing; + Collapsed = false; + + SizeConstraints = new() { MinimumSize = new(821, 750), MaximumSize = new(float.PositiveInfinity) }; + + Service.WindowSystem.AddWindow(this); + } + + public override void OnClose() + { + SolverTokenSource?.Cancel(); + } + + public override void Draw() + { + var modifiedInput = false; + + using (var table = ImRaii.Table("params", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchSame)) + { + if (table) + { + ImGui.TableSetupColumn("col1", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("col2", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableNextColumn(); + modifiedInput = DrawCharacterParams(); + ImGui.TableNextColumn(); + modifiedInput |= DrawRecipeParams(); + } + } + + if (modifiedInput) + RecalculateState(); + + ImGui.Separator(); + + using (var table = ImRaii.Table("macroInfo", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchSame)) { + if (table) + { + ImGui.TableSetupColumn("col1", ImGuiTableColumnFlags.WidthStretch, 2); + ImGui.TableSetupColumn("col2", ImGuiTableColumnFlags.WidthStretch, 3); + ImGui.TableNextColumn(); + DrawActionHotbars(); + ImGui.TableNextColumn(); + DrawMacroInfo(); + DrawMacro(); + } + } + } + + private bool DrawCharacterParams() + { + var oldStats = CharacterStats; + + ImGuiUtils.TextCentered("Crafter"); + + var textClassName = RecipeData.ClassJob.GetAbbreviation(); + Vector2 textClassSize; + { + var layout = AxisFont.LayoutBuilder(textClassName).Build(); + textClassSize = new(layout.Width, layout.Height); + } + + var imageSize = ImGui.GetFrameHeight(); + ImGuiUtils.AlignCentered( + imageSize + 5 + + textClassSize.X); + ImGui.AlignTextToFramePadding(); + + var uv0 = new Vector2(6, 3); + var uv1 = uv0 + new Vector2(44); + uv0 /= new Vector2(56); + uv1 /= new Vector2(56); + + ImGui.Image(Service.IconManager.GetIcon(RecipeData.ClassJob.GetIconId()).ImGuiHandle, new Vector2(imageSize), uv0, uv1); + ImGui.SameLine(0, 5); + AxisFont.Text(textClassName); + + using (var statsTable = ImRaii.Table("stats", 3, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchSame)) + { + if (statsTable) + { + ImGui.TableSetupColumn("col1", ImGuiTableColumnFlags.WidthStretch, 4.5f); + ImGui.TableSetupColumn("col2", ImGuiTableColumnFlags.WidthStretch, 3); + ImGui.TableSetupColumn("col3", ImGuiTableColumnFlags.WidthStretch, 2); + + var inputWidth = ImGui.CalcTextSize(SqText.ToLevelString(9999)).X + ImGui.GetStyle().FramePadding.X * 2 + 5; + + void DrawStat(string name, int value, Action setter) + { + ImGui.AlignTextToFramePadding(); + ImGui.Text(name); + ImGui.SameLine(0, 5); + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + + var text = value.ToString(); + if (ImGui.InputText($"##{name}", ref text, 8, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.CharsDecimal)) + { + setter( + int.TryParse(text, out var newLevel) + ? Math.Clamp(newLevel, 0, 9999) + : 0); + } + } + + ImGui.TableNextColumn(); + DrawStat("Craftsmanship", CharacterStats.Craftsmanship, v => CharacterStats = CharacterStats with { Craftsmanship = v }); + + ImGui.TableNextColumn(); + DrawStat("Control", CharacterStats.Control, v => CharacterStats = CharacterStats with { Control = v }); + + ImGui.TableNextColumn(); + DrawStat("CP", CharacterStats.CP, v => CharacterStats = CharacterStats with { CP = v }); + } + } + + using (var paramTable = ImRaii.Table("params", 3, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchSame)) + { + if (paramTable) + { + ImGui.TableSetupColumn("col1", ImGuiTableColumnFlags.WidthFixed); + ImGui.TableSetupColumn("col2", ImGuiTableColumnFlags.WidthStretch, 3); + ImGui.TableSetupColumn("col3", ImGuiTableColumnFlags.WidthStretch, 2); + + ImGui.TableNextColumn(); + var levelTextWidth = ImGui.CalcTextSize(SqText.ToLevelString(99)).X + ImGui.GetStyle().FramePadding.X * 2 + 5; + ImGuiUtils.AlignCentered( + ImGui.CalcTextSize(SqText.LevelPrefix.ToIconString()).X + 5 + + levelTextWidth); + + ImGui.AlignTextToFramePadding(); + ImGui.Text(SqText.LevelPrefix.ToIconString()); + ImGui.SameLine(0, 3); + ImGui.SetNextItemWidth(levelTextWidth); + var levelText = SqText.ToLevelString(CharacterStats.Level); + bool textChanged; + unsafe + { + textChanged = ImGui.InputText("##levelText", ref levelText, 8, ImGuiInputTextFlags.CallbackCharFilter | ImGuiInputTextFlags.AutoSelectAll, LevelInputCallback); + } + if (textChanged) + CharacterStats = CharacterStats with + { + Level = + SqText.TryParseLevelString(levelText, out var newLevel) + ? Math.Clamp(newLevel, 1, 90) + : 1 + }; + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"CLvl {Gearsets.CalculateCLvl(CharacterStats.Level)}"); + + var disabledTint = new Vector4(0.5f, 0.5f, 0.5f, 0.75f); + var imageButtonPadding = (int)(ImGui.GetStyle().FramePadding.Y / 2f); + var imageButtonSize = imageSize - imageButtonPadding * 2; + { + var v = CharacterStats.HasSplendorousBuff; + var tint = v ? Vector4.One : disabledTint; + if (ImGui.ImageButton(SplendorousBadge.ImGuiHandle, new Vector2(imageButtonSize), default, Vector2.One, imageButtonPadding, default, tint)) + CharacterStats = CharacterStats with { HasSplendorousBuff = !v }; + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(CharacterStats.HasSplendorousBuff ? $"Splendorous Tool" : "No Splendorous Tool"); + } + ImGui.SameLine(0, 5); + bool? newIsSpecialist = null; + { + var v = CharacterStats.IsSpecialist; + var tint = new Vector4(0.99f, 0.97f, 0.62f, 1f) * (v ? Vector4.One : disabledTint); + if (ImGui.ImageButton(SpecialistBadge.ImGuiHandle, new Vector2(imageButtonSize), default, Vector2.One, imageButtonPadding, default, tint)) + { + v = !v; + newIsSpecialist = v; + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(v ? $"Specialist" : "Not a Specialist"); + } + ImGui.SameLine(0, 5); + { + var manipLevel = ActionType.Manipulation.GetActionRow(RecipeData.ClassJob).Action!.ClassJobLevel; + using (var d = ImRaii.Disabled(manipLevel > CharacterStats.Level)) + { + var v = CharacterStats.CanUseManipulation && manipLevel <= CharacterStats.Level; + var tint = (v || manipLevel > CharacterStats.Level) ? disabledTint : Vector4.One; + if (ImGui.ImageButton(v ? ManipulationBadge.ImGuiHandle : NoManipulationBadge.ImGuiHandle, new Vector2(imageButtonSize), default, Vector2.One, imageButtonPadding, default, tint)) + CharacterStats = CharacterStats with { CanUseManipulation = !v }; + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + ImGui.SetTooltip(CharacterStats.CanUseManipulation && manipLevel <= CharacterStats.Level ? $"Can Use Manipulation" : "Cannot Use Manipulation"); + } + + ImGui.TableNextColumn(); + + (uint ItemId, bool HQ)? newFoodBuff = null; + var buffImageSize = new Vector2(imageSize * WellFedBadge.Width / WellFedBadge.Height, imageSize); + ImGui.Image(WellFedBadge.ImGuiHandle, buffImageSize); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Food"); + ImGui.SameLine(0, 5); + { + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + using var combo = ImRaii.Combo("##food", FormatItemBuff(Buffs.Food)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(FormatItemBuffDescription(Buffs.Food)); + if (combo) + { + if (ImGui.Selectable("None", Buffs.Food.ItemId == 0)) + newFoodBuff = (0, false); + + foreach (var food in FoodStatus.OrderedFoods) + { + var row = (food.Item.RowId, false); + if (ImGui.Selectable(FormatItemBuff(row), Buffs.Food == row)) + newFoodBuff = row; + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(FormatItemBuffDescription(row)); + + if (food.Item.CanBeHq) + { + row = (food.Item.RowId, true); + if (ImGui.Selectable(FormatItemBuff(row), Buffs.Food == row)) + newFoodBuff = row; + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(FormatItemBuffDescription(row)); + } + } + } + } + + (uint ItemId, bool HQ)? newMedicineBuff = null; + buffImageSize = new Vector2(imageSize * MedicatedBadge.Width / MedicatedBadge.Height, imageSize); + ImGui.Image(MedicatedBadge.ImGuiHandle, buffImageSize); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Medicine"); + ImGui.SameLine(0, 5); + { + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + using var combo = ImRaii.Combo("##medicine", FormatItemBuff(Buffs.Medicine)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(FormatItemBuffDescription(Buffs.Medicine)); + if (combo) + { + if (ImGui.Selectable("None", Buffs.Medicine.ItemId == 0)) + newMedicineBuff = (0, false); + + foreach (var medicine in FoodStatus.OrderedMedicines) + { + var row = (medicine.Item.RowId, false); + if (ImGui.Selectable(FormatItemBuff(row), Buffs.Medicine == row)) + newMedicineBuff = row; + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(FormatItemBuffDescription(row)); + + if (medicine.Item.CanBeHq) + { + row = (medicine.Item.RowId, true); + if (ImGui.Selectable(FormatItemBuff(row), Buffs.Medicine == row)) + newMedicineBuff = row; + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(FormatItemBuffDescription(row)); + } + } + } + } + + ImGui.TableNextColumn(); + + int? newFCCraftsmanshipBuff = null; + buffImageSize = new Vector2(imageSize * MedicatedBadge.Width / MedicatedBadge.Height, imageSize); + ImGui.Image(EatFromTheHandBadge.ImGuiHandle, buffImageSize); + var fcBuffName = "Eat from the Hand"; + var fcStatName = "Craftsmanship"; + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(fcBuffName); + ImGui.SameLine(0, 5); + { + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + using var combo = ImRaii.Combo("##fcCraftsmanship", FormatFCBuff(fcBuffName, Buffs.FC.Craftsmanship)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(FormatFCBuffDescription(fcBuffName, fcStatName, Buffs.FC.Craftsmanship)); + if (combo) + { + if (ImGui.Selectable("None", Buffs.FC.Craftsmanship == 0)) + newFCCraftsmanshipBuff = 0; + + for (var i = 1; i <= 3; ++i) + { + if (ImGui.Selectable(FormatFCBuff(fcBuffName, i), Buffs.FC.Craftsmanship == i)) + newFCCraftsmanshipBuff = i; + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(FormatFCBuffDescription(fcBuffName, fcStatName, i)); + } + } + } + + int? newFCControlBuff = null; + buffImageSize = new Vector2(imageSize * MedicatedBadge.Width / MedicatedBadge.Height, imageSize); + ImGui.Image(InControlBadge.ImGuiHandle, buffImageSize); + fcBuffName = "In Control"; + fcStatName = "Control"; + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(fcBuffName); + ImGui.SameLine(0, 5); + { + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + using var combo = ImRaii.Combo("##fcControl", FormatFCBuff(fcBuffName, Buffs.FC.Control)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(FormatFCBuffDescription(fcBuffName, fcStatName, Buffs.FC.Control)); + if (combo) + { + if (ImGui.Selectable("None", Buffs.FC.Control == 0)) + newFCControlBuff = 0; + + for (var i = 1; i <= 3; ++i) + { + if (ImGui.Selectable(FormatFCBuff(fcBuffName, i), Buffs.FC.Control == i)) + newFCControlBuff = i; + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(FormatFCBuffDescription(fcBuffName, fcStatName, i)); + } + } + } + + if (newIsSpecialist.HasValue || newFoodBuff.HasValue || newMedicineBuff.HasValue || newFCCraftsmanshipBuff.HasValue || newFCControlBuff.HasValue) + { + var baseStat = GetBaseStats(CharacterStats); + + Buffs = Buffs with + { + Food = newFoodBuff ?? Buffs.Food, + Medicine = newMedicineBuff ?? Buffs.Medicine, + FC = (newFCCraftsmanshipBuff ?? Buffs.FC.Craftsmanship, newFCControlBuff ?? Buffs.FC.Control) + }; + + var newStats = CharacterStats with { Craftsmanship = baseStat.Craftsmanship, Control = baseStat.Control, CP = baseStat.CP }; + if (newIsSpecialist is { } isSpecialist) + { + if (isSpecialist != CharacterStats.IsSpecialist) + { + var craftsmanship = 20; + var control = 20; + var cp = 15; + if (!isSpecialist) + { + craftsmanship *= -1; + control *= -1; + cp *= -1; + } + + newStats = newStats with + { + IsSpecialist = isSpecialist, + Craftsmanship = newStats.Craftsmanship + craftsmanship, + Control = newStats.Control + control, + CP = newStats.CP + cp + }; + } + } + + var bonus = CalculateConsumableBonus(newStats); + CharacterStats = newStats with + { + Craftsmanship = newStats.Craftsmanship + bonus.Craftsmanship, + Control = newStats.Control + bonus.Control, + CP = newStats.CP + bonus.CP + }; + } + } + } + + return oldStats != CharacterStats; + } + + private static unsafe int LevelInputCallback(ImGuiInputTextCallbackData* data) + { + if (data->EventFlag == ImGuiInputTextFlags.CallbackCharFilter) + { + if (SqText.LevelNumReplacements.TryGetValue((char)data->EventChar, out var seChar)) + data->EventChar = seChar.ToIconChar(); + else + return 1; + } + + return 0; + } + + private static string FormatItemBuff((uint ItemId, bool IsHQ) input) + { + if (input.ItemId == 0) + return "None"; + + var name = LuminaSheets.ItemSheet.GetRow(input.ItemId)?.Name.ToDalamudString().ToString() ?? $"Unknown ({input.ItemId})"; + return input.IsHQ ? $"{name} (HQ)" : name; + } + + private static string FormatItemBuffDescription((uint ItemId, bool IsHQ) input) + { + var s = new StringBuilder(FormatItemBuff(input) + "\n"); + + void AddStat(string name, FoodStatus.FoodStat? statNullable) + { + if (statNullable is not { } stat) + return; + + var (value, max) = input.IsHQ ? (stat.ValueHQ, stat.MaxHQ) : (stat.Value, stat.Max); + + if (!stat.IsRelative) + s.AppendLine($"{name} +{value}"); + else + s.AppendLine($"{name} +{value}%% (Max {max})"); + } + + if (FoodStatus.TryGetFood(input.ItemId) is { } food) + { + AddStat("Craftsmanship", food.Craftsmanship); + AddStat("Control", food.Control); + AddStat("CP", food.CP); + } + return s.ToString(); + } + + private static string FormatFCBuff(string name, int level) + { + if (level == 0) + return "None"; + + return $"{name} {new string('I', level)}"; + } + + private static string FormatFCBuffDescription(string name, string statName, int level) + { + if (level == 0) + return FormatFCBuff(name, level); + + return $"{FormatFCBuff(name, level)}\n{statName} +{level * 5}"; + } + + private (int Craftsmanship, int Control, int CP) GetBaseStats(CharacterStats stats) + { + var (craftsmanship, control, cp) = (stats.Craftsmanship, stats.Control, stats.CP); + + craftsmanship -= Buffs.FC.Craftsmanship * 5; + control -= Buffs.FC.Control * 5; + + var food = FoodStatus.TryGetFood(Buffs.Food.ItemId); + var medicine = FoodStatus.TryGetFood(Buffs.Medicine.ItemId); + + static void GetBaseStat(ref int val, bool isHq, FoodStatus.FoodStat? food, out float a, out int b) + { + a = 1; + b = 0; + if (food is { } stat) + { + if (stat.IsRelative) + { + a = (isHq ? stat.ValueHQ : stat.Value) / 100f; + b = isHq ? stat.MaxHQ : stat.Max; + } + else + val -= isHq ? stat.ValueHQ : stat.Value; + } + } + + static int GetBaseStat2(int val, bool foodHq, FoodStatus.FoodStat? food, bool medicineHq, FoodStatus.FoodStat? medicine) + { + GetBaseStat(ref val, foodHq, food, out var a, out var b); + GetBaseStat(ref val, medicineHq, medicine, out var c, out var d); + return CalculateBaseStat(val, a, b, c, d); + } + + craftsmanship = GetBaseStat2(craftsmanship, Buffs.Food.IsHQ, food?.Craftsmanship, Buffs.Medicine.IsHQ, medicine?.Craftsmanship); + control = GetBaseStat2(control, Buffs.Food.IsHQ, food?.Control, Buffs.Medicine.IsHQ, medicine?.Control); + cp = GetBaseStat2(cp, Buffs.Food.IsHQ, food?.CP, Buffs.Medicine.IsHQ, medicine?.CP); + + return (craftsmanship, control, cp); + } + + private (int Craftsmanship, int Control, int CP) CalculateConsumableBonus(CharacterStats stats) + { + int craftsmanship = 0, control = 0, cp = 0; + static int CalculateStatBonus(int val, bool isHq, FoodStatus.FoodStat? food) + { + if (food is { } stat) + { + if (stat.IsRelative) + return (int)Math.Min((isHq ? stat.ValueHQ : stat.Value) / 100f * val, isHq ? stat.MaxHQ : stat.Max); + else + return isHq ? stat.ValueHQ : stat.Value; + } + return 0; + } + var food = FoodStatus.TryGetFood(Buffs.Food.ItemId); + + craftsmanship += CalculateStatBonus(stats.Craftsmanship, Buffs.Food.IsHQ, food?.Craftsmanship); + control += CalculateStatBonus(stats.Control, Buffs.Food.IsHQ, food?.Control); + cp += CalculateStatBonus(stats.CP, Buffs.Food.IsHQ, food?.CP); + + var medicine = FoodStatus.TryGetFood(Buffs.Medicine.ItemId); + craftsmanship += CalculateStatBonus(stats.Craftsmanship, Buffs.Medicine.IsHQ, medicine?.Craftsmanship); + control += CalculateStatBonus(stats.Control, Buffs.Medicine.IsHQ, medicine?.Control); + cp += CalculateStatBonus(stats.CP, Buffs.Medicine.IsHQ, medicine?.CP); + + craftsmanship += Buffs.FC.Craftsmanship * 5; + control += Buffs.FC.Control * 5; + + return (craftsmanship, control, cp); + } + + // y: output stat + // a: coefficient + // b: max value for a product + // c: coefficient + // d: max value for c product + // Implementation of https://www.desmos.com/calculator/qlj9f9qjqy for calculating x from y + private static int CalculateBaseStat(int y, float a, int b, float c, int d) + { + if (y <= 0) + return 0; + + if (d / c < b / a) + (a, b, c, d) = (c, d, a, b); + + var dc = d / c; + var ba = b / a; + if (dc + b + d <= y) + return y - b - d; + else if (y <= (1 + a + c) * ba) + return (int)Math.Ceiling(y / (a + c + 1)); + else + return (int)Math.Ceiling((y - b) / (c + 1)); + } + + private bool DrawRecipeParams() + { + var oldStartingQuality = StartingQuality; + + ImGuiUtils.TextCentered("Recipe"); + + var textStars = new string('★', RecipeData!.Table.Stars); + var textStarsSize = Vector2.Zero; + if (!string.IsNullOrEmpty(textStars)) + { + var layout = AxisFont.LayoutBuilder(textStars).Build(); + textStarsSize = new(layout.Width, layout.Height); + } + var textLevel = SqText.LevelPrefix.ToIconChar() + SqText.ToLevelString(RecipeData.RecipeInfo.ClassJobLevel); + var isExpert = RecipeData.RecipeInfo.IsExpert; + var isCollectable = RecipeData.Recipe.ItemResult.Value!.IsCollectable; + var imageSize = ImGui.GetFrameHeight(); + var textSize = ImGui.GetFontSize(); + var badgeSize = new Vector2(textSize * ExpertBadge.Width / ExpertBadge.Height, textSize); + var badgeOffset = (imageSize - badgeSize.Y) / 2; + + var rightSideWidth = + 5 + ImGui.CalcTextSize(textLevel).X + + (textStarsSize != Vector2.Zero ? textStarsSize.X + 3 : 0) + + (isCollectable ? badgeSize.X + 3 : 0) + + (isExpert ? badgeSize.X + 3 : 0); + ImGui.AlignTextToFramePadding(); + + ImGui.Image(Service.IconManager.GetIcon(RecipeData.Recipe.ItemResult.Value!.Icon).ImGuiHandle, new Vector2(imageSize)); + + ImGui.SameLine(0, 5); + + ushort? newRecipe = null; + { + var recipe = RecipeData.Recipe; + if (ImGuiUtils.SearchableCombo( + "combo", + ref recipe, + LuminaSheets.RecipeSheet.Where(r => r.RecipeLevelTable.Row != 0 && r.ItemResult.Row != 0), + AxisFont.ImFont, + ImGui.GetContentRegionAvail().X - rightSideWidth, + r => r.ItemResult.Value!.Name.ToDalamudString().ToString(), + r => r.RowId.ToString(), + r => + { + ImGui.TextUnformatted($"{r.ItemResult.Value!.Name.ToDalamudString()}"); + + var classJob = (ClassJob)r.CraftType.Row; + var textLevel = SqText.LevelPrefix.ToIconChar() + SqText.ToLevelString(r.RecipeLevelTable.Value!.ClassJobLevel); + var textLevelSize = ImGui.CalcTextSize(textLevel); + ImGui.SameLine(); + + var imageSize = AxisFont.ImFont.FontSize; + ImGuiUtils.AlignRight( + imageSize + 5 + + textLevelSize.X, + ImGui.GetContentRegionAvail().X); + + var uv0 = new Vector2(6, 3); + var uv1 = uv0 + new Vector2(44); + uv0 /= new Vector2(56); + uv1 /= new Vector2(56); + + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetStyle().FramePadding.Y / 2); + ImGui.Image(Service.IconManager.GetIcon(classJob.GetIconId()).ImGuiHandle, new Vector2(imageSize), uv0, uv1); + ImGui.SameLine(0, 5); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (AxisFont.ImFont.FontSize - textLevelSize.Y) / 2); + ImGui.Text(textLevel); + })) + { + newRecipe = (ushort)recipe.RowId; + } + } + + ImGui.SameLine(0, 5); + ImGui.Text(textLevel); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"RLvl {RecipeData.RecipeInfo.RLvl}"); + + if (textStarsSize != Vector2.Zero) + { + ImGui.SameLine(0, 3); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (imageSize - textStarsSize.Y) / 2); + AxisFont.Text(textStars); + } + + if (isCollectable) + { + ImGui.SameLine(0, 3); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + badgeOffset); + ImGui.Image(CollectibleBadge.ImGuiHandle, badgeSize); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"Collectible"); + } + + if (isExpert) + { + ImGui.SameLine(0, 3); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + badgeOffset); + ImGui.Image(ExpertBadge.ImGuiHandle, badgeSize); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"Expert Recipe"); + } + + using (var statsTable = ImRaii.Table("stats", 3, ImGuiTableFlags.BordersInnerV)) + { + if (statsTable) + { + ImGui.TableSetupColumn("col1", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("col2", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("col3", ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.Text("Progress"); + ImGui.SameLine(); + ImGuiUtils.TextRight($"{RecipeData.RecipeInfo.MaxProgress}"); + + ImGui.TableNextColumn(); + ImGui.Text("Quality"); + ImGui.SameLine(); + ImGuiUtils.TextRight($"{RecipeData.RecipeInfo.MaxQuality}"); + + ImGui.TableNextColumn(); + ImGui.Text("Durability"); + ImGui.SameLine(); + ImGuiUtils.TextRight($"{RecipeData.RecipeInfo.MaxDurability}"); + } + } + + using (var table = ImRaii.Table("ingredientTable", 4, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchSame)) + { + if (table) + { + ImGui.TableSetupColumn("col1", ImGuiTableColumnFlags.WidthStretch, 2); + ImGui.TableSetupColumn("col2", ImGuiTableColumnFlags.WidthStretch, 2); + ImGui.TableSetupColumn("col3", ImGuiTableColumnFlags.WidthStretch, 2); + ImGui.TableSetupColumn("col4", ImGuiTableColumnFlags.WidthStretch, 2); + + var ingredients = RecipeData.Ingredients.GetEnumerator(); + var hqCount = HQIngredientCounts.GetEnumerator(); + + ImGui.TableNextColumn(); + DrawIngredientHQEntry(0); + DrawIngredientHQEntry(1); + + ImGui.TableNextColumn(); + DrawIngredientHQEntry(2); + DrawIngredientHQEntry(3); + + ImGui.TableNextColumn(); + DrawIngredientHQEntry(4); + DrawIngredientHQEntry(5); + + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetStyle().FramePadding.Y); + ImGuiUtils.TextCentered($"Starting Quality"); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ImGui.GetStyle().FramePadding.Y); + ImGuiUtils.TextCentered($"{StartingQuality}"); + } + } + + if (newRecipe is { } recipeId) + { + RecipeData = new(recipeId); + HQIngredientCounts.Clear(); + HQIngredientCounts.AddRange(Enumerable.Repeat(0, RecipeData.Ingredients.Count)); + return true; + } + + return oldStartingQuality != StartingQuality; + } + + private void DrawIngredientHQEntry(int idx) + { + if (idx >= RecipeData.Ingredients.Count) + { + ImGui.Dummy(new(0, ImGui.GetFrameHeight())); + return; + } + + var ingredient = RecipeData.Ingredients[idx]; + var hqCount = HQIngredientCounts[idx]; + + var canHq = ingredient.Item.CanBeHq; + var icon = Service.IconManager.GetHqIcon(ingredient.Item.Icon, canHq); + var imageSize = ImGui.GetFrameHeight(); + + using (var d = ImRaii.Disabled(!canHq)) + ImGui.Image(icon.ImGuiHandle, new Vector2(imageSize)); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + if (canHq) + { + var perItem = RecipeData.CalculateItemStartingQuality(idx, 1); + var total = RecipeData.CalculateItemStartingQuality(idx, hqCount); + ImGui.SetTooltip($"{ingredient.Item.Name.ToDalamudString()} {SeIconChar.HighQuality.ToIconString()}\n+{perItem} Quality/Item{(total > 0 ? $"\n+{total} Quality" : "")}"); + } + else + ImGui.SetTooltip($"{ingredient.Item.Name.ToDalamudString()}"); + } + ImGui.SameLine(0, 5); + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X - (5 + ImGui.CalcTextSize("/").X + 5 + ImGui.CalcTextSize($"99").X)); + using var d2 = ImRaii.Disabled(!canHq); + if (canHq) + { + var text = hqCount.ToString(); + if (ImGui.InputText($"##ingredient{idx}", ref text, 8, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.CharsDecimal)) + { + HQIngredientCounts[idx] = + int.TryParse(text, out var newCount) + ? Math.Clamp(newCount, 0, ingredient.Amount) + : 0; + } + } + else + { + var text = ingredient.Amount.ToString(); + ImGui.InputText($"##ingredient{idx}", ref text, 8, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.CharsDecimal); + } + ImGui.SameLine(0, 5); + ImGui.AlignTextToFramePadding(); + ImGui.Text("/"); + ImGui.SameLine(0, 5); + ImGui.AlignTextToFramePadding(); + ImGuiUtils.TextCentered($"{ingredient.Amount}"); + } + + private void DrawActionHotbars() + { + var sim = new Sim(State); + + var imageSize = ImGui.GetFrameHeight() * 2; + var spacing = ImGui.GetStyle().ItemSpacing.Y; + + using var _color = ImRaii.PushColor(ImGuiCol.Button, Vector4.Zero); + using var _color3 = ImRaii.PushColor(ImGuiCol.ButtonHovered, Vector4.Zero); + using var _color2 = ImRaii.PushColor(ImGuiCol.ButtonActive, Vector4.Zero); + using var _alpha = ImRaii.PushStyle(ImGuiStyleVar.DisabledAlpha, ImGui.GetStyle().DisabledAlpha * .5f); + foreach (var category in Enum.GetValues()) + { + if (category == ActionCategory.Combo) + continue; + + var actions = category.GetActions(); + using var panel = ImGuiUtils.GroupPanel(category.GetDisplayName(), -1, out var availSpace); + var itemsPerRow = (int)MathF.Floor((availSpace + spacing) / (imageSize + spacing)); + var itemCount = actions.Count; + var iterCount = (int)(Math.Ceiling((float)itemCount / itemsPerRow) * itemsPerRow); + for (var i = 0; i < iterCount; i++) + { + if (i % itemsPerRow != 0) + ImGui.SameLine(0, spacing); + if (i < itemCount) + { + var actionBase = actions[i].Base(); + var canUse = actionBase.CanUse(sim); + if (ImGui.ImageButton(actions[i].GetIcon(RecipeData!.ClassJob).ImGuiHandle, new(imageSize), default, Vector2.One, 0, default, !canUse ? new(1, 1, 1, ImGui.GetStyle().DisabledAlpha) : Vector4.One)) + AddStep(actions[i]); + if (!canUse && + (CharacterStats.Level < actionBase.Level || + (actions[i] == ActionType.Manipulation && !CharacterStats.CanUseManipulation) || + (actions[i] is ActionType.HeartAndSoul or ActionType.CarefulObservation && !CharacterStats.IsSpecialist) + ) + ) + { + Vector2 v1 = ImGui.GetItemRectMin(), v2 = ImGui.GetItemRectMax(); + ImGui.PushClipRect(v1, v2, true); + (v1.X, v2.X) = (v2.X, v1.X); + ImGui.GetWindowDrawList().AddLine(v1, v2, ImGui.GetColorU32(new Vector4(1, 0, 0, ImGui.GetStyle().DisabledAlpha / 2)), 5); + ImGui.PopClipRect(); + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + ImGui.SetTooltip($"{actions[i].GetName(RecipeData!.ClassJob)}\n{actionBase.GetTooltip(sim, true)}"); + } + else + ImGui.Dummy(new(imageSize)); + } + } + + var minY = ImGui.GetCursorPosY() + ImGui.GetStyle().WindowPadding.Y - ImGui.GetStyle().CellPadding.Y; + if (SizeConstraints!.Value.MinimumSize.Y != minY) + SizeConstraints = SizeConstraints.Value with { MinimumSize = SizeConstraints.Value.MinimumSize with { Y = minY } }; + } + + private void DrawMacroInfo() + { + using (var barsTable = ImRaii.Table("simBars", 2, ImGuiTableFlags.SizingStretchSame)) + { + if (barsTable) + { + ImGui.TableSetupColumn("col1", ImGuiTableColumnFlags.WidthStretch, 1); + ImGui.TableSetupColumn("col2", ImGuiTableColumnFlags.WidthStretch, 2); + + ImGui.TableNextColumn(); + var datas = new List(3) + { + new("Durability", Colors.Durability, State.Durability, RecipeData.RecipeInfo.MaxDurability, null, null), + new("Condition", default, 0, 0, null, State.Condition) + }; + if (RecipeData.Recipe.ItemResult.Value!.IsCollectable) + datas.Add(new("Collectability", Colors.HQ, State.Collectability, State.MaxCollectability, $"{State.Collectability}", null)); + else if (RecipeData.Recipe.RequiredQuality > 0) + datas.Add(new("Quality %", Colors.HQ, State.Quality, RecipeData.Recipe.RequiredQuality, $"{(float)State.Quality / RecipeData.Recipe.RequiredQuality * 100:0}%", null)); + else if (RecipeData.RecipeInfo.MaxQuality > 0) + datas.Add(new("HQ %", Colors.HQ, State.HQPercent, 100, $"{State.HQPercent}%", null)); + DrawBars(datas); + + ImGui.TableNextColumn(); + datas = new List(3) + { + new("Progress", Colors.Progress, State.Progress, RecipeData.RecipeInfo.MaxProgress, null, null), + new("Quality", Colors.Quality, State.Quality, RecipeData.RecipeInfo.MaxQuality, null, null), + new("CP", Colors.CP, State.CP, CharacterStats.CP, null, null) + }; + if (RecipeData.RecipeInfo.MaxQuality <= 0) + datas.RemoveAt(1); + DrawBars(datas); + } + } + + using (var panel = ImGuiUtils.GroupPanel("Buffs", -1, out _)) + { + using var _font = ImRaii.PushFont(AxisFont.ImFont); + + var iconHeight = ImGui.GetFrameHeight() * 1.75f; + var durationShift = iconHeight * .2f; + + ImGui.Dummy(new(0, iconHeight + ImGui.GetStyle().ItemSpacing.Y + ImGui.GetTextLineHeight() - durationShift)); + ImGui.SameLine(0, 0); + + var effects = State.ActiveEffects; + foreach(var effect in Enum.GetValues()) + { + if (!effects.HasEffect(effect)) + continue; + + using (var group = ImRaii.Group()) + { + var icon = effect.GetIcon(effects.GetStrength(effect)); + var size = new Vector2(iconHeight * icon.Width / icon.Height, iconHeight); + + ImGui.Image(icon.ImGuiHandle, size); + if (!effect.IsIndefinite()) + { + ImGui.SetCursorPosY(ImGui.GetCursorPosY() - durationShift); + ImGuiUtils.TextCentered($"{effects.GetDuration(effect)}", size.X); + } + } + if (ImGui.IsItemHovered()) + { + var status = effect.Status(); + using var _reset = ImRaii.DefaultFont(); + ImGui.SetTooltip($"{status.Name.ToDalamudString()}\n{status.Description.ToDalamudString()}"); + } + ImGui.SameLine(); + } + } + } + + private readonly record struct BarData(string Name, Vector4 Color, float Value, float Max, string? Caption, Condition? Condition); + private void DrawBars(IEnumerable bars) + { + var spacing = ImGui.GetStyle().ItemSpacing.X; + var totalSize = ImGui.GetContentRegionAvail().X; + totalSize -= 2 * spacing; + var textSize = bars.Max(b => + { + if (b.Caption is { } caption) + return ImGui.CalcTextSize(caption).X; + // max (sp/2) "/" (sp/2) max + return Math.Max(ImGui.CalcTextSize($"{b.Value:0}").X, ImGui.CalcTextSize($"{b.Max:0}").X) * 2 + + spacing + + ImGui.CalcTextSize("/").X; + }); + var maxSize = (textSize - 2 * spacing - ImGui.CalcTextSize("/").X) / 2; + var barSize = totalSize - textSize - spacing; + foreach(var bar in bars) + { + using var panel = ImGuiUtils.GroupPanel(bar.Name, totalSize, out _); + if (bar.Condition is { } condition) + { + using (var g = ImRaii.Group()) + { + var size = ImGui.GetFrameHeight() + spacing + ImGui.CalcTextSize(condition.Name()).X; + ImGuiUtils.AlignCentered(size, totalSize); + ImGui.GetWindowDrawList().AddCircleFilled( + ImGui.GetCursorScreenPos() + new Vector2(ImGui.GetFrameHeight() / 2), + ImGui.GetFrameHeight() / 2, + ImGui.ColorConvertFloat4ToU32(new Vector4(.35f, .35f, .35f, 0) + condition.GetColor(DateTime.UtcNow.TimeOfDay))); + ImGui.Dummy(new(ImGui.GetFrameHeight())); + ImGui.SameLine(0, spacing); + ImGui.AlignTextToFramePadding(); + ImGui.Text(condition.Name()); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(condition.Description(CharacterStats.HasSplendorousBuff)); + } + else + { + using (var color = ImRaii.PushColor(ImGuiCol.PlotHistogram, bar.Color)) + ImGui.ProgressBar(Math.Clamp(bar.Value / bar.Max, 0, 1), new(barSize, ImGui.GetFrameHeight()), string.Empty); + ImGui.SameLine(0, spacing); + ImGui.AlignTextToFramePadding(); + if (bar.Caption is { } caption) + ImGuiUtils.TextRight(caption, textSize); + else + { + ImGuiUtils.TextRight($"{bar.Value:0}", maxSize); + ImGui.SameLine(0, spacing / 2); + ImGui.Text("/"); + ImGui.SameLine(0, spacing / 2); + ImGuiUtils.TextRight($"{bar.Max:0}", maxSize); + } + } + } + } + + private void DrawMacro() + { + var spacing = ImGui.GetStyle().ItemSpacing.X; + var imageSize = ImGui.GetFrameHeight() * 2; + var lastState = InitialState; + + using var panel = ImGuiUtils.GroupPanel("Macro", -1, out var availSpace); + ImGui.Dummy(new(0, imageSize)); + ImGui.SameLine(0, 0); + + var macroActionsHeight = ImGui.GetFrameHeightWithSpacing(); + var childHeight = ImGui.GetContentRegionAvail().Y - ImGui.GetStyle().ItemSpacing.Y * 2 - ImGui.GetStyle().CellPadding.Y - macroActionsHeight - ImGui.GetStyle().ItemSpacing.Y * 2; + + using (var child = ImRaii.Child("##macroActions", new(availSpace, childHeight))) + { + var itemsPerRow = (int)Math.Max(1, MathF.Floor((ImGui.GetContentRegionAvail().X + spacing) / (imageSize + spacing))); + using var _color = ImRaii.PushColor(ImGuiCol.Button, Vector4.Zero); + using var _color3 = ImRaii.PushColor(ImGuiCol.ButtonHovered, Vector4.Zero); + using var _color2 = ImRaii.PushColor(ImGuiCol.ButtonActive, Vector4.Zero); + for (var i = 0; i < Macro.Count; i++) + { + if (i % itemsPerRow != 0) + ImGui.SameLine(0, spacing); + var (action, response, state) = (Macro[i].Action, Macro[i].Response, Macro[i].State); + var actionBase = action.Base(); + var failedAction = response != ActionResponse.UsedAction; + using var id = ImRaii.PushId(i); + if (ImGui.ImageButton(action.GetIcon(RecipeData!.ClassJob).ImGuiHandle, new(imageSize), default, Vector2.One, 0, default, failedAction ? new(1, 1, 1, ImGui.GetStyle().DisabledAlpha) : Vector4.One)) + RemoveStep(i); + if (response is ActionResponse.ActionNotUnlocked) + { + Vector2 v1 = ImGui.GetItemRectMin(), v2 = ImGui.GetItemRectMax(); + ImGui.PushClipRect(v1, v2, true); + (v1.X, v2.X) = (v2.X, v1.X); + ImGui.GetWindowDrawList().AddLine(v1, v2, ImGui.GetColorU32(new Vector4(1, 0, 0, ImGui.GetStyle().DisabledAlpha / 2)), 5); + ImGui.PopClipRect(); + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + var sim = new Sim(lastState); + ImGui.SetTooltip($"{action.GetName(RecipeData!.ClassJob)}\n{actionBase.GetTooltip(sim, true)}"); + } + lastState = state; + } + } + + var pos = ImGui.GetCursorScreenPos(); + ImGui.Dummy(default); + ImGui.GetWindowDrawList().AddLine(pos, pos + new Vector2(availSpace, 0), ImGui.GetColorU32(ImGuiCol.Border)); + ImGui.Dummy(default); + DrawMacroActions(availSpace); + } + + private void DrawMacroActions(float availWidth) + { + var height = ImGui.GetFrameHeight(); + var spacing = ImGui.GetStyle().ItemSpacing.X; + var width = availWidth - (spacing + height) * (DefaultActions.Length > 0 ? 3 : 2); // small buttons at the end + var halfWidth = (width - spacing) / 2f; + var quarterWidth = (halfWidth - spacing) / 2f; + + using (var _disabled = ImRaii.Disabled(SolverRunning)) + { + if (MacroSetter != null) + { + if (ImGui.Button("Save", new(quarterWidth, height))) + SaveMacro(); + ImGui.SameLine(); + if (ImGui.Button("Save As", new(quarterWidth, height))) + ShowSaveAsPopup(); + } + else + { + if (ImGui.Button("Save", new(halfWidth, height))) + ShowSaveAsPopup(); + } + } + DrawSaveAsPopup(); + ImGui.SameLine(); + if (SolverRunning) + { + if (SolverTokenSource?.IsCancellationRequested ?? false) + { + using var _disabled = ImRaii.Disabled(); + ImGui.Button("Stopping", new(halfWidth, height)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("This might could a while, sorry! Please report\n" + + "if this takes longer than a second."); + } + else + { + if (ImGui.Button("Stop", new(halfWidth, height))) + SolverTokenSource?.Cancel(); + } + } + else + { + if (ImGui.Button(SolverStartStepCount.HasValue ? "Regenerate" : "Generate", new(halfWidth, height))) + CalculateBestMacro(); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Suggest a way to finish the crafting recipe.\n" + + "Results aren't perfect, and levels of success\n" + + "can vary wildly depending on the solver's settings."); + } + ImGui.SameLine(); + if (ImGuiUtils.IconButtonSized(FontAwesomeIcon.Copy, new(height))) + Service.Plugin.CopyMacro(Macro.Select(s => s.Action).ToArray()); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Copy to Clipboard"); + ImGui.SameLine(); + if (DefaultActions.Length > 0) + { + using (var _disabled = ImRaii.Disabled(SolverRunning)) + { + if (ImGuiUtils.IconButtonSized(FontAwesomeIcon.Undo, new(height))) + { + SolverStartStepCount = null; + Macro.Clear(); + foreach (var action in DefaultActions) + AddStep(action); + } + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + ImGui.SetTooltip("Reset"); + } + ImGui.SameLine(); + using (var _disabled = ImRaii.Disabled(SolverRunning)) + { + if (ImGuiUtils.IconButtonSized(FontAwesomeIcon.Trash, new(height))) + { + SolverStartStepCount = null; + Macro.Clear(); + } + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + ImGui.SetTooltip("Clear"); + } + + private string popupMacroName = string.Empty; + private void ShowSaveAsPopup() + { + ImGui.OpenPopup($"##saveAsPopup"); + popupMacroName = string.Empty; + ImGui.SetNextWindowPos(ImGui.GetMousePos() - new Vector2(ImGui.CalcItemWidth() * .25f, ImGui.GetFrameHeight() + ImGui.GetStyle().WindowPadding.Y * 2)); + } + + private void DrawSaveAsPopup() + { + using var popup = ImRaii.Popup($"##saveAsPopup"); + if (popup) + { + if (ImGui.IsWindowAppearing()) + ImGui.SetKeyboardFocusHere(); + ImGui.SetNextItemWidth(ImGui.CalcItemWidth()); + if (ImGui.InputTextWithHint($"##setName", "Name", ref popupMacroName, 100, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.EnterReturnsTrue)) + { + if (!string.IsNullOrWhiteSpace(popupMacroName)) + { + var newMacro = new Macro() { Name = popupMacroName, Actions = Macro.Select(s => s.Action).ToArray() }; + Service.Configuration.AddMacro(newMacro); + MacroSetter = actions => + { + newMacro.ActionEnumerable = actions; + Service.Configuration.Save(); + }; + ImGui.CloseCurrentPopup(); + } + } + } + } + + private void CalculateBestMacro() + { + SolverTokenSource?.Cancel(); + SolverTokenSource = new(); + SolverException = null; + + RevertPreviousMacro(); + SolverStartStepCount = Macro.Count; + + var token = SolverTokenSource.Token; + var state = State; + var task = Task.Run(() => CalculateBestMacroTask(state, token), token); + _ = task.ContinueWith(t => + { + if (token == SolverTokenSource.Token) + SolverTokenSource = null; + }); + _ = task.ContinueWith(t => + { + if (token.IsCancellationRequested) + return; + + try + { + t.Exception!.Flatten().Handle(ex => ex is TaskCanceledException or OperationCanceledException); + } + catch (AggregateException e) + { + SolverException = e; + Log.Error(e, "Calculating macro failed"); + } + }, TaskContinuationOptions.OnlyOnFaulted); + } + + private void CalculateBestMacroTask(SimulationState state, CancellationToken token) + { + var config = Service.Configuration.SimulatorSolverConfig; + + token.ThrowIfCancellationRequested(); + + var solver = new Solver.Solver(config, state) { Token = token }; + solver.OnLog += Log.Debug; + solver.OnNewAction += a => AddStep(a, isMacro: true); + solver.Start(); + _ = solver.GetTask().GetAwaiter().GetResult(); + + token.ThrowIfCancellationRequested(); + } + + private void RevertPreviousMacro() + { + if (SolverStartStepCount is { } stepCount && stepCount < Macro.Count) + Macro.RemoveRange(stepCount, Macro.Count - stepCount); + } + + private void SaveMacro() + { + MacroSetter?.Invoke(Macro.Select(s => s.Action)); + } + + private void RecalculateState() + { + InitialState = new SimulationState(new(CharacterStats, RecipeData.RecipeInfo, StartingQuality)); + var sim = new Sim(InitialState); + var lastState = InitialState; + foreach (var step in Macro) + lastState = ((step.Response, step.State) = sim.Execute(lastState, step.Action)).State; + } + + private void AddStep(ActionType action, int index = -1, bool isMacro = false) + { + if (index < -1 || index >= Macro.Count) + throw new ArgumentOutOfRangeException(nameof(index)); + if (!isMacro && SolverRunning) + throw new InvalidOperationException("Cannot add steps while solver is running"); + if (!SolverRunning) + SolverStartStepCount = null; + + if (index == -1) + { + var sim = new Sim(State); + var resp = sim.Execute(State, action); + Macro.Add(new() { Action = action, Response = resp.Response, State = resp.NewState }); + } + else { + var state = index == 0 ? InitialState : Macro[index - 1].State; + var sim = new Sim(state); + var resp = sim.Execute(state, action); + Macro.Insert(index, new() { Action = action, Response = resp.Response, State = resp.NewState }); + state = resp.NewState; + for(var i = index + 1; i < Macro.Count; i++) + state = ((Macro[i].Response, Macro[i].State) = sim.Execute(state, Macro[i].Action)).State; + } + } + + private void RemoveStep(int index) + { + if (index < 0 || index >= Macro.Count) + throw new ArgumentOutOfRangeException(nameof(index)); + if (SolverRunning) + throw new InvalidOperationException("Cannot remove steps while solver is running"); + SolverStartStepCount = null; + + Macro.RemoveAt(index); + + var state = index == 0 ? InitialState : Macro[index - 1].State; + var sim = new Sim(state); + for (var i = index; i < Macro.Count; i++) + state = ((Macro[i].Response, Macro[i].State) = sim.Execute(state, Macro[i].Action)).State; + } + + public void Dispose() + { + Service.WindowSystem.RemoveWindow(this); + + AxisFont.Dispose(); + } +} diff --git a/Craftimizer/Windows/MacroList.cs b/Craftimizer/Windows/MacroList.cs new file mode 100644 index 0000000..101e169 --- /dev/null +++ b/Craftimizer/Windows/MacroList.cs @@ -0,0 +1,361 @@ +using Craftimizer.Plugin; +using Craftimizer.Utils; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface; +using Dalamud.Interface.Windowing; +using ImGuiNET; +using System; +using Craftimizer.Simulator; +using Craftimizer.Simulator.Actions; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Sim = Craftimizer.Simulator.SimulatorNoRandom; +using Dalamud.Interface.Utility; + +namespace Craftimizer.Windows; + +public sealed class MacroList : Window, IDisposable +{ + private const ImGuiWindowFlags WindowFlags = ImGuiWindowFlags.None; + + public CharacterStats? CharacterStats { get; private set; } + public RecipeData? RecipeData { get; private set; } + private MacroEditor? EditorWindow { get; set; } + + private IReadOnlyList Macros => Service.Configuration.Macros; + private Dictionary MacroStateCache { get; } = new(); + + public MacroList() : base("Craftimizer Macro List", WindowFlags, false) + { + RefreshSearch(); + + Macro.OnMacroChanged += OnMacroChanged; + Configuration.OnMacroListChanged += OnMacroListChanged; + + CollapsedCondition = ImGuiCond.Appearing; + Collapsed = false; + + SizeConstraints = new() { MinimumSize = new(500, 520), MaximumSize = new(float.PositiveInfinity) }; + + Service.WindowSystem.AddWindow(this); + } + + public override bool DrawConditions() + { + return Service.ClientState.LocalPlayer != null; + } + + public override void PreDraw() + { + var oldCharacterStats = CharacterStats; + var oldRecipeData = RecipeData; + + EditorWindow = Service.Plugin.EditorWindow; + EditorWindow = (EditorWindow?.IsOpen ?? false) ? EditorWindow : null; + RecipeData = EditorWindow?.RecipeData ?? Service.Plugin.RecipeNoteWindow.RecipeData; + CharacterStats = EditorWindow?.CharacterStats ?? Service.Plugin.RecipeNoteWindow.CharacterStats; + + if (oldCharacterStats != CharacterStats || oldRecipeData != RecipeData) + RecalculateStats(); + } + + public override void Draw() + { + DrawSearchBar(); + using var group = ImRaii.Child("macros", new(-1, -1)); + if (sortedMacros.Count > 0) + { + var macros = new List(sortedMacros); + foreach (var macro in macros) + DrawMacro(macro); + } + else + { + var text1 = "You have no macros! Create one by opening"; + var text2 = "the Macro Editor here or from the Crafting Log."; + var text3 = "Open Crafting Log"; + var text4 = "Open Macro Editor"; + var buttonRowWidth = ImGui.CalcTextSize(text3).X + ImGui.CalcTextSize(text4).X + ImGui.GetStyle().ItemSpacing.X * 5; + var size = new Vector2( + Math.Max( + Math.Max(ImGui.CalcTextSize(text1).X, ImGui.CalcTextSize(text2).X), + buttonRowWidth + ), + ImGui.GetTextLineHeightWithSpacing() * 2 + ImGui.GetFrameHeight() + ); + ImGuiUtils.AlignMiddle(size); + using var child = ImRaii.Child("##macroMessage", size); + ImGuiUtils.TextCentered(text1); + ImGuiUtils.TextCentered(text2); + ImGuiUtils.AlignCentered(buttonRowWidth); + if (ImGui.Button(text3)) + Service.Plugin.OpenCraftingLog(); + ImGui.SameLine(); + if (ImGui.Button(text4)) + OpenEditor(null); + } + } + + private string searchText = string.Empty; + private List sortedMacros = null!; + private void DrawSearchBar() + { + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + if (ImGui.InputTextWithHint("##search", "Search", ref searchText, 100)) + RefreshSearch(); + } + + private void DrawMacro(Macro macro) + { + var windowHeight = 2 * ImGui.GetFrameHeightWithSpacing(); + + if (macro.Actions.Any(a => a.Category() == ActionCategory.Combo)) + throw new InvalidOperationException("Combo actions should be sanitized away"); + + var stateNullable = GetMacroState(macro); + + using var panel = ImGuiUtils.GroupPanel(macro.Name, -1, out var availWidth); + var stepsAvailWidthOffset = ImGui.GetContentRegionAvail().X - availWidth; + var spacing = ImGui.GetStyle().ItemSpacing.Y; + var miniRowHeight = (windowHeight - spacing) / 2f; + + using var table = ImRaii.Table("table", stateNullable.HasValue ? 3 : 2, ImGuiTableFlags.BordersInnerV); + if (table) + { + if (stateNullable.HasValue) + ImGui.TableSetupColumn("stats", ImGuiTableColumnFlags.WidthFixed, 0); + ImGui.TableSetupColumn("actions", ImGuiTableColumnFlags.WidthFixed, 0); + ImGui.TableSetupColumn("steps", ImGuiTableColumnFlags.WidthStretch, 0); + + ImGui.TableNextRow(ImGuiTableRowFlags.None, windowHeight); + if (stateNullable is { } state) + { + ImGui.TableNextColumn(); + if (Service.Configuration.ShowOptimalMacroStat) + { + var progressHeight = windowHeight; + if (state.Progress >= state.Input.Recipe.MaxProgress && state.Input.Recipe.MaxQuality > 0) + { + ImGuiUtils.ArcProgress( + (float)state.Quality / state.Input.Recipe.MaxQuality, + progressHeight / 2f, + .5f, + ImGui.GetColorU32(ImGuiCol.TableBorderLight), + ImGui.GetColorU32(Colors.Quality)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"Quality: {state.Quality} / {state.Input.Recipe.MaxQuality}"); + } + else + { + ImGuiUtils.ArcProgress( + (float)state.Progress / state.Input.Recipe.MaxProgress, + progressHeight / 2f, + .5f, + ImGui.GetColorU32(ImGuiCol.TableBorderLight), + ImGui.GetColorU32(Colors.Progress)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"Progress: {state.Progress} / {state.Input.Recipe.MaxProgress}"); + } + } + else + { + ImGuiUtils.ArcProgress( + (float)state.Progress / state.Input.Recipe.MaxProgress, + miniRowHeight / 2f, + .5f, + ImGui.GetColorU32(ImGuiCol.TableBorderLight), + ImGui.GetColorU32(Colors.Progress)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"Progress: {state.Progress} / {state.Input.Recipe.MaxProgress}"); + + ImGui.SameLine(0, spacing); + ImGuiUtils.ArcProgress( + (float)state.Quality / state.Input.Recipe.MaxQuality, + miniRowHeight / 2f, + .5f, + ImGui.GetColorU32(ImGuiCol.TableBorderLight), + ImGui.GetColorU32(Colors.Quality)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"Quality: {state.Quality} / {state.Input.Recipe.MaxQuality}"); + + ImGuiUtils.ArcProgress((float)state.Durability / state.Input.Recipe.MaxDurability, + miniRowHeight / 2f, + .5f, + ImGui.GetColorU32(ImGuiCol.TableBorderLight), + ImGui.GetColorU32(Colors.Durability)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"Remaining Durability: {state.Durability} / {state.Input.Recipe.MaxDurability}"); + + ImGui.SameLine(0, spacing); + ImGuiUtils.ArcProgress( + (float)state.CP / state.Input.Stats.CP, + miniRowHeight / 2f, + .5f, + ImGui.GetColorU32(ImGuiCol.TableBorderLight), + ImGui.GetColorU32(Colors.CP)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"Remaining CP: {state.CP} / {state.Input.Stats.CP}"); + } + } + + ImGui.TableNextColumn(); + { + if (ImGuiUtils.IconButtonSized(FontAwesomeIcon.Copy, new(miniRowHeight))) + Service.Plugin.CopyMacro(macro.Actions); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Copy to Clipboard"); + ImGui.SameLine(); + if (ImGuiUtils.IconButtonSized(FontAwesomeIcon.Trash, new(miniRowHeight)) && ImGui.GetIO().KeyShift) + Service.Configuration.RemoveMacro(macro); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Delete (Hold Shift)"); + + if (ImGuiUtils.IconButtonSized(FontAwesomeIcon.PencilAlt, new(miniRowHeight))) + ShowRenamePopup(macro); + DrawRenamePopup(macro); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Rename"); + ImGui.SameLine(); + if (ImGuiUtils.IconButtonSized(FontAwesomeIcon.Edit, new(miniRowHeight))) + OpenEditor(macro); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Open in Simulator"); + } + + ImGui.TableNextColumn(); + { + var itemsPerRow = (int)MathF.Floor((ImGui.GetContentRegionAvail().X - stepsAvailWidthOffset + spacing) / (miniRowHeight + spacing)); + var itemCount = macro.Actions.Count; + for (var i = 0; i < itemsPerRow * 2; i++) + { + if (i % itemsPerRow != 0) + ImGui.SameLine(0, spacing); + if (i < itemCount) + { + var shouldShowMore = i + 1 == itemsPerRow * 2 && i + 1 < itemCount; + if (!shouldShowMore) + { + ImGui.Image(macro.Actions[i].GetIcon(RecipeData!.ClassJob).ImGuiHandle, new(miniRowHeight)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(macro.Actions[i].GetName(RecipeData!.ClassJob)); + } + else + { + var amtMore = itemCount - itemsPerRow * 2; + var pos = ImGui.GetCursorPos(); + ImGui.Image(macro.Actions[i].GetIcon(RecipeData!.ClassJob).ImGuiHandle, new(miniRowHeight), default, Vector2.One, new(1, 1, 1, .5f)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"{macro.Actions[i].GetName(RecipeData!.ClassJob)}\nand {amtMore} more"); + ImGui.SetCursorPos(pos); + ImGui.GetWindowDrawList().AddRectFilled(ImGui.GetCursorScreenPos(), ImGui.GetCursorScreenPos() + new Vector2(miniRowHeight), ImGui.GetColorU32(ImGuiCol.FrameBg), miniRowHeight / 8f); + ImGui.GetWindowDrawList().AddTextClippedEx(ImGui.GetCursorScreenPos(), ImGui.GetCursorScreenPos() + new Vector2(miniRowHeight), $"+{amtMore}", null, new(.5f), null); + } + } + else + ImGui.Dummy(new(miniRowHeight)); + } + } + } + } + + private string popupMacroName = string.Empty; + private Macro? popupMacro; + private void ShowRenamePopup(Macro macro) + { + ImGui.OpenPopup($"##renamePopup-{macro.GetHashCode()}"); + popupMacro = macro; + popupMacroName = macro.Name; + ImGui.SetNextWindowPos(ImGui.GetMousePos() - new Vector2(ImGui.CalcItemWidth() * .25f, ImGui.GetFrameHeight() + ImGui.GetStyle().WindowPadding.Y * 2)); + } + + private void DrawRenamePopup(Macro macro) + { + using var popup = ImRaii.Popup($"##renamePopup-{macro.GetHashCode()}"); + if (popup) + { + if (ImGui.IsWindowAppearing()) + ImGui.SetKeyboardFocusHere(); + ImGui.SetNextItemWidth(ImGui.CalcItemWidth()); + if (ImGui.InputTextWithHint($"##setName", "Name", ref popupMacroName, 100, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.EnterReturnsTrue)) + { + if (!string.IsNullOrWhiteSpace(popupMacroName)) + { + popupMacro!.Name = popupMacroName; + ImGui.CloseCurrentPopup(); + } + } + } + } + + private void RecalculateStats() + { + MacroStateCache.Clear(); + } + + private void RefreshSearch() + { + if (string.IsNullOrWhiteSpace(searchText)) + { + sortedMacros = new(Macros); + return; + } + var matcher = new FuzzyMatcher(searchText.ToLowerInvariant(), MatchMode.FuzzyParts); + var query = Macros.AsParallel().Select(i => (Item: i, Score: matcher.Matches(i.Name.ToLowerInvariant()))) + .Where(t => t.Score > 0) + .OrderByDescending(t => t.Score) + .Select(t => t.Item); + sortedMacros = query.ToList(); + } + + private void OpenEditor(Macro? macro) + { + var character = CharacterStats ?? new() + { + Craftsmanship = 100, + Control = 100, + CP = 200, + Level = 10, + CanUseManipulation = false, + HasSplendorousBuff = false, + IsSpecialist = false, + CLvl = 10, + }; + var recipe = RecipeData ?? new(1023); + + var buffs = EditorWindow?.Buffs ?? new(Service.Plugin.RecipeNoteWindow.CharacterStats != null ? Service.ClientState.LocalPlayer?.StatusList : null); + Service.Plugin.OpenMacroEditor(character, recipe, buffs, macro?.Actions ?? Enumerable.Empty(), macro != null ? (actions => { macro.ActionEnumerable = actions; Service.Configuration.Save(); }) : null); + } + + private void OnMacroChanged(Macro macro) + { + MacroStateCache.Remove(macro); + } + + private void OnMacroListChanged() + { + RefreshSearch(); + } + + private SimulationState? GetMacroState(Macro macro) + { + if (CharacterStats == null || RecipeData == null) + return null; + + if (MacroStateCache.TryGetValue(macro, out var state)) + return state; + + state = new SimulationState(new(CharacterStats, RecipeData.RecipeInfo)); + var sim = new Sim(state); + (_, state, _) = sim.ExecuteMultiple(state, macro.Actions); + return MacroStateCache[macro] = state; + } + + public void Dispose() + { + Macro.OnMacroChanged -= OnMacroChanged; + Configuration.OnMacroListChanged -= OnMacroListChanged; + + Service.WindowSystem.RemoveWindow(this); + } +} diff --git a/Craftimizer/Windows/RecipeNote.cs b/Craftimizer/Windows/RecipeNote.cs index 185c489..cbc2855 100644 --- a/Craftimizer/Windows/RecipeNote.cs +++ b/Craftimizer/Windows/RecipeNote.cs @@ -4,12 +4,14 @@ using Craftimizer.Simulator; using Craftimizer.Simulator.Actions; using Craftimizer.Solver; using Craftimizer.Utils; +using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; using Dalamud.Interface.GameFonts; using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Utility; @@ -59,6 +61,7 @@ public sealed unsafe class RecipeNote : Window, IDisposable private CancellationTokenSource? BestMacroTokenSource { get; set; } private Exception? BestMacroException { get; set; } public (Macro, SimulationState)? BestSavedMacro { get; private set; } + public bool HasSavedMacro { get; private set; } public SolverSolution? BestSuggestedMacro { get; private set; } private IDalamudTextureWrap ExpertBadge { get; } @@ -82,7 +85,21 @@ public sealed unsafe class RecipeNote : Window, IDisposable IsOpen = true; } + private bool wasOpen; public override bool DrawConditions() + { + var isOpen = ShouldDraw(); + if (isOpen != wasOpen) + { + if (wasOpen) + BestMacroTokenSource?.Cancel(); + } + + wasOpen = isOpen; + return isOpen; + } + + private bool ShouldDraw() { if (Service.ClientState.LocalPlayer == null) return false; @@ -146,7 +163,7 @@ public sealed unsafe class RecipeNote : Window, IDisposable statsChanged = true; } - if (statsChanged && CraftStatus == CraftableStatus.OK) + if ((statsChanged || (BestMacroTokenSource?.IsCancellationRequested ?? false)) && CraftStatus == CraftableStatus.OK) CalculateBestMacros(); return true; @@ -172,16 +189,22 @@ public sealed unsafe class RecipeNote : Window, IDisposable public override void Draw() { + var availWidth = ImGui.GetContentRegionAvail().X; + using (var table = ImRaii.Table("stats", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingFixedSame)) { - using var table = ImRaii.Table("stats", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchSame); if (table) { - ImGui.TableSetupColumn("col1", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("col2", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("col1", ImGuiTableColumnFlags.WidthFixed, 0); + ImGui.TableSetupColumn("col2", ImGuiTableColumnFlags.WidthFixed, 0); ImGui.TableNextColumn(); DrawCharacterStats(); ImGui.TableNextColumn(); DrawRecipeStats(); + + // Ensure that we know the window should be the same size as this table. Any more and it'll grow slowly and won't shrink when it could + ImGui.SameLine(0, 0); + // The -1 is to account for the extra vertical separator on the right that ImGui draws for some reason + availWidth = ImGui.GetCursorPosX() - ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().CellPadding.X - 1; } } @@ -190,35 +213,44 @@ public sealed unsafe class RecipeNote : Window, IDisposable ImGui.Separator(); + using (var table = ImRaii.Table("macros", 1, ImGuiTableFlags.SizingStretchSame)) { - using var table = ImRaii.Table("macros", 1, ImGuiTableFlags.SizingStretchSame); if (table) { ImGui.TableNextColumn(); - ImGui.AlignTextToFramePadding(); - ImGuiUtils.TextCentered("Best Saved Macro"); - if (BestSavedMacro is { } savedMacro) + availWidth -= ImGui.GetStyle().ItemSpacing.X * 2; + using (var panel = ImGuiUtils.GroupPanel("Best Saved Macro", availWidth, out _)) { - ImGuiUtils.TextCentered(savedMacro.Item1.Name); - DrawMacro("savedMacro", (savedMacro.Item1.Actions, savedMacro.Item2)); + var stepsAvailWidthOffset = ImGui.GetContentRegionAvail().X - availWidth; + if (BestSavedMacro is { } savedMacro) + { + ImGuiUtils.TextCentered(savedMacro.Item1.Name, availWidth); + DrawMacro((savedMacro.Item1.Actions, savedMacro.Item2), a => { savedMacro.Item1.ActionEnumerable = a; Service.Configuration.Save(); }, stepsAvailWidthOffset, true); + } + else + { + ImGui.Text(""); + DrawMacro(null, null, stepsAvailWidthOffset, true); + } } - else + + using (var panel = ImGuiUtils.GroupPanel("Suggested Macro", availWidth, out _)) { - ImGui.Text(""); - DrawMacro("savedMacro", null); + var stepsAvailWidthOffset = ImGui.GetContentRegionAvail().X - availWidth; + if (BestSuggestedMacro is { } suggestedMacro) + DrawMacro((suggestedMacro.Actions, suggestedMacro.State), null, stepsAvailWidthOffset, false); + else + DrawMacro(null, null, stepsAvailWidthOffset, false); } - ImGui.Button("View Saved Macros", new(-1, 0)); - ImGui.Separator(); + ImGuiHelpers.ScaledDummy(5); - ImGui.AlignTextToFramePadding(); - ImGuiUtils.TextCentered("Suggested Macro"); - if (BestSuggestedMacro is { } suggestedMacro) - DrawMacro("suggestedMacro", (suggestedMacro.Actions, suggestedMacro.State)); - else - DrawMacro("suggestedMacro", null); - ImGui.Button("Open Simulator", new(-1, 0)); + if (ImGui.Button("View Saved Macros", new(-1, 0))) + Service.Plugin.OpenMacroListWindow(); + + if (ImGui.Button("Open in Simulator", new(-1, 0))) + Service.Plugin.OpenMacroEditor(CharacterStats!, RecipeData!, new(Service.ClientState.LocalPlayer!.StatusList), Enumerable.Empty(), null); } } } @@ -237,7 +269,7 @@ public sealed unsafe class RecipeNote : Window, IDisposable } var levelText = string.Empty; if (level != 0) - levelText = SqText.ToLevelString(level); + levelText = SqText.LevelPrefix.ToIconChar() + SqText.ToLevelString(level); var imageSize = ImGui.GetFrameHeight(); bool hasSplendorous = false, hasSpecialist = false, shouldHaveManip = false; if (CraftStatus is not (CraftableStatus.LockedClassJob or CraftableStatus.WrongClassJob)) @@ -338,6 +370,7 @@ public sealed unsafe class RecipeNote : Window, IDisposable } else ImGuiUtils.TextCentered($"You do not have any {RecipeData.ClassJob.GetName().ToLowerInvariant()} gearsets."); + ImGui.Dummy(default); } break; case CraftableStatus.SpecialistRequired: @@ -439,9 +472,9 @@ public sealed unsafe class RecipeNote : Window, IDisposable var textStarsSize = Vector2.Zero; if (!string.IsNullOrEmpty(textStars)) { var layout = AxisFont.LayoutBuilder(textStars).Build(); - textStarsSize = new(layout.Width + 3, layout.Height); + textStarsSize = new(layout.Width, layout.Height); } - var textLevel = SqText.ToLevelString(RecipeData.RecipeInfo.ClassJobLevel); + var textLevel = SqText.LevelPrefix.ToIconChar() + SqText.ToLevelString(RecipeData.RecipeInfo.ClassJobLevel); var isExpert = RecipeData.RecipeInfo.IsExpert; var isCollectable = RecipeData.Recipe.ItemResult.Value!.IsCollectable; var imageSize = ImGui.GetFrameHeight(); @@ -452,7 +485,7 @@ public sealed unsafe class RecipeNote : Window, IDisposable ImGuiUtils.AlignCentered( imageSize + 5 + ImGui.CalcTextSize(textLevel).X + - textStarsSize.X + + (textStarsSize != Vector2.Zero ? textStarsSize.X + 3 : 0) + (isCollectable ? badgeSize.X + 3 : 0) + (isExpert ? badgeSize.X + 3 : 0) ); @@ -516,16 +549,16 @@ public sealed unsafe class RecipeNote : Window, IDisposable } } - private void DrawMacro(string imGuiId, (List Actions, SimulationState State)? macroValue) + private void DrawMacro((IReadOnlyList Actions, SimulationState State)? macroValue, Action>? setter, float stepsAvailWidthOffset, bool isSavedMacro) { - //using var window = ImRaii.Child(imGuiId, new(-1, (name != null ? ImGui.GetTextLineHeightWithSpacing() : 0) + 2 * ImGui.GetFrameHeightWithSpacing()), false, ImGuiWindowFlags.AlwaysAutoResize); - var windowHeight = 2 * ImGui.GetFrameHeightWithSpacing(); - if (macroValue == null) + if (macroValue is not { } macro) { - if (BestMacroException == null) - ImGuiUtils.TextMiddleNewLine("Calculating...", new(ImGui.GetContentRegionAvail().X, windowHeight + 1 + ImGui.GetStyle().ItemSpacing.Y)); + if (isSavedMacro && !HasSavedMacro) + ImGuiUtils.TextMiddleNewLine("You have no macros!", new(ImGui.GetContentRegionAvail().X - stepsAvailWidthOffset, windowHeight + 1)); + else if (BestMacroException == null) + ImGuiUtils.TextMiddleNewLine("Calculating...", new(ImGui.GetContentRegionAvail().X - stepsAvailWidthOffset, windowHeight + 1)); else { ImGui.AlignTextToFramePadding(); @@ -536,8 +569,8 @@ public sealed unsafe class RecipeNote : Window, IDisposable } return; } - - var macro = macroValue!.Value; + if (macro.Actions.Any(a => a.Category() == ActionCategory.Combo)) + throw new InvalidOperationException("Combo actions should be sanitized away"); using var table = ImRaii.Table("table", 3, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchSame); if (table) @@ -552,7 +585,6 @@ public sealed unsafe class RecipeNote : Window, IDisposable var spacing = ImGui.GetStyle().ItemSpacing.Y; var miniRowHeight = (windowHeight - spacing) / 2f; - //ImGui.Text($"{macro.Actions.Count}"); { if (Service.Configuration.ShowOptimalMacroStat) { @@ -564,7 +596,7 @@ public sealed unsafe class RecipeNote : Window, IDisposable progressHeight / 2f, .5f, ImGui.GetColorU32(ImGuiCol.TableBorderLight), - ImGui.GetColorU32(Plugin.Windows.Simulator.QualityColor)); + ImGui.GetColorU32(Colors.Quality)); if (ImGui.IsItemHovered()) ImGui.SetTooltip($"Quality: {macro.State.Quality} / {macro.State.Input.Recipe.MaxQuality}"); } @@ -575,7 +607,7 @@ public sealed unsafe class RecipeNote : Window, IDisposable progressHeight / 2f, .5f, ImGui.GetColorU32(ImGuiCol.TableBorderLight), - ImGui.GetColorU32(Plugin.Windows.Simulator.ProgressColor)); + ImGui.GetColorU32(Colors.Progress)); if (ImGui.IsItemHovered()) ImGui.SetTooltip($"Progress: {macro.State.Progress} / {macro.State.Input.Recipe.MaxProgress}"); } @@ -587,7 +619,7 @@ public sealed unsafe class RecipeNote : Window, IDisposable miniRowHeight / 2f, .5f, ImGui.GetColorU32(ImGuiCol.TableBorderLight), - ImGui.GetColorU32(Plugin.Windows.Simulator.ProgressColor)); + ImGui.GetColorU32(Colors.Progress)); if (ImGui.IsItemHovered()) ImGui.SetTooltip($"Progress: {macro.State.Progress} / {macro.State.Input.Recipe.MaxProgress}"); @@ -597,7 +629,7 @@ public sealed unsafe class RecipeNote : Window, IDisposable miniRowHeight / 2f, .5f, ImGui.GetColorU32(ImGuiCol.TableBorderLight), - ImGui.GetColorU32(Plugin.Windows.Simulator.QualityColor)); + ImGui.GetColorU32(Colors.Quality)); if (ImGui.IsItemHovered()) ImGui.SetTooltip($"Quality: {macro.State.Quality} / {macro.State.Input.Recipe.MaxQuality}"); @@ -605,7 +637,7 @@ public sealed unsafe class RecipeNote : Window, IDisposable miniRowHeight / 2f, .5f, ImGui.GetColorU32(ImGuiCol.TableBorderLight), - ImGui.GetColorU32(Plugin.Windows.Simulator.DurabilityColor)); + ImGui.GetColorU32(Colors.Durability)); if (ImGui.IsItemHovered()) ImGui.SetTooltip($"Remaining Durability: {macro.State.Durability} / {macro.State.Input.Recipe.MaxDurability}"); @@ -615,7 +647,7 @@ public sealed unsafe class RecipeNote : Window, IDisposable miniRowHeight / 2f, .5f, ImGui.GetColorU32(ImGuiCol.TableBorderLight), - ImGui.GetColorU32(Plugin.Windows.Simulator.CPColor)); + ImGui.GetColorU32(Colors.CP)); if (ImGui.IsItemHovered()) ImGui.SetTooltip($"Remaining CP: {macro.State.CP} / {macro.State.Input.Stats.CP}"); } @@ -623,31 +655,44 @@ public sealed unsafe class RecipeNote : Window, IDisposable ImGui.TableNextColumn(); { - ImGuiUtils.TextMiddleNewLine($"{macro.Actions.Count}", new(miniRowHeight)); + if (ImGuiUtils.IconButtonSized(FontAwesomeIcon.Edit, new(miniRowHeight))) + Service.Plugin.OpenMacroEditor(CharacterStats!, RecipeData!, new(Service.ClientState.LocalPlayer!.StatusList), macro.Actions, setter); if (ImGui.IsItemHovered()) - ImGui.SetTooltip($"{macro.Actions.Count} Step{(macro.Actions.Count != 1 ? "s" : "")}"); - using (var iconFont = ImRaii.PushFont(UiBuilder.IconFont)) - if (ImGuiUtils.ButtonCentered(FontAwesomeIcon.Copy.ToIconString(), new(miniRowHeight))) - { - throw new NotImplementedException(); - } + ImGui.SetTooltip("Open in Simulator"); + if (ImGuiUtils.IconButtonSized(FontAwesomeIcon.Copy, new(miniRowHeight))) + Service.Plugin.CopyMacro(macro.Actions); if (ImGui.IsItemHovered()) ImGui.SetTooltip("Copy to Clipboard"); } ImGui.TableNextColumn(); { - var itemsPerRow = (int)MathF.Ceiling((ImGui.GetContentRegionAvail().X + spacing) / (miniRowHeight + spacing)); - var itemCount = Math.Min(macro.Actions.Count, itemsPerRow * 2); + var itemsPerRow = (int)MathF.Floor((ImGui.GetContentRegionAvail().X - stepsAvailWidthOffset + spacing) / (miniRowHeight + spacing)); + var itemCount = macro.Actions.Count; for (var i = 0; i < itemsPerRow * 2; i++) { if (i % itemsPerRow != 0) ImGui.SameLine(0, spacing); if (i < itemCount) { - ImGui.Image(macro.Actions[i].GetIcon(RecipeData!.ClassJob).ImGuiHandle, new(miniRowHeight)); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip(macro.Actions[i].GetName(RecipeData!.ClassJob)); + var shouldShowMore = i + 1 == itemsPerRow * 2 && i + 1 < itemCount; + if (!shouldShowMore) + { + ImGui.Image(macro.Actions[i].GetIcon(RecipeData!.ClassJob).ImGuiHandle, new(miniRowHeight)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(macro.Actions[i].GetName(RecipeData!.ClassJob)); + } + else + { + var amtMore = itemCount - itemsPerRow * 2; + var pos = ImGui.GetCursorPos(); + ImGui.Image(macro.Actions[i].GetIcon(RecipeData!.ClassJob).ImGuiHandle, new(miniRowHeight), default, Vector2.One, new(1, 1, 1, .5f)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"{macro.Actions[i].GetName(RecipeData!.ClassJob)}\nand {amtMore} more"); + ImGui.SetCursorPos(pos); + ImGui.GetWindowDrawList().AddRectFilled(ImGui.GetCursorScreenPos(), ImGui.GetCursorScreenPos() + new Vector2(miniRowHeight), ImGui.GetColorU32(ImGuiCol.FrameBg), miniRowHeight / 8f); + ImGui.GetWindowDrawList().AddTextClippedEx(ImGui.GetCursorScreenPos(), ImGui.GetCursorScreenPos() + new Vector2(miniRowHeight), $"+{amtMore}", null, new(.5f), null); + } } else ImGui.Dummy(new(miniRowHeight)); @@ -758,25 +803,31 @@ public sealed unsafe class RecipeNote : Window, IDisposable BestMacroTokenSource = new(); BestMacroException = null; BestSavedMacro = null; + HasSavedMacro = false; BestSuggestedMacro = null; var token = BestMacroTokenSource.Token; - _ = Task.Run(() => CalculateBestMacrosTask(token), token) - .ContinueWith(t => - { - if (token.IsCancellationRequested) - return; + var task = Task.Run(() => CalculateBestMacrosTask(token), token); + _ = task.ContinueWith(t => + { + if (token == BestMacroTokenSource.Token) + BestMacroTokenSource = null; + }); + _ = task.ContinueWith(t => + { + if (token.IsCancellationRequested) + return; - try - { - t.Exception!.Flatten().Handle(ex => ex is TaskCanceledException or OperationCanceledException); - } - catch (AggregateException e) - { - BestMacroException = e; - Log.Error(e, "Calculating macros failed"); - } - }, TaskContinuationOptions.OnlyOnFaulted); + try + { + t.Exception!.Flatten().Handle(ex => ex is TaskCanceledException or OperationCanceledException); + } + catch (AggregateException e) + { + BestMacroException = e; + Log.Error(e, "Calculating macros failed"); + } + }, TaskContinuationOptions.OnlyOnFaulted); } private void CalculateBestMacrosTask(CancellationToken token) @@ -790,26 +841,30 @@ public sealed unsafe class RecipeNote : Window, IDisposable token.ThrowIfCancellationRequested(); - var bestSaved = macros - .Select(macro => - { - var (resp, outState, failedIdx) = simulator.ExecuteMultiple(state, macro.Actions); - outState.ActionCount = macro.Actions.Count; - var score = SimulationNode.CalculateScoreForState(outState, simulator.CompletionState, mctsConfig) ?? 0; - if (resp != ActionResponse.SimulationComplete) + HasSavedMacro = macros.Count > 0; + if (HasSavedMacro) + { + var bestSaved = macros + .Select(macro => { - if (failedIdx != -1) - score /= 2; - } - return (macro, outState, score); - }) - .MaxBy(m => m.score); + var (resp, outState, failedIdx) = simulator.ExecuteMultiple(state, macro.Actions); + outState.ActionCount = macro.Actions.Count; + var score = SimulationNode.CalculateScoreForState(outState, simulator.CompletionState, mctsConfig) ?? 0; + if (resp != ActionResponse.SimulationComplete) + { + if (failedIdx != -1) + score /= 2; + } + return (macro, outState, score); + }) + .MaxBy(m => m.score); - token.ThrowIfCancellationRequested(); + token.ThrowIfCancellationRequested(); - BestSavedMacro = (bestSaved.macro, bestSaved.outState); + BestSavedMacro = (bestSaved.macro, bestSaved.outState); - token.ThrowIfCancellationRequested(); + token.ThrowIfCancellationRequested(); + } var solver = new Solver.Solver(config, state) { Token = token }; solver.OnLog += Log.Debug; @@ -819,10 +874,13 @@ public sealed unsafe class RecipeNote : Window, IDisposable token.ThrowIfCancellationRequested(); BestSuggestedMacro = solution; + + token.ThrowIfCancellationRequested(); } public void Dispose() { + Service.WindowSystem.RemoveWindow(this); AxisFont?.Dispose(); } } diff --git a/Craftimizer/Windows/Settings.cs b/Craftimizer/Windows/Settings.cs index a41261e..1f341b2 100644 --- a/Craftimizer/Windows/Settings.cs +++ b/Craftimizer/Windows/Settings.cs @@ -1,38 +1,34 @@ using Craftimizer.Solver; -using Dalamud.Interface; using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; +using FFXIVClientStructs.FFXIV.Client.UI; using ImGuiNET; using System; using System.Numerics; namespace Craftimizer.Plugin.Windows; -public class Settings : Window +public sealed class Settings : Window, IDisposable { + private const ImGuiWindowFlags WindowFlags = ImGuiWindowFlags.None; + private static Configuration Config => Service.Configuration; private const int OptionWidth = 200; private static Vector2 OptionButtonSize => new(OptionWidth, ImGui.GetFrameHeight()); - public const string TabGeneral = "General"; - public const string TabSimulator = "Simulator"; - public const string TabSynthHelper = "Synthesis Helper"; - public const string TabAbout = "About"; - private string? SelectedTab { get; set; } - public Settings() : base("Craftimizer Settings") + public Settings() : base("Craftimizer Settings", WindowFlags) { Service.WindowSystem.AddWindow(this); SizeConstraints = new WindowSizeConstraints() { - MinimumSize = new Vector2(400, 400), - MaximumSize = new Vector2(float.MaxValue, float.MaxValue) + MinimumSize = new(450, 400), + MaximumSize = new(float.PositiveInfinity) }; - Size = SizeConstraints.Value.MinimumSize; - SizeCondition = ImGuiCond.Appearing; } public void SelectTab(string label) @@ -40,16 +36,16 @@ public class Settings : Window SelectedTab = label; } - private bool BeginTabItem(string label) + private ImRaii.IEndObject TabItem(string label) { var isSelected = string.Equals(SelectedTab, label, StringComparison.Ordinal); if (isSelected) { SelectedTab = null; var open = true; - return ImGui.BeginTabItem(label, ref open, ImGuiTabItemFlags.SetSelected); + return ImRaii.TabItem(label, ref open, ImGuiTabItemFlags.SetSelected); } - return ImGui.BeginTabItem(label); + return ImRaii.TabItem(label); } private static void DrawOption(string label, string tooltip, bool val, Action setter, ref bool isDirty) @@ -63,25 +59,44 @@ public class Settings : Window ImGui.SetTooltip(tooltip); } - private static void DrawOption(string label, string tooltip, int val, Action setter, ref bool isDirty) + private static void DrawOption(string label, string tooltip, T value, T min, T max, Action setter, ref bool isDirty) where T : struct, INumber { ImGui.SetNextItemWidth(OptionWidth); - if (ImGui.InputInt(label, ref val)) + var text = value.ToString(); + if (ImGui.InputText(label, ref text, 8, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.CharsDecimal)) { - setter(val); - isDirty = true; + if (T.TryParse(text, null, out var newValue)) + { + newValue = T.Clamp(newValue, min, max); + if (value != newValue) + { + setter(newValue); + isDirty = true; + } + } } if (ImGui.IsItemHovered()) ImGui.SetTooltip(tooltip); } - private static void DrawOption(string label, string tooltip, float val, Action setter, ref bool isDirty) + private static void DrawOption(string label, string tooltip, Func getName, Func getTooltip, T value, Action setter, ref bool isDirty) where T : struct, Enum { ImGui.SetNextItemWidth(OptionWidth); - if (ImGui.InputFloat(label, ref val)) + using (var combo = ImRaii.Combo(label, getName(value))) { - setter(val); - isDirty = true; + if (combo) + { + foreach (var type in Enum.GetValues()) + { + if (ImGui.Selectable(getName(type), value.Equals(type))) + { + setter(type); + isDirty = true; + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(getTooltip(type)); + } + } } if (ImGui.IsItemHovered()) ImGui.SetTooltip(tooltip); @@ -112,14 +127,33 @@ public class Settings : Window _ => "Unknown" }; + private static string GetCopyTypeName(MacroCopyConfiguration.CopyType type) => + type switch + { + MacroCopyConfiguration.CopyType.OpenWindow => "Open a Window", + MacroCopyConfiguration.CopyType.CopyToMacro => "Copy to Macros", + MacroCopyConfiguration.CopyType.CopyToClipboard => "Copy to Clipboard", + _ => "Unknown", + }; + + private static string GetCopyTypeTooltip(MacroCopyConfiguration.CopyType type) => + type switch + { + MacroCopyConfiguration.CopyType.OpenWindow => "Open a dedicated window with all macros being copied.\n" + + "Copy, view, and choose at your own leisure.", + MacroCopyConfiguration.CopyType.CopyToMacro => "Copy directly to the game's macro system.", + MacroCopyConfiguration.CopyType.CopyToClipboard => "Copy to your clipboard. Macros are separated by a blank line.", + _ => "Unknown" + }; + public override void Draw() { if (ImGui.BeginTabBar("settingsTabBar")) { DrawTabGeneral(); DrawTabSimulator(); - if (Config.EnableSynthHelper) - DrawTabSynthHelper(); + //if (Config.EnableSynthHelper) + // DrawTabSynthHelper(); DrawTabAbout(); ImGui.EndTabBar(); @@ -128,31 +162,30 @@ public class Settings : Window private void DrawTabGeneral() { - if (!BeginTabItem("General")) + using var tab = TabItem("General"); + if (!tab) return; ImGuiHelpers.ScaledDummy(5); var isDirty = false; - DrawOption( - "Override Uncraftability Warning", - "Allow simulation for crafts that otherwise wouldn't\n" + - "be able to be crafted with your current gear", - Config.OverrideUncraftability, - v => Config.OverrideUncraftability = v, - ref isDirty - ); - DrawOption( - "Enable Synthesis Helper", - "Adds a helper next to your synthesis window to help solve for the best craft.\n" + - "Extremely useful for expert recipes, where the condition can greatly affect\n" + - "which actions you take.", - Config.EnableSynthHelper, - v => Config.EnableSynthHelper = v, - ref isDirty - ); + using (var g = ImRaii.Group()) + { + using var d = ImRaii.Disabled(); + DrawOption( + "Enable Synthesis Helper", + "Adds a helper next to your synthesis window to help solve for the best craft.\n" + + "Extremely useful for expert recipes, where the condition can greatly affect\n" + + "which actions you take.", + Config.EnableSynthHelper, + v => Config.EnableSynthHelper = v, + ref isDirty + ); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Disabled temporarily for testing"); DrawOption( "Show Only One Macro Stat", @@ -164,10 +197,151 @@ public class Settings : Window ref isDirty ); + ImGuiHelpers.ScaledDummy(5); + + using (var panel = ImGuiUtils.GroupPanel("Copying Settings", -1, out _)) + { + DrawOption( + "Macro Copy Method", + "The method to copy a macro with.", + GetCopyTypeName, + GetCopyTypeTooltip, + Config.MacroCopy.Type, + v => Config.MacroCopy.Type = v, + ref isDirty + ); + + if (Config.MacroCopy.Type == MacroCopyConfiguration.CopyType.CopyToMacro) + { + DrawOption( + "Copy Downwards", + "Copy subsequent macros downward (#1 -> #11) instead of to the right.", + Config.MacroCopy.CopyDown, + v => Config.MacroCopy.CopyDown = v, + ref isDirty + ); + + DrawOption( + "Copy to Shared Macros", + "Copy to the shared macros tab. Leaving this unchecked copies to the\n" + + "individual tab.", + Config.MacroCopy.SharedMacro, + v => Config.MacroCopy.SharedMacro = v, + ref isDirty + ); + + DrawOption( + "Macro Number", + "The # of the macro to being copying to. Subsequent macros will be\n" + + "copied relative to this macro.", + Config.MacroCopy.StartMacroIdx, + 0, 99, + v => Config.MacroCopy.StartMacroIdx = v, + ref isDirty + ); + + DrawOption( + "Max Macro Copy Count", + "The maximum number of macros to be copied. Any more and a window is\n" + + "displayed with the rest of them.", + Config.MacroCopy.MaxMacroCount, + 1, 99, + v => Config.MacroCopy.MaxMacroCount = v, + ref isDirty + ); + } + + DrawOption( + "Use MacroChain's /nextmacro", + "Replaces the last step with /nextmacro to run the next macro\n" + + "automatically. Overrides Add End Notification except for the\n" + + "last macro.", + Config.MacroCopy.UseNextMacro, + v => Config.MacroCopy.UseNextMacro = v, + ref isDirty + ); + + DrawOption( + "Add Macro Lock", + "Adds /mlock to the beginning of every macro. Prevents other\n" + + "macros from being run.", + Config.MacroCopy.UseMacroLock, + v => Config.MacroCopy.UseMacroLock = v, + ref isDirty + ); + + DrawOption( + "Add Notification", + "Replaces the last step of every macro with a /echo notification.", + Config.MacroCopy.AddNotification, + v => Config.MacroCopy.AddNotification = v, + ref isDirty + ); + + if (Config.MacroCopy.AddNotification) + { + DrawOption( + "Add Notification Sound", + "Adds a sound to the end of every macro.", + Config.MacroCopy.AddNotificationSound, + v => Config.MacroCopy.AddNotificationSound = v, + ref isDirty + ); + + if (Config.MacroCopy.AddNotificationSound) + { + DrawOption( + "Intermediate Notification Sound", + "Ending notification sound for an intermediary macro.\n" + + "Uses ", + Config.MacroCopy.IntermediateNotificationSound, + 1, 16, + v => + { + Config.MacroCopy.IntermediateNotificationSound = v; + UIModule.PlayChatSoundEffect((uint)v); + }, + ref isDirty + ); + + DrawOption( + "Final Notification Sound", + "Ending notification sound for the final macro.\n" + + "Uses ", + Config.MacroCopy.EndNotificationSound, + 1, 16, + v => + { + Config.MacroCopy.EndNotificationSound = v; + UIModule.PlayChatSoundEffect((uint)v); + }, + ref isDirty + ); + } + } + + if (Config.MacroCopy.Type != MacroCopyConfiguration.CopyType.CopyToMacro) + { + DrawOption( + "Remove Wait Times", + "Remove at the end of every action. Useful for SomethingNeedDoing.", + Config.MacroCopy.RemoveWaitTimes, + v => Config.MacroCopy.RemoveWaitTimes = v, + ref isDirty + ); + + DrawOption( + "Combine Macro", + "Doesn't split the macro into smaller macros. Useful for SomethingNeedDoing.", + Config.MacroCopy.CombineMacro, + v => Config.MacroCopy.CombineMacro = v, + ref isDirty + ); + } + } + if (isDirty) Config.Save(); - - ImGui.EndTabItem(); } private static void DrawSolverConfig(ref SolverConfig configRef, SolverConfig defaultConfig, out bool isDirty) @@ -176,228 +350,240 @@ public class Settings : Window var config = configRef; - ImGuiUtils.BeginGroupPanel("General"); - - if (ImGui.Button("Reset to Default", OptionButtonSize)) + using (var panel = ImGuiUtils.GroupPanel("General", -1, out _)) { - config = defaultConfig; - isDirty = true; - } - - ImGui.SetNextItemWidth(OptionWidth); - if (ImGui.BeginCombo("Algorithm", GetAlgorithmName(config.Algorithm))) - { - foreach (var alg in Enum.GetValues()) + if (ImGui.Button("Reset to Default", OptionButtonSize)) { - if (ImGui.Selectable(GetAlgorithmName(alg), config.Algorithm == alg)) - { - config = config with { Algorithm = alg }; - isDirty = true; - } - if (ImGui.IsItemHovered()) - ImGui.SetTooltip(GetAlgorithmTooltip(alg)); + config = defaultConfig; + isDirty = true; } - ImGui.EndCombo(); - } - if (ImGui.IsItemHovered()) - ImGui.SetTooltip( + + DrawOption( + "Algorithm", "The algorithm to use when solving for a macro. Different\n" + "algorithms provide different pros and cons for using them.\n" + "By far, the Stepwise Furcated algorithm provides the best\n" + - "results, especially for very difficult crafts." + "results, especially for very difficult crafts.", + GetAlgorithmName, + GetAlgorithmTooltip, + config.Algorithm, + v => config = config with { Algorithm = v }, + ref isDirty ); - DrawOption( - "Iterations", - "The total number of iterations to run per crafting step.\n" + - "Higher values require more computational power. Higher values\n" + - "also may decrease variance, so other values should be tweaked\n" + - "as necessary to get a more favorable outcome.", - config.Iterations, - v => config = config with { Iterations = v }, - ref isDirty - ); + DrawOption( + "Iterations", + "The total number of iterations to run per crafting step.\n" + + "Higher values require more computational power. Higher values\n" + + "also may decrease variance, so other values should be tweaked\n" + + "as necessary to get a more favorable outcome.", + config.Iterations, + 1000, + 500000, + v => config = config with { Iterations = v }, + ref isDirty + ); - DrawOption( - "Max Step Count", - "The maximum number of crafting steps; this is generally the only\n" + - "setting you should change, and it should be set to around 5 steps\n" + - "more than what you'd expect. If this value is too low, the solver\n" + - "won't learn much per iteration; too high and it will waste time\n" + - "on useless extra steps.", - config.MaxStepCount, - v => config = config with { MaxStepCount = v }, - ref isDirty - ); + DrawOption( + "Max Step Count", + "The maximum number of crafting steps; this is generally the only\n" + + "setting you should change, and it should be set to around 5 steps\n" + + "more than what you'd expect. If this value is too low, the solver\n" + + "won't learn much per iteration; too high and it will waste time\n" + + "on useless extra steps.", + config.MaxStepCount, + 1, + 100, + v => config = config with { MaxStepCount = v }, + ref isDirty + ); - DrawOption( - "Exploration Constant", - "A constant that decides how often the solver will explore new,\n" + - "possibly good paths. If this value is too high,\n" + - "moves will mostly be decided at random.", - config.ExplorationConstant, - v => config = config with { ExplorationConstant = v }, - ref isDirty - ); + DrawOption( + "Exploration Constant", + "A constant that decides how often the solver will explore new,\n" + + "possibly good paths. If this value is too high,\n" + + "moves will mostly be decided at random.", + config.ExplorationConstant, + 0, + 10, + v => config = config with { ExplorationConstant = v }, + ref isDirty + ); - DrawOption( - "Score Weighting Constant", - "A constant ranging from 0 to 1 that configures how the solver\n" + - "scores and picks paths to travel to next. A value of 0 means\n" + - "actions will be chosen based on their average outcome, whereas\n" + - "1 uses their best outcome achieved so far.", - config.MaxScoreWeightingConstant, - v => config = config with { MaxScoreWeightingConstant = v }, - ref isDirty - ); + DrawOption( + "Score Weighting Constant", + "A constant ranging from 0 to 1 that configures how the solver\n" + + "scores and picks paths to travel to next. A value of 0 means\n" + + "actions will be chosen based on their average outcome, whereas\n" + + "1 uses their best outcome achieved so far.", + config.MaxScoreWeightingConstant, + 0, + 1, + v => config = config with { MaxScoreWeightingConstant = v }, + ref isDirty + ); - ImGui.BeginDisabled(config.Algorithm is not (SolverAlgorithm.OneshotForked or SolverAlgorithm.StepwiseForked or SolverAlgorithm.StepwiseFurcated)); - DrawOption( - "Max Core Count", - "The number of cores to use when solving. You should use as many\n" + - "as you can. If it's too high, it will have an effect on your gameplay\n" + - $"experience. A good estimate would be 1 or 2 cores less than your\n" + - $"system (FYI, you have {Environment.ProcessorCount} cores,) but\n" + - $"make sure to accomodate for any other tasks you have in the\n" + - $"background, if you have any.\n" + - "(Only used in the Forked and Furcated algorithms)", - config.MaxThreadCount, - v => config = config with { MaxThreadCount = v }, - ref isDirty - ); - ImGui.EndDisabled(); + using (var d = ImRaii.Disabled(config.Algorithm is not (SolverAlgorithm.OneshotForked or SolverAlgorithm.StepwiseForked or SolverAlgorithm.StepwiseFurcated))) + DrawOption( + "Max Core Count", + "The number of cores to use when solving. You should use as many\n" + + "as you can. If it's too high, it will have an effect on your gameplay\n" + + $"experience. A good estimate would be 1 or 2 cores less than your\n" + + $"system (FYI, you have {Environment.ProcessorCount} cores), but make sure to accomodate\n" + + $"for any other tasks you have in the background, if you have any.\n" + + "(Only used in the Forked and Furcated algorithms)", + config.MaxThreadCount, + 1, + Environment.ProcessorCount, + v => config = config with { MaxThreadCount = v }, + ref isDirty + ); - ImGui.BeginDisabled(config.Algorithm is not (SolverAlgorithm.OneshotForked or SolverAlgorithm.StepwiseForked or SolverAlgorithm.StepwiseFurcated)); - DrawOption( - "Fork Count", - "Split the number of iterations across different solvers. In general,\n" + - "you should increase this value to at least the number of cores in\n" + - $"your system (FYI, you have {Environment.ProcessorCount} cores) to attain the most speedup.\n" + - "The higher the number, the more chance you have of finding a\n" + - "better local maximum; this concept similar but not equivalent\n" + - "to the exploration constant.\n" + - "(Only used in the Forked and Furcated algorithms)", - config.ForkCount, - v => config = config with { ForkCount = v }, - ref isDirty - ); - ImGui.EndDisabled(); + using (var d = ImRaii.Disabled(config.Algorithm is not (SolverAlgorithm.OneshotForked or SolverAlgorithm.StepwiseForked or SolverAlgorithm.StepwiseFurcated))) + DrawOption( + "Fork Count", + "Split the number of iterations across different solvers. In general,\n" + + "you should increase this value to at least the number of cores in\n" + + $"your system (FYI, you have {Environment.ProcessorCount} cores) to attain the most speedup.\n" + + "The higher the number, the more chance you have of finding a\n" + + "better local maximum; this concept similar but not equivalent\n" + + "to the exploration constant.\n" + + "(Only used in the Forked and Furcated algorithms)", + config.ForkCount, + 1, + 500, + v => config = config with { ForkCount = v }, + ref isDirty + ); - ImGui.BeginDisabled(config.Algorithm is not SolverAlgorithm.StepwiseFurcated); - DrawOption( - "Furcated Action Count", - "On every craft step, pick this many top solutions and use them as\n" + - "the input for the next craft step. For best results, use Fork Count / 2\n" + - "and add about 1 or 2 more if needed.\n" + - "(Only used in the Stepwise Furcated algorithm)", - config.FurcatedActionCount, - v => config = config with { FurcatedActionCount = v }, - ref isDirty - ); - ImGui.EndDisabled(); - - ImGuiUtils.EndGroupPanel(); - - ImGuiUtils.BeginGroupPanel("Advanced"); - - DrawOption( - "Score Storage Threshold", - "If a craft achieves this certain arbitrary score, the solver will\n" + - "throw away all other possible combinations in favor of that one.\n" + - "Only change this value if you absolutely know what you're doing.", - config.ScoreStorageThreshold, - v => config = config with { ScoreStorageThreshold = v }, - ref isDirty - ); - - DrawOption( - "Max Rollout Step Count", - "The maximum number of crafting steps every iteration can consider.\n" + - "Decreasing this value can have unintended side effects. Only change\n" + - "this value if you absolutely know what you're doing.", - config.MaxRolloutStepCount, - v => config = config with { MaxRolloutStepCount = v }, - ref isDirty - ); - - DrawOption( - "Strict Actions", - "When finding the next possible actions to execute, use a heuristic\n" + - "to restrict which actions to attempt taking. This results in a much\n" + - "better macro at the cost of not finding an extremely creative one.", - config.StrictActions, - v => config = config with { StrictActions = v }, - ref isDirty - ); - - ImGuiUtils.EndGroupPanel(); - - ImGuiUtils.BeginGroupPanel("Score Weights (Advanced)"); - ImGui.TextWrapped("All values should add up to 1. Otherwise, the Score Storage Threshold should be changed."); - ImGuiHelpers.ScaledDummy(10); - - DrawOption( - "Progress", - "Amount of weight to give to the craft's progress.", - config.ScoreProgress, - v => config = config with { ScoreProgress = v }, - ref isDirty - ); - - DrawOption( - "Quality", - "Amount of weight to give to the craft's quality.", - config.ScoreQuality, - v => config = config with { ScoreQuality = v }, - ref isDirty - ); - - DrawOption( - "Durability", - "Amount of weight to give to the craft's remaining durability.", - config.ScoreDurability, - v => config = config with { ScoreDurability = v }, - ref isDirty - ); - - DrawOption( - "CP", - "Amount of weight to give to the craft's remaining CP.", - config.ScoreCP, - v => config = config with { ScoreCP = v }, - ref isDirty - ); - - DrawOption( - "Steps", - "Amount of weight to give to the craft's number of steps. The lower\n" + - "the step count, the higher the score.", - config.ScoreSteps, - v => config = config with { ScoreSteps = v }, - ref isDirty - ); - - if (ImGui.Button("Normalize Weights", OptionButtonSize)) - { - var total = config.ScoreProgress + - config.ScoreQuality + - config.ScoreDurability + - config.ScoreCP + - config.ScoreSteps; - config = config with - { - ScoreProgress = config.ScoreProgress / total, - ScoreQuality = config.ScoreQuality / total, - ScoreDurability = config.ScoreDurability / total, - ScoreCP = config.ScoreCP / total, - ScoreSteps = config.ScoreSteps / total - }; - isDirty = true; + using (var d = ImRaii.Disabled(config.Algorithm is not SolverAlgorithm.StepwiseFurcated)) + DrawOption( + "Furcated Action Count", + "On every craft step, pick this many top solutions and use them as\n" + + "the input for the next craft step. For best results, use Fork Count / 2\n" + + "and add about 1 or 2 more if needed.\n" + + "(Only used in the Stepwise Furcated algorithm)", + config.FurcatedActionCount, + 1, + 500, + v => config = config with { FurcatedActionCount = v }, + ref isDirty + ); } - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Normalize all weights to sum up to 1"); - ImGuiUtils.EndGroupPanel(); + using (var panel = ImGuiUtils.GroupPanel("Advanced", -1, out _)) + { + DrawOption( + "Score Storage Threshold", + "If a craft achieves this certain arbitrary score, the solver will\n" + + "throw away all other possible combinations in favor of that one.\n" + + "Only change this value if you absolutely know what you're doing.", + config.ScoreStorageThreshold, + 0, + 1, + v => config = config with { ScoreStorageThreshold = v }, + ref isDirty + ); + + DrawOption( + "Max Rollout Step Count", + "The maximum number of crafting steps every iteration can consider.\n" + + "Decreasing this value can have unintended side effects. Only change\n" + + "this value if you absolutely know what you're doing.", + config.MaxRolloutStepCount, + 1, + 50, + v => config = config with { MaxRolloutStepCount = v }, + ref isDirty + ); + + DrawOption( + "Strict Actions", + "When finding the next possible actions to execute, use a heuristic\n" + + "to restrict which actions to attempt taking. This results in a much\n" + + "better macro at the cost of not finding an extremely creative one.", + config.StrictActions, + v => config = config with { StrictActions = v }, + ref isDirty + ); + } + + using (var panel = ImGuiUtils.GroupPanel("Score Weights (Advanced)", -1, out _)) + { + ImGui.TextWrapped("All values should add up to 1. Otherwise, the Score Storage Threshold should be changed."); + ImGuiHelpers.ScaledDummy(10); + + DrawOption( + "Progress", + "Amount of weight to give to the craft's progress.", + config.ScoreProgress, + 0, + 1, + v => config = config with { ScoreProgress = v }, + ref isDirty + ); + + DrawOption( + "Quality", + "Amount of weight to give to the craft's quality.", + config.ScoreQuality, + 0, + 1, + v => config = config with { ScoreQuality = v }, + ref isDirty + ); + + DrawOption( + "Durability", + "Amount of weight to give to the craft's remaining durability.", + config.ScoreDurability, + 0, + 1, + v => config = config with { ScoreDurability = v }, + ref isDirty + ); + + DrawOption( + "CP", + "Amount of weight to give to the craft's remaining CP.", + config.ScoreCP, + 0, + 1, + v => config = config with { ScoreCP = v }, + ref isDirty + ); + + DrawOption( + "Steps", + "Amount of weight to give to the craft's number of steps. The lower\n" + + "the step count, the higher the score.", + config.ScoreSteps, + 0, + 1, + v => config = config with { ScoreSteps = v }, + ref isDirty + ); + + if (ImGui.Button("Normalize Weights", OptionButtonSize)) + { + var total = config.ScoreProgress + + config.ScoreQuality + + config.ScoreDurability + + config.ScoreCP + + config.ScoreSteps; + config = config with + { + ScoreProgress = config.ScoreProgress / total, + ScoreQuality = config.ScoreQuality / total, + ScoreDurability = config.ScoreDurability / total, + ScoreCP = config.ScoreCP / total, + ScoreSteps = config.ScoreSteps / total + }; + isDirty = true; + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Normalize all weights to sum up to 1"); + } if (isDirty) configRef = config; @@ -405,30 +591,28 @@ public class Settings : Window private void DrawTabSimulator() { - if (!BeginTabItem("Simulator")) + using var tab = TabItem("Simulator"); + if (!tab) return; ImGuiHelpers.ScaledDummy(5); var isDirty = false; - DrawOption( - "Show Only Learned Actions", - "Don't show crafting actions that haven't been\n" + - "learned yet with your current job on the simulator sidebar", - Config.HideUnlearnedActions, - v => Config.HideUnlearnedActions = v, - ref isDirty - ); - - DrawOption( - "Condition Randomness", - "Allows the simulator condition to fluctuate randomly like a real craft.\n" + - "Turns off when generating a macro.", - Config.ConditionRandomness, - v => Config.ConditionRandomness = v, - ref isDirty - ); + using (var g = ImRaii.Group()) + { + using var d = ImRaii.Disabled(); + DrawOption( + "Condition Randomness", + "Allows the simulator condition to fluctuate randomly like a real craft.\n" + + "Turns off when generating a macro.", + Config.ConditionRandomness, + v => Config.ConditionRandomness = v, + ref isDirty + ); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Disabled temporarily for testing"); ImGuiHelpers.ScaledDummy(5); ImGui.Separator(); @@ -444,13 +628,12 @@ public class Settings : Window if (isDirty) Config.Save(); - - ImGui.EndTabItem(); } private void DrawTabSynthHelper() { - if (!BeginTabItem("Synthesis Helper")) + using var tab = TabItem("Synthesis Helper"); + if (!tab) return; ImGuiHelpers.ScaledDummy(5); @@ -461,6 +644,8 @@ public class Settings : Window "Step Count", "The number of future actions to solve for during an in-game craft.", Config.SynthHelperStepCount, + 1, + 100, v => Config.SynthHelperStepCount = v, ref isDirty ); @@ -479,13 +664,12 @@ public class Settings : Window if (isDirty) Config.Save(); - - ImGui.EndTabItem(); } private void DrawTabAbout() { - if (!BeginTabItem("About")) + using var tab = TabItem("About"); + if (!tab) return; ImGuiHelpers.ScaledDummy(5); @@ -493,28 +677,34 @@ public class Settings : Window var plugin = Service.Plugin; var icon = plugin.Icon; - ImGui.BeginTable("settingsAboutTable", 2); - ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, icon.Width); + using (var table = ImRaii.Table("settingsAboutTable", 2)) + { + if (table) + { + ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, icon.Width); - ImGui.TableNextColumn(); - ImGui.Image(icon.ImGuiHandle, new(icon.Width, icon.Height)); + ImGui.TableNextColumn(); + ImGui.Image(icon.ImGuiHandle, new(icon.Width, icon.Height)); - ImGui.TableNextColumn(); - ImGui.Text($"{plugin.Name} v{plugin.Version} {plugin.BuildConfiguration}"); - ImGui.Text($"By {plugin.Author} ("); - ImGui.SameLine(0, 0); - ImGuiUtils.Hyperlink("WorkingRobot", "https://github.com/WorkingRobot"); - ImGui.SameLine(0, 0); - ImGui.Text(")"); - - ImGui.EndTable(); + ImGui.TableNextColumn(); + ImGui.Text($"Craftimizer v{plugin.Version} {plugin.BuildConfiguration}"); + ImGui.Text($"By {plugin.Author} ("); + ImGui.SameLine(0, 0); + ImGuiUtils.Hyperlink("WorkingRobot", "https://github.com/WorkingRobot"); + ImGui.SameLine(0, 0); + ImGui.Text(")"); + } + } ImGui.Text("Credit to altosock's "); ImGui.SameLine(0, 0); ImGuiUtils.Hyperlink("Craftingway", "https://craftingway.app"); ImGui.SameLine(0, 0); ImGui.Text(" for the original solver algorithm"); + } - ImGui.EndTabItem(); + public void Dispose() + { + Service.WindowSystem.RemoveWindow(this); } } diff --git a/Simulator/ActionCategory.cs b/Simulator/ActionCategory.cs index 24d671b..56ce764 100644 --- a/Simulator/ActionCategory.cs +++ b/Simulator/ActionCategory.cs @@ -1,3 +1,6 @@ +using Craftimizer.Simulator.Actions; +using System.Collections.ObjectModel; + namespace Craftimizer.Simulator; public enum ActionCategory @@ -13,6 +16,25 @@ public enum ActionCategory public static class ActionCategoryUtils { + private static readonly ReadOnlyDictionary SortedActions; + + static ActionCategoryUtils() + { + SortedActions = new( + Enum.GetValues() + .Where(a => a.Category() != ActionCategory.Combo) + .GroupBy(a => a.Category()) + .ToDictionary(g => g.Key, g => g.OrderBy(a => a.Level()).ToArray())); + } + + public static IReadOnlyList GetActions(this ActionCategory me) + { + if (SortedActions.TryGetValue(me, out var actions)) + return actions; + + throw new ArgumentException($"Unknown action category {me}", nameof(me)); + } + public static string GetDisplayName(this ActionCategory category) => category switch { diff --git a/Simulator/Actions/BaseAction.cs b/Simulator/Actions/BaseAction.cs index 47e736f..8b3edb1 100644 --- a/Simulator/Actions/BaseAction.cs +++ b/Simulator/Actions/BaseAction.cs @@ -48,7 +48,8 @@ public abstract class BaseAction s.ActionStates.MutateState(this); s.ActionCount++; - s.ActiveEffects.DecrementDuration(); + if (IncreasesStepCount) + s.ActiveEffects.DecrementDuration(); } public virtual void UseSuccess(Simulator s) diff --git a/Simulator/Actions/BaseBuffAction.cs b/Simulator/Actions/BaseBuffAction.cs index 09458b6..dbabd50 100644 --- a/Simulator/Actions/BaseBuffAction.cs +++ b/Simulator/Actions/BaseBuffAction.cs @@ -14,10 +14,13 @@ internal abstract class BaseBuffAction : BaseAction public override void UseSuccess(Simulator s) => s.AddEffect(Effect, Duration); - public sealed override string GetTooltip(Simulator s, bool addUsability) + public override string GetTooltip(Simulator s, bool addUsability) { var builder = new StringBuilder(base.GetTooltip(s, addUsability)); builder.AppendLine($"{Duration} Steps"); return builder.ToString(); } + + protected string GetBaseTooltip(Simulator s, bool addUsability) => + base.GetTooltip(s, addUsability); } diff --git a/Simulator/Actions/CarefulObservation.cs b/Simulator/Actions/CarefulObservation.cs index 67ee00f..ebee287 100644 --- a/Simulator/Actions/CarefulObservation.cs +++ b/Simulator/Actions/CarefulObservation.cs @@ -15,4 +15,7 @@ internal sealed class CarefulObservation : BaseAction public override bool CanUse(Simulator s) => s.Input.Stats.IsSpecialist && s.ActionStates.CarefulObservationCount < 3; public override void UseSuccess(Simulator s) => s.StepCondition(); + + public override string GetTooltip(Simulator s, bool addUsability) => + $"{base.GetTooltip(s, addUsability)}Specialist Only"; } diff --git a/Simulator/Actions/HeartAndSoul.cs b/Simulator/Actions/HeartAndSoul.cs index 9e662ca..16e7b1a 100644 --- a/Simulator/Actions/HeartAndSoul.cs +++ b/Simulator/Actions/HeartAndSoul.cs @@ -14,4 +14,7 @@ internal sealed class HeartAndSoul : BaseBuffAction public override int CPCost(Simulator s) => 0; public override bool CanUse(Simulator s) => s.Input.Stats.IsSpecialist && !s.ActionStates.UsedHeartAndSoul; + + public override string GetTooltip(Simulator s, bool addUsability) => + $"{GetBaseTooltip(s, addUsability)}Specialist Only"; } diff --git a/Simulator/Actions/Manipulation.cs b/Simulator/Actions/Manipulation.cs index 4b0e6ba..804ee65 100644 --- a/Simulator/Actions/Manipulation.cs +++ b/Simulator/Actions/Manipulation.cs @@ -14,14 +14,16 @@ internal sealed class Manipulation : BaseBuffAction public override void Use(Simulator s) { - if (s.HasEffect(EffectType.Manipulation)) - s.RestoreDurability(5); - - s.ReduceCP(CPCost(s)); - s.ReduceDurability(DurabilityCost); - UseSuccess(s); + s.ReduceCP(CPCost(s)); + s.IncreaseStepCount(); + + s.ActionStates.MutateState(this); + s.ActionCount++; + + if (IncreasesStepCount) + s.ActiveEffects.DecrementDuration(); } } diff --git a/Simulator/Actions/TrainedEye.cs b/Simulator/Actions/TrainedEye.cs index 94c9b08..494fb0c 100644 --- a/Simulator/Actions/TrainedEye.cs +++ b/Simulator/Actions/TrainedEye.cs @@ -18,4 +18,7 @@ internal sealed class TrainedEye : BaseAction public override void UseSuccess(Simulator s) => s.IncreaseQualityRaw(s.Input.Recipe.MaxQuality - s.Quality); + + public override string GetTooltip(Simulator s, bool addUsability) => + $"{base.GetTooltip(s, addUsability)}+{s.Input.Recipe.MaxQuality - s.Quality} Quality"; } diff --git a/Simulator/Effects.cs b/Simulator/Effects.cs index 49cf0b4..6740e16 100644 --- a/Simulator/Effects.cs +++ b/Simulator/Effects.cs @@ -82,6 +82,11 @@ public record struct Effects _ => 0 }; + [Pure] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsIndefinite(EffectType effect) => + effect is EffectType.InnerQuiet or EffectType.HeartAndSoul; + [Pure] [MethodImpl(MethodImplOptions.AggressiveInlining)] public readonly byte GetStrength(EffectType effect) => diff --git a/Simulator/SimulationState.cs b/Simulator/SimulationState.cs index 6b9182f..e743ade 100644 --- a/Simulator/SimulationState.cs +++ b/Simulator/SimulationState.cs @@ -25,6 +25,8 @@ public record struct SimulationState 74, 76, 78, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 94, 96, 98, 100 }; public readonly int HQPercent => HQPercentTable[(int)Math.Clamp((float)Quality / Input.Recipe.MaxQuality * 100, 0, 100)]; + public readonly int Collectability => Math.Max(Quality / 10, 1); + public readonly int MaxCollectability => Math.Max(Input.Recipe.MaxQuality / 10, 1); public readonly bool IsFirstStep => StepCount == 0; diff --git a/Simulator/Simulator.cs b/Simulator/Simulator.cs index 849c288..d108e4c 100644 --- a/Simulator/Simulator.cs +++ b/Simulator/Simulator.cs @@ -63,6 +63,8 @@ public class Simulator return ActionResponse.ActionNotUnlocked; if (action == ActionType.Manipulation && !Input.Stats.CanUseManipulation) return ActionResponse.ActionNotUnlocked; + if (action is ActionType.CarefulObservation or ActionType.HeartAndSoul && !Input.Stats.IsSpecialist) + return ActionResponse.ActionNotUnlocked; if (baseAction.CPCost(this) > CP) return ActionResponse.NotEnoughCP; return ActionResponse.CannotUseAction; diff --git a/Solver/Solver.cs b/Solver/Solver.cs index 3dd5e26..0cb8cec 100644 --- a/Solver/Solver.cs +++ b/Solver/Solver.cs @@ -35,9 +35,6 @@ public sealed class Solver : IDisposable // Always called when a new step is generated. public event NewActionDelegate? OnNewAction; - // Always called when the solver is fully complete. - public event SolutionDelegate? OnSolution; - public Solver(SolverConfig config, SimulationState state) { Config = config; @@ -107,6 +104,12 @@ public sealed class Solver : IDisposable CompletionTask?.Dispose(); } + private void InvokeNewAction(ActionType action) + { + foreach (var sanitizedAction in SolverSolution.SanitizeCombo(action)) + OnNewAction?.Invoke(sanitizedAction); + } + private async Task SearchStepwiseFurcated() { var definiteActionCount = 0; @@ -115,7 +118,7 @@ public sealed class Solver : IDisposable var state = State; var sim = new Simulator(state, Config.MaxStepCount); - var activeStates = new List() { new(new(), state) }; + var activeStates = new List() { new(Array.Empty(), state) }; while (activeStates.Count != 0) { @@ -162,12 +165,12 @@ public sealed class Solver : IDisposable if (bestAction.MaxScore >= Config.ScoreStorageThreshold) { var (_, furcatedActionIdx, solution) = bestAction; - var (activeActions, _) = activeStates[furcatedActionIdx]; + (IEnumerable activeActions, _) = activeStates[furcatedActionIdx]; - activeActions.AddRange(solution.Actions); + activeActions = activeActions.Concat(solution.Actions); foreach (var action in activeActions.Skip(definiteActionCount)) - OnNewAction?.Invoke(action); - return solution with { Actions = activeActions }; + InvokeNewAction(action); + return solution with { ActionEnumerable = activeActions }; } var newStates = new List(Config.FurcatedActionCount); @@ -214,7 +217,7 @@ public sealed class Solver : IDisposable if (definiteCount != equalCount) { foreach(var action in refActions.Take(equalCount).Skip(definiteCount)) - OnNewAction?.Invoke(action); + InvokeNewAction(action); definiteActionCount = equalCount; } @@ -224,11 +227,11 @@ public sealed class Solver : IDisposable } if (bestSims.Count == 0) - return new(new(), state); + return new(Array.Empty(), state); var result = bestSims.MaxBy(s => s.Score).Result; foreach (var action in result.Actions.Skip(definiteActionCount)) - OnNewAction?.Invoke(action); + InvokeNewAction(action); return result; } @@ -282,12 +285,12 @@ public sealed class Solver : IDisposable { actions.AddRange(solution.Actions); foreach (var action in solution.Actions) - OnNewAction?.Invoke(action); + InvokeNewAction(action); return solution with { Actions = actions }; } var chosenAction = solution.Actions[0]; - OnNewAction?.Invoke(chosenAction); + InvokeNewAction(chosenAction); (_, state) = sim.Execute(state, chosenAction); actions.Add(chosenAction); @@ -321,12 +324,12 @@ public sealed class Solver : IDisposable { actions.AddRange(solution.Actions); foreach (var action in solution.Actions) - OnNewAction?.Invoke(action); + InvokeNewAction(action); return Task.FromResult(solution with { Actions = actions }); } var chosenAction = solution.Actions[0]; - OnNewAction?.Invoke(chosenAction); + InvokeNewAction(chosenAction); (_, state) = sim.Execute(state, chosenAction); actions.Add(chosenAction); @@ -365,7 +368,7 @@ public sealed class Solver : IDisposable var solution = tasks.Select(t => t.Result).MaxBy(r => r.MaxScore).Solution; foreach (var action in solution.Actions) - OnNewAction?.Invoke(action); + InvokeNewAction(action); return solution; } @@ -376,7 +379,7 @@ public sealed class Solver : IDisposable solver.Search(Config.Iterations, Token); var solution = solver.Solution(); foreach (var action in solution.Actions) - OnNewAction?.Invoke(action); + InvokeNewAction(action); return Task.FromResult(solution); } diff --git a/Solver/SolverSolution.cs b/Solver/SolverSolution.cs index 8a5ac54..f512479 100644 --- a/Solver/SolverSolution.cs +++ b/Solver/SolverSolution.cs @@ -3,4 +3,43 @@ using Craftimizer.Simulator.Actions; namespace Craftimizer.Solver; -public readonly record struct SolverSolution(List Actions, SimulationState State); +public readonly record struct SolverSolution { + private readonly List actions = null!; + public readonly IReadOnlyList Actions { get => actions; init => ActionEnumerable = value; } + public readonly IEnumerable ActionEnumerable { init => actions = SanitizeCombos(value).ToList(); } + public readonly SimulationState State { get; init; } + + public SolverSolution(IEnumerable actions, SimulationState state) + { + ActionEnumerable = actions; + State = state; + } + + public void Deconstruct(out IReadOnlyList actions, out SimulationState state) + { + actions = Actions; + state = State; + } + + internal static IEnumerable SanitizeCombo(ActionType action) + { + if (action.Base() is BaseComboAction combo) + { + foreach (var a in SanitizeCombo(combo.ActionTypeA)) + yield return a; + foreach (var b in SanitizeCombo(combo.ActionTypeB)) + yield return b; + } + else + yield return action; + } + + internal static IEnumerable SanitizeCombos(IEnumerable actions) + { + foreach (var action in actions) + { + foreach (var sanitizedAction in SanitizeCombo(action)) + yield return sanitizedAction; + } + } +}