Auto-Moving to gathering locations
authorLiza Carvelli <liza@carvel.li>
Sat, 3 Aug 2024 09:17:20 +0000 (11:17 +0200)
committerLiza Carvelli <liza@carvel.li>
Sat, 3 Aug 2024 09:17:20 +0000 (11:17 +0200)
26 files changed:
GatheringPathRenderer/RendererPlugin.cs
GatheringPathRenderer/Windows/EditorWindow.cs
GatheringPaths/6.x - Endwalker/Thavnair/820_Pewter Ore.json
GatheringPaths/7.x - Dawntrail/Urqopacha/974_Mountain Chromite Ore.json
GatheringPaths/7.x - Dawntrail/Urqopacha/992_Snow Cotton.json
GatheringPaths/7.x - Dawntrail/Urqopacha/993_Turali Aloe.json
QuestPaths/6.x - Endwalker/Studium Deliveries/MIN, BTN/4154_Cooking Up a Culture.json
QuestPaths/quest-v1.json
Questionable.Model/GatheringMath.cs [new file with mode: 0644]
Questionable.Model/Questing/GatheredItem.cs [new file with mode: 0644]
Questionable.Model/Questing/QuestStep.cs
Questionable.sln.DotSettings
Questionable/Controller/GatheringController.cs [new file with mode: 0644]
Questionable/Controller/MiniTaskController.cs [new file with mode: 0644]
Questionable/Controller/QuestController.cs
Questionable/Controller/Steps/Gathering/MoveToLandingLocation.cs [new file with mode: 0644]
Questionable/Controller/Steps/Gathering/WaitGather.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/Interact.cs
Questionable/Controller/Steps/Shared/GatheringRequiredItems.cs [new file with mode: 0644]
Questionable/Data/GatheringData.cs [new file with mode: 0644]
Questionable/GameFunctions.cs
Questionable/Questionable.csproj
Questionable/QuestionablePlugin.cs
Questionable/Windows/QuestComponents/ActiveQuestComponent.cs
Questionable/Windows/QuestComponents/RemainingTasksComponent.cs
Questionable/packages.lock.json

index 8e1c864bbaca1afafc4bc381fa1d1a28be3af260..ef2b352ca9bdde995fa458fc92bf2803199d7d9e 100644 (file)
@@ -2,6 +2,7 @@
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
+using System.Security.Cryptography;
 using System.Text.Json;
 using System.Text.Json.Nodes;
 using System.Text.Json.Serialization;
@@ -12,6 +13,7 @@ using Dalamud.Plugin.Services;
 using ECommons;
 using ECommons.Schedulers;
 using ECommons.SplatoonAPI;
+using FFXIVClientStructs.FFXIV.Common.Math;
 using GatheringPathRenderer.Windows;
 using Questionable.Model;
 using Questionable.Model.Gathering;
@@ -36,14 +38,15 @@ public sealed class RendererPlugin : IDalamudPlugin
 
     public RendererPlugin(IDalamudPluginInterface pluginInterface, IClientState clientState,
         ICommandManager commandManager, IDataManager dataManager, ITargetManager targetManager, IChatGui chatGui,
-        IPluginLog pluginLog)
+        IObjectTable objectTable, IPluginLog pluginLog)
     {
         _pluginInterface = pluginInterface;
         _clientState = clientState;
         _pluginLog = pluginLog;
 
         _editorCommands = new EditorCommands(this, dataManager, commandManager, targetManager, clientState, chatGui);
-        _editorWindow = new EditorWindow(this, _editorCommands, dataManager, targetManager, clientState) { IsOpen = true };
+        _editorWindow = new EditorWindow(this, _editorCommands, dataManager, targetManager, clientState, objectTable)
+            { IsOpen = true };
         _windowSystem.AddWindow(_editorWindow);
 
         _pluginInterface.GetIpcSubscriber<object>("Questionable.ReloadData")
@@ -175,7 +178,8 @@ public sealed class RendererPlugin : IDalamudPlugin
                             bool isCone = false;
                             int minimumAngle = 0;
                             int maximumAngle = 0;
-                            if (_editorWindow.TryGetOverride(x.InternalId, out LocationOverride? locationOverride) && locationOverride != null)
+                            if (_editorWindow.TryGetOverride(x.InternalId, out LocationOverride? locationOverride) &&
+                                locationOverride != null)
                             {
                                 if (locationOverride.IsCone())
                                 {
@@ -192,6 +196,8 @@ public sealed class RendererPlugin : IDalamudPlugin
                                 maximumAngle = x.MaximumAngle.GetValueOrDefault();
                             }
 
+                            var a = GatheringMath.CalculateLandingLocation(x, 0, 0);
+                            var b = GatheringMath.CalculateLandingLocation(x, 1, 1);
                             return new List<Element>
                             {
                                 new Element(isCone
@@ -219,6 +225,26 @@ public sealed class RendererPlugin : IDalamudPlugin
                                     Enabled = true,
                                     overlayText =
                                         $"{location.Root.Groups.IndexOf(group)} // {node.DataId} / {node.Locations.IndexOf(x)}",
+                                },
+                                new Element(ElementType.CircleAtFixedCoordinates)
+                                {
+                                    refX = a.X,
+                                    refY = a.Z,
+                                    refZ = a.Y,
+                                    color = _colors[0],
+                                    radius = 0.1f,
+                                    Enabled = true,
+                                    overlayText = "Min Angle"
+                                },
+                                new Element(ElementType.CircleAtFixedCoordinates)
+                                {
+                                    refX = b.X,
+                                    refY = b.Z,
+                                    refZ = b.Y,
+                                    color = _colors[1],
+                                    radius = 0.1f,
+                                    Enabled = true,
+                                    overlayText = "Max Angle"
                                 }
                             };
                         }))))
index ae8a77d3a2f6b4c92093f092ff7a485b4f597827..ec7293f634aa4f98aef3d5b74adee3b1873f4b7c 100644 (file)
@@ -22,15 +22,19 @@ internal sealed class EditorWindow : Window
     private readonly IDataManager _dataManager;
     private readonly ITargetManager _targetManager;
     private readonly IClientState _clientState;
+    private readonly IObjectTable _objectTable;
 
     private readonly Dictionary<Guid, LocationOverride> _changes = [];
 
     private IGameObject? _target;
-    private (RendererPlugin.GatheringLocationContext, GatheringLocation)? _targetLocation;
+
+    private (RendererPlugin.GatheringLocationContext Context, GatheringNode Node, GatheringLocation Location)?
+        _targetLocation;
+
     private string _newFileName = string.Empty;
 
     public EditorWindow(RendererPlugin plugin, EditorCommands editorCommands, IDataManager dataManager,
-        ITargetManager targetManager, IClientState clientState)
+        ITargetManager targetManager, IClientState clientState, IObjectTable objectTable)
         : base("Gathering Path Editor###QuestionableGatheringPathEditor")
     {
         _plugin = plugin;
@@ -38,38 +42,44 @@ internal sealed class EditorWindow : Window
         _dataManager = dataManager;
         _targetManager = targetManager;
         _clientState = clientState;
+        _objectTable = objectTable;
 
         SizeConstraints = new WindowSizeConstraints
         {
             MinimumSize = new Vector2(300, 300),
         };
+        ShowCloseButton = false;
     }
 
     public override void Update()
     {
         _target = _targetManager.Target;
-        if (_target == null || _target.ObjectKind != ObjectKind.GatheringPoint)
-        {
-            _targetLocation = null;
-            return;
-        }
-
         var gatheringLocations = _plugin.GetLocationsInTerritory(_clientState.TerritoryType);
         var location = gatheringLocations.SelectMany(context =>
                 context.Root.Groups.SelectMany(group =>
                     group.Nodes
-                        .Where(node => node.DataId == _target.DataId)
-                        .SelectMany(node => node.Locations)
-                        .Where(location => Vector3.Distance(location.Position, _target.Position) < 0.1f)
-                        .Select(location => new { Context = context, Location = location })))
+                        .SelectMany(node => node.Locations
+                            .Where(location =>
+                            {
+                                if (_target != null)
+                                    return Vector3.Distance(location.Position, _target.Position) < 0.1f;
+                                else
+                                    return Vector3.Distance(location.Position, _clientState.LocalPlayer!.Position) < 3f;
+                            })
+                            .Select(location => new { Context = context, Node = node, Location = location }))))
             .FirstOrDefault();
