Automatic weekly custom delivery turn in + some gathering cleanup
authorLiza Carvelli <liza@carvel.li>
Mon, 5 Aug 2024 15:09:49 +0000 (17:09 +0200)
committerLiza Carvelli <liza@carvel.li>
Mon, 5 Aug 2024 15:09:49 +0000 (17:09 +0200)
81 files changed:
GatheringPaths/4.x - Stormblood/The Peaks/531_Sleeping Stones_MIN.json
LLib
QuestPathGenerator/RoslynShortcuts.cs
QuestPaths/3.x - Heavensward/Custom Deliveries/Zhloe/S1_Zhloe Aliapoh.json
QuestPaths/4.x - Stormblood/Custom Deliveries/Adkiragh/S4_Adkiragh.json
QuestPaths/4.x - Stormblood/Custom Deliveries/Kurenai/S3_Kurenai.json
QuestPaths/4.x - Stormblood/Custom Deliveries/M'naago/S2_M'naago.json
QuestPaths/5.x - Shadowbringers/Custom Deliveries/Charlemend/S7_Charlemend.json
QuestPaths/5.x - Shadowbringers/Custom Deliveries/Ehll Tou/S6_Ehll Tou.json
QuestPaths/5.x - Shadowbringers/Custom Deliveries/Kai-Shirr/S5_Kai-Shirr.json
QuestPaths/6.x - Endwalker/Custom Deliveries/Ameliance/S8_Ameliance.json
QuestPaths/6.x - Endwalker/Custom Deliveries/Anden/S9_Anden.json
QuestPaths/6.x - Endwalker/Custom Deliveries/Margrat/S10_Margrat.json
QuestPaths/6.x - Endwalker/MSQ/M-6.55/4751_When One Door Closes.json
QuestPaths/quest-v1.json
Questionable.Model/Questing/Converter/InteractionTypeConverter.cs
Questionable.Model/Questing/DialogueChoice.cs
Questionable.Model/Questing/EInteractionType.cs
Questionable.Model/Questing/GatheredItem.cs
Questionable/ChatFunctions.cs [deleted file]
Questionable/Controller/CombatController.cs
Questionable/Controller/CommandHandler.cs
Questionable/Controller/ContextMenuController.cs
Questionable/Controller/GameUiController.cs
Questionable/Controller/GatheringController.cs
Questionable/Controller/MovementController.cs
Questionable/Controller/NavigationShortcutController.cs
Questionable/Controller/QuestController.cs
Questionable/Controller/Steps/Common/MountTask.cs
Questionable/Controller/Steps/Common/NextQuest.cs
Questionable/Controller/Steps/Common/UnmountTask.cs
Questionable/Controller/Steps/Gathering/DoGatherCollectable.cs
Questionable/Controller/Steps/Gathering/MoveToLandingLocation.cs
Questionable/Controller/Steps/Gathering/TurnInDelivery.cs [new file with mode: 0644]
Questionable/Controller/Steps/ILastTask.cs
Questionable/Controller/Steps/Interactions/Action.cs
Questionable/Controller/Steps/Interactions/AetherCurrent.cs
Questionable/Controller/Steps/Interactions/AethernetShard.cs
Questionable/Controller/Steps/Interactions/Aetheryte.cs
Questionable/Controller/Steps/Interactions/Combat.cs
Questionable/Controller/Steps/Interactions/Duty.cs
Questionable/Controller/Steps/Interactions/Emote.cs
Questionable/Controller/Steps/Interactions/Interact.cs
Questionable/Controller/Steps/Interactions/Say.cs
Questionable/Controller/Steps/Interactions/UseItem.cs
Questionable/Controller/Steps/Shared/AethernetShortcut.cs
Questionable/Controller/Steps/Shared/AetheryteShortcut.cs
Questionable/Controller/Steps/Shared/GatheringRequiredItems.cs
Questionable/Controller/Steps/Shared/Move.cs
Questionable/Controller/Steps/Shared/SkipCondition.cs
Questionable/Controller/Steps/Shared/SwitchClassJob.cs [new file with mode: 0644]
Questionable/Controller/Steps/Shared/WaitAtEnd.cs
Questionable/Functions/ChatFunctions.cs [new file with mode: 0644]
Questionable/Functions/ExcelFunctions.cs [new file with mode: 0644]
Questionable/Functions/GameFunctions.cs [new file with mode: 0644]
Questionable/Functions/QuestFunctions.cs [new file with mode: 0644]
Questionable/GameFunctions.cs [deleted file]
Questionable/Model/StringOrRegex.cs [new file with mode: 0644]
Questionable/Questionable.csproj
Questionable/QuestionablePlugin.cs
Questionable/Validation/EIssueType.cs
Questionable/Validation/QuestValidator.cs
Questionable/Validation/ValidationIssue.cs
Questionable/Validation/Validators/AethernetShortcutValidator.cs
Questionable/Validation/Validators/BasicSequenceValidator.cs
Questionable/Validation/Validators/CompletionFlagsValidator.cs
Questionable/Validation/Validators/DialogueChoiceValidator.cs [new file with mode: 0644]
Questionable/Validation/Validators/JsonSchemaValidator.cs
Questionable/Validation/Validators/NextQuestValidator.cs
Questionable/Validation/Validators/QuestDisabledValidator.cs
Questionable/Validation/Validators/UniqueStartStopValidator.cs
Questionable/Windows/JournalProgressWindow.cs
Questionable/Windows/QuestComponents/ARealmRebornComponent.cs
Questionable/Windows/QuestComponents/ActiveQuestComponent.cs
Questionable/Windows/QuestComponents/CreationUtilsComponent.cs
Questionable/Windows/QuestComponents/QuestTooltipComponent.cs
Questionable/Windows/QuestComponents/QuickAccessButtonsComponent.cs
Questionable/Windows/QuestSelectionWindow.cs
Questionable/Windows/QuestValidationWindow.cs
Questionable/Windows/QuestWindow.cs
Questionable/Windows/UiUtils.cs

index 10ec9162313b4ab7a49b90d4f5da37f9dbeae8a2..d9e04e5e3088e39d4b40f1eaf96d24fe9ea5c67d 100644 (file)
@@ -79,7 +79,7 @@
                 "Y": 257.4255,
                 "Z": -669.3115
               },
-              "MinimumAngle": -65,
+              "MinimumAngle": -30,
               "MaximumAngle": 5
             }
           ]
       ]
     }
   ]
-}
+}
\ No newline at end of file
diff --git a/LLib b/LLib
index 9db9f95b8cd3f36262b5b4b14f12b7331d3c7279..43c3dba112c202e2d0ff1a6909020c2b83e20dc3 160000 (submodule)
--- a/LLib
+++ b/LLib
@@ -1 +1 @@
-Subproject commit 9db9f95b8cd3f36262b5b4b14f12b7331d3c7279
+Subproject commit 43c3dba112c202e2d0ff1a6909020c2b83e20dc3
index 027e597ae90812e1f3148fd4789860d8d3c07aac..e01b92012eadab0aad4f9ba0b03770224cded300 100644 (file)
@@ -155,6 +155,10 @@ public static class RoslynShortcuts
                                         .AsSyntaxNodeOrToken(),
                                     Assignment(nameof(DialogueChoice.Answer), dialogueChoice.Answer, emptyChoice.Answer)
                                         .AsSyntaxNodeOrToken(),
+                                    Assignment(nameof(DialogueChoice.AnswerIsRegularExpression),
+                                            dialogueChoice.AnswerIsRegularExpression,
+                                            emptyChoice.AnswerIsRegularExpression)
+                                        .AsSyntaxNodeOrToken(),
                                     Assignment(nameof(DialogueChoice.DataId), dialogueChoice.DataId, emptyChoice.DataId)
                                         .AsSyntaxNodeOrToken()))));
             }
@@ -359,6 +363,9 @@ public static class RoslynShortcuts
                                         .AsSyntaxNodeOrToken(),
                                     Assignment(nameof(GatheredItem.Collectability), gatheredItem.Collectability,
                                             emptyItem.Collectability)
+                                        .AsSyntaxNodeOrToken(),
+                                    Assignment(nameof(GatheredItem.ClassJob), gatheredItem.ClassJob,
+                                            emptyItem.ClassJob)
                                         .AsSyntaxNodeOrToken()))));
             }
             else if (value is GatheringNodeGroup nodeGroup)
index e909b81d21961f7646442da9e73293f2cf781993..c2154923aaed120dbce7d7de3ef1e51b91493a06 100644 (file)
           },
           "StopDistance": 5,
           "TerritoryId": 478,
-          "InteractionType": "Interact"
+          "InteractionType": "Interact",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/003/CtsSfsCharacter1_00386",
+              "Prompt": "TEXT_CTSSFSCHARACTER1_00386_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER1_00386_TOPMENU_000_001",
+              "AnswerIsRegularExpression": true
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "Sequence": 1,
+      "Steps": [
+        {
+          "TerritoryId": 635,
+          "InteractionType": "None",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/003/CtsSfsCharacter1_00386",
+              "Prompt": "TEXT_CTSSFSCHARACTER1_00386_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER1_00386_TOPMENU_000_003"
+            }
+          ]
         }
       ]
     }
index 07c9f8284e597536d89cb01f6ba3363d8319ed9b..3912cfc6d6b7dfeb5e27919b98c27c1cdc45e701 100644 (file)
           "TerritoryId": 478,
           "InteractionType": "Interact",
           "RequiredGatheredItems": [],
-          "AetheryteShortcut": "Idyllshire"
+          "AetheryteShortcut": "Idyllshire",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/005/CtsSfsCharacter4_00541",
+              "Prompt": "TEXT_CTSSFSCHARACTER4_00541_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER4_00541_TOPMENU_000_001",
+              "AnswerIsRegularExpression": true
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "Sequence": 1,
+      "Steps": [
+        {
+          "TerritoryId": 635,
+          "InteractionType": "None",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/005/CtsSfsCharacter4_00541",
+              "Prompt": "TEXT_CTSSFSCHARACTER4_00541_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER4_00541_TOPMENU_000_004"
+            }
+          ]
         }
       ]
     }
index edcd6dd8fb368701397f930c2b8b0648403ed957..3fef30bea71c37e36291f0b15322164200e274f9 100644 (file)
           "TerritoryId": 613,
           "InteractionType": "Interact",
           "RequiredGatheredItems": [],
-          "AetheryteShortcut": "Ruby Sea - Tamamizu"
+          "AetheryteShortcut": "Ruby Sea - Tamamizu",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/004/CtsSfsCharacter3_00481",
+              "Prompt": "TEXT_CTSSFSCHARACTER3_00481_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER3_00481_TOPMENU_000_001",
+              "AnswerIsRegularExpression": true
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "Sequence": 1,
+      "Steps": [
+        {
+          "TerritoryId": 613,
+          "InteractionType": "None",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/004/CtsSfsCharacter3_00481",
+              "Prompt": "TEXT_CTSSFSCHARACTER3_00481_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER3_00481_TOPMENU_000_004"
+            }
+          ]
         }
       ]
     }
index 435169ec80ef87572c5516a707c9d5ae3ae7b77a..4acf85fc85037548e3832f8ffc91b71d417b3520 100644 (file)
           "TerritoryId": 635,
           "InteractionType": "Interact",
           "RequiredGatheredItems": [],
-          "AetheryteShortcut": "Rhalgr's Reach"
+          "AetheryteShortcut": "Rhalgr's Reach",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/004/CtsSfsCharacter2_00434",
+              "Prompt": "TEXT_CTSSFSCHARACTER2_00434_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER2_00434_TOPMENU_000_001",
+              "AnswerIsRegularExpression": true
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "Sequence": 1,
+      "Steps": [
+        {
+          "TerritoryId": 635,
+          "InteractionType": "None",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/004/CtsSfsCharacter2_00434",
+              "Prompt": "TEXT_CTSSFSCHARACTER2_00434_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER2_00434_TOPMENU_000_003"
+            }
+          ]
         }
       ]
     }
index 0e73e7d0e550e2d391004b51223346cd6d9a22b5..ea885fd4acee0c794f2214b3bec1615dd74000f4 100644 (file)
           "AethernetShortcut": [
             "[Ishgard] Aetheryte Plaza",
             "[Ishgard] Firmament"
+          ],
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/007/CtsSfsCharacter7_00710",
+              "Prompt": "TEXT_CTSSFSCHARACTER7_00710_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER7_00710_TOPMENU_000_001",
+              "AnswerIsRegularExpression": true
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "Sequence": 1,
+      "Steps": [
+        {
+          "TerritoryId": 635,
+          "InteractionType": "None",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/007/CtsSfsCharacter7_00710",
+              "Prompt": "TEXT_CTSSFSCHARACTER7_00710_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER7_00710_TOPMENU_000_004"
+            }
           ]
         }
       ]
index 2e9c047b2cd05e9f036c2713642e84181ac94e12..6e4e68f3dae42fb575d1938824cb86472e3bdcf2 100644 (file)
           "AethernetShortcut": [
             "[Ishgard] Aetheryte Plaza",
             "[Ishgard] Firmament"
+          ],
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/006/CtsSfsCharacter6_00674",
+              "Prompt": "TEXT_CTSSFSCHARACTER6_00674_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER6_00674_TOPMENU_000_001",
+              "AnswerIsRegularExpression": true
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "Sequence": 1,
+      "Steps": [
+        {
+          "TerritoryId": 635,
+          "InteractionType": "None",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/006/CtsSfsCharacter6_00674",
+              "Prompt": "TEXT_CTSSFSCHARACTER6_00674_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER6_00674_TOPMENU_000_003"
+            }
           ]
         }
       ]
index 9e279e15b9f5732ce5efbb8a03bf9b0d97804f94..325c08cd26a909e170885aeef652ae76c7e9afa1 100644 (file)
           "TerritoryId": 820,
           "InteractionType": "Interact",
           "RequiredGatheredItems": [],
-          "AetheryteShortcut": "Eulmore"
+          "AetheryteShortcut": "Eulmore",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/006/CtsSfsCharacter5_00640",
+              "Prompt": "TEXT_CTSSFSCHARACTER5_00640_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER5_00640_TOPMENU_000_001",
+              "AnswerIsRegularExpression": true
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "Sequence": 1,
+      "Steps": [
+        {
+          "TerritoryId": 635,
+          "InteractionType": "None",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/006/CtsSfsCharacter5_00640",
+              "Prompt": "TEXT_CTSSFSCHARACTER5_00640_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER5_00640_TOPMENU_000_004"
+            }
+          ]
         }
       ]
     }
index cc256d9f1520d7c34eb4ebbab71f5920ff0ba3dc..36229edda3f19a63c62168c0ab8877684f287aa8 100644 (file)
           "AethernetShortcut": [
             "[Old Sharlayan] Aetheryte Plaza",
             "[Old Sharlayan] The Leveilleur Estate"
+          ],
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/007/CtsSfsCharacter8_00773",
+              "Prompt": "TEXT_CTSSFSCHARACTER8_00773_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER8_00773_TOPMENU_000_001",
+              "AnswerIsRegularExpression": true
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "Sequence": 1,
+      "Steps": [
+        {
+          "TerritoryId": 635,
+          "InteractionType": "None",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/007/CtsSfsCharacter8_00773",
+              "Prompt": "TEXT_CTSSFSCHARACTER8_00773_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER8_00773_TOPMENU_000_004"
+            }
           ]
         }
       ]
index 7cdc39047d0b154647bb3a60b63b98c5d7fae530..1ac3ad73d44f8483c52f3d9db830fdb47051764a 100644 (file)
           "InteractionType": "Interact",
           "RequiredGatheredItems": [],
           "AetheryteShortcut": "Il Mheg - Lydha Lran",
-          "Fly": true
+          "Fly": true,
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/008/CtsSfsCharacter9_00815",
+              "Prompt": "TEXT_CTSSFSCHARACTER9_00815_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER9_00815_TOPMENU_000_001",
+              "AnswerIsRegularExpression": true
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "Sequence": 1,
+      "Steps": [
+        {
+          "TerritoryId": 635,
+          "InteractionType": "None",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/008/CtsSfsCharacter9_00815",
+              "Prompt": "TEXT_CTSSFSCHARACTER9_00815_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER9_00815_TOPMENU_000_004"
+            }
+          ]
         }
       ]
     }
index 3d9a80bee7a6bb5971b6537b047f1c5d31ab1f7f..1d5507af07f3b580a34ca58630ed246adde4d976 100644 (file)
             "Z": -65.14081
           },
           "TerritoryId": 956,
-          "InteractionType": "Interact"
+          "InteractionType": "Interact",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/008/CtsSfsCharacter10_00842",
+              "Prompt": "TEXT_CTSSFSCHARACTER10_00842_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER10_00842_TOPMENU_000_001",
+              "AnswerIsRegularExpression": true
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "Sequence": 1,
+      "Steps": [
+        {
+          "TerritoryId": 635,
+          "InteractionType": "None",
+          "DialogueChoices": [
+            {
+              "Type": "List",
+              "ExcelSheet": "custom/008/CtsSfsCharacter10_00842",
+              "Prompt": "TEXT_CTSSFSCHARACTER10_00842_TOPMENU_000_000",
+              "Answer": "TEXT_CTSSFSCHARACTER10_00842_TOPMENU_000_004"
+            }
+          ]
         }
       ]
     }
index 6053a1d5f7dcb0d71adee620f4dc93fafc64fe13..67a6445536893b04ec6fb6e73026f0b8074c9830 100644 (file)
             "Z": -68.40625
           },
           "TerritoryId": 963,
-          "InteractionType": "AcceptQuest",
-          "DialogueChoices": [
-            {
-              "Type": "List",
-              "Prompt": "TEXT_AKTKMM103_04753_Q1_000_000",
-              "Answer": "TEXT_AKTKMM103_04753_A1_000_001"
-            }
-          ]
+          "InteractionType": "AcceptQuest"
         }
       ]
     },
