Second draft for auto-completing quest battles
authorLiza Carvelli <liza@carvel.li>
Thu, 20 Feb 2025 19:45:38 +0000 (20:45 +0100)
committerLiza Carvelli <liza@carvel.li>
Thu, 20 Feb 2025 19:45:38 +0000 (20:45 +0100)
41 files changed:
GatheringPathRenderer/RendererPlugin.cs
QuestPathGenerator/RoslynElements/QuestStepExtensions.cs
QuestPaths/2.x - A Realm Reborn/Class Quests/BRD/76_The One That Got Away.json
QuestPaths/2.x - A Realm Reborn/Class Quests/DRG/439_Proof of Might.json
QuestPaths/2.x - A Realm Reborn/Class Quests/DRG/56_Lance of Destiny.json
QuestPaths/2.x - A Realm Reborn/Class Quests/MNK/567_Return of the Holyfist.json
QuestPaths/3.x - Heavensward/Class Quests/WAR/601_And My Axe.json
QuestPaths/3.x - Heavensward/MSQ/A1-Coerthas Western Highlands 1, Sea of Clouds 1/1595_A Series of Unfortunate Events.json
QuestPaths/3.x - Heavensward/MSQ/A1-Coerthas Western Highlands 1, Sea of Clouds 1/1597_Divine Intervention.json
QuestPaths/3.x - Heavensward/MSQ/A2-Raubahn/1601_Keeping the Flame Alive.json
QuestPaths/3.x - Heavensward/MSQ/A3.1-Coerthas Western Highlands 2/1606_Sounding Out the Amphitheatre.json
QuestPaths/3.x - Heavensward/MSQ/A4-Ishgard/1639_Fire and Blood.json
QuestPaths/3.x - Heavensward/MSQ/A5-Sea of Clouds/1644_Familiar Faces.json
QuestPaths/3.x - Heavensward/MSQ/A6-The Dravanian Hinterlands/1657_An Illuminati Incident.json
QuestPaths/3.x - Heavensward/MSQ/A7-Azys Lla/1667_Close Encounters of the VIth Kind.json
QuestPaths/5.x - Shadowbringers/Trial Quests/3895_Sleep Now in Sapphire.json
QuestPaths/quest-v1.json
Questionable.Model/Questing/ElementId.cs
Questionable.Model/Questing/QuestStep.cs
Questionable.Model/common-aethernetshard.json
Questionable.Model/common-aetheryte.json
Questionable.Model/common-classjob.json
Questionable.Model/common-completionflags.json
Questionable.Model/common-vector3.json
Questionable/Configuration.cs
Questionable/Controller/QuestRegistry.cs
Questionable/Controller/Steps/Common/SendNotification.cs
Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs
Questionable/Controller/Steps/Shared/WaitAtEnd.cs
Questionable/Data/JournalData.cs
Questionable/Data/TerritoryData.cs
Questionable/External/AutoDutyIpc.cs
Questionable/External/BossModIpc.cs
Questionable/External/QuestionableIpc.cs
Questionable/Model/QuestInfo.cs
Questionable/Model/SatisfactionSupplyInfo.cs
Questionable/QuestionablePlugin.cs
Questionable/Validation/EIssueType.cs
Questionable/Validation/Validators/UniqueSinglePlayerInstanceValidator.cs [new file with mode: 0644]
Questionable/Windows/QuestSelectionWindow.cs
global.json [new file with mode: 0644]

index 6553fdadd050f9802a5cc46dbf767d732e29f813..1fa3548f528d730644d2c7b860c90fe816d4433f 100644 (file)
@@ -274,7 +274,7 @@ public sealed class RendererPlugin : IDalamudPlugin
                             locationOverride?.MaximumDistance ?? x.CalculateMaximumDistance(),
                             minimumAngle, maximumAngle, color | 0xFF000000);
 
-                        drawList.AddText(x.Position, 0xFFFFFFFF, $"{location.Root.Groups.IndexOf(group)} // {node.DataId} / {node.Locations.IndexOf(x)} || {minimumAngle}, {maximumAngle}", 1f);
+                        drawList.AddText(x.Position, isUnsaved ? 0xFFFF0000 : 0xFFFFFFFF, $"{location.Root.Groups.IndexOf(group)} // {node.DataId} / {node.Locations.IndexOf(x)} || {minimumAngle}, {maximumAngle}", 1f);
 #if false
                         var a = GatheringMath.CalculateLandingLocation(x, 0, 0);
                         var b = GatheringMath.CalculateLandingLocation(x, 1, 1);
index ae0517d28f4f7646ae4baf64702950abd35acc48..ca5591bd64bdf3c396d237af131fc4f22eb7010c 100644 (file)
@@ -126,6 +126,9 @@ internal static class QuestStepExtensions
                             Assignment(nameof(QuestStep.BossModEnabled),
                                     step.BossModEnabled, emptyStep.BossModEnabled)
                                 .AsSyntaxNodeOrToken(),
+                            Assignment(nameof(QuestStep.SinglePlayerDutyIndex),
+                                    step.SinglePlayerDutyIndex, emptyStep.SinglePlayerDutyIndex)
+                                .AsSyntaxNodeOrToken(),
                             Assignment(nameof(QuestStep.SkipConditions), step.SkipConditions,
                                     emptyStep.SkipConditions)
                                 .AsSyntaxNodeOrToken(),
index d888625ae4dcc4533d7d1f472bb0a6326a52a8e6..f374ebd71b3ad17ea7d395a31b41ee0385defd06 100644 (file)
@@ -57,6 +57,7 @@
           },
           "TerritoryId": 153,
           "InteractionType": "SinglePlayerDuty",