-        if (location == null)
+        if (_target != null && _target.ObjectKind != ObjectKind.GatheringPoint || location == null)
         {
+            _target = null;
             _targetLocation = null;
             return;
         }
 
-        _targetLocation = (location.Context, location.Location);
+        _target ??= _objectTable.FirstOrDefault(
+            x => x.ObjectKind == ObjectKind.GatheringPoint &&
+                 x.DataId == location.Node.DataId &&
+                 Vector3.Distance(location.Location.Position, _clientState.LocalPlayer!.Position) < 3f);
+        _targetLocation = (location.Context, location.Node, location.Location);
     }
 
     public override bool DrawConditions()
@@ -81,8 +91,9 @@ internal sealed class EditorWindow : Window
     {
         if (_target != null && _targetLocation != null)
         {
-            var context = _targetLocation.Value.Item1;
-            var location = _targetLocation.Value.Item2;
+            var context = _targetLocation.Value.Context;
+            var node = _targetLocation.Value.Node;
+            var location = _targetLocation.Value.Location;
             ImGui.Text(context.File.Directory?.Name ?? string.Empty);
             ImGui.Indent();
             ImGui.Text(context.File.Name);
@@ -97,7 +108,7 @@ internal sealed class EditorWindow : Window
             }
 
             int minAngle = locationOverride.MinimumAngle ?? location.MinimumAngle.GetValueOrDefault();
-            if (ImGui.DragInt("Min Angle", ref minAngle, 5, -180, 360))
+            if (ImGui.DragInt("Min Angle", ref minAngle, 5, -360, 360))
             {
                 locationOverride.MinimumAngle = minAngle;
                 locationOverride.MaximumAngle ??= location.MaximumAngle.GetValueOrDefault();
@@ -105,7 +116,7 @@ internal sealed class EditorWindow : Window
             }
 
             int maxAngle = locationOverride.MaximumAngle ?? location.MaximumAngle.GetValueOrDefault();
-            if (ImGui.DragInt("Max Angle", ref maxAngle, 5, -180, 360))
+            if (ImGui.DragInt("Max Angle", ref maxAngle, 5, -360, 360))
             {
                 locationOverride.MinimumAngle ??= location.MinimumAngle.GetValueOrDefault();
                 locationOverride.MaximumAngle = maxAngle;
@@ -119,14 +130,33 @@ internal sealed class EditorWindow : Window
                 location.MaximumAngle = locationOverride.MaximumAngle;
                 _plugin.Save(context.File, context.Root);
             }
+
             ImGui.SameLine();
             if (ImGui.Button("Reset"))
             {
                 _changes[location.InternalId] = new LocationOverride();
                 _plugin.Redraw();
             }
+
             ImGui.EndDisabled();
 
+
+            List<IGameObject> nodesInObjectTable = _objectTable
+                .Where(x => x.ObjectKind == ObjectKind.GatheringPoint && x.DataId == _target.DataId)
+                .ToList();
+            List<IGameObject> missingLocations = nodesInObjectTable
+                .Where(x => !node.Locations.Any(y => Vector3.Distance(x.Position, y.Position) < 0.1f))
+                .ToList();
+            if (missingLocations.Count > 0)
+            {
+                if (ImGui.Button("Add missing locations"))
+                {
+                    foreach (var missing in missingLocations)
+                        _editorCommands.AddToExistingGroup(context.Root, missing);
+
+                    _plugin.Save(context.File, context.Root);
+                }
+            }
         }
         else if (_target != null)
         {
@@ -154,6 +184,7 @@ internal sealed class EditorWindow : Window
                     _editorCommands.AddToNewGroup(root, _target);
                     _plugin.Save(targetFile, root);
                 }
+
                 ImGui.EndDisabled();
             }
             else
@@ -176,7 +207,7 @@ internal sealed class EditorWindow : Window
         => _changes.TryGetValue(internalId, out locationOverride);
 }
 
-internal class LocationOverride
+internal sealed class LocationOverride
 {
     public int? MinimumAngle { get; set; }
     public int? MaximumAngle { get; set; }
index de17572581e8492ac91c71bf6614ecf5e8efe76b..d5a75e9c4e654f29403940f88b388db85e8eab99 100644 (file)
               },
               "MinimumAngle": 200,
               "MaximumAngle": 360
+            },
+            {
+              "Position": {
+                "X": -606.7445,
+                "Y": 38.37634,
+                "Z": -425.5284
+              },
+              "MinimumAngle": -80,
+              "MaximumAngle": 70
             }
           ]
         }
                 "Y": 67.64153,
                 "Z": -477.6673
               },
-              "MinimumAngle": -90,
-              "MaximumAngle": 60
+              "MinimumAngle": -105,
+              "MaximumAngle": 75
             }
           ]
         }
       ]
     }
   ]
-}
+}
\ No newline at end of file
index 83bd6138128530bc70510c89e1a48cc74ef29ff8..8b3ade12c2126f6fee36ccb15dc105ca53cb994d 100644 (file)
@@ -2,6 +2,7 @@
   "$schema": "https://git.carvel.li/liza/Questionable/raw/branch/master/GatheringPaths/gatheringlocation-v1.json",
   "Author": [],
   "TerritoryId": 1187,
+  "AetheryteShortcut": "Urqopacha - Wachunpelo",
   "Groups": [
     {
       "Nodes": [
               },
               "MinimumAngle": -50,
               "MaximumAngle": 210
+            },
+            {
+              "Position": {
+                "X": -394.2657,
+                "Y": -47.86026,
+                "Z": -394.9654
+              },
+              "MinimumAngle": -120,
+              "MaximumAngle": 120
             }
           ]
         }
               },
               "MinimumAngle": 225,
               "MaximumAngle": 360
+            },
+            {
+              "Position": {
+                "X": -532.3487,
+                "Y": -22.79275,
+                "Z": -510.8069
+              },
+              "MinimumAngle": 135,
+              "MaximumAngle": 270
+            },
+            {
+              "Position": {
+                "X": -536.2922,
+                "Y": -23.79476,
+                "Z": -526.0406
+              },
+              "MinimumAngle": -110,
+              "MaximumAngle": 35
             }
           ]
         }
               },
               "MinimumAngle": 0,
               "MaximumAngle": 150
+            },
+            {
+              "Position": {
+                "X": -431.5875,
+                "Y": -16.68724,
+                "Z": -656.528
+              },
+              "MinimumAngle": -35,
+              "MaximumAngle": 90
+            },
+            {
+              "Position": {
+                "X": -439.8079,
+                "Y": -16.67447,
+                "Z": -654.6749
+              },
+              "MinimumAngle": -45,
+              "MaximumAngle": 85
             }
           ]
         }
       ]
     }
   ]
-}
+}
\ No newline at end of file
index 7cf1be27a3e1ca1439d87563b9cf9615c3d62ca9..494b74d6b1597be26649cd56cacd6b275bcd6742 100644 (file)
@@ -2,6 +2,7 @@
   "$schema": "https://git.carvel.li/liza/Questionable/raw/branch/master/GatheringPaths/gatheringlocation-v1.json",
   "Author": [],
   "TerritoryId": 1187,
+  "AetheryteShortcut": "Urqopacha - Wachunpelo",
   "Groups": [
     {
       "Nodes": [
                 "Y": -129.3952,
                 "Z": -396.6573
               }
+            },
+            {
+              "Position": {
+                "X": -16.08351,
+                "Y": -137.6674,
+                "Z": -464.35
+              },
+              "MinimumAngle": -65,
+              "MaximumAngle": 145
+            },
+            {
+              "Position": {
+                "X": -9.000858,
+                "Y": -134.9256,
+                "Z": -439.0332
+              },
+              "MinimumAngle": -125,
+              "MaximumAngle": 105
             }
           ]
         }
               },
               "MinimumAngle": -180,
               "MaximumAngle": 45
