Files
Craftimizer/Craftimizer/Windows/SynthHelper.cs
T
Asriel Camora a041fc6ebf Update for 7.4
2025-12-21 23:55:42 -08:00

682 lines
24 KiB
C#

using Craftimizer.Plugin;
using Craftimizer.Simulator;
using Craftimizer.Simulator.Actions;
using Craftimizer.Utils;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.GameFonts;
using Dalamud.Interface.ManagedFontAtlas;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Shell;
using Dalamud.Bindings.ImGui;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Threading;
using ActionType = Craftimizer.Simulator.Actions.ActionType;
using Sim = Craftimizer.Simulator.Simulator;
using SimNoRandom = Craftimizer.Simulator.SimulatorNoRandom;
namespace Craftimizer.Windows;
public sealed unsafe class SynthHelper : Window, IDisposable
{
private const ImGuiWindowFlags WindowFlagsPinned = WindowFlagsFloating
| ImGuiWindowFlags.NoSavedSettings;
private const ImGuiWindowFlags WindowFlagsFloating =
ImGuiWindowFlags.AlwaysAutoResize
| ImGuiWindowFlags.NoFocusOnAppearing;
private const string WindowNamePinned = "Craftimizer Synthesis Helper###CraftimizerSynthHelper";
private const string WindowNameFloating = $"{WindowNamePinned}Floating";
public AddonSynthesis* Addon { get; private set; }
public RecipeData? RecipeData { get; private set; }
public CharacterStats? CharacterStats { get; private set; }
public SimulationInput? SimulationInput { get; private set; }
public ActionType? NextAction => (ShouldOpen && Macro.Count > 0) ? Macro[0].Action : null;
public bool ShouldDrawAnts => ShouldOpen && !IsCollapsed;
private int CurrentActionCount { get; set; }
private ActionStates CurrentActionStates { get; set; }
private SimulationState CurrentState
{
get => currentState;
set
{
if (currentState != value)
{
currentState = value;
OnStateUpdated();
}
}
}
private SimulationState currentState;
private SimulatedMacro Macro { get; } = new();
private BackgroundTask<int>? SolverTask { get; set; }
private bool SolverRunning => (!SolverTask?.Completed) ?? false;
private Solver.Solver? SolverObject { get; set; }
private IFontHandle AxisFont { get; }
public SynthHelper() : base(WindowNamePinned)
{
AxisFont = Service.PluginInterface.UiBuilder.FontAtlas.NewGameFontHandle(new(GameFontFamilyAndSize.Axis14));
Service.Plugin.Hooks.OnActionUsed += OnUseAction;
RespectCloseHotkey = false;
DisableWindowSounds = true;
ShowCloseButton = false;
IsOpen = true;
SizeConstraints = new WindowSizeConstraints
{
MinimumSize = new(494, -1),
MaximumSize = new(494, 10000)
};
TitleBarButtons =
[
new()
{
Icon = FontAwesomeIcon.Cog,
IconOffset = new(2, 1),
Click = _ => Service.Plugin.OpenSettingsTab("Synthesis Helper"),
ShowTooltip = () => ImGuiUtils.Tooltip("Open Settings")
},
new() {
Icon = FontAwesomeIcon.Heart,
IconOffset = new(2, 1),
Click = _ => Util.OpenLink(Plugin.Plugin.SupportLink),
ShowTooltip = () => ImGuiUtils.Tooltip("Support me on Ko-fi!")
}
];
Service.WindowSystem.AddWindow(this);
}
private bool IsCollapsed { get; set; }
private bool ShouldOpen { get; set; }
private bool WasOpen { get; set; }
private bool WasCollapsed { get; set; }
/// <summary>
/// Used to automatically collapse the helper window when a new craft starts.
/// </summary>
private bool ShouldCollapse { get; set; }
private bool ShouldCalculate => !IsCollapsed && ShouldOpen;
private bool WasCalculatable { get; set; }
private bool IsRecalculateQueued { get; set; }
public override void Update()
{
base.Update();
ShouldOpen = CalculateShouldOpen();
if (ShouldCalculate != WasCalculatable)
{
if (WasCalculatable)
SolverTask?.Cancel();
else if (Macro.Count == 0)
RefreshCurrentState();
}
if (Macro.Count == 0 && ShouldOpen)
{
if (ShouldOpen != WasOpen || IsCollapsed != WasCollapsed)
RefreshCurrentState();
}
if (!ShouldOpen)
{
StyleAlpha = LastAlpha = null;
LastPosition = null;
}
WasOpen = ShouldOpen;
WasCollapsed = IsCollapsed;
WasCalculatable = ShouldCalculate;
}
public override bool DrawConditions() =>
ShouldOpen;
private bool wasInCraftAction;
private bool CalculateShouldOpen()
{
if (Service.Objects.LocalPlayer == null)
return false;
if (!Service.Configuration.EnableSynthHelper)
return false;
var recipeId = CSRecipeNote.Instance()->ActiveCraftRecipeId;
if (recipeId == 0)
{
RecipeData = null;
return false;
}
Addon = (AddonSynthesis*)Service.GameGui.GetAddonByName("Synthesis").Address;
if (Addon == null)
{
RecipeData = null;
return false;
}
// Check if Synthesis addon is visible
if (Addon->AtkUnitBase.WindowNode == null)
return false;
if (Service.Configuration.DisableSynthHelperOnMacro)
{
var module = RaptureShellModule.Instance();
if (module->MacroCurrentLine >= 0)
{
var hasCraftAction = false;
foreach (ref var line in module->MacroLines)
{
if (line.EqualToString("/craftaction"))
{
hasCraftAction = true;
break;
}
}
if (!hasCraftAction)
return false;
}
}
if (RecipeData?.RecipeId != recipeId)
{
OnStartCrafting(recipeId);
OnStateUpdated();
if (Service.Configuration.CollapseSynthHelper) ShouldCollapse = true;
}
if (IsRecalculateQueued)
OnStateUpdated();
Macro.FlushQueue();
var isInCraftAction = Service.Condition[ConditionFlag.ExecutingCraftingAction];
if (!isInCraftAction && wasInCraftAction)
RefreshCurrentState();
wasInCraftAction = isInCraftAction;
return true;
}
private Vector2? LastPosition { get; set; }
private byte? StyleAlpha { get; set; }
private byte? LastAlpha { get; set; }
public override void PreDraw()
{
base.PreDraw();
IsCollapsed = true;
if (Service.Configuration.PinSynthHelperToWindow)
{
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 offset = 5;
var newAlpha = unit.WindowNode->AtkResNode.Alpha_2;
StyleAlpha = LastAlpha ?? newAlpha;
LastAlpha = newAlpha;
var newPosition = pos + new Vector2(size.X, offset * scale);
Position = ImGuiHelpers.MainViewport.Pos + (LastPosition ?? newPosition);
LastPosition = newPosition;
Flags = WindowFlagsPinned;
WindowName = WindowNamePinned;
}
else
{
StyleAlpha = LastAlpha = null;
Position = LastPosition = null;
Flags = WindowFlagsFloating;
WindowName = WindowNameFloating;
}
ImGui.PushStyleVar(ImGuiStyleVar.Alpha, StyleAlpha.HasValue ? (StyleAlpha.Value / 255f) : 1);
}
public override void PostDraw()
{
ImGui.PopStyleVar();
base.PostDraw();
}
public override void Draw()
{
if (ShouldCollapse)
{
ImGui.SetWindowCollapsed(true);
ShouldCollapse = false;
}
IsCollapsed = false;
DrawMacro();
DrawMacroInfo();
ImGuiHelpers.ScaledDummy(5);
DrawMacroActions();
if (SolverRunning && SolverObject is { } solver)
{
ImGuiHelpers.ScaledDummy(5);
DynamicBars.DrawProgressBar(solver);
}
}
private SimulationState? hoveredState;
private SimulationState DisplayedState => hoveredState ?? (Service.Configuration.SynthHelperDisplayOnlyFirstStep ? Macro.FirstState : Macro.State);
private void DrawMacro()
{
var spacing = ImGui.GetStyle().ItemSpacing.X;
var imageSize = ImGui.GetFrameHeight() * 2;
var canExecute = !Service.Condition[ConditionFlag.ExecutingCraftingAction];
var lastState = Macro.InitialState;
hoveredState = null;
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);
var count = Macro.Count;
for (var i = 0; i < 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 (i == 0)
{
var pos = ImGui.GetCursorScreenPos();
var offsetVec2 = ImGui.GetStyle().ItemSpacing / 2;
var offset = new Vector2((offsetVec2.X + offsetVec2.Y) / 2f);
var color = canExecute ? ImGuiColors.DalamudWhite2 : ImGuiColors.DalamudGrey3;
ImGui.GetWindowDrawList().AddRectFilled(pos - offset, pos + new Vector2(imageSize) + offset, ImGui.GetColorU32(color), 4);
}
bool isHovered, isHeld, isPressed;
{
var pos = ImGui.GetCursorScreenPos();
var offset = ImGui.GetStyle().ItemSpacing / 2f;
var size = new Vector2(imageSize);
// yoinked from https://github.com/goatcorp/Dalamud/blob/48e8462550141db9b1a153cab9548e60238500c7/Dalamud/Interface/Windowing/Window.cs#L551
var min = pos - offset;
var max = pos + size + offset;
var bb = new Vector4(min.X, min.Y, max.X, max.Y);
var id = ImGui.GetID($"###ButtonContainer");
var isClipped = !ImGuiExtras.ItemAdd(bb, id, out _, 0);
isPressed = ImGuiExtras.ButtonBehavior(bb, id, out isHovered, out isHeld, ImGuiButtonFlags.None);
}
ImGui.ImageButton(action.GetIcon(RecipeData!.ClassJob).Handle, new(imageSize), default, Vector2.One, 0, default, failedAction ? new(1, 1, 1, ImGui.GetStyle().DisabledAlpha) : Vector4.One);
if (isPressed && i == 0)
{
if (ExecuteNextAction())
break;
}
if (isHovered)
{
ImGuiUtils.Tooltip($"{action.GetName(RecipeData!.ClassJob)}\n" +
$"{actionBase.GetTooltip(CreateSim(lastState), true)}" +
$"{(canExecute && i == 0 ? "Click or run /craftaction to execute" : string.Empty)}");
hoveredState = state;
}
lastState = state;
}
var rows = (int)Math.Max(1, MathF.Ceiling(Service.Configuration.SynthHelperMaxDisplayCount / itemsPerRow));
for (var i = 0; i < rows; ++i)
{
if (count <= i * itemsPerRow)
ImGui.Dummy(new(0, imageSize));
}
}
private void DrawMacroInfo()
{
var state = DisplayedState;
using (var panel = ImRaii2.GroupPanel("Buffs", -1, out _))
{
using var _font = AxisFont.Push();
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<EffectType>())
{
if (!effects.HasEffect(effect))
continue;
using (var group = ImRaii.Group())
{
var icon = effect.GetIcon(effects.GetStrength(effect));
var size = new Vector2(iconHeight * (icon.AspectRatio ?? 1), iconHeight);
ImGui.Image(icon.Handle, size);
if (!effect.IsIndefinite())
{
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - durationShift);
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 1);
ImGuiUtils.TextCentered($"{effects.GetDuration(effect)}", size.X);
}
}
if (ImGui.IsItemHovered())
{
var status = effect.Status();
using var _reset = ImRaii.DefaultFont();
ImGuiUtils.Tooltip($"{status.Name}\n{status.Description}");
}
ImGui.SameLine();
}
}
var reliability = Macro.GetReliability(RecipeData!, Service.Configuration.SynthHelperDisplayOnlyFirstStep ? 0 : ^1);
{
var mainBars = new List<DynamicBars.BarData>()
{
new("Progress", Colors.Progress, reliability.Progress, state.Progress, RecipeData!.RecipeInfo.MaxProgress),
new("Quality", Colors.Quality, reliability.Quality, state.Quality, RecipeData.RecipeInfo.MaxQuality),
new("CP", Colors.CP, state.CP, CharacterStats!.CP),
};
if (RecipeData.RecipeInfo.MaxQuality <= 0)
mainBars.RemoveAt(1);
var halfBars = new List<DynamicBars.BarData>()
{
new("Durability", Colors.Durability, state.Durability, RecipeData.RecipeInfo.MaxDurability),
};
if (RecipeData.IsCollectable)
halfBars.Add(new("Collectability", Colors.Collectability, reliability.ParamScore, state.Collectability, state.MaxCollectability, RecipeData.CollectableThresholds, $"{state.Collectability}", $"{state.MaxCollectability:0}"));
else if (RecipeData.Recipe.RequiredQuality > 0)
{
var qualityPercent = (float)state.Quality / RecipeData.Recipe.RequiredQuality * 100;
halfBars.Add(new("Quality %", Colors.HQ, reliability.ParamScore, qualityPercent, 100, null, $"{qualityPercent:0}%", null));
}
else if (RecipeData.RecipeInfo.MaxQuality > 0)
halfBars.Add(new("HQ %", Colors.HQ, reliability.ParamScore, state.HQPercent, 100, null, $"{state.HQPercent}%", null));
if (halfBars.Count > 1)
{
var textSize = DynamicBars.GetTextSize(mainBars.Concat(halfBars));
DynamicBars.Draw(mainBars, textSize);
using var table = ImRaii.Table($"##{nameof(SynthHelper)}_halfbars", halfBars.Count, ImGuiTableFlags.NoPadOuterX | ImGuiTableFlags.SizingStretchSame);
if (table)
{
foreach (var bar in halfBars)
{
ImGui.TableNextColumn();
DynamicBars.Draw(new[] { bar });
}
}
}
else
{
DynamicBars.Draw(mainBars.Concat(halfBars));
}
}
}
private void DrawMacroActions()
{
if (SolverRunning)
{
if (SolverTask?.Cancelling ?? false)
{
using var _disabled = ImRaii.Disabled();
ImGui.Button("Stopping", new(-1, 0));
if (ImGui.IsItemHovered())
ImGuiUtils.TooltipWrapped("This might could a while, sorry! Please report if this takes longer than a second.");
}
else
{
if (ImGui.Button("Stop", new(-1, 0)))
SolverTask?.Cancel();
}
}
else
{
if (ImGui.Button("Retry", new(-1, 0)))
AttemptRetry();
if (ImGui.IsItemHovered())
ImGuiUtils.TooltipWrapped("Suggest a way to finish the crafting recipe. " +
"Results aren't perfect, and levels of success " +
"can vary wildly depending on the solver's settings.");
}
if (ImGui.Button("Open in Macro Editor", new(-1, 0)))
Service.Plugin.OpenMacroEditor(CharacterStats!, RecipeData!, new(Service.Objects.LocalPlayer!.StatusList), null, [], null);
}
public bool ExecuteNextAction()
{
var canExecute = !Service.Condition[ConditionFlag.ExecutingCraftingAction];
var action = NextAction;
if (canExecute && action != null)
{
Chat.SendMessage($"/ac \"{action.Value.GetName(RecipeData!.ClassJob)}\"");
return true;
}
return false;
}
public void AttemptRetry()
{
if (!SolverRunning)
CalculateBestMacro();
}
private void OnStartCrafting(ushort recipeId)
{
var shouldUpdateInput = false;
if (recipeId != RecipeData?.RecipeId)
{
RecipeData = new(recipeId);
shouldUpdateInput = true;
}
{
var gearStats = Gearsets.CalculateGearsetCurrentStats();
var container = InventoryManager.Instance()->GetInventoryContainer(InventoryType.EquippedItems);
if (container == null)
throw new InvalidOperationException("Could not get inventory container");
var gearItems = Gearsets.GetGearsetItems(container);
var characterStats = Gearsets.CalculateCharacterStats(gearStats, gearItems, RecipeData.ClassJob.GetPlayerLevel(), RecipeData.ClassJob.CanPlayerUseManipulation());
if (characterStats != CharacterStats)
{
CharacterStats = characterStats;
shouldUpdateInput = true;
}
}
if (shouldUpdateInput)
SimulationInput = new(CharacterStats, RecipeData.RecipeInfo);
CurrentActionCount = 0;
CurrentActionStates = new();
CurrentState = GetCurrentState();
}
private void OnUseAction(ActionType action)
{
Addon = (AddonSynthesis*)Service.GameGui.GetAddonByName("Synthesis").Address;
if (Addon == null)
return;
if (Addon->AtkUnitBase.WindowNode == null)
return;
(_, CurrentState) = new SimNoRandom().Execute(GetCurrentState(), action);
CurrentActionCount = CurrentState.ActionCount;
CurrentActionStates = CurrentState.ActionStates;
}
private void RefreshCurrentState() =>
CurrentState = GetCurrentState();
private SimulationState GetCurrentState()
{
var player = Service.Objects.LocalPlayer!;
var values = new SynthesisValues(Addon);
var statusManager = ((Character*)player.Address)->GetStatusManager();
byte GetEffectStack(ushort id)
{
foreach (var status in statusManager->Status)
if (status.StatusId == id)
return (byte)status.Param;
return 0;
}
bool HasEffect(ushort id)
{
foreach (var status in statusManager->Status)
if (status.StatusId == id)
return true;
return false;
}
return new(SimulationInput!)
{
ActionCount = CurrentActionCount,
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((ushort)EffectType.InnerQuiet.StatusId()),
WasteNot = GetEffectStack((ushort)EffectType.WasteNot.StatusId()),
Veneration = GetEffectStack((ushort)EffectType.Veneration.StatusId()),
GreatStrides = GetEffectStack((ushort)EffectType.GreatStrides.StatusId()),
Innovation = GetEffectStack((ushort)EffectType.Innovation.StatusId()),
FinalAppraisal = GetEffectStack((ushort)EffectType.FinalAppraisal.StatusId()),
WasteNot2 = GetEffectStack((ushort)EffectType.WasteNot2.StatusId()),
MuscleMemory = GetEffectStack((ushort)EffectType.MuscleMemory.StatusId()),
Manipulation = GetEffectStack((ushort)EffectType.Manipulation.StatusId()),
Expedience = GetEffectStack((ushort)EffectType.Expedience.StatusId()),
TrainedPerfection = HasEffect((ushort)EffectType.TrainedPerfection.StatusId()),
HeartAndSoul = HasEffect((ushort)EffectType.HeartAndSoul.StatusId()),
},
ActionStates = CurrentActionStates
};
}
private void OnStateUpdated()
{
if (!ShouldOpen || IsCollapsed)
{
IsRecalculateQueued = true;
return;
}
IsRecalculateQueued = false;
Macro.Clear();
Macro.InitialState = CurrentState;
CalculateBestMacro();
}
private void CalculateBestMacro()
{
SolverTask?.Cancel();
Macro.ClearQueue();
Macro.Clear();
if (Service.Configuration.ConditionRandomness)
{
Service.Configuration.ConditionRandomness = false;
Service.Configuration.Save();
Macro.RecalculateState();
}
var state = CurrentState;
SolverTask = new(token => CalculateBestMacroTask(state, token, Gearsets.HasDelineations()));
SolverTask.Start();
}
private int CalculateBestMacroTask(SimulationState state, CancellationToken token, bool hasDelineations)
{
var config = Service.Configuration.SynthHelperSolverConfig;
var canUseDelineations = !Service.Configuration.CheckDelineations || hasDelineations;
if (!canUseDelineations)
config = config.FilterSpecialistActions();
token.ThrowIfCancellationRequested();
var solver = new Solver.Solver(config, state) { Token = token };
solver.OnLog += Log.Debug;
solver.OnWarn += t => Plugin.Plugin.DisplaySolverWarning(t);
solver.OnNewAction += EnqueueAction;
SolverObject = solver;
solver.Start();
_ = solver.GetTask().GetAwaiter().GetResult();
token.ThrowIfCancellationRequested();
return 0;
}
private void EnqueueAction(ActionType action)
{
var newSize = Macro.Enqueue(action, Service.Configuration.SynthHelperMaxDisplayCount);
if (newSize >= Service.Configuration.SynthHelperStepCount || newSize >= Service.Configuration.SynthHelperMaxDisplayCount)
SolverTask?.Cancel();
}
private static Sim CreateSim(in SimulationState state) =>
Service.Configuration.ConditionRandomness ? new Sim() { State = state } : new SimNoRandom() { State = state };
public void Dispose()
{
Service.Plugin.Hooks.OnActionUsed -= OnUseAction;
Service.WindowSystem.RemoveWindow(this);
AxisFont.Dispose();
}
}