+          "SinglePlayerDutyIndex": 1,
           "Fly": true
         }
       ]
index 847348ed5511e31bd26a2f70c4d89f6db2009976..c9af0007bc59e50552eb4d35806c1a78dd4f1a65 100644 (file)
@@ -62,6 +62,7 @@
           },\r
           "TerritoryId": 154,\r
           "InteractionType": "SinglePlayerDuty",\r
+          "SinglePlayerDutyIndex": 1,\r
           "AetheryteShortcut": "North Shroud - Fallgourd Float",\r
           "Fly": true\r
         }\r
index a9288d20880b06750f5a61ed117f6a64086ccb85..b588274ccd322dc2b58f544b63ffe06fb916fb9c 100644 (file)
             "Z": 29.06836\r
           },\r
           "TerritoryId": 152,\r
-          "InteractionType": "SinglePlayerDuty"\r
+          "InteractionType": "SinglePlayerDuty",\r
+          "SinglePlayerDutyIndex": 1\r
         }\r
       ]\r
     },\r
index 59a1d90608a9db6f61ea244b5aaf0b158ba199be..b8d8505f5a828f89659083942834d27ce0320dc1 100644 (file)
@@ -92,6 +92,7 @@
           },
           "TerritoryId": 130,
           "InteractionType": "SinglePlayerDuty",
+          "SinglePlayerDutyIndex": 1,
           "AetheryteShortcut": "Ul'dah"
         }
       ]
index dae6eb11616f52222b4163b0135df52ae1e500d0..f0598d7b43820bbc85eb608f88e3e119ad6c6e94 100644 (file)
@@ -96,7 +96,6 @@
           "TerritoryId": 138,
           "InteractionType": "SinglePlayerDuty",
           "Fly": true,
-          "ContentFinderConditionId": 393,
           "BossModEnabled": true
         }
       ]
index b4107b9986a72f4c13e852311de53f94e33ebeb4..10c0755f4e2e11f842b6c34ad4f1c83fe7f802c5 100644 (file)
@@ -59,7 +59,6 @@
           },
           "TerritoryId": 401,
           "InteractionType": "SinglePlayerDuty",
-          "ContentFinderConditionId": 395,
           "BossModEnabled": true
         }
       ]
index 238d8e8760859a46afec2e8ada1c2776ded8e956..e3a1d105dac4f17537948c9eb224775de764ddb4 100644 (file)
@@ -79,7 +79,6 @@
             "[Ishgard] The Forgotten Knight",
             "[Ishgard] The Tribunal"
           ],
-          "ContentFinderConditionId": 396,
           "BossModEnabled": true
         }
       ]
index a42a2a769f4f2b38b96d01b5fc0aefb9513e44ac..6e052e8b4264a71449bd94726eafd61912e2b7f4 100644 (file)
@@ -29,7 +29,6 @@
           },
           "TerritoryId": 145,
           "InteractionType": "SinglePlayerDuty",
-          "ContentFinderConditionId": 400,
           "BossModEnabled": true
         }
       ]
index b00c7cc02597803e747125081d6713736008474f..3503cfee9fd70a82e50d1df08a55493a3af67bd8 100644 (file)
@@ -79,7 +79,6 @@
           "TerritoryId": 397,
           "InteractionType": "SinglePlayerDuty",
           "DisableNavmesh": true,
-          "ContentFinderConditionId": 397,
           "BossModEnabled": true
         }
       ]
index d3573b34fe53ab8801e36b1ff9bea6e9eb877c8a..eb9876a68589f150dd45436bc8be8fd7c51fddbe 100644 (file)
@@ -75,7 +75,6 @@
           },
           "TerritoryId": 418,
           "InteractionType": "SinglePlayerDuty",
-          "ContentFinderConditionId": 398,
           "BossModEnabled": true
         }
       ]
index 0122bc84d5ad77c4665276012650754ccca6f512..b156029fd6d29dd8e5e46b0c924f330b276a701f 100644 (file)
@@ -57,7 +57,6 @@
           "InteractionType": "SinglePlayerDuty",
           "Emote": "lookout",
           "StopDistance": 0.25,
-          "ContentFinderConditionId": 401,
           "BossModEnabled": true
         }
       ]
index f3e3acdb50511eb59a71391a2a8986b3e809d9a2..31218140f582c0b002b1ac59c200295a31b194e6 100644 (file)
@@ -48,7 +48,6 @@
             "[Idyllshire] Aetheryte Plaza",
             "[Idyllshire] Epilogue Gate (Eastern Hinterlands)"
           ],
-          "ContentFinderConditionId": 422,
           "BossModEnabled": true
         }
       ]
index 792ea96132e9af888b496b2080ba82d927770cf9..a498f657c684b157e0fc84af3ef9167e54eb6e80 100644 (file)
@@ -69,7 +69,6 @@
           },
           "TerritoryId": 402,
           "InteractionType": "SinglePlayerDuty",
-          "ContentFinderConditionId": 399,
           "BossModEnabled": true
         }
       ]
index 2c5cc5169bd4102494f65012f3a4c073a497e0ca..160a2970dbc43bc24f50f410302320bc2f6b6207 100644 (file)
           "StopDistance": 5,
           "TerritoryId": 829,
           "InteractionType": "SinglePlayerDuty",