+            },
+            {
+              "Position": {
+                "X": -249.7221,
+                "Y": -96.55618,
+                "Z": -386.2397
+              },
+              "MinimumAngle": 35,
+              "MaximumAngle": 280
+            },
+            {
+              "Position": {
+                "X": -241.8424,
+                "Y": -99.37369,
+                "Z": -386.2889
+              },
+              "MinimumAngle": -300,
+              "MaximumAngle": -45
             }
           ]
         }
                 "Y": -85.61841,
                 "Z": -240.1007
               }
+            },
+            {
+              "Position": {
+                "X": -116.6446,
+                "Y": -93.99508,
+                "Z": -274.6102
+              },
+              "MinimumAngle": -140,
+              "MaximumAngle": 150
+            },
+            {
+              "Position": {
+                "X": -133.936,
+                "Y": -91.54122,
+                "Z": -273.3963
+              },
+              "MinimumAngle": -155,
+              "MaximumAngle": 85
             }
           ]
         },
index e17f0f0dde614798d172fe785080ba6b7895d8b8..b011797075b12115374bf85dbdd07cad74266bde 100644 (file)
@@ -2,6 +2,7 @@
   "$schema": "https://git.carvel.li/liza/Questionable/raw/branch/master/GatheringPaths/gatheringlocation-v1.json",
   "Author": [],
   "TerritoryId": 1187,
+  "AetheryteShortcut": "Urqopacha - Wachunpelo",
   "Groups": [
     {
       "Nodes": [
                 "X": 242.7737,
                 "Y": -135.9734,
                 "Z": -431.2313
+              },
+              "MinimumAngle": -55,
+              "MaximumAngle": 100
+            },
+            {
+              "Position": {
+                "X": 302.1836,
+                "Y": -135.4149,
+                "Z": -359.7965
+              },
+              "MinimumAngle": 5,
+              "MaximumAngle": 155
+            },
+            {
+              "Position": {
+                "X": 256.1657,
+                "Y": -135.744,
+                "Z": -414.7577
               }
             }
           ]
@@ -25,7 +44,9 @@
                 "X": 269.7338,
                 "Y": -134.0488,
                 "Z": -381.6242
-              }
+              },
+              "MinimumAngle": -85,
+              "MaximumAngle": 145
             }
           ]
         }
               },
               "MinimumAngle": 105,
               "MaximumAngle": 345
+            },
+            {
+              "Position": {
+                "X": 401.9319,
+                "Y": -150.0004,
+                "Z": -408.114
+              },
+              "MinimumAngle": -70,
+              "MaximumAngle": 85
+            },
+            {
+              "Position": {
+                "X": 406.1098,
+                "Y": -152.2166,
+                "Z": -364.7227
+              },
+              "MinimumAngle": -210,
+              "MaximumAngle": 35
             }
           ]
         },
                 "Y": -161.1972,
                 "Z": -644.0471
               }
+            },
+            {
+              "Position": {
+                "X": 307.4235,
+                "Y": -159.1669,
+                "Z": -622.6444
+              }
+            },
+            {
+              "Position": {
+                "X": 348.5925,
+                "Y": -165.3805,
+                "Z": -671.4193
+              }
             }
           ]
         },
index ea7b708c3368eabd5715fd56be3b5d76f0c6c368..4be51ddada1774a75dc3d70b644ee9a67833d87e 100644 (file)
             "Z": -102.983154
           },
           "TerritoryId": 962,
-          "InteractionType": "CompleteQuest"
+          "InteractionType": "CompleteQuest",
+          "RequiredGatheredItems": [
+            {
+              "ItemId": 35600,
+              "ItemCount": 6,
+              "Collectability": 600
+            }
+          ]
         }
       ]
     }
index a11a610c6be5d1687243eac6e181f11af3ca8c22..f18d33a74eb154110ce6342d8668a3418033108b 100644 (file)
                     }
                   }
                 },