index e7d7ee2b4ebe721b07a6aa96cba2421cb0b4c184..f0029f01f971e2a5d962451aa086720522a2dc91 100644 (file)
                   "type": "string",
                   "description": "What to do at the position",
                   "enum": [
+                    "None",
                     "Interact",
                     "WalkTo",
                     "AttuneAethernetShard",
index 23f0366e573d6f16974e33c8d085e39c367e78e2..4e375415189d1b5ccde902d2d2777bfc58c905a5 100644 (file)
@@ -7,6 +7,7 @@ public sealed class InteractionTypeConverter() : EnumConverter<EInteractionType>
 {
     private static readonly Dictionary<EInteractionType, string> Values = new()
     {
+        { EInteractionType.None, "None" },
         { EInteractionType.Interact, "Interact" },
         { EInteractionType.WalkTo, "WalkTo" },
         { EInteractionType.AttuneAethernetShard, "AttuneAethernetShard" },
index 91370f5d9714cffa88b456945c5e259f6c60b5d0..41cb25192d4593c9597baa2bf4201df363ec6e01 100644 (file)
@@ -16,6 +16,7 @@ public sealed class DialogueChoice
 
     [JsonConverter(typeof(ExcelRefConverter))]
     public ExcelRef? Answer { get; set; }
+    public bool AnswerIsRegularExpression { get; set; }
 
     /// <summary>
     /// If set, only applies when focusing the given target id.
index 5080714a81923cb88d2eedc4448a747f84e28186..e080f07cf5d648fb76e346ab4f791de708b94c50 100644 (file)
@@ -6,6 +6,7 @@ namespace Questionable.Model.Questing;
 [JsonConverter(typeof(InteractionTypeConverter))]
 public enum EInteractionType
 {
+    None,
     Interact,
     WalkTo,
     AttuneAethernetShard,
index bfc6fd1a4a64da03b3787cca3270e2d8b9411c04..8b915954d12784ef815dba572ba62d2c351bd120 100644 (file)
@@ -5,4 +5,9 @@ public sealed class GatheredItem
     public uint ItemId { get; set; }
     public int ItemCount { get; set; }
     public ushort Collectability { get; set; }
+
+    /// <summary>
+    /// Either miner or botanist; null if it is irrelevant (prefers current class/job, then any unlocked ones).
+    /// </summary>
+    public uint? ClassJob { get; set; }
 }
diff --git a/Questionable/ChatFunctions.cs b/Questionable/ChatFunctions.cs
deleted file mode 100644 (file)
index d28946a..0000000
+++ /dev/null
@@ -1,192 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-using System.Runtime.InteropServices;
-using System.Text;
-using Dalamud.Game;
-using Dalamud.Game.ClientState.Objects;
-using Dalamud.Game.ClientState.Objects.Types;
-using Dalamud.Plugin.Services;
-using FFXIVClientStructs.FFXIV.Client.System.Framework;
-using FFXIVClientStructs.FFXIV.Client.System.Memory;
-using FFXIVClientStructs.FFXIV.Client.System.String;
-using Lumina.Excel.GeneratedSheets;
-using Microsoft.Extensions.Logging;
-using Questionable.Model.Questing;
-
-namespace Questionable;
-
-internal sealed unsafe class ChatFunctions
-{
-    private delegate void ProcessChatBoxDelegate(IntPtr uiModule, IntPtr message, IntPtr unused, byte a4);
-
-    private readonly ReadOnlyDictionary<EEmote, string> _emoteCommands;
-
-    private readonly GameFunctions _gameFunctions;
-    private readonly ITargetManager _targetManager;
-    private readonly ILogger<ChatFunctions> _logger;
-    private readonly ProcessChatBoxDelegate _processChatBox;
-    private readonly delegate* unmanaged<Utf8String*, int, IntPtr, void> _sanitiseString;
-
-    public ChatFunctions(ISigScanner sigScanner, IDataManager dataManager, GameFunctions gameFunctions,
-        ITargetManager targetManager, ILogger<ChatFunctions> logger)
-    {
-        _gameFunctions = gameFunctions;
-        _targetManager = targetManager;
-        _logger = logger;
-        _processChatBox =
-            Marshal.GetDelegateForFunctionPointer<ProcessChatBoxDelegate>(sigScanner.ScanText(Signatures.SendChat));
-        _sanitiseString =
-            (delegate* unmanaged<Utf8String*, int, IntPtr, void>)sigScanner.ScanText(Signatures.SanitiseString);
-
-        _emoteCommands = dataManager.GetExcelSheet<Emote>()!
-            .Where(x => x.RowId > 0)
-            .Where(x => x.TextCommand != null && x.TextCommand.Value != null)
-            .Select(x => (x.RowId, Command: x.TextCommand.Value!.Command?.ToString()))
-            .Where(x => x.Command != null && x.Command.StartsWith('/'))
-            .ToDictionary(x => (EEmote)x.RowId, x => x.Command!)
-            .AsReadOnly();
-    }
-
-    /// <summary>
-    /// <para>
-    /// Send a given message to the chat box. <b>This can send chat to the server.</b>
-    /// </para>
-    /// <para>
-    /// <b>This method is unsafe.</b> This method does no checking on your input and
-    /// may send content to the server that the normal client could not. You must
-    /// verify what you're sending and handle content and length to properly use
-    /// this.
-    /// </para>
-    /// </summary>
-    /// <param name="message">Message to send</param>
-    /// <exception cref="InvalidOperationException">If the signature for this function could not be found</exception>
-    private void SendMessageUnsafe(byte[] message)
-    {
-        var uiModule = (IntPtr)Framework.Instance()->GetUIModule();
-
-        using var payload = new ChatPayload(message);
-        var mem1 = Marshal.AllocHGlobal(400);
-        Marshal.StructureToPtr(payload, mem1, false);
-
-        _processChatBox(uiModule, mem1, IntPtr.Zero, 0);
-
-        Marshal.FreeHGlobal(mem1);
-    }
-
-    /// <summary>
-    /// <para>
-    /// Send a given message to the chat box. <b>This can send chat to the server.</b>
-    /// </para>
-    /// <para>
-    /// This method is slightly less unsafe than <see cref="SendMessageUnsafe"/>. It
-    /// will throw exceptions for certain inputs that the client can't normally send,
-    /// but it is still possible to make mistakes. Use with caution.
-    /// </para>
-    /// </summary>
-    /// <param name="message">message to send</param>
-    /// <exception cref="ArgumentException">If <paramref name="message"/> is empty, longer than 500 bytes in UTF-8, or contains invalid characters.</exception>
-    /// <exception cref="InvalidOperationException">If the signature for this function could not be found</exception>
-    private void SendMessage(string message)
-    {
-        _logger.LogDebug("Attempting to send chat message '{Message}'", message);
-        var bytes = Encoding.UTF8.GetBytes(message);
-        if (bytes.Length == 0)
-            throw new ArgumentException("message is empty", nameof(message));
-
-        if (bytes.Length > 500)
-            throw new ArgumentException("message is longer than 500 bytes", nameof(message));
-
-        if (message.Length != SanitiseText(message).Length)
-            throw new ArgumentException("message contained invalid characters", nameof(message));
-
-        SendMessageUnsafe(bytes);
-    }
-
-    /// <summary>
-    /// <para>
-    /// Sanitises a string by removing any invalid input.
-    /// </para>
-    /// <para>
-    /// The result of this method is safe to use with
-    /// <see cref="SendMessage"/>, provided that it is not empty or too
-    /// long.
-    /// </para>
-    /// </summary>
-    /// <param name="text">text to sanitise</param>
-    /// <returns>sanitised text</returns>
-    /// <exception cref="InvalidOperationException">If the signature for this function could not be found</exception>
-    private string SanitiseText(string text)
-    {
-        var uText = Utf8String.FromString(text);
-
-        _sanitiseString(uText, 0x27F, IntPtr.Zero);
-        var sanitised = uText->ToString();
-
-        uText->Dtor();
-        IMemorySpace.Free(uText);
-
-        return sanitised;
-    }
-
-    public void ExecuteCommand(string command)
-    {
-        if (!command.StartsWith('/'))
-            return;
-
-        SendMessage(command);
-    }
-
-    public void UseEmote(uint dataId, EEmote emote)
-    {
-        IGameObject? gameObject = _gameFunctions.FindObjectByDataId(dataId);
-        if (gameObject != null)
-        {
-            _targetManager.Target = gameObject;
-            ExecuteCommand($"{_emoteCommands[emote]} motion");
-        }
-    }
-
-    public void UseEmote(EEmote emote)
-    {
-        ExecuteCommand($"{_emoteCommands[emote]} motion");
-    }
-
-    private static class Signatures
-    {
-        internal const string SendChat = "48 89 5C 24 ?? 57 48 83 EC 20 48 8B FA 48 8B D9 45 84 C9";
-        internal const string SanitiseString = "E8 ?? ?? ?? ?? 48 8D 4C 24 ?? 0F B6 F0 E8 ?? ?? ?? ?? 48 8D 4D C0";
-    }
-
-    [StructLayout(LayoutKind.Explicit)]
-    [SuppressMessage("ReSharper", "PrivateFieldCanBeConvertedToLocalVariable")]
-    private readonly struct ChatPayload : IDisposable
-    {
-        [FieldOffset(0)] private readonly IntPtr textPtr;
-
-        [FieldOffset(16)] private readonly ulong textLen;
-
-        [FieldOffset(8)] private readonly ulong unk1;
-
-        [FieldOffset(24)] private readonly ulong unk2;
-
-        internal ChatPayload(byte[] stringBytes)
-        {
-            textPtr = Marshal.AllocHGlobal(stringBytes.Length + 30);
-            Marshal.Copy(stringBytes, 0, textPtr, stringBytes.Length);
-            Marshal.WriteByte(textPtr + stringBytes.Length, 0);
-
-            textLen = (ulong)(stringBytes.Length + 1);
-
-            unk1 = 64;
-            unk2 = 0;
-        }
-
-        public void Dispose()
-        {
-            Marshal.FreeHGlobal(textPtr);
-        }
-    }
-}
index 8c9998257958b9725a1eacb85195b64d381a4c38..057604d5263b68366e04921f19576b50454ac7ba 100644 (file)
@@ -14,6 +14,7 @@ using FFXIVClientStructs.FFXIV.Common.Math;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller.CombatModules;
 using Questionable.Controller.Utils;
+using Questionable.Functions;
 using Questionable.Model.Questing;
 
 namespace Questionable.Controller;
@@ -26,7 +27,7 @@ internal sealed class CombatController : IDisposable
     private readonly IObjectTable _objectTable;
     private readonly ICondition _condition;
     private readonly IClientState _clientState;
-    private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
     private readonly ILogger<CombatController> _logger;
 
     private CurrentFight? _currentFight;
@@ -39,7 +40,7 @@ internal sealed class CombatController : IDisposable
         IObjectTable objectTable,
         ICondition condition,
         IClientState clientState,
-        GameFunctions gameFunctions,
+        QuestFunctions questFunctions,
         ILogger<CombatController> logger)
     {
         _combatModules = combatModules.ToList();
@@ -48,7 +49,7 @@ internal sealed class CombatController : IDisposable
         _objectTable = objectTable;
         _condition = condition;
         _clientState = clientState;
-        _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
         _logger = logger;
 
         _clientState.TerritoryChanged += TerritoryChanged;
@@ -168,9 +169,9 @@ internal sealed class CombatController : IDisposable
                     }
                 }
 
-                if (QuestWorkUtils.HasCompletionFlags(condition.CompletionQuestVariablesFlags) && _currentFight.Data.QuestElementId is QuestId questId)
+                if (QuestWorkUtils.HasCompletionFlags(condition.CompletionQuestVariablesFlags) && _currentFight.Data.ElementId is QuestId questId)
                 {
-                    var questWork = _gameFunctions.GetQuestEx(questId);
+                    var questWork = _questFunctions.GetQuestEx(questId);
                     if (questWork != null && QuestWorkUtils.MatchesQuestWork(condition.CompletionQuestVariablesFlags,
                             questWork.Value))
                     {
@@ -303,7 +304,7 @@ internal sealed class CombatController : IDisposable
 
     public sealed class CombatData
     {
-        public required ElementId QuestElementId { get; init; }
+        public required ElementId ElementId { get; init; }
         public required EEnemySpawnType SpawnType { get; init; }
         public required List<uint> KillEnemyDataIds { get; init; }
         public required List<ComplexCombatData> ComplexCombatDatas { get; init; }
index 142d3263a1e66b4db53ce5271bf3fa71bb4b0b4f..322e6ccd1e49dc439180a8b1b77118d8024b7fa1 100644 (file)
@@ -3,6 +3,7 @@ using System.Linq;
 using Dalamud.Game.ClientState.Objects;
 using Dalamud.Game.Command;
 using Dalamud.Plugin.Services;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 using Questionable.Windows;
@@ -23,7 +24,7 @@ internal sealed class CommandHandler : IDisposable
     private readonly QuestWindow _questWindow;
     private readonly QuestSelectionWindow _questSelectionWindow;
     private readonly ITargetManager _targetManager;
-    private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
 
     public CommandHandler(
         ICommandManager commandManager,
@@ -37,7 +38,7 @@ internal sealed class CommandHandler : IDisposable
         QuestWindow questWindow,
         QuestSelectionWindow questSelectionWindow,
         ITargetManager targetManager,
-        GameFunctions gameFunctions)
+        QuestFunctions questFunctions)
     {
         _commandManager = commandManager;
         _chatGui = chatGui;
@@ -50,7 +51,7 @@ internal sealed class CommandHandler : IDisposable
         _questWindow = questWindow;
         _questSelectionWindow = questSelectionWindow;
         _targetManager = targetManager;
-        _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
 
         _commandManager.AddHandler("/qst", new CommandInfo(ProcessCommand)
         {
@@ -149,7 +150,7 @@ internal sealed class CommandHandler : IDisposable
     {
         if (arguments.Length >= 1 && ElementId.TryFromString(arguments[0], out ElementId? questId) && questId != null)
         {
-            if (_gameFunctions.IsQuestLocked(questId))
+            if (_questFunctions.IsQuestLocked(questId))
                 _chatGui.PrintError($"[Questionable] Quest {questId} is locked.");
             else if (_questRegistry.TryGetQuest(questId, out Quest? quest))
             {
index e4f7e3296cf4358691efd31fafa4e2618f331fe0..6b3f7f9f79e9f485ed68edf4fc0ac5397a418c22 100644 (file)
@@ -8,6 +8,7 @@ using FFXIVClientStructs.FFXIV.Client.UI.Agent;
 using LLib.GameData;
 using Microsoft.Extensions.Logging;
 using Questionable.Data;
+using Questionable.Functions;
 using Questionable.GameStructs;
 using Questionable.Model;
 using Questionable.Model.Questing;
@@ -21,6 +22,8 @@ internal sealed class ContextMenuController : IDisposable
     private readonly GatheringData _gatheringData;
     private readonly QuestRegistry _questRegistry;
     private readonly QuestData _questData;
+    private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
     private readonly IGameGui _gameGui;
     private readonly IChatGui _chatGui;
     private readonly IClientState _clientState;
@@ -32,6 +35,8 @@ internal sealed class ContextMenuController : IDisposable
         GatheringData gatheringData,
         QuestRegistry questRegistry,
         QuestData questData,
+        GameFunctions gameFunctions,
+        QuestFunctions questFunctions,
         IGameGui gameGui,
         IChatGui chatGui,
         IClientState clientState,
@@ -42,6 +47,8 @@ internal sealed class ContextMenuController : IDisposable
         _gatheringData = gatheringData;
         _questRegistry = questRegistry;
         _questData = questData;
+        _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
         _gameGui = gameGui;
         _chatGui = chatGui;
         _clientState = clientState;
@@ -52,7 +59,7 @@ internal sealed class ContextMenuController : IDisposable
 
     private void MenuOpened(IMenuOpenedArgs args)
     {
-        uint itemId = (uint) _gameGui.HoveredItem;
+        uint itemId = (uint)_gameGui.HoveredItem;
         if (itemId == 0)
             return;
 
@@ -62,43 +69,66 @@ internal sealed class ContextMenuController : IDisposable
         if (itemId >= 500_000)
             itemId -= 500_000;
 
-        if (!_gatheringData.TryGetGatheringPointId(itemId, (EClassJob)_clientState.LocalPlayer!.ClassJob.Id, out _))
+        if (_gatheringData.TryGetCustomDeliveryNpc(itemId, out uint npcId))
+        {
+            AddContextMenuEntry(args, itemId, npcId, EClassJob.Miner, "Mine");
+            AddContextMenuEntry(args, itemId, npcId, EClassJob.Botanist, "Harvest");
+        }
+    }
+
+    private void AddContextMenuEntry(IMenuOpenedArgs args, uint itemId, uint npcId, EClassJob classJob, string verb)
+    {
+        EClassJob currentClassJob = (EClassJob)_clientState.LocalPlayer!.ClassJob.Id;
+        if (classJob != currentClassJob && currentClassJob is EClassJob.Miner or EClassJob.Botanist)
+            return;
+
+        if (!_gatheringData.TryGetGatheringPointId(itemId, classJob, out _))
         {
             _logger.LogInformation("No gathering point found for current job.");
             return;
         }
 
-        if (_gatheringData.TryGetCustomDeliveryNpc(itemId, out uint npcId))
-        {
-            ushort collectability = _gatheringData.GetRecommendedCollectability(itemId);
-            int quantityToGather = collectability > 0 ? 6 : int.MaxValue;
-            if (collectability == 0)
-                return;
+        ushort collectability = _gatheringData.GetRecommendedCollectability(itemId);
+        int quantityToGather = collectability > 0 ? 6 : int.MaxValue;
+        if (collectability == 0)
+            return;
 
-            unsafe
+        unsafe
+        {
+            var agentSatisfactionSupply = AgentSatisfactionSupply.Instance();
+            if (agentSatisfactionSupply->IsAgentActive())
             {
-                var agentSatisfactionSupply = AgentSatisfactionSupply.Instance();
-                if (agentSatisfactionSupply->IsAgentActive())
-                {
-                    quantityToGather = Math.Min(agentSatisfactionSupply->RemainingAllowances,
-                        ((AgentSatisfactionSupply2*)agentSatisfactionSupply)->TurnInsToNextRank);
-                }
+                quantityToGather = Math.Min(agentSatisfactionSupply->RemainingAllowances,
+                    ((AgentSatisfactionSupply2*)agentSatisfactionSupply)->TurnInsToNextRank);
             }
-
-            args.AddMenuItem(new MenuItem
-            {
-                Prefix = SeIconChar.Hyadelyn,
-                PrefixColor = 52,
-                Name = "Gather with Questionable",
-                OnClicked = _ => StartGathering(npcId, itemId, quantityToGather, collectability),
-                IsEnabled = quantityToGather > 0,
-            });
         }
+
+        string lockedReasonn = string.Empty;
+        if (!_questFunctions.IsClassJobUnlocked(classJob))
+            lockedReasonn = $"{classJob} not unlocked";
+        else if (quantityToGather == 0)
+            lockedReasonn = "No allowances";
+        else if (_gameFunctions.IsOccupied())
+            lockedReasonn = "Can't be used while interacting";
+
+        string name = $"{verb} with Questionable";
+        if (!string.IsNullOrEmpty(lockedReasonn))
+            name += $" ({lockedReasonn})";
+
+        args.AddMenuItem(new MenuItem
+        {
+            Prefix = SeIconChar.Hyadelyn,
+            PrefixColor = 52,
+            Name = name,
+            OnClicked = _ => StartGathering(npcId, itemId, quantityToGather, collectability, classJob),
+            IsEnabled = string.IsNullOrEmpty(lockedReasonn),
+        });
     }
 
-    private void StartGathering(uint npcId, uint itemId, int quantity, ushort collectability)
+    private void StartGathering(uint npcId, uint itemId, int quantity, ushort collectability, EClassJob classJob)
     {
-        var info = (SatisfactionSupplyInfo)_questData.GetAllByIssuerDataId(npcId).Single(x => x is SatisfactionSupplyInfo);
+        var info = (SatisfactionSupplyInfo)_questData.GetAllByIssuerDataId(npcId)
+            .Single(x => x is SatisfactionSupplyInfo);
         if (_questRegistry.TryGetQuest(info.QuestId, out Quest? quest))
         {
             var step = quest.FindSequence(0)!.FindStep(0)!;
@@ -108,7 +138,8 @@ internal sealed class ContextMenuController : IDisposable
                 {
                     ItemId = itemId,
                     ItemCount = quantity,
-                    Collectability = collectability
+                    Collectability = collectability,
+                    ClassJob = (uint)classJob,
                 }
             ];
             _questController.SetGatheringQuest(quest);
index 7f8a6223d2d6b74314d1972534062c746f4ed95e..8cd3f046abaadff17e2201a100718a3b0015a5e7 100644 (file)
@@ -14,6 +14,8 @@ using LLib.GameUI;
 using Lumina.Excel.GeneratedSheets;
 using Microsoft.Extensions.Logging;
 using Questionable.Data;
+using Questionable.Functions;
+using Questionable.Model;
 using Questionable.Model.Questing;
 using Quest = Questionable.Model.Quest;
 using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
@@ -25,6 +27,8 @@ internal sealed class GameUiController : IDisposable
     private readonly IAddonLifecycle _addonLifecycle;
     private readonly IDataManager _dataManager;
     private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
+    private readonly ExcelFunctions _excelFunctions;
     private readonly QuestController _questController;
     private readonly QuestRegistry _questRegistry;
     private readonly QuestData _questData;
@@ -33,13 +37,24 @@ internal sealed class GameUiController : IDisposable
     private readonly ILogger<GameUiController> _logger;
     private readonly Regex _returnRegex;
 
-    public GameUiController(IAddonLifecycle addonLifecycle, IDataManager dataManager, GameFunctions gameFunctions,
-        QuestController questController, QuestRegistry questRegistry, QuestData questData, IGameGui gameGui,
-        ITargetManager targetManager, IPluginLog pluginLog, ILogger<GameUiController> logger)
+    public GameUiController(
+        IAddonLifecycle addonLifecycle,
+        IDataManager dataManager,
+        GameFunctions gameFunctions,
+        QuestFunctions questFunctions,
+        ExcelFunctions excelFunctions,
+        QuestController questController,
+        QuestRegistry questRegistry,
+        QuestData questData,
+        IGameGui gameGui,
+        ITargetManager targetManager,
+        IPluginLog pluginLog, ILogger<GameUiController> logger)
     {
         _addonLifecycle = addonLifecycle;
         _dataManager = dataManager;
         _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
+        _excelFunctions = excelFunctions;
         _questController = questController;
         _questRegistry = questRegistry;
         _questData = questData;
@@ -188,7 +203,7 @@ internal sealed class GameUiController : IDisposable
     {
         // it is possible for this to be a quest selection
         string questName = quest.Info.Name;
-        int questSelection = answers.FindIndex(x => GameStringEquals(questName, x));
+        int questSelection = answers.FindIndex(x => GameFunctions.GameStringEquals(questName, x));
         if (questSelection >= 0)
         {
             addonSelectIconString->AtkUnitBase.FireCallbackInt(questSelection);
@@ -210,7 +225,7 @@ internal sealed class GameUiController : IDisposable
     private int? HandleListChoice(string? actualPrompt, List<string?> answers, bool checkAllSteps)
     {
         List<DialogueChoiceInfo> dialogueChoices = [];
-        var currentQuest = _questController.SimulatedQuest ?? _questController.StartedQuest;
+        var currentQuest = _questController.SimulatedQuest ?? _questController.GatheringQuest ?? _questController.StartedQuest;
         if (currentQuest != null)
         {
             var quest = currentQuest.Quest;
@@ -260,9 +275,9 @@ internal sealed class GameUiController : IDisposable
         var target = _targetManager.Target;
         if (target != null)
         {
-            foreach (var questInfo in _questData.GetAllByIssuerDataId(target.DataId))
+            foreach (var questInfo in _questData.GetAllByIssuerDataId(target.DataId).Where(x => x.QuestId is QuestId))
             {
-                if (_gameFunctions.IsReadyToAcceptQuest(questInfo.QuestId) &&
+                if (_questFunctions.IsReadyToAcceptQuest(questInfo.QuestId) &&
                     _questRegistry.TryGetQuest(questInfo.QuestId, out Quest? knownQuest))
                 {
                     var questChoices = knownQuest.FindSequence(0)?.Steps
@@ -300,8 +315,10 @@ internal sealed class GameUiController : IDisposable
                 continue;
             }
 
-            string? excelPrompt = ResolveReference(quest, dialogueChoice.ExcelSheet, dialogueChoice.Prompt);
-            string? excelAnswer = ResolveReference(quest, dialogueChoice.ExcelSheet, dialogueChoice.Answer);
+            string? excelPrompt = ResolveReference(quest, dialogueChoice.ExcelSheet, dialogueChoice.Prompt, false)
+                ?.GetString();
+            StringOrRegex? excelAnswer = ResolveReference(quest, dialogueChoice.ExcelSheet, dialogueChoice.Answer,
+                dialogueChoice.AnswerIsRegularExpression);
 
             if (actualPrompt == null && !string.IsNullOrEmpty(excelPrompt))
             {
@@ -309,7 +326,8 @@ internal sealed class GameUiController : IDisposable
                 continue;
             }
 
-            if (actualPrompt != null && (excelPrompt == null || !GameStringEquals(actualPrompt, excelPrompt)))
+            if (actualPrompt != null &&
+                (excelPrompt == null || !GameFunctions.GameStringEquals(actualPrompt, excelPrompt)))
             {
                 _logger.LogInformation("Unexpected excelPrompt: {ExcelPrompt}, actualPrompt: {ActualPrompt}",
                     excelPrompt, actualPrompt);
@@ -320,10 +338,22 @@ internal sealed class GameUiController : IDisposable
             {
                 _logger.LogTrace("Checking if {ActualAnswer} == {ExpectedAnswer}",
                     answers[i], excelAnswer);
-                if (GameStringEquals(answers[i], excelAnswer))
+                if (IsMatch(answers[i], excelAnswer))
                 {
                     _logger.LogInformation("Returning {Index}: '{Answer}' for '{Prompt}'",
                         i, answers[i], actualPrompt);
+
+                    // ensure we only open the dialog once
+                    if (quest.Id is SatisfactionSupplyNpcId)
+                    {
+                        if (_questController.GatheringQuest == null ||
+                            _questController.GatheringQuest.Sequence == 255)
+                            return null;
+
+                        _questController.GatheringQuest.SetSequence(1);
+                        _questController.ExecuteNextStep(QuestController.EAutomationType.CurrentQuestOnly);
+                    }
+
                     return i;
                 }
             }
@@ -333,13 +363,24 @@ internal sealed class GameUiController : IDisposable
         return null;
     }
 
+    private static bool IsMatch(string? actualAnswer, StringOrRegex? expectedAnswer)
+    {
+        if (actualAnswer == null && expectedAnswer == null)
+            return true;
+
+        if (actualAnswer == null || expectedAnswer == null)
+            return false;
+
+        return expectedAnswer.IsMatch(actualAnswer);
+    }
+
     private int? HandleInstanceListChoice(string? actualPrompt)
     {
         if (!_questController.IsRunning)
             return null;
 
-        string? expectedPrompt = _gameFunctions.GetDialogueTextByRowId("Addon", 2090);
-        if (GameStringEquals(actualPrompt, expectedPrompt))
+        string? expectedPrompt = _excelFunctions.GetDialogueTextByRowId("Addon", 2090, false).GetString();
+        if (GameFunctions.GameStringEquals(actualPrompt, expectedPrompt))
         {
             _logger.LogInformation("Selecting no prefered instance as answer for '{Prompt}'", actualPrompt);
             return 0; // any instance
@@ -419,8 +460,9 @@ internal sealed class GameUiController : IDisposable
                 continue;
             }
 
-            string? excelPrompt = ResolveReference(quest, dialogueChoice.ExcelSheet, dialogueChoice.Prompt);
-            if (excelPrompt == null || !GameStringEquals(actualPrompt, excelPrompt))
+            string? excelPrompt = ResolveReference(quest, dialogueChoice.ExcelSheet, dialogueChoice.Prompt, false)
+                ?.GetString();
+            if (excelPrompt == null || !GameFunctions.GameStringEquals(actualPrompt, excelPrompt))
             {
                 _logger.LogInformation("Unexpected excelPrompt: {ExcelPrompt}, actualPrompt: {ActualPrompt}",
                     excelPrompt, actualPrompt);
@@ -506,13 +548,13 @@ internal sealed class GameUiController : IDisposable
             string? excelName = entry.Name?.ToString();
             string? excelQuestion = entry.Question?.ToString();
 
-            if (excelQuestion != null && GameStringEquals(excelQuestion, actualPrompt))
+            if (excelQuestion != null && GameFunctions.GameStringEquals(excelQuestion, actualPrompt))
             {
                 warpId = entry.RowId;
                 warpText = excelQuestion;
                 return true;
             }
-            else if (excelName != null && GameStringEquals(excelName, actualPrompt))
+            else if (excelName != null && GameFunctions.GameStringEquals(excelName, actualPrompt))
             {
                 warpId = entry.RowId;
                 warpText = excelName;
@@ -642,31 +684,17 @@ internal sealed class GameUiController : IDisposable
         }
     }
 
-    /// <summary>
-    /// Ensures characters like '-' are handled equally in both strings.
-    /// </summary>
-    public static bool GameStringEquals(string? a, string? b)
-    {
-        if (a == null)
-            return b == null;
-
-        if (b == null)
-            return false;
-
-        return a.ReplaceLineEndings().Replace('\u2013', '-') == b.ReplaceLineEndings().Replace('\u2013', '-');
-    }
-
-    private string? ResolveReference(Quest quest, string? excelSheet, ExcelRef? excelRef)
+    private StringOrRegex? ResolveReference(Quest quest, string? excelSheet, ExcelRef? excelRef, bool isRegExp)
     {
         if (excelRef == null)
             return null;
 
         if (excelRef.Type == ExcelRef.EType.Key)
-            return _gameFunctions.GetDialogueText(quest, excelSheet, excelRef.AsKey());
+            return _excelFunctions.GetDialogueText(quest, excelSheet, excelRef.AsKey(), isRegExp);
         else if (excelRef.Type == ExcelRef.EType.RowId)
-            return _gameFunctions.GetDialogueTextByRowId(excelSheet, excelRef.AsRowId());
+            return _excelFunctions.GetDialogueTextByRowId(excelSheet, excelRef.AsRowId(), isRegExp);
         else if (excelRef.Type == ExcelRef.EType.RawString)
-            return excelRef.AsRawString();
+            return new StringOrRegex(excelRef.AsRawString());
 
         return null;
     }
index 053b552fb26556a372bcdb056a5552ae6cfa044e..87a1eee12aac692733ff1b41cadb760b8c4be77d 100644 (file)
@@ -14,6 +14,7 @@ using Questionable.Controller.Steps.Gathering;
 using Questionable.Controller.Steps.Interactions;
 using Questionable.Controller.Steps.Shared;
 using Questionable.External;
+using Questionable.Functions;
 using Questionable.GatheringPaths;
 using Questionable.Model.Gathering;
 
index c4f1b05b2e212fa438c96c8d52bb10c5f8e19671..2f40a3beafc5d35124fdc29276023693965552ec 100644 (file)
@@ -17,6 +17,7 @@ using FFXIVClientStructs.FFXIV.Client.Game.Control;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller.NavigationOverrides;
 using Questionable.External;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Common;
 using Questionable.Model.Common.Converter;
index fcdd8fa43f3bbf84e8762cf8b01a3cc832dc55f1..f3294f2a3cd4661d67f04832f1c8e0caee272f53 100644 (file)
@@ -1,6 +1,7 @@
 using System.Numerics;
 using Dalamud.Plugin.Services;
 using FFXIVClientStructs.FFXIV.Client.UI;
+using Questionable.Functions;
 using Questionable.Model;
 
 namespace Questionable.Controller;
index cde68ae2173aef3af300b5208967354677690c34..1b62bc6507e29f19a37f055b5e8921e6d1294b76 100644 (file)
@@ -5,10 +5,12 @@ using Dalamud.Game.ClientState.Conditions;
 using Dalamud.Game.ClientState.Keys;
 using Dalamud.Plugin.Services;
 using FFXIVClientStructs.FFXIV.Client.Game;
+using FFXIVClientStructs.FFXIV.Client.UI.Agent;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller.Steps;
 using Questionable.Controller.Steps.Shared;
 using Questionable.External;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
@@ -18,6 +20,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
 {
     private readonly IClientState _clientState;
     private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
     private readonly MovementController _movementController;
     private readonly CombatController _combatController;
     private readonly GatheringController _gatheringController;
@@ -46,6 +49,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
     public QuestController(
         IClientState clientState,
         GameFunctions gameFunctions,
+        QuestFunctions questFunctions,
         MovementController movementController,
         CombatController combatController,
         GatheringController gatheringController,
@@ -61,6 +65,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
     {
         _clientState = clientState;
         _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
         _movementController = movementController;
         _combatController = combatController;
         _gatheringController = gatheringController;
@@ -78,7 +83,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
         {
             if (_simulatedQuest != null)
                 return (_simulatedQuest, ECurrentQuestType.Simulated);
-            else if (_nextQuest != null && _gameFunctions.IsReadyToAcceptQuest(_nextQuest.Quest.Id))
+            else if (_nextQuest != null && _questFunctions.IsReadyToAcceptQuest(_nextQuest.Quest.Id))
                 return (_nextQuest, ECurrentQuestType.Next);
             else if (_gatheringQuest != null)
                 return (_gatheringQuest, ECurrentQuestType.Gathering);
@@ -177,7 +182,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
         UpdateCurrentTask();
     }
 
-    private void UpdateCurrentQuest()
+    private unsafe void UpdateCurrentQuest()
     {
         lock (_progressLock)
         {
@@ -188,9 +193,9 @@ internal sealed class QuestController : MiniTaskController<QuestController>
                 // if the quest is accepted, we no longer track it
                 bool canUseNextQuest;
                 if (_nextQuest.Quest.Info.IsRepeatable)
-                    canUseNextQuest = !_gameFunctions.IsQuestAccepted(_nextQuest.Quest.Id);
+                    canUseNextQuest = !_questFunctions.IsQuestAccepted(_nextQuest.Quest.Id);
                 else
-                    canUseNextQuest = !_gameFunctions.IsQuestAcceptedOrComplete(_nextQuest.Quest.Id);
+                    canUseNextQuest = !_questFunctions.IsQuestAcceptedOrComplete(_nextQuest.Quest.Id);
 
                 if (!canUseNextQuest)
                 {
@@ -207,7 +212,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
                 currentSequence = _simulatedQuest.Sequence;
                 questToRun = _simulatedQuest;
             }
-            else if (_nextQuest != null && _gameFunctions.IsReadyToAcceptQuest(_nextQuest.Quest.Id))
+            else if (_nextQuest != null && _questFunctions.IsReadyToAcceptQuest(_nextQuest.Quest.Id))
             {
                 questToRun = _nextQuest;
                 currentSequence = _nextQuest.Sequence; // by definition, this should always be 0
@@ -226,11 +231,10 @@ internal sealed class QuestController : MiniTaskController<QuestController>
                     _taskQueue.Count == 0 &&
                     _automationType == EAutomationType.Automatic)
                     ExecuteNextStep(_automationType);
-
             }
             else
             {
-                (ElementId? currentQuestId, currentSequence) = _gameFunctions.GetCurrentQuest();
+                (ElementId? currentQuestId, currentSequence) = _questFunctions.GetCurrentQuest();
                 if (currentQuestId == null || currentQuestId.Value == 0)
                 {
                     if (_startedQuest != null)
@@ -276,7 +280,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
                 return;
             }
 
-            if (_gameFunctions.IsOccupied())
+            if (_gameFunctions.IsOccupied() && !_gameFunctions.IsOccupiedWithCustomDeliveryNpc(questToRun.Quest))
             {
                 DebugState = "Occupied";
                 return;
@@ -303,7 +307,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
             if (questToRun.Sequence != currentSequence)
             {
                 questToRun.SetSequence(currentSequence);
-                Stop($"New sequence {questToRun == _startedQuest}/{_gameFunctions.GetCurrentQuestInternal()}",
+                Stop($"New sequence {questToRun == _startedQuest}/{_questFunctions.GetCurrentQuestInternal()}",
                     continueIfAutomatic: true);
             }
 
@@ -455,7 +459,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
 
     protected override void UpdateCurrentTask()
     {
-        if (_gameFunctions.IsOccupied())
+        if (_gameFunctions.IsOccupied() && !_gameFunctions.IsOccupiedWithCustomDeliveryNpc(CurrentQuest?.Quest))
             return;
 
         base.UpdateCurrentTask();
@@ -469,7 +473,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
 
     protected override void OnNextStep(ILastTask task)
     {
-        IncreaseStepCount(task.QuestElementId, task.Sequence, true);
+        IncreaseStepCount(task.ElementId, task.Sequence, true);
     }
 
     public void ExecuteNextStep(EAutomationType automatic)
@@ -484,7 +488,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
         if (CurrentQuest == null || seq == null || step == null)
         {
             if (CurrentQuestDetails?.Progress.Quest.Id is SatisfactionSupplyNpcId &&
-                CurrentQuestDetails?.Progress.Sequence == 0 &&
+                CurrentQuestDetails?.Progress.Sequence == 1 &&
                 CurrentQuestDetails?.Progress.Step == 255 &&
                 CurrentQuestDetails?.Type == ECurrentQuestType.Gathering)
             {
@@ -590,7 +594,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
         }
     }
 
-    public void Skip(ElementId questQuestElementId, byte currentQuestSequence)
+    public void Skip(ElementId elementId, byte currentQuestSequence)
     {
         lock (_progressLock)
         {
@@ -609,13 +613,13 @@ internal sealed class QuestController : MiniTaskController<QuestController>
                 if (_taskQueue.Count == 0)
                 {
                     Stop("Skip");
-                    IncreaseStepCount(questQuestElementId, currentQuestSequence);
+                    IncreaseStepCount(elementId, currentQuestSequence);
                 }
             }
             else
             {
                 Stop("SkipNx");
-                IncreaseStepCount(questQuestElementId, currentQuestSequence);
+                IncreaseStepCount(elementId, currentQuestSequence);
             }
         }
     }
@@ -657,7 +661,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
         foreach (var id in priorityQuests)
         {
             var questId = new QuestId(id);
-            if (_gameFunctions.IsReadyToAcceptQuest(questId) && _questRegistry.TryGetQuest(questId, out var quest))
+            if (_questFunctions.IsReadyToAcceptQuest(questId) && _questRegistry.TryGetQuest(questId, out var quest))
             {
                 SetNextQuest(quest);
                 _chatGui.Print(
index bcf489d11a852673d88c426b1b8da579feaf78c3..6dfba0b0883f80d9562b7f0167b4ecf03b497778 100644 (file)
@@ -5,6 +5,7 @@ using Dalamud.Plugin.Services;
 using FFXIVClientStructs.FFXIV.Client.Game;
 using Microsoft.Extensions.Logging;
 using Questionable.Data;
+using Questionable.Functions;
 
 namespace Questionable.Controller.Steps.Common;
 
index 2afc6ecb124592090e4865e9045eea35ad1179c2..4efe08ae8679876883d00172fcc0949c13080141 100644 (file)
@@ -1,6 +1,7 @@
 using System;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
@@ -26,32 +27,32 @@ internal static class NextQuest
         }
     }
 
-    internal sealed class SetQuest(QuestRegistry questRegistry, QuestController questController, GameFunctions gameFunctions, ILogger<SetQuest> logger) : ITask
+    internal sealed class SetQuest(QuestRegistry questRegistry, QuestController questController, QuestFunctions questFunctions, ILogger<SetQuest> logger) : ITask
     {
-        public ElementId NextQuestElementId { get; set; } = null!;
-        public ElementId CurrentQuestElementId { get; set; } = null!;
+        public ElementId NextQuestId { get; set; } = null!;
+        public ElementId CurrentQuestId { get; set; } = null!;
 
-        public ITask With(ElementId nextQuestElementId, ElementId currentQuestElementId)
+        public ITask With(ElementId nextQuestId, ElementId currentQuestId)
         {
-            NextQuestElementId = nextQuestElementId;
-            CurrentQuestElementId = currentQuestElementId;
+            NextQuestId = nextQuestId;
+            CurrentQuestId = currentQuestId;
             return this;
         }
 
         public bool Start()
         {
-            if (gameFunctions.IsQuestLocked(NextQuestElementId, CurrentQuestElementId))
+            if (questFunctions.IsQuestLocked(NextQuestId, CurrentQuestId))
             {
-                logger.LogInformation("Can't set next quest to {QuestId}, quest is locked", NextQuestElementId);
+                logger.LogInformation("Can't set next quest to {QuestId}, quest is locked", NextQuestId);
             }
-            else if (questRegistry.TryGetQuest(NextQuestElementId, out Quest? quest))
+            else if (questRegistry.TryGetQuest(NextQuestId, out Quest? quest))
             {
-                logger.LogInformation("Setting next quest to {QuestId}: '{QuestName}'", NextQuestElementId, quest.Info.Name);
+                logger.LogInformation("Setting next quest to {QuestId}: '{QuestName}'", NextQuestId, quest.Info.Name);
                 questController.SetNextQuest(quest);
             }
             else
             {
-                logger.LogInformation("Next quest with id {QuestId} not found", NextQuestElementId);
+                logger.LogInformation("Next quest with id {QuestId} not found", NextQuestId);
                 questController.SetNextQuest(null);
             }
 
@@ -60,6 +61,6 @@ internal static class NextQuest
 
         public ETaskResult Update() => ETaskResult.TaskComplete;
 
-        public override string ToString() => $"SetNextQuest({NextQuestElementId})";
+        public override string ToString() => $"SetNextQuest({NextQuestId})";
     }
 }
index 5ee6784458ac4fd81617c32d5d1cd009b880bb42..a379dc9dd19ec25e6e88f66567e1c91f55b564b5 100644 (file)
@@ -2,6 +2,7 @@
 using Dalamud.Game.ClientState.Conditions;
 using Dalamud.Plugin.Services;
 using Microsoft.Extensions.Logging;
+using Questionable.Functions;
 
 namespace Questionable.Controller.Steps.Common;
 
index 2590f7cfaa7ba9f23c16f6b72e7a935a131b031b..45f4113d229ba971ec2e4d2a65e552a7500e1f7e 100644 (file)
@@ -5,6 +5,7 @@ using FFXIVClientStructs.FFXIV.Component.GUI;
 using LLib.GameData;
 using LLib.GameUI;
 using Microsoft.Extensions.Logging;
+using Questionable.Functions;
 using Questionable.Model.Gathering;
 using Questionable.Model.Questing;
 
index 46cc268c8c8a465fe40919c91f5af6085ecc9523..b565cfc1e18c179a00d82e73db22a4a2bff2b7da 100644 (file)
@@ -8,6 +8,7 @@ using GatheringPathRenderer;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller.Steps.Shared;
+using Questionable.Functions;
 using Questionable.Model.Gathering;
 
 namespace Questionable.Controller.Steps.Gathering;
diff --git a/Questionable/Controller/Steps/Gathering/TurnInDelivery.cs b/Questionable/Controller/Steps/Gathering/TurnInDelivery.cs
new file mode 100644 (file)
index 0000000..f3a4ed5
--- /dev/null
@@ -0,0 +1,83 @@
+using System;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using FFXIVClientStructs.FFXIV.Client.UI.Agent;
+using FFXIVClientStructs.FFXIV.Component.GUI;
+using LLib.GameUI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Model;
+using Questionable.Model.Questing;
+using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
+
+namespace Questionable.Controller.Steps.Gathering;
+
+internal static class TurnInDelivery
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (quest.Id is not SatisfactionSupplyNpcId || sequence.Sequence != 1)
+                return null;
+
+            return serviceProvider.GetRequiredService<SatisfactionSupplyTurnIn>();
+        }
+    }
+
+    internal sealed class SatisfactionSupplyTurnIn(ILogger<SatisfactionSupplyTurnIn> logger) : ITask
+    {
+        private ushort? _remainingAllowances;
+
+        public bool Start() => true;
+
+        public unsafe ETaskResult Update()
+        {
+            AgentSatisfactionSupply* agentSatisfactionSupply = AgentSatisfactionSupply.Instance();
+            if (agentSatisfactionSupply == null || !agentSatisfactionSupply->IsAgentActive())
+                return _remainingAllowances == null ? ETaskResult.StillRunning : ETaskResult.TaskComplete;
+
+            var addonId = agentSatisfactionSupply->GetAddonId();
+            if (addonId == 0)
+                return _remainingAllowances == null ? ETaskResult.StillRunning : ETaskResult.TaskComplete;
+
+            AtkUnitBase* addon = LAddon.GetAddonById(addonId);
+            if (addon == null || !LAddon.IsAddonReady(addon))
+                return ETaskResult.StillRunning;
+
+            ushort remainingAllowances = agentSatisfactionSupply->RemainingAllowances;
+            if (remainingAllowances == 0)
+            {
+                logger.LogInformation("No remaining weekly allowances");
+                addon->FireCallbackInt(0);
+                return ETaskResult.TaskComplete;
+            }
+
+            if (InventoryManager.Instance()->GetInventoryItemCount(agentSatisfactionSupply->Items[1].Id,
+                    minCollectability: (short)agentSatisfactionSupply->Items[1].Collectability1) == 0)
+            {
+                logger.LogInformation("Inventory has no {ItemId}", agentSatisfactionSupply->Items[1].Id);
+                addon->FireCallbackInt(0);
+                return ETaskResult.TaskComplete;
+            }
+
+            // we should at least wait until we have less allowances
+            if (_remainingAllowances == remainingAllowances)
+                return ETaskResult.StillRunning;
+
+            // try turning it in...
+            logger.LogInformation("Attempting turn-in (remaining allowances: {RemainingAllowances})",
+                remainingAllowances);
+            _remainingAllowances = remainingAllowances;
+
+            var pickGatheringItem = stackalloc AtkValue[]
+            {
+                new() { Type = ValueType.Int, Int = 1 },
+                new() { Type = ValueType.Int, Int = 1 }
+            };
+            addon->FireCallback(2, pickGatheringItem);
+            return ETaskResult.StillRunning;
+        }
+
+        public override string ToString() => "WeeklyDeliveryTurnIn";
+    }
+}
index 71e8b0a60399823e0b9f3979ac0c1a53867c495d..0e0b275a7ead7e53df5ff76aa7bfcd5b835dd35f 100644 (file)
@@ -4,6 +4,6 @@ namespace Questionable.Controller.Steps;
 
 internal interface ILastTask : ITask
 {
-    public ElementId QuestElementId { get; }
+    public ElementId ElementId { get; }
     public int Sequence { get; }
 }
index 000811ca66de29df46933c0dcf752066443e8c49..cf60b6c70412bda5109fae2085c0018aa5f152a1 100644 (file)
@@ -6,6 +6,7 @@ using FFXIVClientStructs.FFXIV.Client.Game;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller.Steps.Common;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
index 3ae2c4b31365328115f6937c129cc21deba5c4a0..7df86e784b44f997fff7105a8f1d624548c2f1a6 100644 (file)
@@ -3,6 +3,7 @@ using Dalamud.Plugin.Services;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Questionable.Data;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
index b3219a6e5fe1fcfa06ee5c078138322da359a7de..741d92abe1fd90316ebed95024e9a37ad44880dc 100644 (file)
@@ -1,6 +1,7 @@
 using System;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Common;
 using Questionable.Model.Questing;
index c2cab7df423b72fb194ddfea9c56015b3b52b29b..c38d20cea448ef059ea1c8b5c7dedc74c060b736 100644 (file)
@@ -1,6 +1,7 @@
 using System;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Common;
 using Questionable.Model.Questing;
index d23608f0bdc7e9fe890c4cc6408bf9e0cc1babd0..5112046405f7a27b5df80bba142f1c63a4c46cc8 100644 (file)
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
 using Questionable.Controller.Steps.Common;
 using Questionable.Controller.Steps.Shared;
 using Questionable.Controller.Utils;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
@@ -78,19 +79,19 @@ internal static class Combat
         }
     }
 
-    internal sealed class HandleCombat(CombatController combatController, GameFunctions gameFunctions) : ITask
+    internal sealed class HandleCombat(CombatController combatController, QuestFunctions questFunctions) : ITask
     {
         private bool _isLastStep;
         private CombatController.CombatData _combatData = null!;
         private IList<QuestWorkValue?> _completionQuestVariableFlags = null!;
 
-        public ITask With(ElementId questElementId, bool isLastStep, EEnemySpawnType enemySpawnType, IList<uint> killEnemyDataIds,
+        public ITask With(ElementId elementId, bool isLastStep, EEnemySpawnType enemySpawnType, IList<uint> killEnemyDataIds,
             IList<QuestWorkValue?> completionQuestVariablesFlags, IList<ComplexCombatData> complexCombatData)
         {
             _isLastStep = isLastStep;
             _combatData = new CombatController.CombatData
             {
-                QuestElementId = questElementId,
+                ElementId = elementId,
                 SpawnType = enemySpawnType,
                 KillEnemyDataIds = killEnemyDataIds.ToList(),
                 ComplexCombatDatas = complexCombatData.ToList(),
@@ -107,9 +108,9 @@ internal static class Combat
                 return ETaskResult.StillRunning;
 
             // if our quest step has any completion flags, we need to check if they are set
-            if (QuestWorkUtils.HasCompletionFlags(_completionQuestVariableFlags) && _combatData.QuestElementId is QuestId questId)
+            if (QuestWorkUtils.HasCompletionFlags(_completionQuestVariableFlags) && _combatData.ElementId is QuestId questId)
             {
-                var questWork = gameFunctions.GetQuestEx(questId);
+                var questWork = questFunctions.GetQuestEx(questId);
                 if (questWork == null)
                     return ETaskResult.StillRunning;
 
index ab2afdcd1fa97f4bbc67135a77661cd8f79ab7f5..35a73dd976d238054c9a35968c7bcca2f7efd4f1 100644 (file)
@@ -2,6 +2,7 @@
 using Dalamud.Game.ClientState.Conditions;
 using Dalamud.Plugin.Services;
 using Microsoft.Extensions.DependencyInjection;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
index 0a5e9064994f45bfa5f7c0c087f66156aa46a6a9..fafa0fd836c2ae9ba316128b1fca385b13d460cf 100644 (file)
@@ -2,6 +2,7 @@
 using System.Collections.Generic;
 using Microsoft.Extensions.DependencyInjection;
 using Questionable.Controller.Steps.Common;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
index 869a1fcfdc0b754ceb6330da8cbb52ba59bca93b..06674e929b07d07321cfae04f345495a13857dda 100644 (file)
@@ -7,6 +7,7 @@ using Dalamud.Plugin.Services;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller.Steps.Shared;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
index 20fc7f673cb2fcd26ca5aad047cafe52f2c2fe52..ee900af35b0748ec1aa72eaac471e5a079e8fd79 100644 (file)
@@ -2,6 +2,7 @@
 using System.Collections.Generic;
 using Microsoft.Extensions.DependencyInjection;
 using Questionable.Controller.Steps.Common;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
@@ -9,7 +10,7 @@ namespace Questionable.Controller.Steps.Interactions;
 
 internal static class Say
 {
-    internal sealed class Factory(IServiceProvider serviceProvider, GameFunctions gameFunctions) : ITaskFactory
+    internal sealed class Factory(IServiceProvider serviceProvider, ExcelFunctions excelFunctions) : ITaskFactory
     {
         public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
         {
@@ -20,7 +21,7 @@ internal static class Say
             ArgumentNullException.ThrowIfNull(step.ChatMessage);
 
             string? excelString =
-                gameFunctions.GetDialogueText(quest, step.ChatMessage.ExcelSheet, step.ChatMessage.Key);
+                excelFunctions.GetDialogueText(quest, step.ChatMessage.ExcelSheet, step.ChatMessage.Key, false).GetString();
             ArgumentNullException.ThrowIfNull(excelString);
 
             var unmount = serviceProvider.GetRequiredService<UnmountTask>();
index cf86641440f47d080afe7b6e894339884baa8d3a..dca8260f01f9ac29b6af64724d6a82558a415804 100644 (file)
@@ -12,6 +12,7 @@ using Questionable.Controller.Steps.Common;
 using Questionable.Controller.Steps.Shared;
 using Questionable.Controller.Utils;
 using Questionable.Data;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Common;
 using Questionable.Model.Questing;
@@ -103,7 +104,7 @@ internal static class UseItem
             yield return serviceProvider.GetRequiredService<AetheryteShortcut.UseAetheryteShortcut>()
                 .With(null, EAetheryteLocation.Limsa, territoryId);
             yield return serviceProvider.GetRequiredService<AethernetShortcut.UseAethernetShortcut>()
-                .With(EAetheryteLocation.Limsa, EAetheryteLocation.LimsaArcanist, null);
+                .With(EAetheryteLocation.Limsa, EAetheryteLocation.LimsaArcanist);
             yield return serviceProvider.GetRequiredService<WaitAtEnd.WaitDelay>();
             yield return serviceProvider.GetRequiredService<Move.MoveInternal>()
                 .With(territoryId, destination, dataId: npcId, sprint: false);
@@ -112,7 +113,7 @@ internal static class UseItem
         }
     }
 
-    internal abstract class UseItemBase(GameFunctions gameFunctions, ICondition condition, ILogger logger) : ITask
+    internal abstract class UseItemBase(QuestFunctions questFunctions, ICondition condition, ILogger logger) : ITask
     {
         private bool _usedItem;
         private DateTime _continueAt;
@@ -144,7 +145,7 @@ internal static class UseItem
         {
             if (QuestId is QuestId questId && QuestWorkUtils.HasCompletionFlags(CompletionQuestVariablesFlags))
             {
-                QuestWork? questWork = gameFunctions.GetQuestEx(questId);
+                QuestWork? questWork = questFunctions.GetQuestEx(questId);
                 if (questWork != null &&
                     QuestWorkUtils.MatchesQuestWork(CompletionQuestVariablesFlags, questWork.Value))
                     return ETaskResult.TaskComplete;
@@ -196,11 +197,9 @@ internal static class UseItem
     }
 
 
-    internal sealed class UseOnGround(GameFunctions gameFunctions, ICondition condition, ILogger<UseOnGround> logger)
-        : UseItemBase(gameFunctions, condition, logger)
+    internal sealed class UseOnGround(GameFunctions gameFunctions, QuestFunctions questFunctions, ICondition condition, ILogger<UseOnGround> logger)
+        : UseItemBase(questFunctions, condition, logger)
     {
-        private readonly GameFunctions _gameFunctions = gameFunctions;
-
         public uint DataId { get; set; }
 
         public ITask With(ElementId? questId, uint dataId, uint itemId, IList<QuestWorkValue?> completionQuestVariablesFlags)
@@ -212,19 +211,18 @@ internal static class UseItem
             return this;
         }
 
-        protected override bool UseItem() => _gameFunctions.UseItemOnGround(DataId, ItemId);
+        protected override bool UseItem() => gameFunctions.UseItemOnGround(DataId, ItemId);
 
         public override string ToString() => $"UseItem({ItemId} on ground at {DataId})";
     }
 
     internal sealed class UseOnPosition(
         GameFunctions gameFunctions,
+        QuestFunctions questFunctions,
         ICondition condition,
         ILogger<UseOnPosition> logger)
-        : UseItemBase(gameFunctions, condition, logger)
+        : UseItemBase(questFunctions, condition, logger)
     {
-        private readonly GameFunctions _gameFunctions = gameFunctions;
-
         public Vector3 Position { get; set; }
 
         public ITask With(ElementId? questId, Vector3 position, uint itemId, IList<QuestWorkValue?> completionQuestVariablesFlags)
@@ -236,17 +234,15 @@ internal static class UseItem
             return this;
         }
 
-        protected override bool UseItem() => _gameFunctions.UseItemOnPosition(Position, ItemId);
+        protected override bool UseItem() => gameFunctions.UseItemOnPosition(Position, ItemId);
 
         public override string ToString() =>
             $"UseItem({ItemId} on ground at {Position.ToString("G", CultureInfo.InvariantCulture)})";
     }
 
-    internal sealed class UseOnObject(GameFunctions gameFunctions, ICondition condition, ILogger<UseOnObject> logger)
-        : UseItemBase(gameFunctions, condition, logger)
+    internal sealed class UseOnObject(QuestFunctions questFunctions, GameFunctions gameFunctions, ICondition condition, ILogger<UseOnObject> logger)
+        : UseItemBase(questFunctions, condition, logger)
     {
-        private readonly GameFunctions _gameFunctions = gameFunctions;
-
         public uint DataId { get; set; }
 
         public ITask With(ElementId? questId, uint dataId, uint itemId, IList<QuestWorkValue?> completionQuestVariablesFlags,
@@ -260,16 +256,14 @@ internal static class UseItem
             return this;
         }
 
-        protected override bool UseItem() => _gameFunctions.UseItem(DataId, ItemId);
+        protected override bool UseItem() => gameFunctions.UseItem(DataId, ItemId);
 
         public override string ToString() => $"UseItem({ItemId} on {DataId})";
     }
 
-    internal sealed class Use(GameFunctions gameFunctions, ICondition condition, ILogger<Use> logger)
-        : UseItemBase(gameFunctions, condition, logger)
+    internal sealed class Use(GameFunctions gameFunctions, QuestFunctions questFunctions, ICondition condition, ILogger<Use> logger)
+        : UseItemBase(questFunctions, condition, logger)
     {
-        private readonly GameFunctions _gameFunctions = gameFunctions;
-
         public ITask With(ElementId? questId, uint itemId, IList<QuestWorkValue?> completionQuestVariablesFlags)
         {
             QuestId = questId;
@@ -278,7 +272,7 @@ internal static class UseItem
             return this;
         }
 
-        protected override bool UseItem() => _gameFunctions.UseItem(ItemId);
+        protected override bool UseItem() => gameFunctions.UseItem(ItemId);
 
         public override string ToString() => $"UseItem({ItemId})";
     }
index d8d33cfba7c1b1b322534588493540f7fb0e4ad9..82643de17f303a96e7e27e30bcbd08db2c7a8162 100644 (file)
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Questionable.Data;
 using Questionable.External;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Common;
 using Questionable.Model.Common.Converter;
index ba03874a84c097cd798acdfa386628d751100f0e..b73f9585b94bd19050ec138146ed1959296f9df1 100644 (file)
@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller.Steps.Common;
 using Questionable.Data;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Common;
 using Questionable.Model.Questing;
index df213a33f072bd4524c0932732732a82e90d246c..1a083c17c36e9cf6c80cf0a71d7e6c79d1e584aa 100644 (file)
@@ -28,14 +28,25 @@ internal static class GatheringRequiredItems
         {
             foreach (var requiredGatheredItems in step.RequiredGatheredItems)
             {
-                if (!gatheringData.TryGetGatheringPointId(requiredGatheredItems.ItemId,
-                        (EClassJob)clientState.LocalPlayer!.ClassJob.Id, out var gatheringPointId))
+                EClassJob currentClassJob = (EClassJob)clientState.LocalPlayer!.ClassJob.Id;
+                EClassJob classJob = currentClassJob;
+                if (requiredGatheredItems.ClassJob != null)
+                    classJob = (EClassJob)requiredGatheredItems.ClassJob.Value;
+
+                if (!gatheringData.TryGetGatheringPointId(requiredGatheredItems.ItemId, classJob,
+                        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 {gatheringPointId}");
 
+                if (classJob != currentClassJob)
+                {
+                    yield return serviceProvider.GetRequiredService<SwitchClassJob>()
+                        .With(classJob);
+                }
+
                 if (HasRequiredItems(requiredGatheredItems))
                     continue;
 
@@ -71,7 +82,8 @@ internal static class GatheringRequiredItems
             InventoryManager* inventoryManager = InventoryManager.Instance();
             return inventoryManager != null &&
                    inventoryManager->GetInventoryItemCount(requiredGatheredItems.ItemId,
-                       minCollectability: (short)requiredGatheredItems.Collectability) >= requiredGatheredItems.ItemCount;
+                       minCollectability: (short)requiredGatheredItems.Collectability) >=
+                   requiredGatheredItems.ItemCount;
         }
 
         public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
index 24d3e7f88da25a03d5b14c2c92d36541e3ff9dd0..8ba93503a3a2fba141d334907ec647616d8b641c 100644 (file)
@@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging;
 using Questionable.Controller.NavigationOverrides;
 using Questionable.Controller.Steps.Common;
 using Questionable.Data;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
index c95894eb86ab52500b428a1bdcace4c6fa260c16..731d5d3856c75dcfd4902663a993fd69f12463ed 100644 (file)
@@ -10,6 +10,7 @@ using FFXIVClientStructs.FFXIV.Client.System.Framework;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller.Utils;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Common;
 using Questionable.Model.Questing;
@@ -41,17 +42,18 @@ internal static class SkipCondition
     internal sealed class CheckSkip(
         ILogger<CheckSkip> logger,
         GameFunctions gameFunctions,
+        QuestFunctions questFunctions,
         IClientState clientState) : ITask
     {
         public QuestStep Step { get; set; } = null!;
         public SkipStepConditions SkipConditions { get; set; } = null!;
-        public ElementId QuestElementId { get; set; } = null!;
+        public ElementId ElementId { get; set; } = null!;
 
-        public ITask With(QuestStep step, SkipStepConditions skipConditions, ElementId questElementId)
+        public ITask With(QuestStep step, SkipStepConditions skipConditions, ElementId elementId)
         {
             Step = step;
             SkipConditions = skipConditions;
-            QuestElementId = questElementId;
+            ElementId = elementId;
             return this;
         }
 
@@ -95,14 +97,14 @@ internal static class SkipCondition
             }
 
             if (SkipConditions.QuestsCompleted.Count > 0 &&
-                SkipConditions.QuestsCompleted.All(gameFunctions.IsQuestComplete))
+                SkipConditions.QuestsCompleted.All(questFunctions.IsQuestComplete))
             {
                 logger.LogInformation("Skipping step, all prequisite quests are complete");
                 return true;
             }
 
             if (SkipConditions.QuestsAccepted.Count > 0 &&
-                SkipConditions.QuestsAccepted.All(gameFunctions.IsQuestAccepted))
+                SkipConditions.QuestsAccepted.All(questFunctions.IsQuestAccepted))
             {
                 logger.LogInformation("Skipping step, all prequisite quests are accepted");
                 return true;
@@ -156,9 +158,9 @@ internal static class SkipCondition
                 return true;
             }
 
-            if (QuestElementId is QuestId questId)
+            if (ElementId is QuestId questId)
             {
-                QuestWork? questWork = gameFunctions.GetQuestEx(questId);
+                QuestWork? questWork = questFunctions.GetQuestEx(questId);
                 if (QuestWorkUtils.HasCompletionFlags(Step.CompletionQuestVariablesFlags) && questWork != null)
                 {
                     if (QuestWorkUtils.MatchesQuestWork(Step.CompletionQuestVariablesFlags, questWork.Value))
@@ -198,13 +200,13 @@ internal static class SkipCondition
                 }
             }
 
-            if (Step.PickUpQuestId != null && gameFunctions.IsQuestAcceptedOrComplete(Step.PickUpQuestId))
+            if (Step.PickUpQuestId != null && questFunctions.IsQuestAcceptedOrComplete(Step.PickUpQuestId))
             {
                 logger.LogInformation("Skipping step, as we have already picked up the relevant quest");
                 return true;
             }
 
-            if (Step.TurnInQuestId != null && gameFunctions.IsQuestComplete(Step.TurnInQuestId))
+            if (Step.TurnInQuestId != null && questFunctions.IsQuestComplete(Step.TurnInQuestId))
             {
                 logger.LogInformation("Skipping step, as we have already completed the relevant quest");
                 return true;
diff --git a/Questionable/Controller/Steps/Shared/SwitchClassJob.cs b/Questionable/Controller/Steps/Shared/SwitchClassJob.cs
new file mode 100644 (file)
index 0000000..c8159b9
--- /dev/null
@@ -0,0 +1,44 @@
+using Dalamud.Plugin.Services;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using FFXIVClientStructs.FFXIV.Client.UI.Misc;
+using LLib.GameData;
+using Questionable.Controller.Steps.Common;
+
+namespace Questionable.Controller.Steps.Shared;
+
+internal sealed class SwitchClassJob(IClientState clientState) : AbstractDelayedTask
+{
+    private EClassJob _classJob;
+
+    public ITask With(EClassJob classJob)
+    {
+        _classJob = classJob;
+        return this;
+    }
+
+    protected override unsafe bool StartInternal()
+    {
+        if (clientState.LocalPlayer!.ClassJob.Id == (uint)_classJob)
+            return false;
+
+        var gearsetModule = RaptureGearsetModule.Instance();
+        if (gearsetModule != null)
+        {
+            for (int i = 0; i < 100; ++i)
+            {
+                var gearset = gearsetModule->GetGearset(i);
+                if (gearset->ClassJob == (byte)_classJob)
+                {
+                    gearsetModule->EquipGearset(gearset->Id, gearset->BannerIndex);
+                    return true;
+                }
+            }
+        }
+
+        throw new TaskException($"No gearset found for {_classJob}");
+    }
+
+    protected override ETaskResult UpdateInternal() => ETaskResult.TaskComplete;
+
+    public override string ToString() => $"SwitchJob({_classJob})";
+}
index fcaacdf649731f0a28b8aa6a5c88a17eeda947a4..badab7dc7b60285d6ec4b749e2716c1c25d1c0c8 100644 (file)
@@ -11,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection;
 using Questionable.Controller.Steps.Common;
 using Questionable.Controller.Utils;
 using Questionable.Data;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
@@ -160,7 +161,7 @@ internal static class WaitAtEnd
         public override string ToString() => "Wait(next step or sequence)";
     }
 
-    internal sealed class WaitForCompletionFlags(GameFunctions gameFunctions) : ITask
+    internal sealed class WaitForCompletionFlags(QuestFunctions questFunctions) : ITask
     {
         public QuestId Quest { get; set; } = null!;
         public QuestStep Step { get; set; } = null!;
@@ -178,7 +179,7 @@ internal static class WaitAtEnd
 
         public ETaskResult Update()
         {
-            QuestWork? questWork = gameFunctions.GetQuestEx(Quest);
+            QuestWork? questWork = questFunctions.GetQuestEx(Quest);
             return questWork != null &&
                    QuestWorkUtils.MatchesQuestWork(Step.CompletionQuestVariablesFlags, questWork.Value)
                 ? ETaskResult.TaskComplete
@@ -214,13 +215,13 @@ internal static class WaitAtEnd
             $"WaitObj({DataId} at {Destination.ToString("G", CultureInfo.InvariantCulture)} < {Distance})";
     }
 
-    internal sealed class WaitQuestAccepted(GameFunctions gameFunctions) : ITask
+    internal sealed class WaitQuestAccepted(QuestFunctions questFunctions) : ITask
     {
-        public ElementId QuestElementId { get; set; } = null!;
+        public ElementId ElementId { get; set; } = null!;
 
-        public ITask With(ElementId questElementId)
+        public ITask With(ElementId elementId)
         {
-            QuestElementId = questElementId;
+            ElementId = elementId;
             return this;
         }
 
@@ -228,21 +229,21 @@ internal static class WaitAtEnd
 
         public ETaskResult Update()
         {
-            return gameFunctions.IsQuestAccepted(QuestElementId)
+            return questFunctions.IsQuestAccepted(ElementId)
                 ? ETaskResult.TaskComplete
                 : ETaskResult.StillRunning;
         }
 
-        public override string ToString() => $"WaitQuestAccepted({QuestElementId})";
+        public override string ToString() => $"WaitQuestAccepted({ElementId})";
     }
 
-    internal sealed class WaitQuestCompleted(GameFunctions gameFunctions) : ITask
+    internal sealed class WaitQuestCompleted(QuestFunctions questFunctions) : ITask
     {
-        public ElementId QuestElementId { get; set; } = null!;
+        public ElementId ElementId { get; set; } = null!;
 
-        public ITask With(ElementId questElementId)
+        public ITask With(ElementId elementId)
         {
-            QuestElementId = questElementId;
+            ElementId = elementId;
             return this;
         }
 
@@ -250,15 +251,15 @@ internal static class WaitAtEnd
 
         public ETaskResult Update()
         {
-            return gameFunctions.IsQuestComplete(QuestElementId) ? ETaskResult.TaskComplete : ETaskResult.StillRunning;
+            return questFunctions.IsQuestComplete(ElementId) ? ETaskResult.TaskComplete : ETaskResult.StillRunning;
         }
 
-        public override string ToString() => $"WaitQuestComplete({QuestElementId})";
+        public override string ToString() => $"WaitQuestComplete({ElementId})";
     }
 
-    internal sealed class NextStep(ElementId questElementId, int sequence) : ILastTask
+    internal sealed class NextStep(ElementId elementId, int sequence) : ILastTask
     {
-        public ElementId QuestElementId { get; } = questElementId;
+        public ElementId ElementId { get; } = elementId;
         public int Sequence { get; } = sequence;
 
         public bool Start() => true;
@@ -270,7 +271,7 @@ internal static class WaitAtEnd
 
     internal sealed class EndAutomation : ILastTask
     {
-        public ElementId QuestElementId => throw new InvalidOperationException();
+        public ElementId ElementId => throw new InvalidOperationException();
         public int Sequence => throw new InvalidOperationException();
 
         public bool Start() => true;
diff --git a/Questionable/Functions/ChatFunctions.cs b/Questionable/Functions/ChatFunctions.cs
new file mode 100644 (file)
index 0000000..5c31e4d
--- /dev/null
@@ -0,0 +1,192 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text;
+using Dalamud.Game;
+using Dalamud.Game.ClientState.Objects;
+using Dalamud.Game.ClientState.Objects.Types;
+using Dalamud.Plugin.Services;
+using FFXIVClientStructs.FFXIV.Client.System.Framework;
+using FFXIVClientStructs.FFXIV.Client.System.Memory;
+using FFXIVClientStructs.FFXIV.Client.System.String;
+using Lumina.Excel.GeneratedSheets;
+using Microsoft.Extensions.Logging;
+using Questionable.Model.Questing;
+
+namespace Questionable.Functions;
+
+internal sealed unsafe class ChatFunctions
+{
+    private delegate void ProcessChatBoxDelegate(IntPtr uiModule, IntPtr message, IntPtr unused, byte a4);
+
+    private readonly ReadOnlyDictionary<EEmote, string> _emoteCommands;
+
+    private readonly GameFunctions _gameFunctions;
+    private readonly ITargetManager _targetManager;
+    private readonly ILogger<ChatFunctions> _logger;
+    private readonly ProcessChatBoxDelegate _processChatBox;
+    private readonly delegate* unmanaged<Utf8String*, int, IntPtr, void> _sanitiseString;
+
+    public ChatFunctions(ISigScanner sigScanner, IDataManager dataManager, GameFunctions gameFunctions,
+        ITargetManager targetManager, ILogger<ChatFunctions> logger)
+    {
+        _gameFunctions = gameFunctions;
+        _targetManager = targetManager;
+        _logger = logger;
+        _processChatBox =
+            Marshal.GetDelegateForFunctionPointer<ProcessChatBoxDelegate>(sigScanner.ScanText(Signatures.SendChat));
+        _sanitiseString =
+            (delegate* unmanaged<Utf8String*, int, IntPtr, void>)sigScanner.ScanText(Signatures.SanitiseString);
+
+        _emoteCommands = dataManager.GetExcelSheet<Emote>()!
+            .Where(x => x.RowId > 0)
+            .Where(x => x.TextCommand != null && x.TextCommand.Value != null)
+            .Select(x => (x.RowId, Command: x.TextCommand.Value!.Command?.ToString()))
+            .Where(x => x.Command != null && x.Command.StartsWith('/'))
+            .ToDictionary(x => (EEmote)x.RowId, x => x.Command!)
+            .AsReadOnly();
+    }
+
+    /// <summary>
+    /// <para>
+    /// Send a given message to the chat box. <b>This can send chat to the server.</b>
+    /// </para>
+    /// <para>
+    /// <b>This method is unsafe.</b> This method does no checking on your input and
+    /// may send content to the server that the normal client could not. You must
+    /// verify what you're sending and handle content and length to properly use
+    /// this.
+    /// </para>
+    /// </summary>
+    /// <param name="message">Message to send</param>
+    /// <exception cref="InvalidOperationException">If the signature for this function could not be found</exception>
+    private void SendMessageUnsafe(byte[] message)
+    {
+        var uiModule = (IntPtr)Framework.Instance()->GetUIModule();
+
+        using var payload = new ChatPayload(message);
+        var mem1 = Marshal.AllocHGlobal(400);
+        Marshal.StructureToPtr(payload, mem1, false);
+
+        _processChatBox(uiModule, mem1, IntPtr.Zero, 0);
+
+        Marshal.FreeHGlobal(mem1);
+    }
+
+    /// <summary>
+    /// <para>
+    /// Send a given message to the chat box. <b>This can send chat to the server.</b>
+    /// </para>
+    /// <para>
+    /// This method is slightly less unsafe than <see cref="SendMessageUnsafe"/>. It
+    /// will throw exceptions for certain inputs that the client can't normally send,
+    /// but it is still possible to make mistakes. Use with caution.
+    /// </para>
+    /// </summary>
+    /// <param name="message">message to send</param>
+    /// <exception cref="ArgumentException">If <paramref name="message"/> is empty, longer than 500 bytes in UTF-8, or contains invalid characters.</exception>
+    /// <exception cref="InvalidOperationException">If the signature for this function could not be found</exception>
+    private void SendMessage(string message)
+    {
+        _logger.LogDebug("Attempting to send chat message '{Message}'", message);
+        var bytes = Encoding.UTF8.GetBytes(message);
+        if (bytes.Length == 0)
+            throw new ArgumentException("message is empty", nameof(message));
+
+        if (bytes.Length > 500)
+            throw new ArgumentException("message is longer than 500 bytes", nameof(message));
+
+        if (message.Length != SanitiseText(message).Length)
+            throw new ArgumentException("message contained invalid characters", nameof(message));
+
+        SendMessageUnsafe(bytes);
+    }
+
+    /// <summary>
+    /// <para>
+    /// Sanitises a string by removing any invalid input.
+    /// </para>
+    /// <para>
+    /// The result of this method is safe to use with
+    /// <see cref="SendMessage"/>, provided that it is not empty or too
+    /// long.
+    /// </para>
+    /// </summary>
+    /// <param name="text">text to sanitise</param>
+    /// <returns>sanitised text</returns>
+    /// <exception cref="InvalidOperationException">If the signature for this function could not be found</exception>
+    private string SanitiseText(string text)
+    {
+        var uText = Utf8String.FromString(text);
+
+        _sanitiseString(uText, 0x27F, IntPtr.Zero);
+        var sanitised = uText->ToString();
+
+        uText->Dtor();
+        IMemorySpace.Free(uText);
+
+        return sanitised;
+    }
+
+    public void ExecuteCommand(string command)
+    {
+        if (!command.StartsWith('/'))
+            return;
+
+        SendMessage(command);
+    }
+
+    public void UseEmote(uint dataId, EEmote emote)
+    {
+        IGameObject? gameObject = _gameFunctions.FindObjectByDataId(dataId);
+        if (gameObject != null)
+        {
+            _targetManager.Target = gameObject;
+            ExecuteCommand($"{_emoteCommands[emote]} motion");
+        }
+    }
+
+    public void UseEmote(EEmote emote)
+    {
+        ExecuteCommand($"{_emoteCommands[emote]} motion");
+    }
+
+    private static class Signatures
+    {
+        internal const string SendChat = "48 89 5C 24 ?? 57 48 83 EC 20 48 8B FA 48 8B D9 45 84 C9";
+        internal const string SanitiseString = "E8 ?? ?? ?? ?? 48 8D 4C 24 ?? 0F B6 F0 E8 ?? ?? ?? ?? 48 8D 4D C0";
+    }
+
+    [StructLayout(LayoutKind.Explicit)]
+    [SuppressMessage("ReSharper", "PrivateFieldCanBeConvertedToLocalVariable")]
+    private readonly struct ChatPayload : IDisposable
+    {
+        [FieldOffset(0)] private readonly IntPtr textPtr;
+
+        [FieldOffset(16)] private readonly ulong textLen;
+
+        [FieldOffset(8)] private readonly ulong unk1;
+
+        [FieldOffset(24)] private readonly ulong unk2;
+
+        internal ChatPayload(byte[] stringBytes)
+        {
+            textPtr = Marshal.AllocHGlobal(stringBytes.Length + 30);
+            Marshal.Copy(stringBytes, 0, textPtr, stringBytes.Length);
+            Marshal.WriteByte(textPtr + stringBytes.Length, 0);
+
+            textLen = (ulong)(stringBytes.Length + 1);
+
+            unk1 = 64;
+            unk2 = 0;
+        }
+
+        public void Dispose()
+        {
+            Marshal.FreeHGlobal(textPtr);
+        }
+    }
+}
diff --git a/Questionable/Functions/ExcelFunctions.cs b/Questionable/Functions/ExcelFunctions.cs
new file mode 100644 (file)
index 0000000..cdf5065
--- /dev/null
@@ -0,0 +1,101 @@
+using System;
+using System.Linq;
+using Dalamud.Plugin.Services;
+using Dalamud.Utility;
+using LLib;
+using Lumina.Excel.CustomSheets;
+using Lumina.Excel.GeneratedSheets;
+using Lumina.Text;
+using Microsoft.Extensions.Logging;
+using Questionable.Model;
+using Quest = Questionable.Model.Quest;
+using GimmickYesNo = Lumina.Excel.GeneratedSheets2.GimmickYesNo;
+
+namespace Questionable.Functions;
+
+internal sealed class ExcelFunctions
+{
+    private readonly IDataManager _dataManager;
+    private readonly ILogger<ExcelFunctions> _logger;
+
+    public ExcelFunctions(IDataManager dataManager, ILogger<ExcelFunctions> logger)
+    {
+        _dataManager = dataManager;
+        _logger = logger;
+    }
+
+    public StringOrRegex GetDialogueText(Quest currentQuest, string? excelSheetName, string key, bool isRegex)
+    {
+        var seString = GetRawDialogueText(currentQuest, excelSheetName, key);
+        if (isRegex)
+            return new StringOrRegex(seString.ToRegex());
+        else
+            return new StringOrRegex(seString?.ToDalamudString().ToString());
+    }
+
+    public SeString? GetRawDialogueText(Quest currentQuest, string? excelSheetName, string key)
+    {
+        if (excelSheetName == null)
+        {
+            var questRow =
+                _dataManager.GetExcelSheet<Lumina.Excel.GeneratedSheets2.Quest>()!.GetRow((uint)currentQuest.Id.Value +
+                    0x10000);
+            if (questRow == null)
+            {
+                _logger.LogError("Could not find quest row for {QuestId}", currentQuest.Id);
+                return null;
+            }
+
+            excelSheetName = $"quest/{(currentQuest.Id.Value / 100):000}/{questRow.Id}";
+        }
+
+        var excelSheet = _dataManager.Excel.GetSheet<QuestDialogueText>(excelSheetName);
+        if (excelSheet == null)
+        {
+            _logger.LogError("Unknown excel sheet '{SheetName}'", excelSheetName);
+            return null;
+        }
+
+        return excelSheet.FirstOrDefault(x => x.Key == key)?.Value;
+    }
+
+    public StringOrRegex GetDialogueTextByRowId(string? excelSheet, uint rowId, bool isRegex)
+    {
+        var seString = GetRawDialogueTextByRowId(excelSheet, rowId);
+        if (isRegex)
+            return new StringOrRegex(seString.ToRegex());
+        else
+            return new StringOrRegex(seString?.ToDalamudString().ToString());
+    }
+
+    public SeString? GetRawDialogueTextByRowId(string? excelSheet, uint rowId)
+    {
+        if (excelSheet == "GimmickYesNo")
+        {
+            var questRow = _dataManager.GetExcelSheet<GimmickYesNo>()!.GetRow(rowId);
+            return questRow?.Unknown0;
+        }
+        else if (excelSheet == "Warp")
+        {
+            var questRow = _dataManager.GetExcelSheet<Warp>()!.GetRow(rowId);
+            return questRow?.Name;
+        }
+        else if (excelSheet is "Addon")
+        {
+            var questRow = _dataManager.GetExcelSheet<Addon>()!.GetRow(rowId);
+            return questRow?.Text;
+        }
+        else if (excelSheet is "EventPathMove")
+        {
+            var questRow = _dataManager.GetExcelSheet<EventPathMove>()!.GetRow(rowId);
+            return questRow?.Unknown10;
+        }
+        else if (excelSheet is "ContentTalk" or null)
+        {
+            var questRow = _dataManager.GetExcelSheet<ContentTalk>()!.GetRow(rowId);
+            return questRow?.Text;
+        }
+        else
+            throw new ArgumentOutOfRangeException(nameof(excelSheet), $"Unsupported excel sheet {excelSheet}");
+    }
+}
diff --git a/Questionable/Functions/GameFunctions.cs b/Questionable/Functions/GameFunctions.cs
new file mode 100644 (file)
index 0000000..689c340
--- /dev/null
@@ -0,0 +1,480 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Numerics;
+using Dalamud.Game.ClientState.Conditions;
+using Dalamud.Game.ClientState.Objects;
+using Dalamud.Game.ClientState.Objects.Types;
+using Dalamud.Plugin.Services;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using FFXIVClientStructs.FFXIV.Client.Game.Control;
+using FFXIVClientStructs.FFXIV.Client.Game.Object;
+using FFXIVClientStructs.FFXIV.Client.Game.UI;
+using FFXIVClientStructs.FFXIV.Client.UI.Agent;
+using FFXIVClientStructs.FFXIV.Component.GUI;
+using LLib.GameUI;
+using Microsoft.Extensions.Logging;
+using Questionable.Model;
+using Questionable.Model.Common;
+using Questionable.Model.Questing;
+using Action = Lumina.Excel.GeneratedSheets2.Action;
+using BattleChara = FFXIVClientStructs.FFXIV.Client.Game.Character.BattleChara;
+using ContentFinderCondition = Lumina.Excel.GeneratedSheets.ContentFinderCondition;
+using ObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
+using Quest = Questionable.Model.Quest;
+using TerritoryType = Lumina.Excel.GeneratedSheets.TerritoryType;
+
+namespace Questionable.Functions;
+
+internal sealed unsafe class GameFunctions
+{
+    private readonly ReadOnlyDictionary<ushort, byte> _territoryToAetherCurrentCompFlgSet;
+    private readonly ReadOnlyDictionary<uint, ushort> _contentFinderConditionToContentId;
+
+    private readonly QuestFunctions _questFunctions;
+    private readonly IDataManager _dataManager;
+    private readonly IObjectTable _objectTable;
+    private readonly ITargetManager _targetManager;
+    private readonly ICondition _condition;
+    private readonly IClientState _clientState;
+    private readonly IGameGui _gameGui;
+    private readonly Configuration _configuration;
+    private readonly ILogger<GameFunctions> _logger;
+
+    public GameFunctions(
+        QuestFunctions questFunctions,
+        IDataManager dataManager,
+        IObjectTable objectTable,
+        ITargetManager targetManager,
+        ICondition condition,
+        IClientState clientState,
+        IGameGui gameGui,
+        Configuration configuration,
+        ILogger<GameFunctions> logger)
+    {
+        _questFunctions = questFunctions;
+        _dataManager = dataManager;
+        _objectTable = objectTable;
+        _targetManager = targetManager;
+        _condition = condition;
+        _clientState = clientState;
+        _gameGui = gameGui;
+        _configuration = configuration;
+        _logger = logger;
+
+        _territoryToAetherCurrentCompFlgSet = dataManager.GetExcelSheet<TerritoryType>()!
+            .Where(x => x.RowId > 0)
+            .Where(x => x.Unknown32 > 0)
+            .ToDictionary(x => (ushort)x.RowId, x => x.Unknown32)
+            .AsReadOnly();
+        _contentFinderConditionToContentId = dataManager.GetExcelSheet<ContentFinderCondition>()!
+            .Where(x => x.RowId > 0 && x.Content > 0)
+            .ToDictionary(x => x.RowId, x => x.Content)
+            .AsReadOnly();
+    }
+
+    public DateTime ReturnRequestedAt { get; set; } = DateTime.MinValue;
+
+    public bool IsAetheryteUnlocked(uint aetheryteId, out byte subIndex)
+    {
+        subIndex = 0;
+
+        var uiState = UIState.Instance();
+        return uiState != null && uiState->IsAetheryteUnlocked(aetheryteId);
+    }
+
+    public bool IsAetheryteUnlocked(EAetheryteLocation aetheryteLocation)
+    {
+        if (aetheryteLocation == EAetheryteLocation.IshgardFirmament)
+            return _questFunctions.IsQuestComplete(new QuestId(3672));
+        return IsAetheryteUnlocked((uint)aetheryteLocation, out _);
+    }
+
+    public bool CanTeleport(EAetheryteLocation aetheryteLocation)
+    {
+        if ((ushort)aetheryteLocation == PlayerState.Instance()->HomeAetheryteId &&
+            ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 8) == 0)
+            return true;
+
+        return ActionManager.Instance()->GetActionStatus(ActionType.Action, 5) == 0;
+    }
+
+    public bool TeleportAetheryte(uint aetheryteId)
+    {
+        _logger.LogDebug("Attempting to teleport to aetheryte {AetheryteId}", aetheryteId);
+        if (IsAetheryteUnlocked(aetheryteId, out var subIndex))
+        {
+            if (aetheryteId == PlayerState.Instance()->HomeAetheryteId &&
+                ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 8) == 0)
+            {
+                ReturnRequestedAt = DateTime.Now;
+                if (ActionManager.Instance()->UseAction(ActionType.GeneralAction, 8))
+                {
+                    _logger.LogInformation("Using 'return' for home aetheryte");
+                    return true;
+                }
+            }
+
+            if (ActionManager.Instance()->GetActionStatus(ActionType.Action, 5) == 0)
+            {
+                // fallback if return isn't available or (more likely) on a different aetheryte
+                _logger.LogInformation("Teleporting to aetheryte {AetheryteId}", aetheryteId);
+                return Telepo.Instance()->Teleport(aetheryteId, subIndex);
+            }
+        }
+
+        return false;
+    }
+
+    public bool TeleportAetheryte(EAetheryteLocation aetheryteLocation)
+        => TeleportAetheryte((uint)aetheryteLocation);
+
+    public bool IsFlyingUnlocked(ushort territoryId)
+    {
+        if (_configuration.Advanced.NeverFly)
+            return false;
+
+        if (_questFunctions.IsQuestAccepted(new QuestId(3304)) && _condition[ConditionFlag.Mounted])
+        {
+            BattleChara* battleChara = (BattleChara*)(_clientState.LocalPlayer?.Address ?? 0);
+            if (battleChara != null && battleChara->Mount.MountId == 198) // special quest amaro, not the normal one
+                return true;
+        }
+
+        var playerState = PlayerState.Instance();
+        return playerState != null &&
+               _territoryToAetherCurrentCompFlgSet.TryGetValue(territoryId, out byte aetherCurrentCompFlgSet) &&
+               playerState->IsAetherCurrentZoneComplete(aetherCurrentCompFlgSet);
+    }
+
+    public bool IsFlyingUnlockedInCurrentZone() => IsFlyingUnlocked(_clientState.TerritoryType);
+
+    public bool IsAetherCurrentUnlocked(uint aetherCurrentId)
+    {
+        var playerState = PlayerState.Instance();
+        return playerState != null &&
+               playerState->IsAetherCurrentUnlocked(aetherCurrentId);
+    }
+
+    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;
+
+            if (gameObject.DataId == dataId && (kind == null || kind.Value == gameObject.ObjectKind))
+            {
+                return gameObject;
+            }
+        }
+
+        _logger.LogWarning("Could not find GameObject with dataId {DataId}", dataId);
+        return null;
+    }
+
+    public bool InteractWith(uint dataId, ObjectKind? kind = null)
+    {
+        IGameObject? gameObject = FindObjectByDataId(dataId, kind);
+        if (gameObject != null)
+            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;
+        }
+    }
+
+    public bool UseItem(uint itemId)
+    {
+        long result = AgentInventoryContext.Instance()->UseItem(itemId);
+        _logger.LogInformation("UseItem result: {Result}", result);
+
+        return result == 0;
+    }
+
+    public bool UseItem(uint dataId, uint itemId)
+    {
+        IGameObject? gameObject = FindObjectByDataId(dataId);
+        if (gameObject != null)
+        {
+            _targetManager.Target = gameObject;
+            long result = AgentInventoryContext.Instance()->UseItem(itemId);
+
+            _logger.LogInformation("UseItem result on {DataId}: {Result}", dataId, result);
+
+            // TODO is 1 a generally accepted result?
+            return result == 0 || (itemId == 2002450 && result == 1);
+        }
+
+        return false;
+    }
+
+    public bool UseItemOnGround(uint dataId, uint itemId)
+    {
+        IGameObject? gameObject = FindObjectByDataId(dataId);
+        if (gameObject != null)
+        {
+            Vector3 position = gameObject.Position;
+            return ActionManager.Instance()->UseActionLocation(ActionType.KeyItem, itemId, location: &position);
+        }
+
+        return false;
+    }
+
+    public bool UseItemOnPosition(Vector3 position, uint itemId)
+    {
+        return ActionManager.Instance()->UseActionLocation(ActionType.KeyItem, itemId, location: &position);
+    }
+
+    public bool UseAction(EAction action)
+    {
+        if (ActionManager.Instance()->GetActionStatus(ActionType.Action, (uint)action) == 0)
+        {
+            bool result = ActionManager.Instance()->UseAction(ActionType.Action, (uint)action);
+            _logger.LogInformation("UseAction {Action} result: {Result}", action, result);
+
+            return result;
+        }
+
+        return false;
+    }
+
+    public bool UseAction(IGameObject gameObject, EAction action)
+    {
+        var actionRow = _dataManager.GetExcelSheet<Action>()!.GetRow((uint)action)!;
+        if (!ActionManager.CanUseActionOnTarget((uint)action, (GameObject*)gameObject.Address))
+        {
+            _logger.LogWarning("Can not use action {Action} on target {Target}", action, gameObject);
+            return false;
+        }
+
+        _targetManager.Target = gameObject;
+        if (ActionManager.Instance()->GetActionStatus(ActionType.Action, (uint)action, gameObject.GameObjectId) == 0)
+        {
+            bool result;
+            if (actionRow.TargetArea)
+            {
+                Vector3 position = gameObject.Position;
+                result = ActionManager.Instance()->UseActionLocation(ActionType.Action, (uint)action,
+                    location: &position);
+                _logger.LogInformation("UseAction {Action} on target area {Target} result: {Result}", action,
+                    gameObject,
+                    result);
+            }
+            else
+            {
+                result = ActionManager.Instance()->UseAction(ActionType.Action, (uint)action, gameObject.GameObjectId);
+                _logger.LogInformation("UseAction {Action} on target {Target} result: {Result}", action, gameObject,
+                    result);
+            }
+
+            return result;
+        }
+
+        return false;
+    }
+
+    public bool IsObjectAtPosition(uint dataId, Vector3 position, float distance)
+    {
+        IGameObject? gameObject = FindObjectByDataId(dataId);
+        return gameObject != null && (gameObject.Position - position).Length() < distance;
+    }
+
+    public bool HasStatusPreventingMount()
+    {
+        if (_condition[ConditionFlag.Swimming] && !IsFlyingUnlockedInCurrentZone())
+            return true;
+
+        // company chocobo is locked
+        var playerState = PlayerState.Instance();
+        if (playerState != null && !playerState->IsMountUnlocked(1))
+            return true;
+
+        var localPlayer = _clientState.LocalPlayer;
+        if (localPlayer == null)
+            return false;
+
+        var battleChara = (BattleChara*)localPlayer.Address;
+        StatusManager* statusManager = battleChara->GetStatusManager();
+        if (statusManager->HasStatus(1151))
+            return true;
+
+        return HasCharacterStatusPreventingMountOrSprint();
+    }
+
+    public bool HasStatusPreventingSprint() => HasCharacterStatusPreventingMountOrSprint();
+
+    private bool HasCharacterStatusPreventingMountOrSprint()
+    {
+        var localPlayer = _clientState.LocalPlayer;
+        if (localPlayer == null)
+            return false;
+
+        var battleChara = (BattleChara*)localPlayer.Address;
+        StatusManager* statusManager = battleChara->GetStatusManager();
+        return statusManager->HasStatus(565) ||
+               statusManager->HasStatus(404) ||
+               statusManager->HasStatus(416) ||
+               statusManager->HasStatus(2729) ||
+               statusManager->HasStatus(2730);
+    }
+
+    public bool Mount()
+    {
+        if (_condition[ConditionFlag.Mounted])
+            return true;
+
+        var playerState = PlayerState.Instance();
+        if (playerState != null && _configuration.General.MountId != 0 &&
+            playerState->IsMountUnlocked(_configuration.General.MountId))
+        {
+            if (ActionManager.Instance()->GetActionStatus(ActionType.Mount, _configuration.General.MountId) == 0)
+            {
+                _logger.LogDebug("Attempting to use preferred mount...");
+                if (ActionManager.Instance()->UseAction(ActionType.Mount, _configuration.General.MountId))
+                {
+                    _logger.LogInformation("Using preferred mount");
+                    return true;
+                }
+
+                return false;
+            }
+        }
+        else
+        {
+            if (ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 9) == 0)
+            {
+                _logger.LogDebug("Attempting to use mount roulette...");
+                if (ActionManager.Instance()->UseAction(ActionType.GeneralAction, 9))
+                {
+                    _logger.LogInformation("Using mount roulette");
+                    return true;
+                }
+
+                return false;
+            }
+        }
+
+        return false;
+    }
+
+    public bool Unmount()
+    {
+        if (!_condition[ConditionFlag.Mounted])
+            return true;
+
+        if (ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 23) == 0)
+        {
+            _logger.LogDebug("Attempting to unmount...");
+            if (ActionManager.Instance()->UseAction(ActionType.GeneralAction, 23))
+            {
+                _logger.LogInformation("Unmounted");
+                return true;
+            }
+
+            return false;
+        }
+        else
+        {
+            _logger.LogWarning("Can't unmount right now?");
+            return false;
+        }
+    }
+
+    public void OpenDutyFinder(uint contentFinderConditionId)
+    {
+        if (_contentFinderConditionToContentId.TryGetValue(contentFinderConditionId, out ushort contentId))
+        {
+            if (UIState.IsInstanceContentUnlocked(contentId))
+                AgentContentsFinder.Instance()->OpenRegularDuty(contentFinderConditionId);
+            else
+                _logger.LogError(
+                    "Trying to access a locked duty (cf: {ContentFinderId}, content: {ContentId})",
+                    contentFinderConditionId, contentId);
+        }
+        else
+            _logger.LogError("Could not find content for content finder condition (cf: {ContentFinderId})",
+                contentFinderConditionId);
+    }
+
+    /// <summary>
+    /// Ensures characters like '-' are handled equally in both strings.
+    /// </summary>
+    public static bool GameStringEquals(string? a, string? b)
+    {
+        if (a == null)
+            return b == null;
+
+        if (b == null)
+            return false;
+
+        return a.ReplaceLineEndings().Replace('\u2013', '-') == b.ReplaceLineEndings().Replace('\u2013', '-');
+    }
+
+    public bool IsOccupied()
+    {
+        if (!_clientState.IsLoggedIn || _clientState.LocalPlayer == null)
+            return true;
+
+        if (IsLoadingScreenVisible())
+            return true;
+
+        return _condition[ConditionFlag.Occupied] || _condition[ConditionFlag.Occupied30] ||
+               _condition[ConditionFlag.Occupied33] || _condition[ConditionFlag.Occupied38] ||
+               _condition[ConditionFlag.Occupied39] || _condition[ConditionFlag.OccupiedInEvent] ||
+               _condition[ConditionFlag.OccupiedInQuestEvent] || _condition[ConditionFlag.OccupiedInCutSceneEvent] ||
+               _condition[ConditionFlag.Casting] || _condition[ConditionFlag.Unknown57] ||
+               _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51] ||
+               _condition[ConditionFlag.Jumping61] || _condition[ConditionFlag.Gathering42];
+    }
+
+    public bool IsOccupiedWithCustomDeliveryNpc(Quest? currentQuest)
+    {
+        // not a supply quest?
+        if (currentQuest is not { Info: SatisfactionSupplyInfo })
+            return false;
+
+        if (_targetManager.Target == null || _targetManager.Target.DataId != currentQuest.Info.IssuerDataId)
+            return false;
+
+        if (!AgentSatisfactionSupply.Instance()->IsAgentActive())
+            return false;
+
+        var flags = _condition.AsReadOnlySet();
+        return flags.Count == 2 &&
+               flags.Contains(ConditionFlag.NormalConditions) &&
+               flags.Contains(ConditionFlag.OccupiedInQuestEvent);
+    }
+
+    public bool IsLoadingScreenVisible()
+    {
+        return _gameGui.TryGetAddonByName("FadeMiddle", out AtkUnitBase* fade) &&
+               LAddon.IsAddonReady(fade) &&
+               fade->IsVisible;
+    }
+}
diff --git a/Questionable/Functions/QuestFunctions.cs b/Questionable/Functions/QuestFunctions.cs
new file mode 100644 (file)
index 0000000..505546d
--- /dev/null
@@ -0,0 +1,346 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Dalamud.Memory;
+using Dalamud.Plugin.Services;
+using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using FFXIVClientStructs.FFXIV.Client.Game.UI;
+using FFXIVClientStructs.FFXIV.Client.UI.Agent;
+using FFXIVClientStructs.FFXIV.Component.GUI;
+using LLib.GameData;
+using LLib.GameUI;
+using Lumina.Excel.GeneratedSheets;
+using Questionable.Controller;
+using Questionable.Data;
+using Questionable.Model;
+using Questionable.Model.Questing;
+using GrandCompany = FFXIVClientStructs.FFXIV.Client.UI.Agent.GrandCompany;
+using Quest = Questionable.Model.Quest;
+
+namespace Questionable.Functions;
+
+internal sealed unsafe class QuestFunctions
+{
+    private readonly QuestRegistry _questRegistry;
+    private readonly QuestData _questData;
+    private readonly Configuration _configuration;
+    private readonly IDataManager _dataManager;
+    private readonly IClientState _clientState;
+    private readonly IGameGui _gameGui;
+
+    public QuestFunctions(QuestRegistry questRegistry, QuestData questData, Configuration configuration, IDataManager dataManager, IClientState clientState, IGameGui gameGui)
+    {
+        _questRegistry = questRegistry;
+        _questData = questData;
+        _configuration = configuration;
+        _dataManager = dataManager;
+        _clientState = clientState;
+        _gameGui = gameGui;
+    }
+
+    public (ElementId? CurrentQuest, byte Sequence) GetCurrentQuest()
+    {
+        var (currentQuest, sequence) = GetCurrentQuestInternal();
+        PlayerState* playerState = PlayerState.Instance();
+
+        if (currentQuest == null || currentQuest.Value == 0)
+        {
+            if (_clientState.TerritoryType == 181) // Starting in Limsa
+                return (new QuestId(107), 0);
+            if (_clientState.TerritoryType == 182) // Starting in Ul'dah
+                return (new QuestId(594), 0);
+            if (_clientState.TerritoryType == 183) // Starting in Gridania
+                return (new QuestId(39), 0);
+            return default;
+        }
+        else if (currentQuest.Value == 681)
+        {
+            // if we have already picked up the GC quest, just return the progress for it
+            if (IsQuestAccepted(currentQuest) || IsQuestComplete(currentQuest))
+                return (currentQuest, sequence);
+
+            // The company you keep...
+            return _configuration.General.GrandCompany switch
+            {
+                GrandCompany.TwinAdder => (new QuestId(680), 0),
+                GrandCompany.Maelstrom => (new QuestId(681), 0),
+                _ => default
+            };
+        }
+        else if (currentQuest.Value == 3856 && !playerState->IsMountUnlocked(1)) // we come in peace
+        {
+            ushort chocoboQuest = (GrandCompany)playerState->GrandCompany switch
+            {
+                GrandCompany.TwinAdder => 700,
+                GrandCompany.Maelstrom => 701,
+                _ => 0
+            };
+
+            if (chocoboQuest != 0 && !QuestManager.IsQuestComplete(chocoboQuest))
+                return (new QuestId(chocoboQuest), QuestManager.GetQuestSequence(chocoboQuest));
+        }
+        else if (currentQuest.Value == 801)
+        {
+            // skeletons in her closet, finish 'broadening horizons' to unlock the white wolf gate
+            QuestId broadeningHorizons = new QuestId(802);
+            if (IsQuestAccepted(broadeningHorizons))
+                return (broadeningHorizons, QuestManager.GetQuestSequence(broadeningHorizons.Value));
+        }
+
+        return (currentQuest, sequence);
+    }
+
+    public (ElementId? CurrentQuest, byte Sequence) GetCurrentQuestInternal()
+    {
+        var questManager = QuestManager.Instance();
+        if (questManager != null)
+        {
+            // always prioritize accepting MSQ quests, to make sure we don't turn in one MSQ quest and then go off to do
+            // side quests until the end of time.
+            var msqQuest = GetMainScenarioQuest(questManager);
+            if (msqQuest.CurrentQuest is { Value: not 0 } && _questRegistry.IsKnownQuest(msqQuest.CurrentQuest))
+                return msqQuest;
+
+            // Use the quests in the same order as they're shown in the to-do list, e.g. if the MSQ is the first item,
+            // do the MSQ; if a side quest is the first item do that side quest.
+            //
+            // If no quests are marked as 'priority', accepting a new quest adds it to the top of the list.
+            for (int i = questManager->TrackedQuests.Length - 1; i >= 0; --i)
+            {
+                ElementId currentQuest;
+                var trackedQuest = questManager->TrackedQuests[i];
+                switch (trackedQuest.QuestType)
+                {
+                    default:
+                        continue;
+
+                    case 1: // normal quest
+                        currentQuest = new QuestId(questManager->NormalQuests[trackedQuest.Index].QuestId);
+                        break;
+                }
+
+                if (_questRegistry.IsKnownQuest(currentQuest))
+                    return (currentQuest, QuestManager.GetQuestSequence(currentQuest.Value));
+            }
+
+            // if we know no quest of those currently in the to-do list, just do MSQ
+            return msqQuest;
+        }
+
+        return default;
+    }
+
+    private (QuestId? CurrentQuest, byte Sequence) GetMainScenarioQuest(QuestManager* questManager)
+    {
+        if (QuestManager.IsQuestComplete(3759)) // Memories Rekindled
+        {
+            AgentInterface* questRedoHud = AgentModule.Instance()->GetAgentByInternalId(AgentId.QuestRedoHud);
+            if (questRedoHud != null && questRedoHud->IsAgentActive())
+            {
+                // there's surely better ways to check this, but the one in the OOB Plugin was even less reliable
+                if (_gameGui.TryGetAddonByName<AtkUnitBase>("QuestRedoHud", out var addon) &&
+                    addon->AtkValuesCount == 4 &&
+                    // 0 seems to be active,
+                    // 1 seems to be paused,
+                    // 2 is unknown, but it happens e.g. before the quest 'Alzadaal's Legacy'
+                    // 3 seems to be having /ng+ open while active,
+                    // 4 seems to be when (a) suspending the chapter, or (b) having turned in a quest
+                    addon->AtkValues[0].UInt is 0 or 2 or 3 or 4)
+                {
+                    // redoHud+44 is chapter
+                    // redoHud+46 is quest
+                    ushort questId = MemoryHelper.Read<ushort>((nint)questRedoHud + 46);
+                    return (new QuestId(questId), QuestManager.GetQuestSequence(questId));
+                }
+            }
+        }
+
+        var scenarioTree = AgentScenarioTree.Instance();
+        if (scenarioTree == null)
+            return default;
+
+        if (scenarioTree->Data == null)
+            return default;
+
+        QuestId currentQuest = new QuestId(scenarioTree->Data->CurrentScenarioQuest);
+        if (currentQuest.Value == 0)
+            return default;
+
+        // if the MSQ is hidden, we generally ignore it
+        if (IsQuestAccepted(currentQuest) && questManager->GetQuestById(currentQuest.Value)->IsHidden)
+            return default;
+
+        // it can sometimes happen (although this isn't reliably reproducible) that the quest returned here
+        // is one you've just completed.
+        if (!IsReadyToAcceptQuest(currentQuest))
+            return default;
+
+        // if we're not at a high enough level to continue, we also ignore it
+        var currentLevel = _clientState.LocalPlayer?.Level ?? 0;
+        if (currentLevel != 0 &&
+            _questRegistry.TryGetQuest(currentQuest, out Quest? quest)
+            && quest.Info.Level > currentLevel)
+            return default;
+
+        return (currentQuest, QuestManager.GetQuestSequence(currentQuest.Value));
+    }
+
+    public QuestWork? GetQuestEx(QuestId questId)
+    {
+        QuestWork* questWork = QuestManager.Instance()->GetQuestById(questId.Value);
+        return questWork != null ? *questWork : null;
+    }
+
+    public bool IsReadyToAcceptQuest(ElementId elementId)
+    {
+        if (elementId is QuestId questId)
+            return IsReadyToAcceptQuest(questId);
+        else if (elementId is SatisfactionSupplyNpcId)
+            return true;
+        else
+            throw new ArgumentOutOfRangeException(nameof(elementId));
+    }
+
+    public bool IsReadyToAcceptQuest(QuestId questId)
+    {
+        _questRegistry.TryGetQuest(questId, out var quest);
+        if (quest is { Info.IsRepeatable: true })
+        {
+            if (IsQuestAccepted(questId))
+                return false;
+        }
+        else
+        {
+            if (IsQuestAcceptedOrComplete(questId))
+                return false;
+        }
+
+        if (IsQuestLocked(questId))
+            return false;
+
+        // if we're not at a high enough level to continue, we also ignore it
+        var currentLevel = _clientState.LocalPlayer?.Level ?? 0;
+        if (currentLevel != 0 && quest != null && quest.Info.Level > currentLevel)
+            return false;
+
+        return true;
+    }
+
+    public bool IsQuestAcceptedOrComplete(ElementId elementId)
+    {
+        return IsQuestComplete(elementId) || IsQuestAccepted(elementId);
+    }
+
+    public bool IsQuestAccepted(ElementId elementId)
+    {
+        if (elementId is QuestId questId)
+            return IsQuestAccepted(questId);
+        else if (elementId is SatisfactionSupplyNpcId)
+            return false;
+        else
+            throw new ArgumentOutOfRangeException(nameof(elementId));
+    }
+
+    public bool IsQuestAccepted(QuestId questId)
+    {
+        QuestManager* questManager = QuestManager.Instance();
+        return questManager->IsQuestAccepted(questId.Value);
+    }
+
+    public bool IsQuestComplete(ElementId elementId)
+    {
+        if (elementId is QuestId questId)
+            return IsQuestComplete(questId);
+        else if (elementId is SatisfactionSupplyNpcId)
+            return false;
+        else
+            throw new ArgumentOutOfRangeException(nameof(elementId));
+    }
+
+    [SuppressMessage("Performance", "CA1822")]
+    public bool IsQuestComplete(QuestId questId)
+    {
+        return QuestManager.IsQuestComplete(questId.Value);
+    }
+
+    public bool IsQuestLocked(ElementId elementId, ElementId? extraCompletedQuest = null)
+    {
+        if (elementId is QuestId questId)
+            return IsQuestLocked(questId, extraCompletedQuest);
+        else if (elementId is SatisfactionSupplyNpcId)
+            return false;
+        else
+            throw new ArgumentOutOfRangeException(nameof(elementId));
+    }
+
+    public bool IsQuestLocked(QuestId questId, ElementId? extraCompletedQuest = null)
+    {
+        var questInfo = (QuestInfo)_questData.GetQuestInfo(questId);
+        if (questInfo.QuestLocks.Count > 0)
+        {
+            var completedQuests = questInfo.QuestLocks.Count(x => IsQuestComplete(x) || x.Equals(extraCompletedQuest));
+            if (questInfo.QuestLockJoin == QuestInfo.QuestJoin.All && questInfo.QuestLocks.Count == completedQuests)
+                return true;
+            else if (questInfo.QuestLockJoin == QuestInfo.QuestJoin.AtLeastOne && completedQuests > 0)
+                return true;
+        }
+
+        if (questInfo.GrandCompany != GrandCompany.None && questInfo.GrandCompany != GetGrandCompany())
+            return true;
+
+        return !HasCompletedPreviousQuests(questInfo, extraCompletedQuest) || !HasCompletedPreviousInstances(questInfo);
+    }
+
+    private bool HasCompletedPreviousQuests(QuestInfo questInfo, ElementId? extraCompletedQuest)
+    {
+        if (questInfo.PreviousQuests.Count == 0)
+            return true;
+
+        var completedQuests = questInfo.PreviousQuests.Count(x => IsQuestComplete(x) || x.Equals(extraCompletedQuest));
+        if (questInfo.PreviousQuestJoin == QuestInfo.QuestJoin.All &&
+            questInfo.PreviousQuests.Count == completedQuests)
+            return true;
+        else if (questInfo.PreviousQuestJoin == QuestInfo.QuestJoin.AtLeastOne && completedQuests > 0)
+            return true;
+        else
+            return false;
+    }
+
+    private static bool HasCompletedPreviousInstances(QuestInfo questInfo)
+    {
+        if (questInfo.PreviousInstanceContent.Count == 0)
+            return true;
+
+        var completedInstances = questInfo.PreviousInstanceContent.Count(x => UIState.IsInstanceContentCompleted(x));
+        if (questInfo.PreviousInstanceContentJoin == QuestInfo.QuestJoin.All &&
+            questInfo.PreviousInstanceContent.Count == completedInstances)
+            return true;
+        else if (questInfo.PreviousInstanceContentJoin == QuestInfo.QuestJoin.AtLeastOne && completedInstances > 0)
+            return true;
+        else
+            return false;
+    }
+
+    public bool IsClassJobUnlocked(EClassJob classJob)
+    {
+        var classJobRow = _dataManager.GetExcelSheet<ClassJob>()!.GetRow((uint)classJob)!;
+        var questId = (ushort)classJobRow.UnlockQuest.Row;
+        if (questId != 0)
+            return IsQuestComplete(new QuestId(questId));
+
+        PlayerState* playerState = PlayerState.Instance();
+        return playerState != null && playerState->ClassJobLevels[classJobRow.ExpArrayIndex] > 0;
+    }
+
+    public bool IsJobUnlocked(EClassJob classJob)
+    {
+        var classJobRow = _dataManager.GetExcelSheet<ClassJob>()!.GetRow((uint)classJob)!;
+        return IsClassJobUnlocked((EClassJob)classJobRow.ClassJobParent.Row);
+    }
+
+    public GrandCompany GetGrandCompany()
+    {
+        return (GrandCompany)PlayerState.Instance()->GrandCompany;
+    }
+}
diff --git a/Questionable/GameFunctions.cs b/Questionable/GameFunctions.cs
deleted file mode 100644 (file)
index b247cd8..0000000
+++ /dev/null
@@ -1,806 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-using System.Numerics;
-using Dalamud.Game.ClientState.Conditions;
-using Dalamud.Game.ClientState.Objects;
-using Dalamud.Game.ClientState.Objects.Types;
-using Dalamud.Memory;
-using Dalamud.Plugin.Services;
-using Dalamud.Utility;
-using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
-using FFXIVClientStructs.FFXIV.Client.Game;
-using FFXIVClientStructs.FFXIV.Client.Game.Control;
-using FFXIVClientStructs.FFXIV.Client.Game.Object;
-using FFXIVClientStructs.FFXIV.Client.Game.UI;
-using FFXIVClientStructs.FFXIV.Client.UI.Agent;
-using FFXIVClientStructs.FFXIV.Component.GUI;
-using LLib.GameUI;
-using Lumina.Excel.CustomSheets;
-using Lumina.Excel.GeneratedSheets2;
-using Microsoft.Extensions.Logging;
-using Questionable.Controller;
-using Questionable.Data;
-using Questionable.Model;
-using Questionable.Model.Common;
-using Questionable.Model.Questing;
-using Action = Lumina.Excel.GeneratedSheets2.Action;
-using BattleChara = FFXIVClientStructs.FFXIV.Client.Game.Character.BattleChara;
-using ContentFinderCondition = Lumina.Excel.GeneratedSheets.ContentFinderCondition;
-using ContentTalk = Lumina.Excel.GeneratedSheets.ContentTalk;
-using EventPathMove = Lumina.Excel.GeneratedSheets.EventPathMove;
-using GrandCompany = FFXIVClientStructs.FFXIV.Client.UI.Agent.GrandCompany;
-using ObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
-using Quest = Questionable.Model.Quest;
-using TerritoryType = Lumina.Excel.GeneratedSheets.TerritoryType;
-
-namespace Questionable;
-
-internal sealed unsafe class GameFunctions
-{
-    private readonly ReadOnlyDictionary<ushort, byte> _territoryToAetherCurrentCompFlgSet;
-    private readonly ReadOnlyDictionary<uint, ushort> _contentFinderConditionToContentId;
-
-    private readonly IDataManager _dataManager;
-    private readonly IObjectTable _objectTable;
-    private readonly ITargetManager _targetManager;
-    private readonly ICondition _condition;
-    private readonly IClientState _clientState;
-    private readonly QuestRegistry _questRegistry;
-    private readonly QuestData _questData;
-    private readonly IGameGui _gameGui;
-    private readonly Configuration _configuration;
-    private readonly ILogger<GameFunctions> _logger;
-
-    public GameFunctions(IDataManager dataManager,
-        IObjectTable objectTable,
-        ITargetManager targetManager,
-        ICondition condition,
-        IClientState clientState,
-        QuestRegistry questRegistry,
-        QuestData questData,
-        IGameGui gameGui,
-        Configuration configuration,
-        ILogger<GameFunctions> logger)
-    {
-        _dataManager = dataManager;
-        _objectTable = objectTable;
-        _targetManager = targetManager;
-        _condition = condition;
-        _clientState = clientState;
-        _questRegistry = questRegistry;
-        _questData = questData;
-        _gameGui = gameGui;
-        _configuration = configuration;
-        _logger = logger;
-
-        _territoryToAetherCurrentCompFlgSet = dataManager.GetExcelSheet<TerritoryType>()!
-            .Where(x => x.RowId > 0)
-            .Where(x => x.Unknown32 > 0)
-            .ToDictionary(x => (ushort)x.RowId, x => x.Unknown32)
-            .AsReadOnly();
-        _contentFinderConditionToContentId = dataManager.GetExcelSheet<ContentFinderCondition>()!
-            .Where(x => x.RowId > 0 && x.Content > 0)
-            .ToDictionary(x => x.RowId, x => x.Content)
-            .AsReadOnly();
-    }
-
-    public DateTime ReturnRequestedAt { get; set; } = DateTime.MinValue;
-
-    public (ElementId? CurrentQuest, byte Sequence) GetCurrentQuest()
-    {
-        var (currentQuest, sequence) = GetCurrentQuestInternal();
-        PlayerState* playerState = PlayerState.Instance();
-
-        if (currentQuest == null || currentQuest.Value == 0)
-        {
-            if (_clientState.TerritoryType == 181) // Starting in Limsa
-                return (new QuestId(107), 0);
-            if (_clientState.TerritoryType == 182) // Starting in Ul'dah
-                return (new QuestId(594), 0);
-            if (_clientState.TerritoryType == 183) // Starting in Gridania
-                return (new QuestId(39), 0);
-            return default;
-        }
-        else if (currentQuest.Value == 681)
-        {
-            // if we have already picked up the GC quest, just return the progress for it
-            if (IsQuestAccepted(currentQuest) || IsQuestComplete(currentQuest))
-                return (currentQuest, sequence);
-
-            // The company you keep...
-            return _configuration.General.GrandCompany switch
-            {
-                GrandCompany.TwinAdder => (new QuestId(680), 0),
-                GrandCompany.Maelstrom => (new QuestId(681), 0),
-                _ => default
-            };
-        }
-        else if (currentQuest.Value == 3856 && !playerState->IsMountUnlocked(1)) // we come in peace
-        {
-            ushort chocoboQuest = (GrandCompany)playerState->GrandCompany switch
-            {
-                GrandCompany.TwinAdder => 700,
-                GrandCompany.Maelstrom => 701,
-                _ => 0
-            };
-
-            if (chocoboQuest != 0 && !QuestManager.IsQuestComplete(chocoboQuest))
-                return (new QuestId(chocoboQuest), QuestManager.GetQuestSequence(chocoboQuest));
-        }
-        else if (currentQuest.Value == 801)
-        {
-            // skeletons in her closet, finish 'broadening horizons' to unlock the white wolf gate
-            QuestId broadeningHorizons = new QuestId(802);
-            if (IsQuestAccepted(broadeningHorizons))
-                return (broadeningHorizons, QuestManager.GetQuestSequence(broadeningHorizons.Value));
-        }
-
-        return (currentQuest, sequence);
-    }
-
-    public (ElementId? CurrentQuest, byte Sequence) GetCurrentQuestInternal()
-    {
-        var questManager = QuestManager.Instance();
-        if (questManager != null)
-        {
-            // always prioritize accepting MSQ quests, to make sure we don't turn in one MSQ quest and then go off to do
-            // side quests until the end of time.
-            var msqQuest = GetMainScenarioQuest(questManager);
-            if (msqQuest.CurrentQuest is { Value: not 0 } && _questRegistry.IsKnownQuest(msqQuest.CurrentQuest))
-                return msqQuest;
-
-            // Use the quests in the same order as they're shown in the to-do list, e.g. if the MSQ is the first item,
-            // do the MSQ; if a side quest is the first item do that side quest.
-            //
-            // If no quests are marked as 'priority', accepting a new quest adds it to the top of the list.
-            for (int i = questManager->TrackedQuests.Length - 1; i >= 0; --i)
-            {
-                ElementId currentQuest;
-                var trackedQuest = questManager->TrackedQuests[i];
-                switch (trackedQuest.QuestType)
-                {
-                    default:
-                        continue;
-
-                    case 1: // normal quest
-                        currentQuest = new QuestId(questManager->NormalQuests[trackedQuest.Index].QuestId);
-                        break;
-                }
-
-                if (_questRegistry.IsKnownQuest(currentQuest))
-                    return (currentQuest, QuestManager.GetQuestSequence(currentQuest.Value));
-            }
-
-            // if we know no quest of those currently in the to-do list, just do MSQ
-            return msqQuest;
-        }
-
-        return default;
-    }
-
-    private (QuestId? CurrentQuest, byte Sequence) GetMainScenarioQuest(QuestManager* questManager)
-    {
-        if (QuestManager.IsQuestComplete(3759)) // Memories Rekindled
-        {
-            AgentInterface* questRedoHud = AgentModule.Instance()->GetAgentByInternalId(AgentId.QuestRedoHud);
-            if (questRedoHud != null && questRedoHud->IsAgentActive())
-            {
-                // there's surely better ways to check this, but the one in the OOB Plugin was even less reliable
-                if (_gameGui.TryGetAddonByName<AtkUnitBase>("QuestRedoHud", out var addon) &&
-                    addon->AtkValuesCount == 4 &&
-                    // 0 seems to be active,
-                    // 1 seems to be paused,
-                    // 2 is unknown, but it happens e.g. before the quest 'Alzadaal's Legacy'
-                    // 3 seems to be having /ng+ open while active,
-                    // 4 seems to be when (a) suspending the chapter, or (b) having turned in a quest
-                    addon->AtkValues[0].UInt is 0 or 2 or 3 or 4)
-                {
-                    // redoHud+44 is chapter
-                    // redoHud+46 is quest
-                    ushort questId = MemoryHelper.Read<ushort>((nint)questRedoHud + 46);
-                    return (new QuestId(questId), QuestManager.GetQuestSequence(questId));
-                }
-            }
-        }
-
-        var scenarioTree = AgentScenarioTree.Instance();
-        if (scenarioTree == null)
-            return default;
-
-        if (scenarioTree->Data == null)
-            return default;
-
-        QuestId currentQuest = new QuestId(scenarioTree->Data->CurrentScenarioQuest);
-        if (currentQuest.Value == 0)
-            return default;
-
-        // if the MSQ is hidden, we generally ignore it
-        if (IsQuestAccepted(currentQuest) && questManager->GetQuestById(currentQuest.Value)->IsHidden)
-            return default;
-
-        // it can sometimes happen (although this isn't reliably reproducible) that the quest returned here
-        // is one you've just completed.
-        if (!IsReadyToAcceptQuest(currentQuest))
-            return default;
-
-        // if we're not at a high enough level to continue, we also ignore it
-        var currentLevel = _clientState.LocalPlayer?.Level ?? 0;
-        if (currentLevel != 0 &&
-            _questRegistry.TryGetQuest(currentQuest, out Quest? quest)
-            && quest.Info.Level > currentLevel)
-            return default;
-
-        return (currentQuest, QuestManager.GetQuestSequence(currentQuest.Value));
-    }
-
-    public QuestWork? GetQuestEx(QuestId questId)
-    {
-        QuestWork* questWork = QuestManager.Instance()->GetQuestById(questId.Value);
-        return questWork != null ? *questWork : null;
-    }
-
-    public bool IsReadyToAcceptQuest(ElementId elementId)
-    {
-        if (elementId is QuestId questId)
-            return IsReadyToAcceptQuest(questId);
-        else if (elementId is SatisfactionSupplyNpcId)
-            return true;
-        else
-            throw new ArgumentOutOfRangeException(nameof(elementId));
-    }
-
-    public bool IsReadyToAcceptQuest(QuestId questId)
-    {
-        _questRegistry.TryGetQuest(questId, out var quest);
-        if (quest is { Info.IsRepeatable: true })
-        {
-            if (IsQuestAccepted(questId))
-                return false;
-        }
-        else
-        {
-            if (IsQuestAcceptedOrComplete(questId))
-                return false;
-        }
-
-        if (IsQuestLocked(questId))
-            return false;
-
-        // if we're not at a high enough level to continue, we also ignore it
-        var currentLevel = _clientState.LocalPlayer?.Level ?? 0;
-        if (currentLevel != 0 && quest != null && quest.Info.Level > currentLevel)
-            return false;
-
-        return true;
-    }
-
-    public bool IsQuestAcceptedOrComplete(ElementId questElementId)
-    {
-        return IsQuestComplete(questElementId) || IsQuestAccepted(questElementId);
-    }
-
-    public bool IsQuestAccepted(ElementId elementId)
-    {
-        if (elementId is QuestId questId)
-            return IsQuestAccepted(questId);
-        else if (elementId is SatisfactionSupplyNpcId)
-            return false;
-        else
-            throw new ArgumentOutOfRangeException(nameof(elementId));
-    }
-
-    public bool IsQuestAccepted(QuestId questId)
-    {
-        QuestManager* questManager = QuestManager.Instance();
-        return questManager->IsQuestAccepted(questId.Value);
-    }
-
-    public bool IsQuestComplete(ElementId elementId)
-    {
-        if (elementId is QuestId questId)
-            return IsQuestComplete(questId);
-        else if (elementId is SatisfactionSupplyNpcId)
-            return false;
-        else
-            throw new ArgumentOutOfRangeException(nameof(elementId));
-    }
-
-    [SuppressMessage("Performance", "CA1822")]
-    public bool IsQuestComplete(QuestId questId)
-    {
-        return QuestManager.IsQuestComplete(questId.Value);
-    }
-
-    public bool IsQuestLocked(ElementId elementId, ElementId? extraCompletedQuest = null)
-    {
-        if (elementId is QuestId questId)
-            return IsQuestLocked(questId, extraCompletedQuest);
-        else if (elementId is SatisfactionSupplyNpcId)
-            return false;
-        else
-            throw new ArgumentOutOfRangeException(nameof(elementId));
-    }
-
-    public bool IsQuestLocked(QuestId questId, ElementId? extraCompletedQuest = null)
-    {
-        var questInfo = (QuestInfo) _questData.GetQuestInfo(questId);
-        if (questInfo.QuestLocks.Count > 0)
-        {
-            var completedQuests = questInfo.QuestLocks.Count(x => IsQuestComplete(x) || x.Equals(extraCompletedQuest));
-            if (questInfo.QuestLockJoin == QuestInfo.QuestJoin.All && questInfo.QuestLocks.Count == completedQuests)
-                return true;
-            else if (questInfo.QuestLockJoin == QuestInfo.QuestJoin.AtLeastOne && completedQuests > 0)
-                return true;
-        }
-
-        if (questInfo.GrandCompany != GrandCompany.None && questInfo.GrandCompany != GetGrandCompany())
-            return true;
-
-        return !HasCompletedPreviousQuests(questInfo, extraCompletedQuest) || !HasCompletedPreviousInstances(questInfo);
-    }
-
-    private bool HasCompletedPreviousQuests(QuestInfo questInfo, ElementId? extraCompletedQuest)
-    {
-        if (questInfo.PreviousQuests.Count == 0)
-            return true;
-
-        var completedQuests = questInfo.PreviousQuests.Count(x => IsQuestComplete(x) || x.Equals(extraCompletedQuest));
-        if (questInfo.PreviousQuestJoin == QuestInfo.QuestJoin.All &&
-            questInfo.PreviousQuests.Count == completedQuests)
-            return true;
-        else if (questInfo.PreviousQuestJoin == QuestInfo.QuestJoin.AtLeastOne && completedQuests > 0)
-            return true;
-        else
-            return false;
-    }
-
-    private static bool HasCompletedPreviousInstances(QuestInfo questInfo)
-    {
-        if (questInfo.PreviousInstanceContent.Count == 0)
-            return true;
-
-        var completedInstances = questInfo.PreviousInstanceContent.Count(x => UIState.IsInstanceContentCompleted(x));
-        if (questInfo.PreviousInstanceContentJoin == QuestInfo.QuestJoin.All &&
-            questInfo.PreviousInstanceContent.Count == completedInstances)
-            return true;
-        else if (questInfo.PreviousInstanceContentJoin == QuestInfo.QuestJoin.AtLeastOne && completedInstances > 0)
-            return true;
-        else
-            return false;
-    }
-
-    public bool IsAetheryteUnlocked(uint aetheryteId, out byte subIndex)
-    {
-        subIndex = 0;
-
-        var uiState = UIState.Instance();
-        return uiState != null && uiState->IsAetheryteUnlocked(aetheryteId);
-    }
-
-    public bool IsAetheryteUnlocked(EAetheryteLocation aetheryteLocation)
-    {
-        if (aetheryteLocation == EAetheryteLocation.IshgardFirmament)
-            return IsQuestComplete(new QuestId(3672));
-        return IsAetheryteUnlocked((uint)aetheryteLocation, out _);
-    }
-
-    public bool CanTeleport(EAetheryteLocation aetheryteLocation)
-    {
-        if ((ushort)aetheryteLocation == PlayerState.Instance()->HomeAetheryteId &&
-            ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 8) == 0)
-            return true;
-
-        return ActionManager.Instance()->GetActionStatus(ActionType.Action, 5) == 0;
-    }
-
-    public bool TeleportAetheryte(uint aetheryteId)
-    {
-        _logger.LogDebug("Attempting to teleport to aetheryte {AetheryteId}", aetheryteId);
-        if (IsAetheryteUnlocked(aetheryteId, out var subIndex))
-        {
-            if (aetheryteId == PlayerState.Instance()->HomeAetheryteId &&
-                ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 8) == 0)
-            {
-                ReturnRequestedAt = DateTime.Now;
-                if (ActionManager.Instance()->UseAction(ActionType.GeneralAction, 8))
-                {
-                    _logger.LogInformation("Using 'return' for home aetheryte");
-                    return true;
-                }
-            }
-
-            if (ActionManager.Instance()->GetActionStatus(ActionType.Action, 5) == 0)
-            {
-                // fallback if return isn't available or (more likely) on a different aetheryte
-                _logger.LogInformation("Teleporting to aetheryte {AetheryteId}", aetheryteId);
-                return Telepo.Instance()->Teleport(aetheryteId, subIndex);
-            }
-        }
-
-        return false;
-    }
-
-    public bool TeleportAetheryte(EAetheryteLocation aetheryteLocation)
-        => TeleportAetheryte((uint)aetheryteLocation);
-
-    public bool IsFlyingUnlocked(ushort territoryId)
-    {
-        if (_configuration.Advanced.NeverFly)
-            return false;
-
-        if (IsQuestAccepted(new QuestId(3304)) && _condition[ConditionFlag.Mounted])
-        {
-            BattleChara* battleChara = (BattleChara*)(_clientState.LocalPlayer?.Address ?? 0);
-            if (battleChara != null && battleChara->Mount.MountId == 198) // special quest amaro, not the normal one
-                return true;
-        }
-
-        var playerState = PlayerState.Instance();
-        return playerState != null &&
-               _territoryToAetherCurrentCompFlgSet.TryGetValue(territoryId, out byte aetherCurrentCompFlgSet) &&
-               playerState->IsAetherCurrentZoneComplete(aetherCurrentCompFlgSet);
-    }
-
-    public bool IsFlyingUnlockedInCurrentZone() => IsFlyingUnlocked(_clientState.TerritoryType);
-
-    public bool IsAetherCurrentUnlocked(uint aetherCurrentId)
-    {
-        var playerState = PlayerState.Instance();
-        return playerState != null &&
-               playerState->IsAetherCurrentUnlocked(aetherCurrentId);
-    }
-
-    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;
-
-            if (gameObject.DataId == dataId && (kind == null || kind.Value == gameObject.ObjectKind))
-            {
-                return gameObject;
-            }
-        }
-
-        _logger.LogWarning("Could not find GameObject with dataId {DataId}", dataId);
-        return null;
-    }
-
-    public bool InteractWith(uint dataId, ObjectKind? kind = null)
-    {
-        IGameObject? gameObject = FindObjectByDataId(dataId, kind);
-        if (gameObject != null)
-            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;
-        }
-    }
-
-    public bool UseItem(uint itemId)
-    {
-        long result = AgentInventoryContext.Instance()->UseItem(itemId);
-        _logger.LogInformation("UseItem result: {Result}", result);
-
-        return result == 0;
-    }
-
-    public bool UseItem(uint dataId, uint itemId)
-    {
-        IGameObject? gameObject = FindObjectByDataId(dataId);
-        if (gameObject != null)
-        {
-            _targetManager.Target = gameObject;
-            long result = AgentInventoryContext.Instance()->UseItem(itemId);
-
-            _logger.LogInformation("UseItem result on {DataId}: {Result}", dataId, result);
-
-            // TODO is 1 a generally accepted result?
-            return result == 0 || (itemId == 2002450 && result == 1);
-        }
-
-        return false;
-    }
-
-    public bool UseItemOnGround(uint dataId, uint itemId)
-    {
-        IGameObject? gameObject = FindObjectByDataId(dataId);
-        if (gameObject != null)
-        {
-            Vector3 position = gameObject.Position;
-            return ActionManager.Instance()->UseActionLocation(ActionType.KeyItem, itemId, location: &position);
-        }
-
-        return false;
-    }
-
-    public bool UseItemOnPosition(Vector3 position, uint itemId)
-    {
-        return ActionManager.Instance()->UseActionLocation(ActionType.KeyItem, itemId, location: &position);
-    }
-
-    public bool UseAction(EAction action)
-    {
-        if (ActionManager.Instance()->GetActionStatus(ActionType.Action, (uint)action) == 0)
-        {
-            bool result = ActionManager.Instance()->UseAction(ActionType.Action, (uint)action);
-            _logger.LogInformation("UseAction {Action} result: {Result}", action, result);
-
-            return result;
-        }
-
-        return false;
-    }
-
-    public bool UseAction(IGameObject gameObject, EAction action)
-    {
-        var actionRow = _dataManager.GetExcelSheet<Action>()!.GetRow((uint)action)!;
-        if (!ActionManager.CanUseActionOnTarget((uint)action, (GameObject*)gameObject.Address))
-        {
-            _logger.LogWarning("Can not use action {Action} on target {Target}", action, gameObject);
-            return false;
-        }
-
-        _targetManager.Target = gameObject;
-        if (ActionManager.Instance()->GetActionStatus(ActionType.Action, (uint)action, gameObject.GameObjectId) == 0)
-        {
-            bool result;
-            if (actionRow.TargetArea)
-            {
-                Vector3 position = gameObject.Position;
-                result = ActionManager.Instance()->UseActionLocation(ActionType.Action, (uint)action,
-                    location: &position);
-                _logger.LogInformation("UseAction {Action} on target area {Target} result: {Result}", action,
-                    gameObject,
-                    result);
-            }
-            else
-            {
-                result = ActionManager.Instance()->UseAction(ActionType.Action, (uint)action, gameObject.GameObjectId);
-                _logger.LogInformation("UseAction {Action} on target {Target} result: {Result}", action, gameObject,
-                    result);
-            }
-
-            return result;
-        }
-
-        return false;
-    }
-
-    public bool IsObjectAtPosition(uint dataId, Vector3 position, float distance)
-    {
-        IGameObject? gameObject = FindObjectByDataId(dataId);
-        return gameObject != null && (gameObject.Position - position).Length() < distance;
-    }
-
-    public bool HasStatusPreventingMount()
-    {
-        if (_condition[ConditionFlag.Swimming] && !IsFlyingUnlockedInCurrentZone())
-            return true;
-
-        // company chocobo is locked
-        var playerState = PlayerState.Instance();
-        if (playerState != null && !playerState->IsMountUnlocked(1))
-            return true;
-
-        var localPlayer = _clientState.LocalPlayer;
-        if (localPlayer == null)
-            return false;
-
-        var battleChara = (BattleChara*)localPlayer.Address;
-        StatusManager* statusManager = battleChara->GetStatusManager();
-        if (statusManager->HasStatus(1151))
-            return true;
-
-        return HasCharacterStatusPreventingMountOrSprint();
-    }
-
-    public bool HasStatusPreventingSprint() => HasCharacterStatusPreventingMountOrSprint();
-
-    private bool HasCharacterStatusPreventingMountOrSprint()
-    {
-        var localPlayer = _clientState.LocalPlayer;
-        if (localPlayer == null)
-            return false;
-
-        var battleChara = (BattleChara*)localPlayer.Address;
-        StatusManager* statusManager = battleChara->GetStatusManager();
-        return statusManager->HasStatus(565) ||
-               statusManager->HasStatus(404) ||
-               statusManager->HasStatus(416) ||
-               statusManager->HasStatus(2729) ||
-               statusManager->HasStatus(2730);
-    }
-
-    public bool Mount()
-    {
-        if (_condition[ConditionFlag.Mounted])
-            return true;
-
-        var playerState = PlayerState.Instance();
-        if (playerState != null && _configuration.General.MountId != 0 &&
-            playerState->IsMountUnlocked(_configuration.General.MountId))
-        {
-            if (ActionManager.Instance()->GetActionStatus(ActionType.Mount, _configuration.General.MountId) == 0)
-            {
-                _logger.LogDebug("Attempting to use preferred mount...");
-                if (ActionManager.Instance()->UseAction(ActionType.Mount, _configuration.General.MountId))
-                {
-                    _logger.LogInformation("Using preferred mount");
-                    return true;
-                }
-
-                return false;
-            }
-        }
-        else
-        {
-            if (ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 9) == 0)
-            {
-                _logger.LogDebug("Attempting to use mount roulette...");
-                if (ActionManager.Instance()->UseAction(ActionType.GeneralAction, 9))
-                {
-                    _logger.LogInformation("Using mount roulette");
-                    return true;
-                }
-
-                return false;
-            }
-        }
-
-        return false;
-    }
-
-    public bool Unmount()
-    {
-        if (!_condition[ConditionFlag.Mounted])
-            return true;
-
-        if (ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 23) == 0)
-        {
-            _logger.LogDebug("Attempting to unmount...");
-            if (ActionManager.Instance()->UseAction(ActionType.GeneralAction, 23))
-            {
-                _logger.LogInformation("Unmounted");
-                return true;
-            }
-
-            return false;
-        }
-        else
-        {
-            _logger.LogWarning("Can't unmount right now?");
-            return false;
-        }
-    }
-
-    public void OpenDutyFinder(uint contentFinderConditionId)
-    {
-        if (_contentFinderConditionToContentId.TryGetValue(contentFinderConditionId, out ushort contentId))
-        {
-            if (UIState.IsInstanceContentUnlocked(contentId))
-                AgentContentsFinder.Instance()->OpenRegularDuty(contentFinderConditionId);
-            else
-                _logger.LogError(
-                    "Trying to access a locked duty (cf: {ContentFinderId}, content: {ContentId})",
-                    contentFinderConditionId, contentId);
-        }
-        else
-            _logger.LogError("Could not find content for content finder condition (cf: {ContentFinderId})",
-                contentFinderConditionId);
-    }
-
-    public string? GetDialogueText(Quest currentQuest, string? excelSheetName, string key)
-    {
-        if (excelSheetName == null)
-        {
-            var questRow =
-                _dataManager.GetExcelSheet<Lumina.Excel.GeneratedSheets2.Quest>()!.GetRow((uint)currentQuest.Id.Value +
-                    0x10000);
-            if (questRow == null)
-            {
-                _logger.LogError("Could not find quest row for {QuestId}", currentQuest.Id);
-                return null;
-            }
-
-            excelSheetName = $"quest/{(currentQuest.Id.Value / 100):000}/{questRow.Id}";
-        }
-
-        var excelSheet = _dataManager.Excel.GetSheet<QuestDialogueText>(excelSheetName);
-        if (excelSheet == null)
-        {
-            _logger.LogError("Unknown excel sheet '{SheetName}'", excelSheetName);
-            return null;
-        }
-
-        return excelSheet.FirstOrDefault(x => x.Key == key)?.Value?.ToDalamudString().ToString();
-    }
-
-    public string? GetDialogueTextByRowId(string? excelSheet, uint rowId)
-    {
-        if (excelSheet == "GimmickYesNo")
-        {
-            var questRow = _dataManager.GetExcelSheet<GimmickYesNo>()!.GetRow(rowId);
-            return questRow?.Unknown0?.ToString();
-        }
-        else if (excelSheet == "Warp")
-        {
-            var questRow = _dataManager.GetExcelSheet<Warp>()!.GetRow(rowId);
-            return questRow?.Name?.ToString();
-        }
-        else if (excelSheet is "Addon")
-        {
-            var questRow = _dataManager.GetExcelSheet<Addon>()!.GetRow(rowId);
-            return questRow?.Text?.ToString();
-        }
-        else if (excelSheet is "EventPathMove")
-        {
-            var questRow = _dataManager.GetExcelSheet<EventPathMove>()!.GetRow(rowId);
-            return questRow?.Unknown10?.ToString();
-        }
-        else if (excelSheet is "ContentTalk" or null)
-        {
-            var questRow = _dataManager.GetExcelSheet<ContentTalk>()!.GetRow(rowId);
-            return questRow?.Text?.ToString();
-        }
-        else
-            throw new ArgumentOutOfRangeException(nameof(excelSheet), $"Unsupported excel sheet {excelSheet}");
-    }
-
-    public bool IsOccupied()
-    {
-        if (!_clientState.IsLoggedIn || _clientState.LocalPlayer == null)
-            return true;
-
-        if (IsLoadingScreenVisible())
-            return true;
-
-        return _condition[ConditionFlag.Occupied] || _condition[ConditionFlag.Occupied30] ||
-               _condition[ConditionFlag.Occupied33] || _condition[ConditionFlag.Occupied38] ||
-               _condition[ConditionFlag.Occupied39] || _condition[ConditionFlag.OccupiedInEvent] ||
-               _condition[ConditionFlag.OccupiedInQuestEvent] || _condition[ConditionFlag.OccupiedInCutSceneEvent] ||
-               _condition[ConditionFlag.Casting] || _condition[ConditionFlag.Unknown57] ||
-               _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51] ||
-               _condition[ConditionFlag.Jumping61] || _condition[ConditionFlag.Gathering42];
-    }
-
-    public bool IsLoadingScreenVisible()
-    {
-        return _gameGui.TryGetAddonByName("FadeMiddle", out AtkUnitBase* fade) &&
-               LAddon.IsAddonReady(fade) &&
-               fade->IsVisible;
-    }
-
-    public GrandCompany GetGrandCompany()
-    {
-        return (GrandCompany)PlayerState.Instance()->GrandCompany;
-    }
-}
diff --git a/Questionable/Model/StringOrRegex.cs b/Questionable/Model/StringOrRegex.cs
new file mode 100644 (file)
index 0000000..638c679
--- /dev/null
@@ -0,0 +1,43 @@
+using System;
+using System.Text.RegularExpressions;
+using Questionable.Functions;
+
+namespace Questionable.Model;
+
+internal sealed class StringOrRegex
+{
+    private readonly Regex? _regex;
+    private readonly string? _stringValue;
+
+    public StringOrRegex(Regex? regex)
+    {
+        ArgumentNullException.ThrowIfNull(regex);
+        _regex = regex;
+        _stringValue = null;
+    }
+
+    public StringOrRegex(string? str)
+    {
+        ArgumentNullException.ThrowIfNull(str);
+        _regex = null;
+        _stringValue = str;
+    }
+
+    public bool IsMatch(string other)
+    {
+        if (_regex != null)
+            return _regex.IsMatch(other);
+        else
+            return GameFunctions.GameStringEquals(_stringValue, other);
+    }
+
+    public string? GetString()
+    {
+        if (_stringValue == null)
+            throw new InvalidOperationException();
+
+        return _stringValue;
+    }
+
+    public override string? ToString() => _regex?.ToString() ?? _stringValue;
+}
index bc46664552c962813e1fc3836ae34045882fc5b8..afd9f8918b529d74a97b79d6f04cc303cbccdeaf 100644 (file)
@@ -1,6 +1,6 @@
 <Project Sdk="Dalamud.NET.Sdk/10.0.0">
     <PropertyGroup>
-        <Version>2.1</Version>
+        <Version>2.2</Version>
         <OutputPath>dist</OutputPath>
         <PathMap Condition="$(SolutionDir) != ''">$(SolutionDir)=X:\</PathMap>
         <Platforms>x64</Platforms>
index 477a37232b951de96a14ef0852f7992ce90acc77..766bb45d09f085424fb2c2ef1b05be047f99f44c 100644 (file)
@@ -17,6 +17,7 @@ using Questionable.Controller.Steps.Gathering;
 using Questionable.Controller.Steps.Interactions;
 using Questionable.Data;
 using Questionable.External;
+using Questionable.Functions;
 using Questionable.Validation;
 using Questionable.Validation.Validators;
 using Questionable.Windows;
@@ -47,50 +48,58 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         IContextMenu contextMenu)
     {
         ArgumentNullException.ThrowIfNull(pluginInterface);
-
-        ServiceCollection serviceCollection = new();
-        serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Trace)
-            .ClearProviders()
-            .AddDalamudLogger(pluginLog, t => t[(t.LastIndexOf('.') + 1)..]));
-        serviceCollection.AddSingleton<IDalamudPlugin>(this);
-        serviceCollection.AddSingleton(pluginInterface);
-        serviceCollection.AddSingleton(clientState);
-        serviceCollection.AddSingleton(targetManager);
-        serviceCollection.AddSingleton(framework);
-        serviceCollection.AddSingleton(gameGui);
-        serviceCollection.AddSingleton(dataManager);
-        serviceCollection.AddSingleton(sigScanner);
-        serviceCollection.AddSingleton(objectTable);
-        serviceCollection.AddSingleton(pluginLog);
-        serviceCollection.AddSingleton(condition);
-        serviceCollection.AddSingleton(chatGui);
-        serviceCollection.AddSingleton(commandManager);
-        serviceCollection.AddSingleton(addonLifecycle);
-        serviceCollection.AddSingleton(keyState);
-        serviceCollection.AddSingleton(contextMenu);
-        serviceCollection.AddSingleton(new WindowSystem(nameof(Questionable)));
-        serviceCollection.AddSingleton((Configuration?)pluginInterface.GetPluginConfig() ?? new Configuration());
-
-        AddBasicFunctionsAndData(serviceCollection);
-        AddTaskFactories(serviceCollection);
-        AddControllers(serviceCollection);
-        AddWindows(serviceCollection);
-        AddQuestValidators(serviceCollection);
-
-        serviceCollection.AddSingleton<CommandHandler>();
-        serviceCollection.AddSingleton<DalamudInitializer>();
-
-        _serviceProvider = serviceCollection.BuildServiceProvider();
-        _serviceProvider.GetRequiredService<QuestRegistry>().Reload();
-        _serviceProvider.GetRequiredService<CommandHandler>();
-        _serviceProvider.GetRequiredService<ContextMenuController>();
-        _serviceProvider.GetRequiredService<DalamudInitializer>();
+        ArgumentNullException.ThrowIfNull(chatGui);
+        try
+        {
+            ServiceCollection serviceCollection = new();
+            serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Trace)
+                .ClearProviders()
+                .AddDalamudLogger(pluginLog, t => t[(t.LastIndexOf('.') + 1)..]));
+            serviceCollection.AddSingleton<IDalamudPlugin>(this);
+            serviceCollection.AddSingleton(pluginInterface);
+            serviceCollection.AddSingleton(clientState);
+            serviceCollection.AddSingleton(targetManager);
+            serviceCollection.AddSingleton(framework);
+            serviceCollection.AddSingleton(gameGui);
+            serviceCollection.AddSingleton(dataManager);
+            serviceCollection.AddSingleton(sigScanner);
+            serviceCollection.AddSingleton(objectTable);
+            serviceCollection.AddSingleton(pluginLog);
+            serviceCollection.AddSingleton(condition);
+            serviceCollection.AddSingleton(chatGui);
+            serviceCollection.AddSingleton(commandManager);
+            serviceCollection.AddSingleton(addonLifecycle);
+            serviceCollection.AddSingleton(keyState);
+            serviceCollection.AddSingleton(contextMenu);
+            serviceCollection.AddSingleton(new WindowSystem(nameof(Questionable)));
+            serviceCollection.AddSingleton((Configuration?)pluginInterface.GetPluginConfig() ?? new Configuration());
+
+            AddBasicFunctionsAndData(serviceCollection);
+            AddTaskFactories(serviceCollection);
+            AddControllers(serviceCollection);
+            AddWindows(serviceCollection);
+            AddQuestValidators(serviceCollection);
+
+            serviceCollection.AddSingleton<CommandHandler>();
+            serviceCollection.AddSingleton<DalamudInitializer>();
+
+            _serviceProvider = serviceCollection.BuildServiceProvider();
+            Initialize(_serviceProvider);
+        }
+        catch (Exception)
+        {
+            chatGui.PrintError("Unable to load plugin, check /xllog for details", "Questionable");
+            throw;
+        }
     }
 
     private static void AddBasicFunctionsAndData(ServiceCollection serviceCollection)
     {
+        serviceCollection.AddSingleton<ExcelFunctions>();
         serviceCollection.AddSingleton<GameFunctions>();
         serviceCollection.AddSingleton<ChatFunctions>();
+        serviceCollection.AddSingleton<QuestFunctions>();
+
         serviceCollection.AddSingleton<AetherCurrentData>();
         serviceCollection.AddSingleton<AetheryteData>();
         serviceCollection.AddSingleton<GatheringData>();
@@ -110,6 +119,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddTransient<MoveToLandingLocation>();
         serviceCollection.AddTransient<DoGather>();
         serviceCollection.AddTransient<DoGatherCollectable>();
+        serviceCollection.AddTransient<SwitchClassJob>();
 
         // task factories
         serviceCollection.AddTaskWithFactory<StepDisabled.Factory, StepDisabled.Task>();
@@ -135,6 +145,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddTaskWithFactory<Say.Factory, Say.UseChat>();
         serviceCollection.AddTaskWithFactory<UseItem.Factory, UseItem.UseOnGround, UseItem.UseOnObject, UseItem.Use, UseItem.UseOnPosition>();
         serviceCollection.AddTaskWithFactory<EquipItem.Factory, EquipItem.DoEquip>();
+        serviceCollection.AddTaskWithFactory<TurnInDelivery.Factory, TurnInDelivery.SatisfactionSupplyTurnIn>();
         serviceCollection
             .AddTaskWithFactory<SinglePlayerDuty.Factory, SinglePlayerDuty.DisableYesAlready,
                 SinglePlayerDuty.RestoreYesAlready>();
@@ -192,10 +203,19 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddSingleton<IQuestValidator, NextQuestValidator>();
         serviceCollection.AddSingleton<IQuestValidator, CompletionFlagsValidator>();
         serviceCollection.AddSingleton<IQuestValidator, AethernetShortcutValidator>();
+        serviceCollection.AddSingleton<IQuestValidator, DialogueChoiceValidator>();
         serviceCollection.AddSingleton<JsonSchemaValidator>();
         serviceCollection.AddSingleton<IQuestValidator>(sp => sp.GetRequiredService<JsonSchemaValidator>());
     }
 
+    private static void Initialize(IServiceProvider serviceProvider)
+    {
+        serviceProvider.GetRequiredService<QuestRegistry>().Reload();
+        serviceProvider.GetRequiredService<CommandHandler>();
+        serviceProvider.GetRequiredService<ContextMenuController>();
+        serviceProvider.GetRequiredService<DalamudInitializer>();
+    }
+
     public void Dispose()
     {
         _serviceProvider?.Dispose();
index a94f1f23534aa9d33d4dcc6779c7ed560e317ff1..0f51ce6be15fe5e77bfa0052320799f9ff19b019 100644 (file)
@@ -16,4 +16,5 @@ public enum EIssueType
     UnexpectedAcceptQuestStep,
     UnexpectedCompleteQuestStep,
     InvalidAethernetShortcut,
+    InvalidExcelRef,
 }
index ce5215987e7b9b2368870aa514f43ffab0c1caac..137e62b46f968fb9c4aa4aa1ed6562e3a3c588ae 100644 (file)
@@ -56,7 +56,7 @@ internal sealed class QuestValidator
                                 : LogLevel.Information;
                             _logger.Log(level,
                                 "Validation failed: {QuestId} ({QuestName}) / {QuestSequence} / {QuestStep} - {Description}",
-                                issue.QuestId, quest.Info.Name, issue.Sequence, issue.Step, issue.Description);
+                                issue.ElementId, quest.Info.Name, issue.Sequence, issue.Step, issue.Description);
                             if (issue.Type == EIssueType.QuestDisabled && quest.Info.BeastTribe != EBeastTribe.None)
                             {
                                 disabledTribeQuests.TryAdd(quest.Info.BeastTribe, 0);
@@ -70,12 +70,12 @@ internal sealed class QuestValidator
 
                 var disabledQuests = issues
                     .Where(x => x.Type == EIssueType.QuestDisabled)
-                    .Select(x => x.QuestId)
+                    .Select(x => x.ElementId)
                     .ToList();
 
                 _validationIssues = issues
-                    .Where(x => !disabledQuests.Contains(x.QuestId) || x.Type == EIssueType.QuestDisabled)
-                    .OrderBy(x => x.QuestId)
+                    .Where(x => !disabledQuests.Contains(x.ElementId) || x.Type == EIssueType.QuestDisabled)
+                    .OrderBy(x => x.ElementId)
                     .ThenBy(x => x.Sequence)
                     .ThenBy(x => x.Step)
                     .ThenBy(x => x.Description)
@@ -95,7 +95,7 @@ internal sealed class QuestValidator
             .OrderBy(x => x.Key)
             .Select(x => new ValidationIssue
             {
-                QuestId = null,
+                ElementId = null,
                 Sequence = null,
                 Step = null,
                 BeastTribe = x.Key,
index 31402620948fcf230ccf080e9caa18b061581aec..654ac514bbd3410f75cefe01ea4503328994fce5 100644 (file)
@@ -5,7 +5,7 @@ namespace Questionable.Validation;
 
 internal sealed record ValidationIssue
 {
-    public required ElementId? QuestId { get; init; }
+    public required ElementId? ElementId { get; init; }
     public required byte? Sequence { get; init; }
     public required int? Step { get; init; }
     public EBeastTribe BeastTribe { get; init; } = EBeastTribe.None;
index 037ba3f4e0ef648a932b8bcb2dfdc04ed1a5d0b4..13a00aa870c9d32d093397bac499741816cf3d3b 100644 (file)
@@ -24,7 +24,7 @@ internal sealed class AethernetShortcutValidator : IQuestValidator
             .Cast<ValidationIssue>();
     }
 
-    private ValidationIssue? Validate(ElementId questElementId, int sequenceNo, int stepId, AethernetShortcut? aethernetShortcut)
+    private ValidationIssue? Validate(ElementId elementId, int sequenceNo, int stepId, AethernetShortcut? aethernetShortcut)
     {
         if (aethernetShortcut == null)
             return null;
@@ -35,7 +35,7 @@ internal sealed class AethernetShortcutValidator : IQuestValidator
         {
             return new ValidationIssue
             {
-                QuestId = questElementId,
+                ElementId = elementId,
                 Sequence = (byte)sequenceNo,
                 Step = stepId,
                 Type = EIssueType.InvalidAethernetShortcut,
index 7fa73dfb915568d2322a65c5f517208d75de7394..0c2bee27f6130b2773e791086bdbf1747bd6d651 100644 (file)
@@ -18,7 +18,7 @@ internal sealed class BasicSequenceValidator : IQuestValidator
         {
             yield return new ValidationIssue
             {
-                QuestId = quest.Id,
+                ElementId = quest.Id,
                 Sequence = 0,
                 Step = null,
                 Type = EIssueType.MissingSequence0,
@@ -37,7 +37,7 @@ internal sealed class BasicSequenceValidator : IQuestValidator
 
                 yield return new ValidationIssue
                 {
-                    QuestId = quest.Id,
+                    ElementId = quest.Id,
                     Sequence = (byte)sequence.Sequence,
                     Step = null,
                     Type = EIssueType.InstantQuestWithMultipleSteps,
@@ -73,7 +73,7 @@ internal sealed class BasicSequenceValidator : IQuestValidator
         {
             return new ValidationIssue
             {
-                QuestId = quest.Id,
+                ElementId = quest.Id,
                 Sequence = (byte)sequenceNo,
                 Step = null,
                 Type = EIssueType.MissingSequence,
@@ -85,7 +85,7 @@ internal sealed class BasicSequenceValidator : IQuestValidator
         {
             return new ValidationIssue
             {
-                QuestId = quest.Id,
+                ElementId = quest.Id,
                 Sequence = (byte)sequenceNo,
                 Step = null,
                 Type = EIssueType.DuplicateSequence,
index 9aa67760e0557bad2ddd8629766ae659e6700c62..e5c9fdc1240eef20d8a65d21214202cc10d887d5 100644 (file)
@@ -45,7 +45,7 @@ internal sealed class CompletionFlagsValidator : IQuestValidator
                 {
                     yield return new ValidationIssue
                     {
-                        QuestId = quest.Id,
+                        ElementId = quest.Id,
                         Sequence = (byte)sequence.Sequence,
                         Step = i,
                         Type = EIssueType.DuplicateCompletionFlags,
diff --git a/Questionable/Validation/Validators/DialogueChoiceValidator.cs b/Questionable/Validation/Validators/DialogueChoiceValidator.cs
new file mode 100644 (file)
index 0000000..ee6d6f5
--- /dev/null
@@ -0,0 +1,83 @@
+using System.Collections.Generic;
+using Questionable.Functions;
+using Questionable.Model;
+using Questionable.Model.Questing;
+
+namespace Questionable.Validation.Validators;
+
+internal sealed class DialogueChoiceValidator : IQuestValidator
+{
+    private readonly ExcelFunctions _excelFunctions;
+
+    public DialogueChoiceValidator(ExcelFunctions excelFunctions)
+    {
+        _excelFunctions = excelFunctions;
+    }
+
+    public IEnumerable<ValidationIssue> Validate(Quest quest)
+    {
+        foreach (var x in quest.AllSteps())
+        {
+            if (x.Step.DialogueChoices.Count == 0)
+                continue;
+
+            foreach (var dialogueChoice in x.Step.DialogueChoices)
+            {
+                ExcelRef? prompt = dialogueChoice.Prompt;
+                if (prompt != null)
+                {
+                    ValidationIssue? promptIssue = Validate(quest, x.Sequence, x.StepId, dialogueChoice.ExcelSheet,
+                        prompt, "Prompt");
+                    if (promptIssue != null)
+                        yield return promptIssue;
+                }
+
+                ExcelRef? answer = dialogueChoice.Answer;
+                if (answer != null)
+                {
+                    ValidationIssue? answerIssue = Validate(quest, x.Sequence, x.StepId, dialogueChoice.ExcelSheet,
+                        answer, "Answer");
+                    if (answerIssue != null)
+                        yield return answerIssue;
+                }
+            }
+        }
+    }
+
+    private ValidationIssue? Validate(Quest quest, QuestSequence sequence, int stepId, string? excelSheet,
+        ExcelRef excelRef, string label)
+    {
+        if (excelRef.Type == ExcelRef.EType.Key)
+        {
+            if (_excelFunctions.GetRawDialogueText(quest, excelSheet, excelRef.AsKey()) == null)
+            {
+                return new ValidationIssue
+                {
+                    ElementId = quest.Id,
+                    Sequence = (byte)sequence.Sequence,
+                    Step = stepId,
+                    Type = EIssueType.InvalidExcelRef,
+                    Severity = EIssueSeverity.Error,
+                    Description = $"{label} invalid: {excelSheet} → {excelRef.AsKey()}",
+                };
+            }
+        }
+        else if (excelRef.Type == ExcelRef.EType.RowId)
+        {
+            if (_excelFunctions.GetRawDialogueTextByRowId(excelSheet, excelRef.AsRowId()) == null)
+            {
+                return new ValidationIssue
+                {
+                    ElementId = quest.Id,
+                    Sequence = (byte)sequence.Sequence,
+                    Step = stepId,
+                    Type = EIssueType.InvalidExcelRef,
+                    Severity = EIssueSeverity.Error,
+                    Description = $"{label} invalid: {excelSheet} → {excelRef.AsRowId()}",
+                };
+            }
+        }
+
+        return null;
+    }
+}
index b68fa96798e373558b8f2538c1e3b30c4af6a8f3..d6bb483d69bbee879d3efed7d3d0fc70258ea7e3 100644 (file)
@@ -36,7 +36,7 @@ internal sealed class JsonSchemaValidator : IQuestValidator
             {
                 yield return new ValidationIssue
                 {
-                    QuestId = quest.Id,
+                    ElementId = quest.Id,
                     Sequence = null,
                     Step = null,
                     Type = EIssueType.InvalidJsonSchema,
@@ -47,7 +47,7 @@ internal sealed class JsonSchemaValidator : IQuestValidator
         }
     }
 
-    public void Enqueue(ElementId questElementId, JsonNode questNode) => _questNodes[questElementId] = questNode;
+    public void Enqueue(ElementId elementId, JsonNode questNode) => _questNodes[elementId] = questNode;
 
     public void Reset() => _questNodes.Clear();
 }
index c2e899a4f49c9c4d0f840e3bcf356e6e2c3f3c0b..d08bea612feeafdae8e256f2ceea0184cc294f16 100644 (file)
@@ -12,7 +12,7 @@ internal sealed class NextQuestValidator : IQuestValidator
         {
             yield return new ValidationIssue
             {
-                QuestId = quest.Id,
+                ElementId = quest.Id,
                 Sequence = (byte)invalidNextQuest.Sequence.Sequence,
                 Step = invalidNextQuest.StepId,
                 Type = EIssueType.InvalidNextQuestId,
index 60539d5bd4713f574a97a9c40902367857b789c7..8ff522eb6f3d0f83b66dadbc823395844cde3a7e 100644 (file)
@@ -11,7 +11,7 @@ internal sealed class QuestDisabledValidator : IQuestValidator
         {
             yield return new ValidationIssue
             {
-                QuestId = quest.Id,
+                ElementId = quest.Id,
                 Sequence = null,
                 Step = null,
                 Type = EIssueType.QuestDisabled,
index 5ffe6e0f001bed8f68ead6994a8cad7ecb838c18..60f67b12d9018bd9e51d23280b130bbbbe14013e 100644 (file)
@@ -21,7 +21,7 @@ internal sealed class UniqueStartStopValidator : IQuestValidator
             {
                 yield return new ValidationIssue
                 {
-                    QuestId = quest.Id,
+                    ElementId = quest.Id,
                     Sequence = (byte)accept.Sequence.Sequence,
                     Step = accept.StepId,
                     Type = EIssueType.UnexpectedAcceptQuestStep,
@@ -35,7 +35,7 @@ internal sealed class UniqueStartStopValidator : IQuestValidator
         {
             yield return new ValidationIssue
             {
-                QuestId = quest.Id,
+                ElementId = quest.Id,
                 Sequence = 0,
                 Step = null,
                 Type = EIssueType.MissingQuestAccept,
@@ -53,7 +53,7 @@ internal sealed class UniqueStartStopValidator : IQuestValidator
             {
                 yield return new ValidationIssue
                 {
-                    QuestId = quest.Id,
+                    ElementId = quest.Id,
                     Sequence = (byte)complete.Sequence.Sequence,
                     Step = complete.StepId,
                     Type = EIssueType.UnexpectedCompleteQuestStep,
@@ -67,7 +67,7 @@ internal sealed class UniqueStartStopValidator : IQuestValidator
         {
             yield return new ValidationIssue
             {
-                QuestId = quest.Id,
+                ElementId = quest.Id,
                 Sequence = 255,
                 Step = null,
                 Type = EIssueType.MissingQuestComplete,
index bdad03da29de2f392dfb8149a7b5663167cd8be0..2d19c1be2449778a5cf7ba408c055f70a4e0b678 100644 (file)
@@ -12,6 +12,7 @@ using ImGuiNET;
 using LLib.ImGui;
 using Questionable.Controller;
 using Questionable.Data;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Windows.QuestComponents;
 
@@ -21,7 +22,7 @@ internal sealed class JournalProgressWindow : LWindow, IDisposable
 {
     private readonly JournalData _journalData;
     private readonly QuestRegistry _questRegistry;
-    private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
     private readonly UiUtils _uiUtils;
     private readonly QuestTooltipComponent _questTooltipComponent;
     private readonly IDalamudPluginInterface _pluginInterface;
@@ -37,7 +38,7 @@ internal sealed class JournalProgressWindow : LWindow, IDisposable
 
     public JournalProgressWindow(JournalData journalData,
         QuestRegistry questRegistry,
-        GameFunctions gameFunctions,
+        QuestFunctions questFunctions,
         UiUtils uiUtils,
         QuestTooltipComponent questTooltipComponent,
         IDalamudPluginInterface pluginInterface,
@@ -47,7 +48,7 @@ internal sealed class JournalProgressWindow : LWindow, IDisposable
     {
         _journalData = journalData;
         _questRegistry = questRegistry;
-        _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
         _uiUtils = uiUtils;
         _questTooltipComponent = questTooltipComponent;
         _pluginInterface = pluginInterface;
@@ -327,7 +328,7 @@ internal sealed class JournalProgressWindow : LWindow, IDisposable
         {
             int available = genre.Quests.Count(x =>
                 _questRegistry.TryGetQuest(x.QuestId, out var quest) && !quest.Root.Disabled);
-            int completed = genre.Quests.Count(x => _gameFunctions.IsQuestComplete(x.QuestId));
+            int completed = genre.Quests.Count(x => _questFunctions.IsQuestComplete(x.QuestId));
             _genreCounts[genre] = (available, completed);
         }
 
index 39e9ec97bed80f882fa051829d70bb097b68f07d..a94f253be7e2206fde0e0bf8efd41899764e0615 100644 (file)
@@ -4,6 +4,7 @@ using Dalamud.Interface.Utility.Raii;
 using FFXIVClientStructs.FFXIV.Client.Game.UI;
 using FFXIVClientStructs.FFXIV.Common.Math;
 using Questionable.Data;
+using Questionable.Functions;
 using Questionable.Model.Questing;
 
 namespace Questionable.Windows.QuestComponents;
@@ -18,26 +19,26 @@ internal sealed class ARealmRebornComponent
     private static readonly QuestId[] RequiredAllianceRaidQuests =
         [new(1709), new(1200), new(1201), new(1202), new(1203), new(1474), new(494), new(495)];
 
-    private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
     private readonly QuestData _questData;
     private readonly TerritoryData _territoryData;
     private readonly UiUtils _uiUtils;
 
-    public ARealmRebornComponent(GameFunctions gameFunctions, QuestData questData, TerritoryData territoryData,
+    public ARealmRebornComponent(QuestFunctions questFunctions, QuestData questData, TerritoryData territoryData,
         UiUtils uiUtils)
     {
-        _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
         _questData = questData;
         _territoryData = territoryData;
         _uiUtils = uiUtils;
     }
 
-    public bool ShouldDraw => !_gameFunctions.IsQuestAcceptedOrComplete(ATimeForEveryPurpose) &&
-                              _gameFunctions.IsQuestComplete(TheUltimateWeapon);
+    public bool ShouldDraw => !_questFunctions.IsQuestAcceptedOrComplete(ATimeForEveryPurpose) &&
+                              _questFunctions.IsQuestComplete(TheUltimateWeapon);
 
     public void Draw()
     {
-        if (!_gameFunctions.IsQuestAcceptedOrComplete(GoodIntentions))
+        if (!_questFunctions.IsQuestAcceptedOrComplete(GoodIntentions))
             DrawPrimals();
 
         DrawAllianceRaids();
@@ -63,7 +64,7 @@ internal sealed class ARealmRebornComponent
 
     private void DrawAllianceRaids()
     {
-        bool complete = _gameFunctions.IsQuestComplete(RequiredAllianceRaidQuests.Last());
+        bool complete = _questFunctions.IsQuestComplete(RequiredAllianceRaidQuests.Last());
         bool hover = _uiUtils.ChecklistItem("Crystal Tower Raids", complete);
         if (complete || !hover)
             return;
index eb48f4c431c0c5eb5b4f57a0fcdd928d03f54c69..3c1a92ff2f3f5f8f5f0b919f7a8d733a71aa8524 100644 (file)
@@ -13,6 +13,7 @@ using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
 using ImGuiNET;
 using Questionable.Controller;
 using Questionable.Controller.Steps.Shared;
+using Questionable.Functions;
 using Questionable.Model.Questing;
 
 namespace Questionable.Windows.QuestComponents;
@@ -24,6 +25,7 @@ internal sealed class ActiveQuestComponent
     private readonly CombatController _combatController;
     private readonly GatheringController _gatheringController;
     private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
     private readonly ICommandManager _commandManager;
     private readonly IDalamudPluginInterface _pluginInterface;
     private readonly Configuration _configuration;
@@ -36,6 +38,7 @@ internal sealed class ActiveQuestComponent
         CombatController combatController,
         GatheringController gatheringController,
         GameFunctions gameFunctions,
+        QuestFunctions questFunctions,
         ICommandManager commandManager,
         IDalamudPluginInterface pluginInterface,
         Configuration configuration,
@@ -47,6 +50,7 @@ internal sealed class ActiveQuestComponent
         _combatController = combatController;
         _gatheringController = gatheringController;
         _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
         _commandManager = commandManager;
         _pluginInterface = pluginInterface;
         _configuration = configuration;
@@ -116,6 +120,12 @@ internal sealed class ActiveQuestComponent
             ImGui.TextUnformatted(
                 $"Simulated Quest: {Shorten(currentQuest.Quest.Info.Name)} / {currentQuest.Sequence} / {currentQuest.Step}");
         }
+        else if (currentQuestType == QuestController.ECurrentQuestType.Gathering)
+        {
+            using var _ = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.ParsedGold);
+            ImGui.TextUnformatted(
+                $"Gathering: {Shorten(currentQuest.Quest.Info.Name)} / {currentQuest.Sequence} / {currentQuest.Step}");
+        }
         else
         {
             var startedQuest = _questController.StartedQuest;
@@ -154,7 +164,7 @@ internal sealed class ActiveQuestComponent
         if (currentQuest.Quest.Id is not QuestId questId)
             return null;
 
-        var questWork = _gameFunctions.GetQuestEx(questId);
+        var questWork = _questFunctions.GetQuestEx(questId);
         if (questWork != null)
         {
             Vector4 color;
index b824f1af28091991ce2ad471db58f4d8dd2bc68b..dfcae518965c29263d50b85df73aee12ffd7c822 100644 (file)
@@ -15,6 +15,7 @@ using ImGuiNET;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller;
 using Questionable.Data;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Common;
 using Questionable.Model.Questing;
@@ -26,6 +27,7 @@ internal sealed class CreationUtilsComponent
 {
     private readonly MovementController _movementController;
     private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
     private readonly TerritoryData _territoryData;
     private readonly QuestData _questData;
     private readonly QuestSelectionWindow _questSelectionWindow;
@@ -35,13 +37,22 @@ internal sealed class CreationUtilsComponent
     private readonly IGameGui _gameGui;
     private readonly ILogger<CreationUtilsComponent> _logger;
 
-    public CreationUtilsComponent(MovementController movementController, GameFunctions gameFunctions,
-        TerritoryData territoryData, QuestData questData, QuestSelectionWindow questSelectionWindow,
-        IClientState clientState, ITargetManager targetManager, ICondition condition, IGameGui gameGui,
+    public CreationUtilsComponent(
+        MovementController movementController,
+        GameFunctions gameFunctions,
+        QuestFunctions questFunctions,
+        TerritoryData territoryData,
+        QuestData questData,
+        QuestSelectionWindow questSelectionWindow,
+        IClientState clientState,
+        ITargetManager targetManager,
+        ICondition condition,
+        IGameGui gameGui,
         ILogger<CreationUtilsComponent> logger)
     {
         _movementController = movementController;
         _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
         _territoryData = territoryData;
         _questData = questData;
         _questSelectionWindow = questSelectionWindow;
@@ -65,7 +76,7 @@ internal sealed class CreationUtilsComponent
             ImGui.Text(SeIconChar.BotanistSprout.ToIconString());
         }
 
-        var q = _gameFunctions.GetCurrentQuest();
+        var q = _questFunctions.GetCurrentQuest();
         ImGui.Text($"Current Quest: {q.CurrentQuest} → {q.Sequence}");
 
 #if false
index 2a19a858b9baa785fe011f10ed76408858d17a46..df2a243bbd11d248d2a9b46842f6634bd38ed58a 100644 (file)
@@ -5,6 +5,7 @@ using FFXIVClientStructs.FFXIV.Client.UI.Agent;
 using ImGuiNET;
 using Questionable.Controller;
 using Questionable.Data;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 
@@ -15,20 +16,20 @@ internal sealed class QuestTooltipComponent
     private readonly QuestRegistry _questRegistry;
     private readonly QuestData _questData;
     private readonly TerritoryData _territoryData;
-    private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
     private readonly UiUtils _uiUtils;
 
     public QuestTooltipComponent(
         QuestRegistry questRegistry,
         QuestData questData,
         TerritoryData territoryData,
-        GameFunctions gameFunctions,
+        QuestFunctions questFunctions,
         UiUtils uiUtils)
     {
         _questRegistry = questRegistry;
         _questData = questData;
         _territoryData = territoryData;
-        _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
         _uiUtils = uiUtils;
     }
 
@@ -161,7 +162,7 @@ internal sealed class QuestTooltipComponent
                 _ => "None",
             };
 
-            GrandCompany currentGrandCompany = _gameFunctions.GetGrandCompany();
+            GrandCompany currentGrandCompany = ~_questFunctions.GetGrandCompany();
             _uiUtils.ChecklistItem($"Grand Company: {gcName}", quest.GrandCompany == currentGrandCompany);
         }
 
index 7cdfb388a6aaa4501321b5d9cff0bcdced812c78..c189686164d8532b41da73f82557c2ef30d324b5 100644 (file)
@@ -12,6 +12,7 @@ using FFXIVClientStructs.FFXIV.Client.UI.Agent;
 using ImGuiNET;
 using Questionable.Controller;
 using Questionable.External;
+using Questionable.Functions;
 
 namespace Questionable.Windows.QuestComponents;
 
index 96d0cfd72b22518fdf945478e6d1a56655ead719..ce6cb0fdc7bbca6377905ff934d4cd5a3a935ffb 100644 (file)
@@ -12,12 +12,12 @@ using Dalamud.Plugin;
 using Dalamud.Plugin.Services;
 using FFXIVClientStructs.FFXIV.Client.Game.UI;
 using FFXIVClientStructs.FFXIV.Client.UI;
-using FFXIVClientStructs.FFXIV.Client.UI.Agent;
 using ImGuiNET;
 using LLib.GameUI;
 using LLib.ImGui;
 using Questionable.Controller;
 using Questionable.Data;
+using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 using Questionable.Windows.QuestComponents;
@@ -30,7 +30,7 @@ internal sealed class QuestSelectionWindow : LWindow
     private readonly QuestData _questData;
     private readonly IGameGui _gameGui;
     private readonly IChatGui _chatGui;
-    private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
     private readonly QuestController _questController;
     private readonly QuestRegistry _questRegistry;
     private readonly IDalamudPluginInterface _pluginInterface;
@@ -43,16 +43,24 @@ internal sealed class QuestSelectionWindow : LWindow
     private List<IQuestInfo> _offeredQuests = [];
     private bool _onlyAvailableQuests = true;
 
-    public QuestSelectionWindow(QuestData questData, IGameGui gameGui, IChatGui chatGui, GameFunctions gameFunctions,
-        QuestController questController, QuestRegistry questRegistry, IDalamudPluginInterface pluginInterface,
-        TerritoryData territoryData, IClientState clientState, UiUtils uiUtils,
+    public QuestSelectionWindow(
+        QuestData questData,
+        IGameGui gameGui,
+        IChatGui chatGui,
+        QuestFunctions questFunctions,
+        QuestController questController,
+        QuestRegistry questRegistry,
+        IDalamudPluginInterface pluginInterface,
+        TerritoryData territoryData,
+        IClientState clientState,
+        UiUtils uiUtils,
         QuestTooltipComponent questTooltipComponent)
         : base($"Quest Selection{WindowId}")
     {
         _questData = questData;
         _gameGui = gameGui;
         _chatGui = chatGui;
-        _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
         _questController = questController;
         _questRegistry = questRegistry;
         _pluginInterface = pluginInterface;
@@ -82,7 +90,7 @@ internal sealed class QuestSelectionWindow : LWindow
             {
                 var answers = GameUiController.GetChoices(addonSelectIconString);
                 _offeredQuests = _quests
-                    .Where(x => answers.Any(y => GameUiController.GameStringEquals(x.Name, y)))
+                    .Where(x => answers.Any(y => GameFunctions.GameStringEquals(x.Name, y)))
                     .ToList();
             }
             else
@@ -216,9 +224,9 @@ internal sealed class QuestSelectionWindow : LWindow
 
                 if (knownQuest != null &&
                     knownQuest.FindSequence(0)?.LastStep()?.InteractionType == EInteractionType.AcceptQuest &&
-                    !_gameFunctions.IsQuestAccepted(quest.QuestId) &&
-                    !_gameFunctions.IsQuestLocked(quest.QuestId) &&
-                    (quest.IsRepeatable || !_gameFunctions.IsQuestAcceptedOrComplete(quest.QuestId)))
+                    !_questFunctions.IsQuestAccepted(quest.QuestId) &&
+                    !_questFunctions.IsQuestLocked(quest.QuestId) &&
+                    (quest.IsRepeatable || !_questFunctions.IsQuestAcceptedOrComplete(quest.QuestId)))
                 {
                     ImGui.BeginDisabled(_questController.NextQuest != null || _questController.SimulatedQuest != null);
 
index 15eff98122fc1f27303d534d2b288fb21d4a3774..8f3eb3ac5581101a7d3e1640e2fc84a3fa6c4d73 100644 (file)
@@ -56,11 +56,11 @@ internal sealed class QuestValidationWindow : LWindow
             ImGui.TableNextRow();
 
             if (ImGui.TableNextColumn())
-                ImGui.TextUnformatted(validationIssue.QuestId?.ToString() ?? string.Empty);
+                ImGui.TextUnformatted(validationIssue.ElementId?.ToString() ?? string.Empty);
 
             if (ImGui.TableNextColumn())
-                ImGui.TextUnformatted(validationIssue.QuestId != null
-                    ? _questData.GetQuestInfo(validationIssue.QuestId).Name
+                ImGui.TextUnformatted(validationIssue.ElementId != null
+                    ? _questData.GetQuestInfo(validationIssue.ElementId).Name
                     : validationIssue.BeastTribe.ToString());
 
             if (ImGui.TableNextColumn())
index 30546bc4c94e77332e902d07d38e5932c66d5ba1..dab69b8b3d31c8b372a2221274e7cce6d23e34b4 100644 (file)
@@ -63,6 +63,11 @@ internal sealed class QuestWindow : LWindow, IPersistableWindowConfig
 
     public void SaveWindowConfig() => _pluginInterface.SavePluginConfig(_configuration);
 
+    public override void PreOpenCheck()
+    {
+        IsOpen |= _questController.IsRunning;
+    }
+
     public override bool DrawConditions()
     {
         if (!_clientState.IsLoggedIn || _clientState.LocalPlayer == null || _clientState.IsPvPExcludingDen)
index d6bdba36b18a6b353b3c42cc4b0ffcb8cf0b270e..d04d5dac59f65dc2656b25c9591245d6ce8ec2ac 100644 (file)
@@ -4,28 +4,29 @@ using Dalamud.Interface.Colors;
 using Dalamud.Plugin;
 using FFXIVClientStructs.FFXIV.Client.Game.UI;
 using ImGuiNET;
+using Questionable.Functions;
 using Questionable.Model.Questing;
 
 namespace Questionable.Windows;
 
 internal sealed class UiUtils
 {
-    private readonly GameFunctions _gameFunctions;
+    private readonly QuestFunctions _questFunctions;
     private readonly IDalamudPluginInterface _pluginInterface;
 
-    public UiUtils(GameFunctions gameFunctions, IDalamudPluginInterface pluginInterface)
+    public UiUtils(QuestFunctions questFunctions, IDalamudPluginInterface pluginInterface)
     {
-        _gameFunctions = gameFunctions;
+        _questFunctions = questFunctions;
         _pluginInterface = pluginInterface;
     }
 
-    public (Vector4 color, FontAwesomeIcon icon, string status) GetQuestStyle(ElementId questElementId)
+    public (Vector4 color, FontAwesomeIcon icon, string status) GetQuestStyle(ElementId elementId)
     {
-        if (_gameFunctions.IsQuestAccepted(questElementId))
+        if (_questFunctions.IsQuestAccepted(elementId))
             return (ImGuiColors.DalamudYellow, FontAwesomeIcon.PersonWalkingArrowRight, "Active");
-        else if (_gameFunctions.IsQuestAcceptedOrComplete(questElementId))
+        else if (_questFunctions.IsQuestAcceptedOrComplete(elementId))
             return (ImGuiColors.ParsedGreen, FontAwesomeIcon.Check, "Complete");
-        else if (_gameFunctions.IsQuestLocked(questElementId))
+        else if (_questFunctions.IsQuestLocked(elementId))
             return (ImGuiColors.DalamudRed, FontAwesomeIcon.Times, "Locked");
         else
             return (ImGuiColors.DalamudYellow, FontAwesomeIcon.Running, "Available");