Add importing of ffxivteamcraft macros

This commit is contained in:
Asriel Camora
2023-10-25 12:45:02 -07:00
parent 246234620a
commit f364b09af9
3 changed files with 452 additions and 17 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ public static class MacroCopy
var module = RaptureMacroModule.Instance();
var macro = module->GetMacro(isShared ? 1u : 0u, (uint)idx);
var text = Utf8String.FromString(macroText.Replace(Environment.NewLine, "\n"));
var text = Utf8String.FromString(macroText.ReplaceLineEndings("\n"));
module->ReplaceMacroLines(macro, text);
text->Dtor();
IMemorySpace.Free(text);
+269
View File
@@ -0,0 +1,269 @@
using Craftimizer.Plugin;
using Craftimizer.Simulator;
using Craftimizer.Simulator.Actions;
using Dalamud.Networking.Http;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace Craftimizer.Utils;
public static class MacroImport
{
public static IReadOnlyList<ActionType>? TryParseMacro(string inputMacro)
{
var actions = new List<ActionType>();
foreach(var line in inputMacro.ReplaceLineEndings("\n").Split("\n"))
{
if (TryParseLine(line) is { } action)
actions.Add(action);
}
return actions.Count > 0 ? actions : null;
}
private static ActionType? TryParseLine(string line)
{
if (line.StartsWith("/ac", StringComparison.OrdinalIgnoreCase))
line = line[3..];
else if (line.StartsWith("/action", StringComparison.OrdinalIgnoreCase))
line = line[7..];
else
return null;
line = line.TrimStart();
// get first word
if (line.StartsWith('"'))
{
line = line[1..];
var end = line.IndexOf('"', 1);
if (end != -1)
line = line[..end];
}
else
{
var end = line.IndexOf(' ', 1);
if (end != -1)
line = line[..end];
}
foreach(var action in Enum.GetValues<ActionType>())
{
if (line.Equals(action.GetName(ClassJob.Carpenter), StringComparison.OrdinalIgnoreCase))
return action;
}
return null;
}
public static bool TryParseUrl(string url, out Uri uri)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out uri!))
return false;
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
return false;
if (!uri.IsDefaultPort)
return false;
return uri.DnsSafeHost is "ffxivteamcraft.com" or "craftingway.app";
}
public static Task<RetrievedMacro> RetrieveUrl(string url, CancellationToken token)
{
if (!TryParseUrl(url, out var uri))
throw new ArgumentException("Unsupported url", nameof(url));
switch (uri.DnsSafeHost)
{
case "ffxivteamcraft.com":
return RetrieveTeamcraftUrl(uri, token);
case "craftingway.app":
return RetrieveCraftingwayUrl(uri, token);
default:
throw new UnreachableException("TryParseUrl should handle miscellaneous edge cases");
}
}
private sealed record TeamcraftMacro
{
public sealed record StringValue
{
[JsonPropertyName("stringValue")]
[JsonRequired]
public required string Value { get; set; }
public static implicit operator string(StringValue v) => v.Value;
}
public sealed record IntegerValue
{
[JsonPropertyName("integerValue")]
[JsonRequired]
public required int Value { get; set; }
public static implicit operator int(IntegerValue v) => v.Value;
}
public sealed record MapValue<T>
{
public sealed record ValueData
{
[JsonRequired]
public required T Fields { get; set; }
}
[JsonPropertyName("mapValue")]
[JsonRequired]
public required ValueData Data { get; set; }
public T Value => Data.Fields;
public static implicit operator T(MapValue<T> v) => v.Value;
}
public sealed record ArrayValue<T>
{
public sealed record ValueData
{
[JsonRequired]
public required T[] Values { get; set; }
}
[JsonPropertyName("arrayValue")]
[JsonRequired]
public required ValueData Data { get; set; }
public T[] Value => Data.Values;
public static implicit operator T[](ArrayValue<T> v) => v.Value;
}
public sealed record RecipeFieldData
{
[JsonRequired]
public required IntegerValue RLvl { get; set; }
[JsonRequired]
public required IntegerValue Durability { get; set; }
}
public sealed record FieldData
{
public StringValue? Name { get; set; }
[JsonRequired]
public required ArrayValue<StringValue> Rotation { get; set; }
public MapValue<RecipeFieldData>? Recipe { get; set; }
}
public sealed record ErrorData
{
public required int Code { get; set; }
public required string Message { get; set; }
public required string Status { get; set; }
}
public FieldData? Fields { get; set; }
public ErrorData? Error { get; set; }
}
public readonly record struct RetrievedMacro(string Name, IReadOnlyList<ActionType> Actions);
private static async Task<RetrievedMacro> RetrieveTeamcraftUrl(Uri uri, CancellationToken token)
{
using var heCallback = new HappyEyeballsCallback();
using var client = new HttpClient(new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.All,
ConnectCallback = heCallback.ConnectCallback,
});
var path = uri.GetComponents(UriComponents.Path, UriFormat.SafeUnescaped);
if (!path.StartsWith("simulator/", StringComparison.Ordinal))
throw new ArgumentException("Teamcraft macro url should start with /simulator", nameof(uri));
path = path[10..];
var lastSlash = path.LastIndexOf('/');
if (lastSlash == -1)
throw new ArgumentException("Teamcraft macro url is not in the right format", nameof(uri));
var id = path[(lastSlash + 1)..];
var resp = await client.GetFromJsonAsync<TeamcraftMacro>($"https://firestore.googleapis.com/v1beta1/projects/ffxivteamcraft/databases/(default)/documents/rotations/{id}", token).ConfigureAwait(false);
if (resp is null)
throw new Exception("Internal error; failed to retrieve macro");
if (resp.Error is { } error)
throw new Exception($"Internal server error ({error.Status}); {error.Message}");
if (resp.Fields is not { } rotation)
throw new Exception($"Internal error; No fields or error was returned");
// https://github.com/ffxiv-teamcraft/ffxiv-teamcraft/blob/67f453041c6b2b31d32fcf6e1fd53aa38ed7a12b/apps/client/src/app/model/other/crafting-rotation.ts#L49
var name = rotation.Name?.Value ??
(rotation.Recipe is { Value: var recipe } ?
$"rlvl{recipe.RLvl.Value} - {rotation.Rotation.Value.Length} steps, {recipe.Durability.Value} dur" :
"New Teamcraft Rotation");
var actions = new List<ActionType>();
foreach(var action in rotation.Rotation.Value)
{
ActionType? actionType = action.Value switch
{
"BasicSynthesis" => ActionType.BasicSynthesis,
"CarefulSynthesis" => ActionType.CarefulSynthesis,
"PrudentSynthesis" => ActionType.PrudentSynthesis,
"RapidSynthesis" => ActionType.RapidSynthesis,
"Groundwork" => ActionType.Groundwork,
"FocusedSynthesis" => ActionType.FocusedSynthesis,
"MuscleMemory" => ActionType.MuscleMemory,
"IntensiveSynthesis" => ActionType.IntensiveSynthesis,
"BasicTouch" => ActionType.BasicTouch,
"StandardTouch" => ActionType.StandardTouch,
"AdvancedTouch" => ActionType.AdvancedTouch,
"HastyTouch" => ActionType.HastyTouch,
"ByregotsBlessing" => ActionType.ByregotsBlessing,
"PreciseTouch" => ActionType.PreciseTouch,
"FocusedTouch" => ActionType.FocusedTouch,
"PrudentTouch" => ActionType.PrudentTouch,
"TrainedEye" => ActionType.TrainedEye,
"PreparatoryTouch" => ActionType.PreparatoryTouch,
"Reflect" => ActionType.Reflect,
"TrainedFinesse" => ActionType.TrainedFinesse,
"TricksOfTheTrade" => ActionType.TricksOfTheTrade,
"MastersMend" => ActionType.MastersMend,
"Manipulation" => ActionType.Manipulation,
"WasteNot" => ActionType.WasteNot,
"WasteNotII" => ActionType.WasteNot2,
"GreatStrides" => ActionType.GreatStrides,
"Innovation" => ActionType.Innovation,
"Veneration" => ActionType.Veneration,
"FinalAppraisal" => ActionType.FinalAppraisal,
"Observe" => ActionType.Observe,
"HeartAndSoul" => ActionType.HeartAndSoul,
"CarefulObservation" => ActionType.CarefulObservation,
"DelicateSynthesis" => ActionType.DelicateSynthesis,
"RemoveFinalAppraisal" => throw new Exception("Removing Final Appraisal is an unsupported action"),
null => null,
{ } actionValue => throw new Exception($"Unknown action {actionValue}"),
};
if (actionType.HasValue)
actions.Add(actionType.Value);
}
return new(name, actions);
}
private static async Task<RetrievedMacro> RetrieveCraftingwayUrl(Uri uri, CancellationToken token)
{
using var heCallback = new HappyEyeballsCallback();
using var client = new HttpClient(new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.All,
ConnectCallback = heCallback.ConnectCallback,
});
throw new NotImplementedException();
}
}
+177 -11
View File
@@ -6,8 +6,10 @@ using Craftimizer.Utils;
using Dalamud.Game.ClientState.Statuses;
using Dalamud.Game.Text;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.GameFonts;
using Dalamud.Interface.Internal;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Utility;
@@ -104,6 +106,14 @@ public sealed class MacroEditor : Window, IDisposable
private IDalamudTextureWrap EatFromTheHandBadge { get; }
private GameFontHandle AxisFont { get; }
private string popupSaveAsMacroName = string.Empty;
private string popupImportText = string.Empty;
private string popupImportUrl = string.Empty;
private string popupImportError = string.Empty;
private CancellationTokenSource? popupImportUrlTokenSource;
private MacroImport.RetrievedMacro? popupImportUrlMacro;
public MacroEditor(CharacterStats characterStats, RecipeData recipeData, CrafterBuffs buffs, IEnumerable<ActionType> actions, Action<IEnumerable<ActionType>>? setter) : base("Craftimizer Macro Editor", WindowFlags, false)
{
CharacterStats = characterStats;
@@ -168,7 +178,8 @@ public sealed class MacroEditor : Window, IDisposable
ImGui.Separator();
using (var table = ImRaii.Table("macroInfo", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchSame)) {
using (var table = ImRaii.Table("macroInfo", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchSame))
{
if (table)
{
ImGui.TableSetupColumn("col1", ImGuiTableColumnFlags.WidthStretch, 2);
@@ -1138,7 +1149,7 @@ public sealed class MacroEditor : Window, IDisposable
{
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 width = availWidth - ((spacing + height) * (3 + (DefaultActions.Length > 0 ? 1 : 0))); // small buttons at the end
var halfWidth = (width - spacing) / 2f;
var quarterWidth = (halfWidth - spacing) / 2f;
@@ -1191,6 +1202,15 @@ public sealed class MacroEditor : Window, IDisposable
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Copy to Clipboard");
ImGui.SameLine();
using (var _disabled = ImRaii.Disabled(SolverRunning))
{
if (ImGuiUtils.IconButtonSized(FontAwesomeIcon.FileImport, new(height)))
ShowImportPopup();
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip("Import Macro");
DrawImportPopup();
ImGui.SameLine();
if (DefaultActions.Length > 0)
{
using (var _disabled = ImRaii.Disabled(SolverRunning))
@@ -1219,11 +1239,10 @@ public sealed class MacroEditor : Window, IDisposable
ImGui.SetTooltip("Clear");
}
private string popupMacroName = string.Empty;
private void ShowSaveAsPopup()
{
ImGui.OpenPopup($"##saveAsPopup");
popupMacroName = string.Empty;
popupSaveAsMacroName = string.Empty;
ImGui.SetNextWindowPos(ImGui.GetMousePos() - new Vector2(ImGui.CalcItemWidth() * .25f, ImGui.GetFrameHeight() + ImGui.GetStyle().WindowPadding.Y * 2));
}
@@ -1235,11 +1254,11 @@ public sealed class MacroEditor : Window, IDisposable
if (ImGui.IsWindowAppearing())
ImGui.SetKeyboardFocusHere();
ImGui.SetNextItemWidth(ImGui.CalcItemWidth());
if (ImGui.InputTextWithHint($"##setName", "Name", ref popupMacroName, 100, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.EnterReturnsTrue))
if (ImGui.InputTextWithHint($"##setName", "Name", ref popupSaveAsMacroName, 100, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.EnterReturnsTrue))
{
if (!string.IsNullOrWhiteSpace(popupMacroName))
if (!string.IsNullOrWhiteSpace(popupSaveAsMacroName))
{
var newMacro = new Macro() { Name = popupMacroName, Actions = Macro.Select(s => s.Action).ToArray() };
var newMacro = new Macro() { Name = popupSaveAsMacroName, Actions = Macro.Select(s => s.Action).ToArray() };
Service.Configuration.AddMacro(newMacro);
MacroSetter = actions =>
{
@@ -1252,6 +1271,152 @@ public sealed class MacroEditor : Window, IDisposable
}
}
private void ShowImportPopup()
{
ImGui.OpenPopup($"##importPopup");
popupImportText = string.Empty;
popupImportUrl = string.Empty;
popupImportError = string.Empty;
popupImportUrlMacro = null;
popupImportUrlTokenSource = null;
}
private void DrawImportPopup()
{
const string ExampleMacro = "/mlock\n/ac \"Muscle Memory\" <wait.3>\n/ac Manipulation <wait.2>\n/ac Veneration <wait.2>\n/ac \"Waste Not II\" <wait.2>\n/ac Groundwork <wait.3>\n/ac Innovation <wait.2>\n/ac \"Preparatory Touch\" <wait.3>\n/ac \"Preparatory Touch\" <wait.3>\n/ac \"Preparatory Touch\" <wait.3>\n/ac \"Preparatory Touch\" <wait.3>\n/ac \"Great Strides\" <wait.2>\n/ac \"Byregot's Blessing\" <wait.3>\n/ac \"Careful Synthesis\" <wait.3>";
const string ExampleUrl = "https://ffxivteamcraft.com/simulator/39630/35499/9XOZDZKhbVXJUIPXjM63";
ImGui.SetNextWindowPos(ImGui.GetMainViewport().GetCenter(), ImGuiCond.Always, new Vector2(0.5f));
ImGui.SetNextWindowSizeConstraints(new(400, 0), new(float.PositiveInfinity));
using var popup = ImRaii.Popup($"##importPopup", ImGuiWindowFlags.Modal | ImGuiWindowFlags.NoMove);
if (popup)
{
bool submittedText, submittedUrl;
using (var panel = ImGuiUtils.GroupPanel("##text", -1, out var availWidth))
{
ImGui.AlignTextToFramePadding();
ImGuiUtils.TextCentered("Paste your macro here");
{
using var font = ImRaii.PushFont(UiBuilder.MonoFont);
ImGuiUtils.InputTextMultilineWithHint("", ExampleMacro, ref popupImportText, 2048, new(availWidth, ImGui.GetTextLineHeight() * 15 + ImGui.GetStyle().FramePadding.Y), ImGuiInputTextFlags.AutoSelectAll);
}
using (var _disabled = ImRaii.Disabled(popupImportUrlTokenSource != null))
submittedText = ImGui.Button("Import", new(availWidth, 0));
}
using (var panel = ImGuiUtils.GroupPanel("##url", -1, out var availWidth))
{
var availOffset = ImGui.GetContentRegionAvail().X - availWidth;
ImGui.AlignTextToFramePadding();
ImGuiUtils.TextCentered("or provide a url to it");
ImGui.SameLine();
using (var color = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey))
{
using var font = ImRaii.PushFont(UiBuilder.IconFont);
ImGuiUtils.TextRight(FontAwesomeIcon.InfoCircle.ToIconString(), ImGui.GetContentRegionAvail().X - availOffset);
}
if (ImGui.IsItemHovered())
{
using var t = ImRaii.Tooltip();
ImGui.Text("Supported sites:");
ImGui.BulletText("ffxivteamcraft.com");
ImGui.BulletText("craftingway.app");
ImGui.Text("More suggestions are appreciated!");
}
ImGui.SetNextItemWidth(availWidth);
submittedUrl = ImGui.InputTextWithHint("", ExampleUrl, ref popupImportUrl, 2048, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.EnterReturnsTrue);
using (var _disabled = ImRaii.Disabled(popupImportUrlTokenSource != null))
submittedUrl = ImGui.Button("Import", new(availWidth, 0)) || submittedUrl;
}
ImGui.Dummy(default);
if (!string.IsNullOrWhiteSpace(popupImportError))
{
using (var c = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed))
ImGui.TextWrapped(popupImportError);
ImGui.Dummy(default);
}
if (ImGuiUtils.ButtonCentered("Nevermind", new(ImGui.GetContentRegionAvail().X / 2f, 0)))
{
popupImportUrlTokenSource?.Cancel();
ImGui.CloseCurrentPopup();
}
if (popupImportUrlTokenSource == null)
{
if (submittedText)
{
if (MacroImport.TryParseMacro(popupImportText) is { } parsedActions)
{
popupImportUrlTokenSource?.Cancel();
Macro.Clear();
foreach (var action in parsedActions)
AddStep(action);
Service.PluginInterface.UiBuilder.AddNotification($"Imported macro with {parsedActions.Count} step{(parsedActions.Count != 1 ? "s" : "")}", "Craftimizer Macro Imported", NotificationType.Success);
popupImportUrlTokenSource?.Cancel();
ImGui.CloseCurrentPopup();
}
else
popupImportError = "Could not find any actions to import. Is it a valid macro?";
}
if (submittedUrl)
{
if (MacroImport.TryParseUrl(popupImportUrl, out _))
{
popupImportUrlTokenSource = new();
popupImportUrlMacro = null;
var token = popupImportUrlTokenSource.Token;
var url = popupImportUrl;
var task = Task.Run(() => MacroImport.RetrieveUrl(url, token), token);
_ = task.ContinueWith(t =>
{
if (token == popupImportUrlTokenSource.Token)
popupImportUrlTokenSource = null;
});
_ = task.ContinueWith(t =>
{
if (token.IsCancellationRequested)
return;
try
{
t.Exception!.Flatten().Handle(ex => ex is TaskCanceledException or OperationCanceledException);
}
catch (AggregateException e)
{
popupImportError = e.Message;
Log.Error(e, "Retrieving macro failed");
}
}, TaskContinuationOptions.OnlyOnFaulted);
_ = task.ContinueWith(t => popupImportUrlMacro = t.Result, TaskContinuationOptions.OnlyOnRanToCompletion);
}
else
popupImportError = "The url is not in the right format for any supported sites.";
}
if (popupImportUrlMacro is { Name: var name, Actions: var actions })
{
Macro.Clear();
foreach(var action in actions)
AddStep(action);
Service.PluginInterface.UiBuilder.AddNotification($"Imported macro \"{name}\"", "Craftimizer Macro Imported", NotificationType.Success);
popupImportUrlTokenSource?.Cancel();
ImGui.CloseCurrentPopup();
}
}
}
else
{
popupImportUrlTokenSource?.Cancel();
popupImportUrlTokenSource = null;
}
}
private void CalculateBestMacro()
{
SolverTokenSource?.Cancel();
@@ -1294,7 +1459,7 @@ public sealed class MacroEditor : Window, IDisposable
var solver = new Solver.Solver(config, state) { Token = token };
solver.OnLog += Log.Debug;
solver.OnNewAction += a => AddStep(a, isMacro: true);
solver.OnNewAction += a => AddStep(a, isSolver: true);
solver.Start();
_ = solver.GetTask().GetAwaiter().GetResult();
@@ -1321,11 +1486,11 @@ public sealed class MacroEditor : Window, IDisposable
lastState = ((step.Response, step.State) = sim.Execute(lastState, step.Action)).State;
}
private void AddStep(ActionType action, int index = -1, bool isMacro = false)
private void AddStep(ActionType action, int index = -1, bool isSolver = false)
{
if (index < -1 || index >= Macro.Count)
throw new ArgumentOutOfRangeException(nameof(index));
if (!isMacro && SolverRunning)
if (!isSolver && SolverRunning)
throw new InvalidOperationException("Cannot add steps while solver is running");
if (!SolverRunning)
SolverStartStepCount = null;
@@ -1336,7 +1501,8 @@ public sealed class MacroEditor : Window, IDisposable
var resp = sim.Execute(State, action);
Macro.Add(new() { Action = action, Response = resp.Response, State = resp.NewState });
}
else {
else
{
var state = index == 0 ? InitialState : Macro[index - 1].State;
var sim = new Sim(state);
var resp = sim.Execute(state, action);