+                "RequiredGatheredItems": {
+                  "type": "array",
+                  "items": {
+                    "type": "object",
+                    "properties": {
+                      "ItemId": {
+                        "type": "number"
+                      },
+                      "ItemCount": {
+                        "type": "number",
+                        "exclusiveMinimum": 0
+                      },
+                      "Collectability": {
+                        "type": "number",
+                        "minimum": 0,
+                        "maximum": 1000
+                      }
+                    },
+                    "required": [
+                      "ItemId",
+                      "ItemCount"
+                    ]
+                  }
+                },
                 "DelaySecondsAtStart": {
                   "description": "Time to wait before starting",
                   "type": [
diff --git a/Questionable.Model/GatheringMath.cs b/Questionable.Model/GatheringMath.cs
new file mode 100644 (file)
index 0000000..46105e1
--- /dev/null
@@ -0,0 +1,53 @@
+using System;
+using System.Numerics;
+using Questionable.Model.Gathering;
+
+namespace GatheringPathRenderer;
+
+public static class GatheringMath
+{
+    private static readonly Random RNG = new Random();
+
+    public static (Vector3, int, float) CalculateLandingLocation(GatheringLocation location)
+    {
+        int degrees;
+        if (location.IsCone())
+            degrees = RNG.Next(
+                location.MinimumAngle.GetValueOrDefault(),
+                location.MaximumAngle.GetValueOrDefault());
+        else
+            degrees = RNG.Next(0, 360);
+
+        float range = RNG.Next(
+            (int)(location.CalculateMinimumDistance() * 100),
+            (int)((location.CalculateMaximumDistance() - location.CalculateMinimumDistance()) * 100)) / 100f;
+        return (CalculateLandingLocation(location.Position, degrees, range), degrees, range);
+    }
+
+    public static Vector3 CalculateLandingLocation(GatheringLocation location, float angleScale, float rangeScale)
+    {
+        int degrees;
+        if (location.IsCone())
+            degrees = location.MinimumAngle.GetValueOrDefault()
+                      + (int)(angleScale * (location.MaximumAngle.GetValueOrDefault() -
+                                            location.MinimumAngle.GetValueOrDefault()));
+        else
+            degrees = (int)(rangeScale * 360);
+
+        float range =
+            location.CalculateMinimumDistance() +
+            rangeScale * (location.CalculateMaximumDistance() - location.CalculateMinimumDistance());
+        return CalculateLandingLocation(location.Position, degrees, range);
+    }
+
+    private static Vector3 CalculateLandingLocation(Vector3 position, int degrees, float range)
+    {
+        float rad = -(float)(degrees * Math.PI / 180);
+        return new Vector3
+        {
+            X = position.X + range * (float)Math.Sin(rad),
+            Y = position.Y,
+            Z = position.Z + range * (float)Math.Cos(rad)
+        };
+    }
+}
diff --git a/Questionable.Model/Questing/GatheredItem.cs b/Questionable.Model/Questing/GatheredItem.cs
new file mode 100644 (file)
index 0000000..41bd062
--- /dev/null
@@ -0,0 +1,8 @@
+namespace Questionable.Model.Questing;
+
+public sealed class GatheredItem
+{
+    public uint ItemId { get; set; }
+    public int ItemCount { get; set; }
+    public short Collectability { get; set; }
+}
index 53ed7ced89d3195ecfa162fc10e93fce2883922d..4ec5f81dcb9bf5b468f70015cab3e498694310bf 100644 (file)
@@ -66,6 +66,7 @@ public sealed class QuestStep
     public SkipConditions? SkipConditions { get; set; }
 
     public List<List<QuestWorkValue>?> RequiredQuestVariables { get; set; } = new();
+    public List<GatheredItem> RequiredGatheredItems { get; set; } = [];
     public IList<QuestWorkValue?> CompletionQuestVariablesFlags { get; set; } = new List<QuestWorkValue?>();
     public IList<DialogueChoice> DialogueChoices { get; set; } = new List<DialogueChoice>();
     public IList<uint> PointMenuChoices { get; set; } = new List<uint>();
index 3588f8f86e7c9fd03393359b61d35e61c107fe0d..a8a650e8a6cf3c6fc7311958754f1d5a4a45b543 100644 (file)
@@ -7,6 +7,7 @@
        <s:Boolean x:Key="/Default/UserDictionary/Words/=bestways/@EntryIndexedValue">True</s:Boolean>
        <s:Boolean x:Key="/Default/UserDictionary/Words/=braax/@EntryIndexedValue">True</s:Boolean>
        <s:Boolean x:Key="/Default/UserDictionary/Words/=brightploom/@EntryIndexedValue">True</s:Boolean>
+       <s:Boolean x:Key="/Default/UserDictionary/Words/=collectability/@EntryIndexedValue">True</s:Boolean>
        <s:Boolean x:Key="/Default/UserDictionary/Words/=earthenshire/@EntryIndexedValue">True</s:Boolean>
        <s:Boolean x:Key="/Default/UserDictionary/Words/=electrope/@EntryIndexedValue">True</s:Boolean>
        <s:Boolean x:Key="/Default/UserDictionary/Words/=hanu/@EntryIndexedValue">True</s:Boolean>
diff --git a/Questionable/Controller/GatheringController.cs b/Questionable/Controller/GatheringController.cs
new file mode 100644 (file)
index 0000000..45fccf6
--- /dev/null
@@ -0,0 +1,179 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using Dalamud.Game.ClientState.Objects.Enums;
+using Dalamud.Plugin.Services;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Controller.Steps;
+using Questionable.Controller.Steps.Common;
+using Questionable.Controller.Steps.Gathering;
+using Questionable.Controller.Steps.Interactions;
+using Questionable.Controller.Steps.Shared;
+using Questionable.Data;
+using Questionable.External;
+using Questionable.GatheringPaths;
+using Questionable.Model.Gathering;
+
+namespace Questionable.Controller;
+
+internal sealed unsafe class GatheringController : MiniTaskController<GatheringController>
+{
+    private readonly MovementController _movementController;
+    private readonly GatheringData _gatheringData;
+    private readonly GameFunctions _gameFunctions;
+    private readonly NavmeshIpc _navmeshIpc;
+    private readonly IObjectTable _objectTable;
+    private readonly IServiceProvider _serviceProvider;
+
+    private CurrentRequest? _currentRequest;
+
+    public GatheringController(MovementController movementController, GatheringData gatheringData,
+        GameFunctions gameFunctions, NavmeshIpc navmeshIpc, IObjectTable objectTable, IChatGui chatGui,
+        ILogger<GatheringController> logger, IServiceProvider serviceProvider)
+        : base(chatGui, logger)
+    {
+        _movementController = movementController;
+        _gatheringData = gatheringData;
+        _gameFunctions = gameFunctions;
+        _navmeshIpc = navmeshIpc;
+        _objectTable = objectTable;
+        _serviceProvider = serviceProvider;
+    }
+
+    public bool Start(GatheringRequest gatheringRequest)
+    {
+        if (!AssemblyGatheringLocationLoader.GetLocations()
+                .TryGetValue(gatheringRequest.GatheringPointId, out GatheringRoot? gatheringRoot))
+        {
+            _logger.LogError("Unable to resolve gathering point, no path found for {ItemId} / point {PointId}",
+                gatheringRequest.ItemId, gatheringRequest.GatheringPointId);
+            return false;
+        }
+
+        _currentRequest = new CurrentRequest
+        {
+            Data = gatheringRequest,
+            Root = gatheringRoot,
+            Nodes = gatheringRoot.Groups
+                .SelectMany(x => x.Nodes)
+                .ToList(),
+        };
+
+        if (HasRequestedItems())
+        {
+            _currentRequest = null;
+            return false;
+        }
+
+        return true;
+    }
+
+    public EStatus Update()
+    {
+        if (_currentRequest == null)
+            return EStatus.Complete;
+
+        if (_movementController.IsPathfinding || _movementController.IsPathfinding)
+            return EStatus.Moving;
+
+        if (HasRequestedItems())
+            return EStatus.Complete;
+
+        if (_currentTask == null && _taskQueue.Count == 0)
+            GoToNextNode();
+
+        UpdateCurrentTask();
+        return EStatus.Gathering;
+    }
+
+    protected override void OnTaskComplete(ITask task) => GoToNextNode();
+
+    public override void Stop(string label)
+    {
+        _currentRequest = null;
+        _currentTask = null;
+        _taskQueue.Clear();
+    }
+
+    private void GoToNextNode()
+    {
+        if (_currentRequest == null)
+            return;
+
+        if (_taskQueue.Count > 0)
+            return;
+
+        var currentNode = _currentRequest.Nodes[_currentRequest.CurrentIndex++ % _currentRequest.Nodes.Count];
+
+        _taskQueue.Enqueue(_serviceProvider.GetRequiredService<MountTask>()
+            .With(_currentRequest.Root.TerritoryId, MountTask.EMountIf.Always));
+        if (currentNode.Locations.Count > 1)
+        {
+            Vector3 averagePosition = new Vector3
+            {
+                X = currentNode.Locations.Sum(x => x.Position.X) / currentNode.Locations.Count,
+                Y = currentNode.Locations.Select(x => x.Position.Y).Max() + 5f,
+                Z = currentNode.Locations.Sum(x => x.Position.Z) / currentNode.Locations.Count,
+            };
+            Vector3? pointOnFloor = _navmeshIpc.GetPointOnFloor(averagePosition);
+            if (pointOnFloor != null)
+                pointOnFloor = pointOnFloor.Value with { Y = pointOnFloor.Value.Y + 3f };
+
+            _taskQueue.Enqueue(_serviceProvider.GetRequiredService<Move.MoveInternal>()
+                .With(_currentRequest.Root.TerritoryId, pointOnFloor ?? averagePosition, 50f, fly: true,
+                    ignoreDistanceToObject: true));
+        }
+
+        _taskQueue.Enqueue(_serviceProvider.GetRequiredService<MoveToLandingLocation>()
+            .With(_currentRequest.Root.TerritoryId, currentNode));
+        _taskQueue.Enqueue(_serviceProvider.GetRequiredService<Interact.DoInteract>()
+            .With(currentNode.DataId, true));
+        _taskQueue.Enqueue(_serviceProvider.GetRequiredService<WaitGather>());
+    }
+
+    private bool HasRequestedItems()
+    {
+        if (_currentRequest == null)
+            return true;
+
+        InventoryManager* inventoryManager = InventoryManager.Instance();
+        if (inventoryManager == null)
+            return false;
+
+        return inventoryManager->GetInventoryItemCount(_currentRequest.Data.ItemId,
+            minCollectability: _currentRequest.Data.Collectability) >= _currentRequest.Data.Quantity;
+    }
+
+    public override IList<string> GetRemainingTaskNames()
+    {
+        if (_currentTask != null)
+            return [_currentTask.ToString() ?? "?", .. base.GetRemainingTaskNames()];
+        else
+            return base.GetRemainingTaskNames();
+    }
+
+    private sealed class CurrentRequest
+    {
+        public required GatheringRequest Data { get; init; }
+        public required GatheringRoot Root { get; init; }
+
+        /// <summary>
+        /// To make indexing easy with <see cref="CurrentIndex"/>, we flatten the list of gathering locations.
+        /// </summary>
+        public required List<GatheringNode> Nodes { get; init; }
+
+        public int CurrentIndex { get; set; }
+    }
+
+    public sealed record GatheringRequest(ushort GatheringPointId, uint ItemId, int Quantity, short Collectability = 0);
+
+    public enum EStatus
+    {
+        Gathering,
+        Moving,
+        Complete,
+    }
+}
diff --git a/Questionable/Controller/MiniTaskController.cs b/Questionable/Controller/MiniTaskController.cs
new file mode 100644 (file)
index 0000000..4d19d73
--- /dev/null
@@ -0,0 +1,134 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Dalamud.Plugin.Services;
+using Microsoft.Extensions.Logging;
+using Questionable.Controller.Steps;
+using Questionable.Controller.Steps.Shared;
+
+namespace Questionable.Controller;
+
+internal abstract class MiniTaskController<T>
+{
+    protected readonly IChatGui _chatGui;
+    protected readonly ILogger<T> _logger;
+
+    protected readonly Queue<ITask> _taskQueue = new();
+    protected ITask? _currentTask;
+
+    public MiniTaskController(IChatGui chatGui, ILogger<T> logger)
+    {
+        _chatGui = chatGui;
+        _logger = logger;
+    }
+
+    protected virtual void UpdateCurrentTask()
+    {
+        if (_currentTask == null)
+        {
+            if (_taskQueue.TryDequeue(out ITask? upcomingTask))
+            {
+                try
+                {
+                    _logger.LogInformation("Starting task {TaskName}", upcomingTask.ToString());
+                    if (upcomingTask.Start())
+                    {
+                        _currentTask = upcomingTask;
+                        return;
+                    }
+                    else
+                    {
+                        _logger.LogTrace("Task {TaskName} was skipped", upcomingTask.ToString());
+                        return;
+                    }
+                }
+                catch (Exception e)
+                {
+                    _logger.LogError(e, "Failed to start task {TaskName}", upcomingTask.ToString());
+                    _chatGui.PrintError(
+                        $"[Questionable] Failed to start task '{upcomingTask}', please check /xllog for details.");
+                    Stop("Task failed to start");
+                    return;
+                }
+            }
+            else
+                return;
+        }
+
+        ETaskResult result;
+        try
+        {
+            result = _currentTask.Update();
+        }
+        catch (Exception e)
+        {
+            _logger.LogError(e, "Failed to update task {TaskName}", _currentTask.ToString());
+            _chatGui.PrintError(
+                $"[Questionable] Failed to update task '{_currentTask}', please check /xllog for details.");
+            Stop("Task failed to update");
+            return;
+        }
+
+        switch (result)
+        {
+            case ETaskResult.StillRunning:
+                return;
+
+            case ETaskResult.SkipRemainingTasksForStep:
+                _logger.LogInformation("{Task} → {Result}, skipping remaining tasks for step",
+                    _currentTask, result);
+                _currentTask = null;
+
+                while (_taskQueue.TryDequeue(out ITask? nextTask))
+                {
+                    if (nextTask is ILastTask)
+                    {
+                        _currentTask = nextTask;
+                        return;
+                    }
+                }
+
+                return;
+
+            case ETaskResult.TaskComplete:
+                _logger.LogInformation("{Task} → {Result}, remaining tasks: {RemainingTaskCount}",
+                    _currentTask, result, _taskQueue.Count);
+
+                OnTaskComplete(_currentTask);
+
+                _currentTask = null;
+
+                // handled in next update
+                return;
+
+            case ETaskResult.NextStep:
+                _logger.LogInformation("{Task} → {Result}", _currentTask, result);
+
+                var lastTask = (ILastTask)_currentTask;
+                _currentTask = null;
+
+                OnNextStep(lastTask);
+                return;
+
+            case ETaskResult.End:
+                _logger.LogInformation("{Task} → {Result}", _currentTask, result);
+                _currentTask = null;
+                Stop("Task end");
+                return;
+        }
+    }
+
+    protected virtual void OnTaskComplete(ITask task)
+    {
+    }
+
+    protected virtual void OnNextStep(ILastTask task)
+    {
+
+    }
+
+    public abstract void Stop(string label);
+
+    public virtual IList<string> GetRemainingTaskNames() =>
+        _taskQueue.Select(x => x.ToString() ?? "?").ToList();
+}
index e50891d2171cab165b8ffd23a9f6dcdcca2c7fcc..a7b77ac2b856b6a1f505ec0a7e57723748875ee5 100644 (file)
@@ -14,16 +14,15 @@ using Questionable.Model.Questing;
 
 namespace Questionable.Controller;
 
-internal sealed class QuestController
+internal sealed class QuestController : MiniTaskController<QuestController>
 {
     private readonly IClientState _clientState;
     private readonly GameFunctions _gameFunctions;
     private readonly MovementController _movementController;
     private readonly CombatController _combatController;
-    private readonly ILogger<QuestController> _logger;
+    private readonly GatheringController _gatheringController;
     private readonly QuestRegistry _questRegistry;
     private readonly IKeyState _keyState;
-    private readonly IChatGui _chatGui;
     private readonly ICondition _condition;
     private readonly Configuration _configuration;
     private readonly YesAlreadyIpc _yesAlreadyIpc;
@@ -34,8 +33,6 @@ internal sealed class QuestController
     private QuestProgress? _startedQuest;
     private QuestProgress? _nextQuest;
     private QuestProgress? _simulatedQuest;
-    private readonly Queue<ITask> _taskQueue = new();
-    private ITask? _currentTask;
     private bool _automatic;
 
     /// <summary>
@@ -50,6 +47,7 @@ internal sealed class QuestController
         GameFunctions gameFunctions,
         MovementController movementController,
         CombatController combatController,
+        GatheringController gatheringController,
         ILogger<QuestController> logger,
         QuestRegistry questRegistry,
         IKeyState keyState,
@@ -58,15 +56,15 @@ internal sealed class QuestController
         Configuration configuration,
         YesAlreadyIpc yesAlreadyIpc,
         IEnumerable<ITaskFactory> taskFactories)
+        : base(chatGui, logger)
     {
         _clientState = clientState;
         _gameFunctions = gameFunctions;
         _movementController = movementController;
         _combatController = combatController;
-        _logger = logger;
+        _gatheringController = gatheringController;
         _questRegistry = questRegistry;
         _keyState = keyState;
-        _chatGui = chatGui;
         _condition = condition;
         _configuration = configuration;
         _yesAlreadyIpc = yesAlreadyIpc;
@@ -138,6 +136,7 @@ internal sealed class QuestController
                 Stop("HP = 0");
                 _movementController.Stop();
                 _combatController.Stop("HP = 0");
+                _gatheringController.Stop("HP = 0");
             }
         }
         else if (_configuration.General.UseEscToCancelQuesting && _keyState[VirtualKey.ESCAPE])
@@ -147,6 +146,7 @@ internal sealed class QuestController
                 Stop("ESC pressed");
                 _movementController.Stop();
                 _combatController.Stop("ESC pressed");
+                _gatheringController.Stop("ESC pressed");
             }
         }
 