+          "SinglePlayerDutyIndex": 1,
           "DialogueChoices": [
             {
               "Type": "List",
index d078b4c83605cd6275b0e523bd571feb4c28dec5..350b0bf132670362aa61d1ef8303331573d5711c 100644 (file)
           },
           "then": {
             "properties": {
-              "ContentFinderConditionId": {
-                "type": "integer",
-                "exclusiveMinimum": 0,
-                "exclusiveMaximum": 3000
-              },
               "BossModEnabled": {
                 "type": "boolean"
+              },
+              "SinglePlayerDutyIndex": {
+                "type": "integer",
+                "minimum": 0,
+                "maximum": 1,
+                "description": "If a quest has multiple solo instances (which affects 5 quests total), indicates which one this is"
               }
             }
           }
index a553ff0d3448939c1e9bd624db22fa1daefdf0ee..6fce25ca1ec27c559afcf35f223eb6aa9425ae13 100644 (file)
@@ -91,6 +91,8 @@ public abstract class ElementId : IComparable<ElementId>, IEquatable<ElementId>
 
 public sealed class QuestId(ushort value) : ElementId(value)
 {
+    public static QuestId FromRowId(uint rowId) => new((ushort)(rowId & 0xFFFF));
+
     public override string ToString()
     {
         return Value.ToString(CultureInfo.InvariantCulture);
index fe7170d5b293fcf0d7fcc49b05a1cba4464a7681..98127cde775eb2eaebf50a9d554550eea4bc93fc 100644 (file)
@@ -76,6 +76,7 @@ public sealed class QuestStep
     public uint? ContentFinderConditionId { get; set; }
     public bool AutoDutyEnabled { get; set; }
     public bool BossModEnabled { get; set; }
+    public byte SinglePlayerDutyIndex { get; set; }
     public SkipConditions? SkipConditions { get; set; }
 
     public List<List<QuestWorkValue>?> RequiredQuestVariables { get; set; } = new();
index a2af4209e243b051997c7d16d7e486856401a30a..15a85c5f17257727614bbb084a087fd0579f26ab 100644 (file)
@@ -1,6 +1,6 @@
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "$id": "/liza/Questionable/raw/branch/master/Questionable.Model/common-aethernetshard.json",
+  "$id": "https://git.carvel.li/liza/Questionable/raw/branch/master/Questionable.Model/common-aethernetshard.json",
   "type": "string",
   "enum": [
     "[Gridania] Aetheryte Plaza",
index 6aa50781fe7b747c16761a167c05dd042737803a..8b033a830526bbcdfba8e71a788b129a1e20b5dd 100644 (file)
@@ -1,6 +1,6 @@
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "$id": "/liza/Questionable/raw/branch/master/Questionable.Model/common-aetheryte.json",
+  "$id": "https://git.carvel.li/liza/Questionable/raw/branch/master/Questionable.Model/common-aetheryte.json",
   "type": "string",
   "enum": [
     "Gridania",
index 5a7749396b22384d91faf2c91fcc998706a314c1..e5e0d3938eb40375fd05ae2063128a9fc554ca9e 100644 (file)
@@ -1,6 +1,6 @@
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "$id": "/liza/Questionable/raw/branch/master/Questionable.Model/common-classjob.json",
+  "$id": "https://git.carvel.li//liza/Questionable/raw/branch/master/Questionable.Model/common-classjob.json",
   "type": "string",
   "enum": [
     "Gladiator",
index eb77d70c72e1fa230a552e3dd30241f7a349c202..b7212b1dc4fcb7aacbc52390210ff870f8be7d68 100644 (file)
@@ -1,6 +1,6 @@
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "$id": "/liza/Questionable/raw/branch/master/Questionable.Model/common-completionflags.json",
+  "$id": "https://git.carvel.li//liza/Questionable/raw/branch/master/Questionable.Model/common-completionflags.json",
   "type": "array",
   "description": "Quest Variables that dictate whether or not this step is skipped: null is don't check, positive values need to be set, negative values need to be unset",
   "items": {
index cfae563795b03f53448996dfd20c354cbfa0c393..028af1c71e40fdc3e03d256c50af7d0239728891 100644 (file)
@@ -1,6 +1,6 @@
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "$id": "/liza/Questionable/raw/branch/master/Questionable.Model/common-vector3.json",
+  "$id": "https://git.carvel.li//liza/Questionable/raw/branch/master/Questionable.Model/common-vector3.json",
   "type": "object",
   "description": "Position in the world",
   "properties": {
index 90c42bb5031ac0d3fa0874b77613383833871b90..74bb05e6ef19743f7088898ae89e4b6dd4bb7bb4 100644 (file)
@@ -14,6 +14,7 @@ internal sealed class Configuration : IPluginConfiguration
     public int PluginSetupCompleteVersion { get; set; }
     public GeneralConfiguration General { get; } = new();
     public DutyConfiguration Duties { get; } = new();
+    public SoloDutyConfiguration SoloDuties { get; } = new();
     public NotificationConfiguration Notifications { get; } = new();
     public AdvancedConfiguration Advanced { get; } = new();
     public WindowConfig DebugWindowConfig { get; } = new();
@@ -41,6 +42,13 @@ internal sealed class Configuration : IPluginConfiguration
         public HashSet<uint> BlacklistedDutyCfcIds { get; set; } = [];
     }
 
+    internal sealed class SoloDutyConfiguration
+    {
+        public bool RunSoloInstancesWithBossMod { get; set; }
+        public HashSet<uint> WhitelistedSoloDutyCfcIds { get; set; } = [];
+        public HashSet<uint> BlacklistedSoloDutyCfcIds { get; set; } = [];
+    }
+
     internal sealed class NotificationConfiguration
     {
         public bool Enabled { get; set; } = true;
index 2c38b2410c37552a11b60c2d64562c126f32a985..5e948761dafde60c75475c37569eafd6b4970607 100644 (file)
@@ -27,6 +27,7 @@ internal sealed class QuestRegistry
     private readonly JsonSchemaValidator _jsonSchemaValidator;
     private readonly ILogger<QuestRegistry> _logger;
     private readonly LeveData _leveData;
+    private readonly TerritoryData _territoryData;
 
     private readonly ICallGateProvider<object> _reloadDataIpc;
     private readonly Dictionary<ElementId, Quest> _quests = [];
@@ -34,7 +35,7 @@ internal sealed class QuestRegistry
 
     public QuestRegistry(IDalamudPluginInterface pluginInterface, QuestData questData,
         QuestValidator questValidator, JsonSchemaValidator jsonSchemaValidator,
-        ILogger<QuestRegistry> logger, LeveData leveData)
+        ILogger<QuestRegistry> logger, LeveData leveData, TerritoryData territoryData)
     {
         _pluginInterface = pluginInterface;
         _questData = questData;
@@ -42,6 +43,7 @@ internal sealed class QuestRegistry
         _jsonSchemaValidator = jsonSchemaValidator;
         _logger = logger;
         _leveData = leveData;
+        _territoryData = territoryData;
         _reloadDataIpc = _pluginInterface.GetIpcProvider<object>("Questionable.ReloadData");
     }
 
@@ -150,10 +152,15 @@ internal sealed class QuestRegistry
         foreach (var quest in _quests.Values)
         {
             foreach (var dutyStep in quest.AllSteps().Where(x =>
-                         x.Step.InteractionType is EInteractionType.Duty or EInteractionType.SinglePlayerDuty
-                         && x.Step.ContentFinderConditionId != null))
+                         x.Step.InteractionType is EInteractionType.Duty or EInteractionType.SinglePlayerDuty))
             {
-                _contentFinderConditionIds[dutyStep.Step.ContentFinderConditionId!.Value] = (quest.Id, dutyStep.Step);
+                if (dutyStep.Step is { InteractionType: EInteractionType.Duty, ContentFinderConditionId: not null })
+                    _contentFinderConditionIds[dutyStep.Step.ContentFinderConditionId!.Value] =
+                        (quest.Id, dutyStep.Step);
+                else if (dutyStep.Step.InteractionType == EInteractionType.SinglePlayerDuty &&
+                         _territoryData.TryGetContentFinderConditionForSoloInstance(quest.Id,
+                             dutyStep.Step.SinglePlayerDutyIndex, out var cfcData))
+                    _contentFinderConditionIds[cfcData.ContentFinderConditionId] = (quest.Id, dutyStep.Step);
             }
         }
     }
index 8bb4fa80392f86bdc7a78459dd2ba8453f89e1e6..b2d146f0018122d169e0de9c4b0c9c0b7f1ab01b 100644 (file)
@@ -14,6 +14,7 @@ internal static class SendNotification
     internal sealed class Factory(
         AutomatonIpc automatonIpc,
         AutoDutyIpc autoDutyIpc,
+        BossModIpc bossModIpc,
         TerritoryData territoryData) : SimpleTaskFactory
     {
         public override ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
@@ -26,7 +27,7 @@ internal static class SendNotification
                     new Task(step.InteractionType, step.ContentFinderConditionId.HasValue
                         ? territoryData.GetContentFinderCondition(step.ContentFinderConditionId.Value)?.Name
                         : step.Comment),
-                EInteractionType.SinglePlayerDuty when !step.BossModEnabled =>
+                EInteractionType.SinglePlayerDuty when !bossModIpc.IsConfiguredToRunSoloInstance(quest.Id, step.SinglePlayerDutyIndex, step.BossModEnabled) =>
                     new Task(step.InteractionType, quest.Info.Name),
                 _ => null,
             };
index 4cd79cec9771aa03dd0ea52cae2eede6b2482e31..b8bf39fe621ecf25625d0e9dc34efa969569324f 100644 (file)
@@ -1,6 +1,7 @@
 using System;
 using System.Collections.Generic;
 using Dalamud.Plugin.Services;
+using FFXIVClientStructs.FFXIV.Client.Game;
 using Questionable.Controller.Steps.Shared;
 using Questionable.Data;
 using Questionable.External;
@@ -11,20 +12,23 @@ namespace Questionable.Controller.Steps.Interactions;
 
 internal static class SinglePlayerDuty
 {
-    internal sealed class Factory : ITaskFactory
+    internal sealed class Factory(
+        BossModIpc bossModIpc,
+        TerritoryData territoryData) : ITaskFactory
     {
         public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
         {
             if (step.InteractionType != EInteractionType.SinglePlayerDuty)
                 yield break;
 
-            if (step.BossModEnabled)
+            if (bossModIpc.IsConfiguredToRunSoloInstance(quest.Id, step.SinglePlayerDutyIndex, step.BossModEnabled))
             {
-                ArgumentNullException.ThrowIfNull(step.ContentFinderConditionId);
+                if (!territoryData.TryGetContentFinderConditionForSoloInstance(quest.Id, step.SinglePlayerDutyIndex, out var cfcData))
+                    throw new TaskException("Failed to get content finder condition for solo instance");
 
-                yield return new StartSinglePlayerDuty(step.ContentFinderConditionId.Value);
+                yield return new StartSinglePlayerDuty(cfcData.ContentFinderConditionId);
                 yield return new EnableAi();
-                yield return new WaitSinglePlayerDuty(step.ContentFinderConditionId.Value);
+                yield return new WaitSinglePlayerDuty(cfcData.ContentFinderConditionId);
                 yield return new DisableAi();
                 yield return new WaitAtEnd.WaitNextStepOrSequence();
             }
@@ -36,19 +40,13 @@ internal static class SinglePlayerDuty
         public override string ToString() => $"Wait(BossMod, entered instance {ContentFinderConditionId})";
     }
 
-    internal sealed class StartSinglePlayerDutyExecutor(
-        TerritoryData territoryData,
-        IClientState clientState) : TaskExecutor<StartSinglePlayerDuty>
+    internal sealed class StartSinglePlayerDutyExecutor : TaskExecutor<StartSinglePlayerDuty>
     {
         protected override bool Start() => true;
 
-        public override ETaskResult Update()
+        public override unsafe ETaskResult Update()
         {
-            if (!territoryData.TryGetContentFinderCondition(Task.ContentFinderConditionId,
-                    out var cfcData))
-                throw new TaskException("Failed to get territory ID for content finder condition");
-
-            return clientState.TerritoryType == cfcData.TerritoryId
+            return GameMain.Instance()->CurrentContentFinderConditionId == Task.ContentFinderConditionId
                 ? ETaskResult.TaskComplete
                 : ETaskResult.StillRunning;
         }
@@ -81,19 +79,13 @@ internal static class SinglePlayerDuty
     }
 
     internal sealed class WaitSinglePlayerDutyExecutor(
-        TerritoryData territoryData,
-        IClientState clientState,
         BossModIpc bossModIpc) : TaskExecutor<WaitSinglePlayerDuty>, IStoppableTaskExecutor
     {
         protected override bool Start() => true;
 
-        public override ETaskResult Update()
+        public override unsafe ETaskResult Update()
         {
-            if (!territoryData.TryGetContentFinderCondition(Task.ContentFinderConditionId,
-                 out var cfcData))
-            throw new TaskException("Failed to get territory ID for content finder condition");
-
-            return clientState.TerritoryType != cfcData.TerritoryId
+            return GameMain.Instance()->CurrentContentFinderConditionId != Task.ContentFinderConditionId
                 ? ETaskResult.TaskComplete
                 : ETaskResult.StillRunning;
         }
index 59d108cdd195cda887f905bc1438f9b46858d8b2..1476ed3c4a66141ca8acef22a10d58d7f5606966 100644 (file)
@@ -21,7 +21,8 @@ internal static class WaitAtEnd
         IClientState clientState,
         ICondition condition,
         TerritoryData territoryData,
-        AutoDutyIpc autoDutyIpc)
+        AutoDutyIpc autoDutyIpc,
+        BossModIpc bossModIpc)
         : ITaskFactory
     {
         public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
@@ -53,7 +54,7 @@ internal static class WaitAtEnd
                     return [new WaitNextStepOrSequence()];
 
                 case EInteractionType.Duty when !autoDutyIpc.IsConfiguredToRunContent(step.ContentFinderConditionId, step.AutoDutyEnabled):
-                case EInteractionType.SinglePlayerDuty when !step.BossModEnabled:
+                case EInteractionType.SinglePlayerDuty when !bossModIpc.IsConfiguredToRunSoloInstance(quest.Id, step.SinglePlayerDutyIndex, step.BossModEnabled):
                     return [new EndAutomation()];
 
                 case EInteractionType.WalkTo:
index c2983fe565928b8f8cf631baadb6de642ffdb5af..80f7560bdd07a55cbc9762750406017590905bba 100644 (file)
@@ -23,17 +23,17 @@ internal sealed class JournalData
         var genreLimsa = new Genre(uint.MaxValue - 3, "Starting in Limsa Lominsa", 1,
             new uint[] { 108, 109 }.Concat(limsaStart.QuestRedoParam.Select(x => x.Quest.RowId))
                 .Where(x => x != 0)
-                .Select(x => questData.GetQuestInfo(new QuestId((ushort)(x & 0xFFFF))))
+                .Select(x => questData.GetQuestInfo(QuestId.FromRowId(x)))
                 .ToList());
         var genreGridania = new Genre(uint.MaxValue - 2, "Starting in Gridania", 1,
             new uint[] { 85, 123, 124 }.Concat(gridaniaStart.QuestRedoParam.Select(x => x.Quest.RowId))
                 .Where(x => x != 0)
-                .Select(x => questData.GetQuestInfo(new QuestId((ushort)(x & 0xFFFF))))
+                .Select(x => questData.GetQuestInfo(QuestId.FromRowId(x)))
                 .ToList());
         var genreUldah = new Genre(uint.MaxValue - 1, "Starting in Ul'dah", 1,
             new uint[] { 568, 569, 570 }.Concat(uldahStart.QuestRedoParam.Select(x => x.Quest.RowId))
                 .Where(x => x != 0)
-                .Select(x => questData.GetQuestInfo(new QuestId((ushort)(x & 0xFFFF))))
+                .Select(x => questData.GetQuestInfo(QuestId.FromRowId(x)))
                 .ToList());
         genres.InsertRange(0, [genreLimsa, genreGridania, genreUldah]);
         genres.Single(x => x.Id == 1)
index 820ae65655667cc0488c1749665a9b5c20ca12f2..c57ada43a09d5b9ff1d4a11b8d77dba48a5d85c8 100644 (file)
@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.Collections.Immutable;
 using System.Diagnostics.CodeAnalysis;
 using System.Globalization;
@@ -7,6 +8,7 @@ using Dalamud.Game;
 using Dalamud.Plugin.Services;
 using Dalamud.Utility;
 using Lumina.Excel.Sheets;
+using Questionable.Model.Questing;
 
 namespace Questionable.Data;
 
@@ -17,6 +19,7 @@ internal sealed class TerritoryData
     private readonly ImmutableDictionary<ushort, uint> _dutyTerritories;
     private readonly ImmutableDictionary<uint, string> _instanceNames;
     private readonly ImmutableDictionary<uint, ContentFinderConditionData> _contentFinderConditions;
+    private readonly ImmutableDictionary<(ElementId QuestId, byte Index), uint> _questsToCfc;
 
     public TerritoryData(IDataManager dataManager)
     {
@@ -48,6 +51,13 @@ internal sealed class TerritoryData
             .Where(x => x.RowId > 0 && x.Content.RowId != 0 && x.ContentLinkType is 1 or 5 && x.ContentType.RowId != 6)
             .Select(x => new ContentFinderConditionData(x, dataManager.Language))
             .ToImmutableDictionary(x => x.ContentFinderConditionId, x => x);
+
+        _questsToCfc = dataManager.GetExcelSheet<Quest>()
+            .Where(x => x is { RowId: > 0, IssuerLocation.RowId: > 0 })
+            .SelectMany(GetQuestBattles)
+            .Select(x => (x.QuestId, x.Index,
+                CfcId: LookupContentFinderConditionForQuestBattle(dataManager, x.QuestBattleId)))
+            .ToImmutableDictionary(x => (x.QuestId, x.Index), x => x.CfcId);
     }
 
     public string? GetName(ushort territoryId) => _territoryNames.GetValueOrDefault(territoryId);
@@ -77,6 +87,18 @@ internal sealed class TerritoryData
         [NotNullWhen(true)] out ContentFinderConditionData? contentFinderConditionData) =>
         _contentFinderConditions.TryGetValue(cfcId, out contentFinderConditionData);
 
+    public bool TryGetContentFinderConditionForSoloInstance(ElementId questId, byte index,
+        [NotNullWhen(true)] out ContentFinderConditionData? contentFinderConditionData)
+    {
+        if (_questsToCfc.TryGetValue((questId, index), out uint cfcId))
+            return _contentFinderConditions.TryGetValue(cfcId, out contentFinderConditionData);
+        else
+        {
+            contentFinderConditionData = null;
+            return false;
+        }
+    }
+
     private static string FixName(string name, ClientLanguage language)
     {
         if (string.IsNullOrEmpty(name) || language != ClientLanguage.English)
@@ -85,6 +107,27 @@ internal sealed class TerritoryData
         return string.Concat(name[0].ToString().ToUpper(CultureInfo.InvariantCulture), name.AsSpan(1));
     }
 
+    private static IEnumerable<(ElementId QuestId, byte Index, uint QuestBattleId)> GetQuestBattles(Quest quest)
+    {
+        foreach (Quest.QuestParamsStruct t in quest.QuestParams)
+        {
+            if (t.ScriptInstruction == "QUESTBATTLE0")
+                yield return (QuestId.FromRowId(quest.RowId), 0, t.ScriptArg);
+            else if (t.ScriptInstruction == "QUESTBATTLE1")
+                yield return (QuestId.FromRowId(quest.RowId), 1, t.ScriptArg);
+            else if (t.ScriptInstruction.IsEmpty)
+                break;
+        }
+    }
+
+    private static uint LookupContentFinderConditionForQuestBattle(IDataManager dataManager, uint questBattleId)
+    {
+        if (questBattleId >= 5000)
+            return dataManager.GetExcelSheet<InstanceContent>().GetRow(questBattleId).Order;
+        else
+            return dataManager.GetExcelSheet<QuestBattleResident>().GetRow(questBattleId).Unknown0;
+    }
+
     public sealed record ContentFinderConditionData(
         uint ContentFinderConditionId,
         string Name,
index 8d9a84828ca0783c8cedd540bee2603046d351af..67ea9fbddc61606a5a8139df9a83ad753ecf0ca8 100644 (file)
@@ -31,7 +31,7 @@ internal sealed class AutoDutyIpc
         _stop = pluginInterface.GetIpcSubscriber<object>("AutoDuty.Stop");
     }
 
-    public bool IsConfiguredToRunContent(uint? cfcId, bool autoDutyEnabled)
+    public bool IsConfiguredToRunContent(uint? cfcId, bool enabledByDefault)
     {
         if (cfcId == null)
             return false;
@@ -46,7 +46,7 @@ internal sealed class AutoDutyIpc
             _territoryData.TryGetContentFinderCondition(cfcId.Value, out _))
             return true;
 
-        return autoDutyEnabled && HasPath(cfcId.Value);
+        return enabledByDefault && HasPath(cfcId.Value);
     }
 
     public bool HasPath(uint cfcId)
index d1d02f794aadef98993efe1a3c2e4078afb89e5d..82a3de68ddad3d33995b64a7ea033609ff9eec63 100644 (file)
@@ -2,22 +2,33 @@ using Dalamud.Plugin;
 using Dalamud.Plugin.Ipc;
 using Dalamud.Plugin.Ipc.Exceptions;
 using Dalamud.Plugin.Services;
+using Questionable.Data;
+using Questionable.Model.Questing;
 
 namespace Questionable.External;
 
 internal sealed class BossModIpc
 {
-    private readonly ICommandManager _commandManager;
     private const string Name = "BossMod";
 
+    private readonly Configuration _configuration;
+    private readonly ICommandManager _commandManager;
+    private readonly TerritoryData _territoryData;
     private readonly ICallGateSubscriber<string, string?> _getPreset;
     private readonly ICallGateSubscriber<string, bool, bool> _createPreset;
     private readonly ICallGateSubscriber<string, bool> _setPreset;
     private readonly ICallGateSubscriber<bool> _clearPreset;
 
-    public BossModIpc(IDalamudPluginInterface pluginInterface, ICommandManager commandManager)
+    public BossModIpc(
+        IDalamudPluginInterface pluginInterface,
+        Configuration configuration,
+        ICommandManager commandManager,
+        TerritoryData territoryData)
     {
+        _configuration = configuration;
         _commandManager = commandManager;
+        _territoryData = territoryData;
+
         _getPreset = pluginInterface.GetIpcSubscriber<string, string?>($"{Name}.Presets.Get");
         _createPreset = pluginInterface.GetIpcSubscriber<string, bool, bool>($"{Name}.Presets.Create");
         _setPreset = pluginInterface.GetIpcSubscriber<string, bool>($"{Name}.Presets.SetActive");
@@ -70,4 +81,21 @@ internal sealed class BossModIpc
         _commandManager.ProcessCommand("/vbm cfg ZoneModuleConfig EnableQuestBattles false");
         ClearPreset();
     }
+
+    public bool IsConfiguredToRunSoloInstance(ElementId questId, byte dutyIndex, bool enabledByDefault)
+    {
+        if (!_configuration.SoloDuties.RunSoloInstancesWithBossMod)
+            return false;
+
+        if (!_territoryData.TryGetContentFinderConditionForSoloInstance(questId, dutyIndex, out var cfcData))
+            return false;
+
+        if (_configuration.SoloDuties.BlacklistedSoloDutyCfcIds.Contains(cfcData.ContentFinderConditionId))
+            return false;
+
+        if (_configuration.SoloDuties.WhitelistedSoloDutyCfcIds.Contains(cfcData.ContentFinderConditionId))
+            return true;
+
+        return enabledByDefault;
+    }
 }
index 2b80d66a80352bf70b478123a11bdc204d72c703..0ff2b1836a829a5f8d72ae8ed0b6db5f8e972ef0 100644 (file)
@@ -41,10 +41,10 @@ internal sealed class QuestionableIpc : IDisposable
             eventInfoComponent.GetCurrentlyActiveEventQuests().Select(q => q.ToString()).ToList());
 
         _startQuest = pluginInterface.GetIpcProvider<string, bool>(IpcStartQuest);
-        _startQuest.RegisterFunc((string questId) => StartQuest(questController, questRegistry, questId, false));
+        _startQuest.RegisterFunc((questId) => StartQuest(questController, questRegistry, questId, false));
 
         _startSingleQuest = pluginInterface.GetIpcProvider<string, bool>(IpcStartSingleQuest);
-        _startSingleQuest.RegisterFunc((string questId) => StartQuest(questController, questRegistry, questId, true));
+        _startSingleQuest.RegisterFunc((questId) => StartQuest(questController, questRegistry, questId, true));
     }
 
     private static bool StartQuest(QuestController qc, QuestRegistry qr, string questId, bool single)
@@ -63,6 +63,7 @@ internal sealed class QuestionableIpc : IDisposable
 
     public void Dispose()
     {
+        _startSingleQuest.UnregisterFunc();
         _startQuest.UnregisterFunc();
         _getCurrentlyActiveEventQuests.UnregisterFunc();
         _getCurrentQuestId.UnregisterFunc();
index 5f261275de5a9558480246b5da07854cc4700567..b7efde8775cecb68eef6b6540d940a6a1fa06109 100644 (file)
@@ -7,6 +7,7 @@ using Lumina.Excel.Sheets;
 using Questionable.Model.Questing;
 using ExcelQuest = Lumina.Excel.Sheets.Quest;
 using GrandCompany = FFXIVClientStructs.FFXIV.Client.UI.Agent.GrandCompany;
+using QQuestId = Questionable.Model.Questing.QuestId;
 
 namespace Questionable.Model;
 
@@ -14,7 +15,7 @@ internal sealed class QuestInfo : IQuestInfo
 {
     public QuestInfo(ExcelQuest quest, uint newGamePlusChapter, byte startingCity, JournalGenreOverrides journalGenreOverrides)
     {
-        QuestId = new QuestId((ushort)(quest.RowId & 0xFFFF));
+        QuestId = QQuestId.FromRowId(quest.RowId);
 
         string suffix = QuestId.Value switch
         {
@@ -41,15 +42,15 @@ internal sealed class QuestInfo : IQuestInfo
         PreviousQuests =
             new List<PreviousQuestInfo>
                 {
-                    new(ReplaceOldQuestIds((ushort)(quest.PreviousQuest[0].RowId & 0xFFFF)), quest.Unknown7),
-                    new(ReplaceOldQuestIds((ushort)(quest.PreviousQuest[1].RowId & 0xFFFF))),
-                    new(ReplaceOldQuestIds((ushort)(quest.PreviousQuest[2].RowId & 0xFFFF)))
+                    new(ReplaceOldQuestIds(QQuestId.FromRowId(quest.PreviousQuest[0].RowId)), quest.Unknown7),
+                    new(ReplaceOldQuestIds(QQuestId.FromRowId(quest.PreviousQuest[1].RowId))),
+                    new(ReplaceOldQuestIds(QQuestId.FromRowId(quest.PreviousQuest[2].RowId)))
                 }
                 .Where(x => x.QuestId.Value != 0)
                 .ToImmutableList();
         PreviousQuestJoin = (EQuestJoin)quest.PreviousQuestJoin;
         QuestLocks = quest.QuestLock
-            .Select(x => new QuestId((ushort)(x.RowId & 0xFFFFF)))
+            .Select(x => QQuestId.FromRowId(x.RowId))
             .Where(x => x.Value != 0)
             .ToImmutableList();
         QuestLockJoin = (EQuestJoin)quest.QuestLockJoin;
@@ -85,13 +86,13 @@ internal sealed class QuestInfo : IQuestInfo
         Expansion = (EExpansionVersion)quest.Expansion.RowId;
     }
 
-    private static QuestId ReplaceOldQuestIds(ushort questId)
+    private static QuestId ReplaceOldQuestIds(QuestId questId)
     {
-        return new QuestId(questId switch
+        return questId.Value switch
         {
-            524 => 4522,
+            524 => new QuestId(4522),
             _ => questId,
-        });
+        };
     }
 
     public ElementId QuestId { get; }
index 21c92936406160da13adaa4eb2852acb4db75f10..b1bba4a4b406671dec49e8ac0b8bc151b047ab7a 100644 (file)
@@ -3,6 +3,7 @@ using System.Collections.Immutable;
 using LLib.GameData;
 using Lumina.Excel.Sheets;
 using Questionable.Model.Questing;
+using QQuestId = Questionable.Model.Questing.QuestId;
 
 namespace Questionable.Model;
 
@@ -16,7 +17,7 @@ internal sealed class SatisfactionSupplyInfo : IQuestInfo
         Level = npc.LevelUnlock;
         SortKey = QuestId.Value;
         Expansion = (EExpansionVersion)npc.QuestRequired.Value.Expansion.RowId;
-        PreviousQuests = [new PreviousQuestInfo(new QuestId((ushort)(npc.QuestRequired.RowId & 0xFFFF)))];
+        PreviousQuests = [new PreviousQuestInfo(QQuestId.FromRowId(npc.QuestRequired.RowId))];
     }
 
     public ElementId QuestId { get; }
index 28d1f66b6f571b6fc54f1d1c40bdf35fc2a387ec..ccd694af46f2decb800c1117e66efa6603d7c6e5 100644 (file)
@@ -311,6 +311,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddSingleton<IQuestValidator, AethernetShortcutValidator>();
         serviceCollection.AddSingleton<IQuestValidator, DialogueChoiceValidator>();
         serviceCollection.AddSingleton<IQuestValidator, ClassQuestShouldHaveShortcutValidator>();
+        serviceCollection.AddSingleton<IQuestValidator, UniqueSinglePlayerInstanceValidator>();
         serviceCollection.AddSingleton<JsonSchemaValidator>();
         serviceCollection.AddSingleton<IQuestValidator>(sp => sp.GetRequiredService<JsonSchemaValidator>());
     }
index 755124814be1cba8adccb48e34f02c3fb3e4e093..4d41f9970e9752ce08944c78fbe1648a0409902f 100644 (file)
@@ -18,4 +18,5 @@ public enum EIssueType
     InvalidAethernetShortcut,
     InvalidExcelRef,
     ClassQuestWithoutAetheryteShortcut,
+    DuplicateSinglePlayerInstance,
 }
diff --git a/Questionable/Validation/Validators/UniqueSinglePlayerInstanceValidator.cs b/Questionable/Validation/Validators/UniqueSinglePlayerInstanceValidator.cs
new file mode 100644 (file)
index 0000000..684e876
--- /dev/null
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+using System.Linq;
+using Questionable.Model;
+using Questionable.Model.Questing;
+
+namespace Questionable.Validation.Validators;
+
+internal sealed class UniqueSinglePlayerInstanceValidator : IQuestValidator
+{
+    public IEnumerable<ValidationIssue> Validate(Quest quest)
+    {
+        var singlePlayerInstances = quest.AllSteps()
+            .Where(x => x.Step.InteractionType == EInteractionType.SinglePlayerDuty)
+            .Select(x => (x.Sequence, x.StepId, x.Step.SinglePlayerDutyIndex))
+            .ToList();
+        if (singlePlayerInstances.DistinctBy(x => x.SinglePlayerDutyIndex).Count() < singlePlayerInstances.Count)
+        {
+            foreach (var singlePlayerInstance in singlePlayerInstances)
+            {
+                yield return new ValidationIssue
+                {
+                    ElementId = quest.Id,
+                    Sequence = (byte)singlePlayerInstance.Sequence.Sequence,
+                    Step = singlePlayerInstance.StepId,
+                    Type = EIssueType.DuplicateSinglePlayerInstance,
+                    Severity = EIssueSeverity.Error,
+                    Description = $"Duplicate singleplayer duty index: {singlePlayerInstance.SinglePlayerDutyIndex}",
+                };
+            }
+        }
+    }
+}
index a53b79e9dbba4f883d24b40120c970c39455da87..481c5f1a04596bd2ea2ca085ba38a59598448ebe 100644 (file)
@@ -119,7 +119,7 @@ internal sealed class QuestSelectionWindow : LWindow
 
         foreach (var unacceptedQuest in Map.Instance()->UnacceptedQuestMarkers)
         {
-            QuestId questId = new QuestId((ushort)(unacceptedQuest.ObjectiveId & 0xFFFF));
+            QuestId questId = QuestId.FromRowId(unacceptedQuest.ObjectiveId);
             if (_quests.All(q => q.QuestId != questId))
                 _quests.Add(_questData.GetQuestInfo(questId));
         }
diff --git a/global.json b/global.json
new file mode 100644 (file)
index 0000000..2ddda36
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "sdk": {
+    "version": "8.0.0",
+    "rollForward": "latestMinor",
+    "allowPrerelease": false
+  }
+}
\ No newline at end of file