@@ -377,9 +377,10 @@ internal sealed class QuestController
 
         _yesAlreadyIpc.RestoreYesAlready();
         _combatController.Stop("ClearTasksInternal");
+        _gatheringController.Stop("ClearTasksInternal");
     }
 
-    public void Stop(string label, bool continueIfAutomatic = false)
+    public void Stop(string label, bool continueIfAutomatic)
     {
         using var scope = _logger.BeginScope(label);
 
@@ -401,6 +402,8 @@ internal sealed class QuestController
         }
     }
 
+    public override void Stop(string label) => Stop(label, false);
+
     public void SimulateQuest(Quest? quest)
     {
         _logger.LogInformation("SimulateQuest: {QuestId}", quest?.QuestId);
@@ -419,103 +422,23 @@ internal sealed class QuestController
             _nextQuest = null;
     }
 
-    private void UpdateCurrentTask()
+    protected override void UpdateCurrentTask()
     {
         if (_gameFunctions.IsOccupied())
             return;
 
-        if (_currentTask == null)
-        {
-            if (_taskQueue.TryDequeue(out ITask? upcomingTask))
-            {
-                try
-                {
-                    _logger.LogInformation("Starting task {TaskName}", upcomingTask.ToString());
-                    if (upcomingTask.Start())
-                    {
-                        _currentTask = upcomingTask;
-                        return;
-                    }
-                    else
-                    {
-                        _logger.LogTrace("Task {TaskName} was skipped", upcomingTask.ToString());
-                        return;
-                    }
-                }
-                catch (Exception e)
-                {
-                    _logger.LogError(e, "Failed to start task {TaskName}", upcomingTask.ToString());
-                    _chatGui.PrintError(
-                        $"[Questionable] Failed to start task '{upcomingTask}', please check /xllog for details.");
-                    Stop("Task failed to start");
-                    return;
-                }
-            }
-            else
-                return;
-        }
-
-        ETaskResult result;
-        try
-        {
-            result = _currentTask.Update();
-        }
-        catch (Exception e)
-        {
-            _logger.LogError(e, "Failed to update task {TaskName}", _currentTask.ToString());
-            _chatGui.PrintError(
-                $"[Questionable] Failed to update task '{_currentTask}', please check /xllog for details.");
-            Stop("Task failed to update");
-            return;
-        }
-
-        switch (result)
-        {
-            case ETaskResult.StillRunning:
-                return;
-
-            case ETaskResult.SkipRemainingTasksForStep:
-                _logger.LogInformation("{Task} → {Result}, skipping remaining tasks for step",
-                    _currentTask, result);
-                _currentTask = null;
-
-                while (_taskQueue.TryDequeue(out ITask? nextTask))
-                {
-                    if (nextTask is ILastTask)
-                    {
-                        _currentTask = nextTask;
-                        return;
-                    }
-                }
-
-                return;
-
-            case ETaskResult.TaskComplete:
-                _logger.LogInformation("{Task} → {Result}, remaining tasks: {RemainingTaskCount}",
-                    _currentTask, result, _taskQueue.Count);
-
-                if (_currentTask is WaitAtEnd.WaitQuestCompleted)
-                    _simulatedQuest = null;
-
-                _currentTask = null;
-
-                // handled in next update
-                return;
-
-            case ETaskResult.NextStep:
-                _logger.LogInformation("{Task} → {Result}", _currentTask, result);
+        base.UpdateCurrentTask();
+    }
 
-                var lastTask = (ILastTask)_currentTask;
-                _currentTask = null;
-                IncreaseStepCount(lastTask.QuestId, lastTask.Sequence, true);
-                return;
+    protected override void OnTaskComplete(ITask task)
+    {
+        if (task is WaitAtEnd.WaitQuestCompleted)
+            _simulatedQuest = null;
+    }
 
-            case ETaskResult.End:
-                _logger.LogInformation("{Task} → {Result}", _currentTask, result);
-                _currentTask = null;
-                Stop("Task end");
-                return;
-        }
+    protected override void OnNextStep(ILastTask task)
+    {
+        IncreaseStepCount(task.QuestId, task.Sequence, true);
     }
 
     public void ExecuteNextStep(bool automatic)
@@ -536,6 +459,7 @@ internal sealed class QuestController
 
         _movementController.Stop();
         _combatController.Stop("Execute next step");
+        _gatheringController.Stop("Execute next step");
 
         var newTasks = _taskFactories
             .SelectMany(x =>
@@ -568,9 +492,6 @@ internal sealed class QuestController
             _taskQueue.Enqueue(task);
     }
 
-    public IList<string> GetRemainingTaskNames() =>
-        _taskQueue.Select(x => x.ToString() ?? "?").ToList();
-
     public string ToStatString()
     {
         return _currentTask == null ? $"- (+{_taskQueue.Count})" : $"{_currentTask} (+{_taskQueue.Count})";
diff --git a/Questionable/Controller/Steps/Gathering/MoveToLandingLocation.cs b/Questionable/Controller/Steps/Gathering/MoveToLandingLocation.cs
new file mode 100644 (file)
index 0000000..3336500
--- /dev/null
@@ -0,0 +1,64 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Numerics;
+using System.Security.Cryptography;
+using Dalamud.Game.ClientState.Objects.Enums;
+using Dalamud.Plugin.Services;
+using GatheringPathRenderer;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Controller.Steps.Shared;
+using Questionable.External;
+using Questionable.Model.Gathering;
+
+namespace Questionable.Controller.Steps.Gathering;
+
+internal sealed class MoveToLandingLocation(
+    IServiceProvider serviceProvider,
+    IObjectTable objectTable,
+    NavmeshIpc navmeshIpc,
+    ILogger<MoveToLandingLocation> logger) : ITask
+{
+    private ushort _territoryId;
+    private GatheringNode _gatheringNode = null!;
+    private ITask _moveTask = null!;
+
+    public ITask With(ushort territoryId, GatheringNode gatheringNode)
+    {
+        _territoryId = territoryId;
+        _gatheringNode = gatheringNode;
+        return this;
+    }
+
+    public bool Start()
+    {
+        var location = _gatheringNode.Locations.First();
+        if (_gatheringNode.Locations.Count > 1)
+        {
+            var gameObject = objectTable.Single(x =>
+                x.ObjectKind == ObjectKind.GatheringPoint && x.DataId == _gatheringNode.DataId && x.IsTargetable);
+            location = _gatheringNode.Locations.Single(x => Vector3.Distance(x.Position, gameObject.Position) < 0.1f);
+        }
+
+        var (target, degrees, range) = GatheringMath.CalculateLandingLocation(location);
+        logger.LogInformation("Preliminary landing location: {Location}, with degrees = {Degrees}, range = {Range}",
+            target.ToString("G", CultureInfo.InvariantCulture), degrees, range);
+
+        Vector3? pointOnFloor = navmeshIpc.GetPointOnFloor(target with { Y = target.Y + 5f });
+        if (pointOnFloor != null)
+            pointOnFloor = pointOnFloor.Value with { Y = pointOnFloor.Value.Y + 0.5f };
+
+        logger.LogInformation("Final landing location: {Location}",
+            (pointOnFloor ?? target).ToString("G", CultureInfo.InvariantCulture));
+
+        _moveTask = serviceProvider.GetRequiredService<Move.MoveInternal>()
+            .With(_territoryId, pointOnFloor ?? target, 0.25f, dataId: _gatheringNode.DataId, fly: true,
+                ignoreDistanceToObject: true);
+        return _moveTask.Start();
+    }
+
+    public ETaskResult Update() => _moveTask.Update();
+
+    public override string ToString() => $"Land/{_moveTask}";
+}
diff --git a/Questionable/Controller/Steps/Gathering/WaitGather.cs b/Questionable/Controller/Steps/Gathering/WaitGather.cs
new file mode 100644 (file)
index 0000000..e2b3a88
--- /dev/null
@@ -0,0 +1,25 @@
+using Dalamud.Game.ClientState.Conditions;
+using Dalamud.Plugin.Services;
+
+namespace Questionable.Controller.Steps.Gathering;
+
+internal sealed class WaitGather(ICondition condition) : ITask
+{
+    private bool _wasGathering;
+
+    public bool Start() => true;
+
+    public ETaskResult Update()
+    {
+        if (condition[ConditionFlag.Gathering])
+        {
+            _wasGathering = true;
+        }
+
+        return _wasGathering && !condition[ConditionFlag.Gathering]
+            ? ETaskResult.TaskComplete
+            : ETaskResult.StillRunning;
+    }
+
+    public override string ToString() => "WaitGather";
+}
index 7dba0d44f8abf6112d5ba93b7a1e0dde2d077759..df27d3c21062c50e64eb66a1b2b74ad09a8e0be2 100644 (file)
@@ -59,7 +59,7 @@ internal static class Interact
 
         public bool Start()
         {
-            IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
+            IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId, targetable: true);
             if (gameObject == null)
             {
                 logger.LogWarning("No game object with dataId {DataId}", DataId);
@@ -67,17 +67,19 @@ internal static class Interact
             }
 
             // this is only relevant for followers on quests
-            if (!gameObject.IsTargetable && condition[ConditionFlag.Mounted])
+            if (!gameObject.IsTargetable && condition[ConditionFlag.Mounted] &&
+                gameObject.ObjectKind != ObjectKind.GatheringPoint)
             {
+                logger.LogInformation("Preparing interaction for {DataId} by unmounting", DataId);
                 _needsUnmount = true;
                 gameFunctions.Unmount();
                 _continueAt = DateTime.Now.AddSeconds(1);
                 return true;
             }
 
-            if (gameObject.IsTargetable && HasAnyMarker(gameObject))
+            if (IsTargetable(gameObject) && HasAnyMarker(gameObject))
             {
-                _interacted = gameFunctions.InteractWith(DataId);
+                _interacted = gameFunctions.InteractWith(gameObject);
                 _continueAt = DateTime.Now.AddSeconds(0.5);
                 return true;
             }
@@ -104,11 +106,11 @@ internal static class Interact
 
             if (!_interacted)
             {
-                IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
-                if (gameObject == null || !gameObject.IsTargetable || !HasAnyMarker(gameObject))
+                IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId, targetable: true);
+                if (gameObject == null || !IsTargetable(gameObject) || !HasAnyMarker(gameObject))
                     return ETaskResult.StillRunning;
 
-                _interacted = gameFunctions.InteractWith(DataId);
+                _interacted = gameFunctions.InteractWith(gameObject);
                 _continueAt = DateTime.Now.AddSeconds(0.5);
                 return ETaskResult.StillRunning;
             }
@@ -125,6 +127,11 @@ internal static class Interact
             return gameObjectStruct->NamePlateIconId != 0;
         }
 
+        private static bool IsTargetable(IGameObject gameObject)
+        {
+            return gameObject.IsTargetable;
+        }
+
         public override string ToString() => $"Interact({DataId})";
     }
 }
diff --git a/Questionable/Controller/Steps/Shared/GatheringRequiredItems.cs b/Questionable/Controller/Steps/Shared/GatheringRequiredItems.cs
new file mode 100644 (file)
index 0000000..ea60272
--- /dev/null
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using Dalamud.Plugin.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Questionable.Data;
+using Questionable.GatheringPaths;
+using Questionable.Model;
+using Questionable.Model.Gathering;
+using Questionable.Model.Questing;
+
+namespace Questionable.Controller.Steps.Shared;
+
+internal static class GatheringRequiredItems
+{
+    internal sealed class Factory(
+        IServiceProvider serviceProvider,
+        IClientState clientState,
+        GatheringData gatheringData) : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            foreach (var requiredGatheredItems in step.RequiredGatheredItems)
+            {
+                if (!gatheringData.TryGetGatheringPointId(requiredGatheredItems.ItemId,
+                        clientState.LocalPlayer!.ClassJob.Id, out var gatheringPointId))
+                    throw new TaskException($"No gathering point found for item {requiredGatheredItems.ItemId}");
+
+                if (!AssemblyGatheringLocationLoader.GetLocations()
+                        .TryGetValue(gatheringPointId, out GatheringRoot? gatheringRoot))
+                    throw new TaskException("No path found for gathering point");
+
+                if (gatheringRoot.AetheryteShortcut != null && clientState.TerritoryType != gatheringRoot.TerritoryId)
+                {
+                    yield return serviceProvider.GetRequiredService<AetheryteShortcut.UseAetheryteShortcut>()
+                        .With(null, gatheringRoot.AetheryteShortcut.Value, gatheringRoot.TerritoryId);
+                }
+
+                yield return serviceProvider.GetRequiredService<StartGathering>()
+                    .With(gatheringPointId, requiredGatheredItems);
+            }
+        }
+
+        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new NotImplementedException();
+    }
+
+    internal sealed class StartGathering(GatheringController gatheringController) : ITask
+    {
+        private ushort _gatheringPointId;
+        private GatheredItem _gatheredItem = null!;
+
+        public ITask With(ushort gatheringPointId, GatheredItem gatheredItem)
+        {
+            _gatheringPointId = gatheringPointId;
+            _gatheredItem = gatheredItem;
+            return this;
+        }
+
+        public bool Start()
+        {
+            return gatheringController.Start(new GatheringController.GatheringRequest(_gatheringPointId,
+                _gatheredItem.ItemId, _gatheredItem.ItemCount, _gatheredItem.Collectability));
+        }
+
+        public ETaskResult Update()
+        {
+            if (gatheringController.Update() == GatheringController.EStatus.Complete)
+                return ETaskResult.TaskComplete;
+
+            return ETaskResult.StillRunning;
+        }
+
+        public override string ToString() => $"Gather({_gatheredItem.ItemCount}x {_gatheredItem.ItemId})";
+    }
+}
diff --git a/Questionable/Data/GatheringData.cs b/Questionable/Data/GatheringData.cs
new file mode 100644 (file)
index 0000000..ed44fdc
--- /dev/null
@@ -0,0 +1,49 @@
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Dalamud.Plugin.Services;
+using Lumina.Excel.GeneratedSheets;
+
+namespace Questionable.Data;
+
+internal sealed class GatheringData
+{
+    private readonly Dictionary<uint, uint> _gatheringItemToItem;
+    private readonly Dictionary<uint, ushort> _minerGatheringPoints = [];
+    private readonly Dictionary<uint, ushort> _botanistGatheringPoints = [];
+
+    public GatheringData(IDataManager dataManager)
+    {
+        _gatheringItemToItem = dataManager.GetExcelSheet<GatheringItem>()!
+            .Where(x => x.RowId != 0 && x.Item != 0)
+            .ToDictionary(x => x.RowId, x => (uint)x.Item);
+
+        foreach (var gatheringPointBase in dataManager.GetExcelSheet<GatheringPointBase>()!)
+        {
+            foreach (var gatheringItemId in gatheringPointBase.Item.Where(x => x != 0))
+            {
+                if (_gatheringItemToItem.TryGetValue((uint)gatheringItemId, out uint itemId))
+                {
+                    if (gatheringPointBase.GatheringType.Row is 0 or 1)
+                        _minerGatheringPoints[itemId] = (ushort)gatheringPointBase.RowId;
+                    else if (gatheringPointBase.GatheringType.Row is 2 or 3)
+                        _botanistGatheringPoints[itemId] = (ushort)gatheringPointBase.RowId;
+                }
+            }
+        }
+    }
+
+
+    public bool TryGetGatheringPointId(uint itemId, uint classJobId, out ushort gatheringPointId)
+    {
+        if (classJobId == 16)
+            return _minerGatheringPoints.TryGetValue(itemId, out gatheringPointId);
+        else if (classJobId == 17)
+            return _botanistGatheringPoints.TryGetValue(itemId, out gatheringPointId);
+        else
+        {
+            gatheringPointId = 0;
+            return false;
+        }
+    }
+}
index 724f2f11e948abdff9b43cb1be2624b8f296ab1f..1cd229d1fee3674df445dfc19ad8f3f17ec8948f 100644 (file)
@@ -407,10 +407,13 @@ internal sealed unsafe class GameFunctions
                playerState->IsAetherCurrentUnlocked(aetherCurrentId);
     }
 
-    public IGameObject? FindObjectByDataId(uint dataId, ObjectKind? kind = null)
+    public IGameObject? FindObjectByDataId(uint dataId, ObjectKind? kind = null, bool targetable = false)
     {
         foreach (var gameObject in _objectTable)
         {
+            if (targetable && !gameObject.IsTargetable)
+                continue;
+
             if (gameObject.ObjectKind is ObjectKind.Player or ObjectKind.Companion or ObjectKind.MountType
                 or ObjectKind.Retainer or ObjectKind.Housing)
                 continue;
@@ -429,19 +432,31 @@ internal sealed unsafe class GameFunctions
     {
         IGameObject? gameObject = FindObjectByDataId(dataId, kind);
         if (gameObject != null)
-        {
-            _logger.LogInformation("Setting target with {DataId} to {ObjectId}", dataId, gameObject.EntityId);
-            _targetManager.Target = null;
-            _targetManager.Target = gameObject;
+            return InteractWith(gameObject);
+
+        _logger.LogDebug("Game object is null");
+        return false;
+    }
 
+    public bool InteractWith(IGameObject gameObject)
+    {
+        _logger.LogInformation("Setting target with {DataId} to {ObjectId}", gameObject.DataId, gameObject.EntityId);
+        _targetManager.Target = null;
+        _targetManager.Target = gameObject;
+
+        if (gameObject.ObjectKind == ObjectKind.GatheringPoint)
+        {
+            TargetSystem.Instance()->OpenObjectInteraction((GameObject*)gameObject.Address);
+            _logger.LogInformation("Interact result: (none) for GatheringPoint");
+            return true;
+        }
+        else
+        {
             long result = (long)TargetSystem.Instance()->InteractWithObject((GameObject*)gameObject.Address, false);
 
             _logger.LogInformation("Interact result: {Result}", result);
             return result != 7 && result > 0;
         }
-
-        _logger.LogDebug("Game object is null");
-        return false;
     }
 
     public bool UseItem(uint itemId)
index ae232ac1921f91892acf4ae6cf9c31e02b8abf12..ceb37e8c42122b4ddbef98156d63e02e204f07cf 100644 (file)
@@ -18,6 +18,7 @@
     </ItemGroup>
 
     <ItemGroup>
+        <ProjectReference Include="..\GatheringPaths\GatheringPaths.csproj" />
         <ProjectReference Include="..\LLib\LLib.csproj"/>
         <ProjectReference Include="..\Questionable.Model\Questionable.Model.csproj"/>
         <ProjectReference Include="..\QuestPaths\QuestPaths.csproj"/>
index df100963831635951399030b13c0cbc1db5e7a37..0febd3bd4feb69f36377d088ecd1a2e76260b5f2 100644 (file)
@@ -13,6 +13,7 @@ using Questionable.Controller.CombatModules;
 using Questionable.Controller.NavigationOverrides;
 using Questionable.Controller.Steps.Shared;
 using Questionable.Controller.Steps.Common;
+using Questionable.Controller.Steps.Gathering;
 using Questionable.Controller.Steps.Interactions;
 using Questionable.Data;
 using Questionable.External;
@@ -89,6 +90,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddSingleton<ChatFunctions>();
         serviceCollection.AddSingleton<AetherCurrentData>();
         serviceCollection.AddSingleton<AetheryteData>();
+        serviceCollection.AddSingleton<GatheringData>();
         serviceCollection.AddSingleton<JournalData>();
         serviceCollection.AddSingleton<QuestData>();
         serviceCollection.AddSingleton<TerritoryData>();
@@ -102,9 +104,12 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         // individual tasks
         serviceCollection.AddTransient<MountTask>();
         serviceCollection.AddTransient<UnmountTask>();
+        serviceCollection.AddTransient<MoveToLandingLocation>();
+        serviceCollection.AddTransient<WaitGather>();
 
         // task factories
         serviceCollection.AddTaskWithFactory<StepDisabled.Factory, StepDisabled.Task>();
+        serviceCollection.AddTaskWithFactory<GatheringRequiredItems.Factory, GatheringRequiredItems.StartGathering>();
         serviceCollection.AddTaskWithFactory<AetheryteShortcut.Factory, AetheryteShortcut.UseAetheryteShortcut>();
         serviceCollection.AddTaskWithFactory<SkipCondition.Factory, SkipCondition.CheckSkip>();
         serviceCollection.AddTaskWithFactory<AethernetShortcut.Factory, AethernetShortcut.UseAethernetShortcut>();
@@ -149,6 +154,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddSingleton<GameUiController>();
         serviceCollection.AddSingleton<NavigationShortcutController>();
         serviceCollection.AddSingleton<CombatController>();
+        serviceCollection.AddSingleton<GatheringController>();
 
         serviceCollection.AddSingleton<ICombatModule, RotationSolverRebornModule>();
     }
index 14b628d3f2bafe726ba7b42c72cbc81e984d826c..17ab37f4c2fdb63c5bfbd8f1040f6eacf07ea27c 100644 (file)
@@ -22,6 +22,7 @@ internal sealed class ActiveQuestComponent
     private readonly QuestController _questController;
     private readonly MovementController _movementController;
     private readonly CombatController _combatController;
+    private readonly GatheringController _gatheringController;
     private readonly GameFunctions _gameFunctions;
     private readonly ICommandManager _commandManager;
     private readonly IDalamudPluginInterface _pluginInterface;
@@ -29,14 +30,22 @@ internal sealed class ActiveQuestComponent
     private readonly QuestRegistry _questRegistry;
     private readonly IChatGui _chatGui;
 
-    public ActiveQuestComponent(QuestController questController, MovementController movementController,
-        CombatController combatController, GameFunctions gameFunctions, ICommandManager commandManager,
-        IDalamudPluginInterface pluginInterface, Configuration configuration, QuestRegistry questRegistry,
+    public ActiveQuestComponent(
+        QuestController questController,
+        MovementController movementController,
+        CombatController combatController,
+        GatheringController gatheringController,
+        GameFunctions gameFunctions,
+        ICommandManager commandManager,
+        IDalamudPluginInterface pluginInterface,
+        Configuration configuration,
+        QuestRegistry questRegistry,
         IChatGui chatGui)
     {
         _questController = questController;
         _movementController = movementController;
         _combatController = combatController;
+        _gatheringController = gatheringController;
         _gameFunctions = gameFunctions;
         _commandManager = commandManager;
         _pluginInterface = pluginInterface;
@@ -93,6 +102,7 @@ internal sealed class ActiveQuestComponent
             {
                 _movementController.Stop();
                 _questController.Stop("Manual (no active quest)");
+                _gatheringController.Stop("Manual (no active quest)");
             }
         }
     }
@@ -233,6 +243,7 @@ internal sealed class ActiveQuestComponent
         {
             _movementController.Stop();
             _questController.Stop("Manual");
+            _gatheringController.Stop("Manual");
         }
 
         bool lastStep = currentStep ==
index bd35f6997e8985327fbe2cdbec7dc8dc9840cae6..84d44c901fb9c122e33553e11bb2d6cc1c5fee93 100644 (file)
@@ -1,4 +1,5 @@
-using ImGuiNET;
+using System.Collections.Generic;
+using ImGuiNET;
 using Questionable.Controller;
 
 namespace Questionable.Windows.QuestComponents;
@@ -6,22 +7,36 @@ namespace Questionable.Windows.QuestComponents;
 internal sealed class RemainingTasksComponent
 {
     private readonly QuestController _questController;
+    private readonly GatheringController _gatheringController;
 
-    public RemainingTasksComponent(QuestController questController)
+    public RemainingTasksComponent(QuestController questController, GatheringController gatheringController)
     {
         _questController = questController;
+        _gatheringController = gatheringController;
     }
 
     public void Draw()
     {
-        var remainingTasks = _questController.GetRemainingTaskNames();
-        if (remainingTasks.Count > 0)
+        IList<string> gatheringTasks = _gatheringController.GetRemainingTaskNames();
+        if (gatheringTasks.Count > 0)
         {
             ImGui.Separator();
             ImGui.BeginDisabled();
-            foreach (var task in remainingTasks)
-                ImGui.TextUnformatted(task);
+            foreach (var task in gatheringTasks)
+                ImGui.TextUnformatted($"G: {task}");
             ImGui.EndDisabled();
         }
+        else
+        {
+            var remainingTasks = _questController.GetRemainingTaskNames();
+            if (remainingTasks.Count > 0)
+            {
+                ImGui.Separator();
+                ImGui.BeginDisabled();
+                foreach (var task in remainingTasks)
+                    ImGui.TextUnformatted(task);
+                ImGui.EndDisabled();
+            }
+        }
     }
 }
index 53ca99c0488723238b85cdf03bc0cd4efd1d446a..1fd1286547b6ea269af375982df3ec51956f758b 100644 (file)
         "resolved": "8.0.0",
         "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ=="
       },
+      "gatheringpaths": {
+        "type": "Project",
+        "dependencies": {
+          "Questionable.Model": "[1.0.0, )"
+        }
+      },
       "llib": {
         "type": "Project",
         "dependencies": {