From 097c67ed5df428d880311c575d36b3eadd428f6a Mon Sep 17 00:00:00 2001 From: Liza Carvelli Date: Thu, 20 Feb 2025 20:45:38 +0100 Subject: [PATCH] Second draft for auto-completing quest battles --- GatheringPathRenderer/RendererPlugin.cs | 2 +- .../RoslynElements/QuestStepExtensions.cs | 3 ++ .../BRD/76_The One That Got Away.json | 1 + .../Class Quests/DRG/439_Proof of Might.json | 1 + .../Class Quests/DRG/56_Lance of Destiny.json | 3 +- .../MNK/567_Return of the Holyfist.json | 1 + .../Class Quests/WAR/601_And My Axe.json | 1 - .../1595_A Series of Unfortunate Events.json | 1 - .../1597_Divine Intervention.json | 1 - .../1601_Keeping the Flame Alive.json | 1 - .../1606_Sounding Out the Amphitheatre.json | 1 - .../MSQ/A4-Ishgard/1639_Fire and Blood.json | 1 - .../A5-Sea of Clouds/1644_Familiar Faces.json | 1 - .../1657_An Illuminati Incident.json | 1 - ...667_Close Encounters of the VIth Kind.json | 1 - .../3895_Sleep Now in Sapphire.json | 1 + QuestPaths/quest-v1.json | 11 ++--- Questionable.Model/Questing/ElementId.cs | 2 + Questionable.Model/Questing/QuestStep.cs | 1 + Questionable.Model/common-aethernetshard.json | 2 +- Questionable.Model/common-aetheryte.json | 2 +- Questionable.Model/common-classjob.json | 2 +- .../common-completionflags.json | 2 +- Questionable.Model/common-vector3.json | 2 +- Questionable/Configuration.cs | 8 ++++ Questionable/Controller/QuestRegistry.cs | 15 +++++-- .../Steps/Common/SendNotification.cs | 3 +- .../Steps/Interactions/SinglePlayerDuty.cs | 36 ++++++---------- .../Controller/Steps/Shared/WaitAtEnd.cs | 5 ++- Questionable/Data/JournalData.cs | 6 +-- Questionable/Data/TerritoryData.cs | 43 +++++++++++++++++++ Questionable/External/AutoDutyIpc.cs | 4 +- Questionable/External/BossModIpc.cs | 32 +++++++++++++- Questionable/External/QuestionableIpc.cs | 5 ++- Questionable/Model/QuestInfo.cs | 19 ++++---- Questionable/Model/SatisfactionSupplyInfo.cs | 3 +- Questionable/QuestionablePlugin.cs | 1 + Questionable/Validation/EIssueType.cs | 1 + .../UniqueSinglePlayerInstanceValidator.cs | 32 ++++++++++++++ Questionable/Windows/QuestSelectionWindow.cs | 2 +- global.json | 7 +++ 41 files changed, 197 insertions(+), 70 deletions(-) create mode 100644 Questionable/Validation/Validators/UniqueSinglePlayerInstanceValidator.cs create mode 100644 global.json diff --git a/GatheringPathRenderer/RendererPlugin.cs b/GatheringPathRenderer/RendererPlugin.cs index 6553fdad..1fa3548f 100644 --- a/GatheringPathRenderer/RendererPlugin.cs +++ b/GatheringPathRenderer/RendererPlugin.cs @@ -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); diff --git a/QuestPathGenerator/RoslynElements/QuestStepExtensions.cs b/QuestPathGenerator/RoslynElements/QuestStepExtensions.cs index ae0517d2..ca5591bd 100644 --- a/QuestPathGenerator/RoslynElements/QuestStepExtensions.cs +++ b/QuestPathGenerator/RoslynElements/QuestStepExtensions.cs @@ -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(), diff --git a/QuestPaths/2.x - A Realm Reborn/Class Quests/BRD/76_The One That Got Away.json b/QuestPaths/2.x - A Realm Reborn/Class Quests/BRD/76_The One That Got Away.json index d888625a..f374ebd7 100644 --- a/QuestPaths/2.x - A Realm Reborn/Class Quests/BRD/76_The One That Got Away.json +++ b/QuestPaths/2.x - A Realm Reborn/Class Quests/BRD/76_The One That Got Away.json @@ -57,6 +57,7 @@ }, "TerritoryId": 153, "InteractionType": "SinglePlayerDuty", + "SinglePlayerDutyIndex": 1, "Fly": true } ] diff --git a/QuestPaths/2.x - A Realm Reborn/Class Quests/DRG/439_Proof of Might.json b/QuestPaths/2.x - A Realm Reborn/Class Quests/DRG/439_Proof of Might.json index 847348ed..c9af0007 100644 --- a/QuestPaths/2.x - A Realm Reborn/Class Quests/DRG/439_Proof of Might.json +++ b/QuestPaths/2.x - A Realm Reborn/Class Quests/DRG/439_Proof of Might.json @@ -62,6 +62,7 @@ }, "TerritoryId": 154, "InteractionType": "SinglePlayerDuty", + "SinglePlayerDutyIndex": 1, "AetheryteShortcut": "North Shroud - Fallgourd Float", "Fly": true } diff --git a/QuestPaths/2.x - A Realm Reborn/Class Quests/DRG/56_Lance of Destiny.json b/QuestPaths/2.x - A Realm Reborn/Class Quests/DRG/56_Lance of Destiny.json index a9288d20..b588274c 100644 --- a/QuestPaths/2.x - A Realm Reborn/Class Quests/DRG/56_Lance of Destiny.json +++ b/QuestPaths/2.x - A Realm Reborn/Class Quests/DRG/56_Lance of Destiny.json @@ -119,7 +119,8 @@ "Z": 29.06836 }, "TerritoryId": 152, - "InteractionType": "SinglePlayerDuty" + "InteractionType": "SinglePlayerDuty", + "SinglePlayerDutyIndex": 1 } ] }, diff --git a/QuestPaths/2.x - A Realm Reborn/Class Quests/MNK/567_Return of the Holyfist.json b/QuestPaths/2.x - A Realm Reborn/Class Quests/MNK/567_Return of the Holyfist.json index 59a1d906..b8d8505f 100644 --- a/QuestPaths/2.x - A Realm Reborn/Class Quests/MNK/567_Return of the Holyfist.json +++ b/QuestPaths/2.x - A Realm Reborn/Class Quests/MNK/567_Return of the Holyfist.json @@ -92,6 +92,7 @@ }, "TerritoryId": 130, "InteractionType": "SinglePlayerDuty", + "SinglePlayerDutyIndex": 1, "AetheryteShortcut": "Ul'dah" } ] diff --git a/QuestPaths/3.x - Heavensward/Class Quests/WAR/601_And My Axe.json b/QuestPaths/3.x - Heavensward/Class Quests/WAR/601_And My Axe.json index dae6eb11..f0598d7b 100644 --- a/QuestPaths/3.x - Heavensward/Class Quests/WAR/601_And My Axe.json +++ b/QuestPaths/3.x - Heavensward/Class Quests/WAR/601_And My Axe.json @@ -96,7 +96,6 @@ "TerritoryId": 138, "InteractionType": "SinglePlayerDuty", "Fly": true, - "ContentFinderConditionId": 393, "BossModEnabled": true } ] diff --git a/QuestPaths/3.x - Heavensward/MSQ/A1-Coerthas Western Highlands 1, Sea of Clouds 1/1595_A Series of Unfortunate Events.json b/QuestPaths/3.x - Heavensward/MSQ/A1-Coerthas Western Highlands 1, Sea of Clouds 1/1595_A Series of Unfortunate Events.json index b4107b99..10c0755f 100644 --- a/QuestPaths/3.x - Heavensward/MSQ/A1-Coerthas Western Highlands 1, Sea of Clouds 1/1595_A Series of Unfortunate Events.json +++ b/QuestPaths/3.x - Heavensward/MSQ/A1-Coerthas Western Highlands 1, Sea of Clouds 1/1595_A Series of Unfortunate Events.json @@ -59,7 +59,6 @@ }, "TerritoryId": 401, "InteractionType": "SinglePlayerDuty", - "ContentFinderConditionId": 395, "BossModEnabled": true } ] diff --git a/QuestPaths/3.x - Heavensward/MSQ/A1-Coerthas Western Highlands 1, Sea of Clouds 1/1597_Divine Intervention.json b/QuestPaths/3.x - Heavensward/MSQ/A1-Coerthas Western Highlands 1, Sea of Clouds 1/1597_Divine Intervention.json index 238d8e87..e3a1d105 100644 --- a/QuestPaths/3.x - Heavensward/MSQ/A1-Coerthas Western Highlands 1, Sea of Clouds 1/1597_Divine Intervention.json +++ b/QuestPaths/3.x - Heavensward/MSQ/A1-Coerthas Western Highlands 1, Sea of Clouds 1/1597_Divine Intervention.json @@ -79,7 +79,6 @@ "[Ishgard] The Forgotten Knight", "[Ishgard] The Tribunal" ], - "ContentFinderConditionId": 396, "BossModEnabled": true } ] diff --git a/QuestPaths/3.x - Heavensward/MSQ/A2-Raubahn/1601_Keeping the Flame Alive.json b/QuestPaths/3.x - Heavensward/MSQ/A2-Raubahn/1601_Keeping the Flame Alive.json index a42a2a76..6e052e8b 100644 --- a/QuestPaths/3.x - Heavensward/MSQ/A2-Raubahn/1601_Keeping the Flame Alive.json +++ b/QuestPaths/3.x - Heavensward/MSQ/A2-Raubahn/1601_Keeping the Flame Alive.json @@ -29,7 +29,6 @@ }, "TerritoryId": 145, "InteractionType": "SinglePlayerDuty", - "ContentFinderConditionId": 400, "BossModEnabled": true } ] diff --git a/QuestPaths/3.x - Heavensward/MSQ/A3.1-Coerthas Western Highlands 2/1606_Sounding Out the Amphitheatre.json b/QuestPaths/3.x - Heavensward/MSQ/A3.1-Coerthas Western Highlands 2/1606_Sounding Out the Amphitheatre.json index b00c7cc0..3503cfee 100644 --- a/QuestPaths/3.x - Heavensward/MSQ/A3.1-Coerthas Western Highlands 2/1606_Sounding Out the Amphitheatre.json +++ b/QuestPaths/3.x - Heavensward/MSQ/A3.1-Coerthas Western Highlands 2/1606_Sounding Out the Amphitheatre.json @@ -79,7 +79,6 @@ "TerritoryId": 397, "InteractionType": "SinglePlayerDuty", "DisableNavmesh": true, - "ContentFinderConditionId": 397, "BossModEnabled": true } ] diff --git a/QuestPaths/3.x - Heavensward/MSQ/A4-Ishgard/1639_Fire and Blood.json b/QuestPaths/3.x - Heavensward/MSQ/A4-Ishgard/1639_Fire and Blood.json index d3573b34..eb9876a6 100644 --- a/QuestPaths/3.x - Heavensward/MSQ/A4-Ishgard/1639_Fire and Blood.json +++ b/QuestPaths/3.x - Heavensward/MSQ/A4-Ishgard/1639_Fire and Blood.json @@ -75,7 +75,6 @@ }, "TerritoryId": 418, "InteractionType": "SinglePlayerDuty", - "ContentFinderConditionId": 398, "BossModEnabled": true } ] diff --git a/QuestPaths/3.x - Heavensward/MSQ/A5-Sea of Clouds/1644_Familiar Faces.json b/QuestPaths/3.x - Heavensward/MSQ/A5-Sea of Clouds/1644_Familiar Faces.json index 0122bc84..b156029f 100644 --- a/QuestPaths/3.x - Heavensward/MSQ/A5-Sea of Clouds/1644_Familiar Faces.json +++ b/QuestPaths/3.x - Heavensward/MSQ/A5-Sea of Clouds/1644_Familiar Faces.json @@ -57,7 +57,6 @@ "InteractionType": "SinglePlayerDuty", "Emote": "lookout", "StopDistance": 0.25, - "ContentFinderConditionId": 401, "BossModEnabled": true } ] diff --git a/QuestPaths/3.x - Heavensward/MSQ/A6-The Dravanian Hinterlands/1657_An Illuminati Incident.json b/QuestPaths/3.x - Heavensward/MSQ/A6-The Dravanian Hinterlands/1657_An Illuminati Incident.json index f3e3acdb..31218140 100644 --- a/QuestPaths/3.x - Heavensward/MSQ/A6-The Dravanian Hinterlands/1657_An Illuminati Incident.json +++ b/QuestPaths/3.x - Heavensward/MSQ/A6-The Dravanian Hinterlands/1657_An Illuminati Incident.json @@ -48,7 +48,6 @@ "[Idyllshire] Aetheryte Plaza", "[Idyllshire] Epilogue Gate (Eastern Hinterlands)" ], - "ContentFinderConditionId": 422, "BossModEnabled": true } ] diff --git a/QuestPaths/3.x - Heavensward/MSQ/A7-Azys Lla/1667_Close Encounters of the VIth Kind.json b/QuestPaths/3.x - Heavensward/MSQ/A7-Azys Lla/1667_Close Encounters of the VIth Kind.json index 792ea961..a498f657 100644 --- a/QuestPaths/3.x - Heavensward/MSQ/A7-Azys Lla/1667_Close Encounters of the VIth Kind.json +++ b/QuestPaths/3.x - Heavensward/MSQ/A7-Azys Lla/1667_Close Encounters of the VIth Kind.json @@ -69,7 +69,6 @@ }, "TerritoryId": 402, "InteractionType": "SinglePlayerDuty", - "ContentFinderConditionId": 399, "BossModEnabled": true } ] diff --git a/QuestPaths/5.x - Shadowbringers/Trial Quests/3895_Sleep Now in Sapphire.json b/QuestPaths/5.x - Shadowbringers/Trial Quests/3895_Sleep Now in Sapphire.json index 2c5cc516..160a2970 100644 --- a/QuestPaths/5.x - Shadowbringers/Trial Quests/3895_Sleep Now in Sapphire.json +++ b/QuestPaths/5.x - Shadowbringers/Trial Quests/3895_Sleep Now in Sapphire.json @@ -104,6 +104,7 @@ "StopDistance": 5, "TerritoryId": 829, "InteractionType": "SinglePlayerDuty", + "SinglePlayerDutyIndex": 1, "DialogueChoices": [ { "Type": "List", diff --git a/QuestPaths/quest-v1.json b/QuestPaths/quest-v1.json index d078b4c8..350b0bf1 100644 --- a/QuestPaths/quest-v1.json +++ b/QuestPaths/quest-v1.json @@ -1267,13 +1267,14 @@ }, "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" } } } diff --git a/Questionable.Model/Questing/ElementId.cs b/Questionable.Model/Questing/ElementId.cs index a553ff0d..6fce25ca 100644 --- a/Questionable.Model/Questing/ElementId.cs +++ b/Questionable.Model/Questing/ElementId.cs @@ -91,6 +91,8 @@ public abstract class ElementId : IComparable, IEquatable 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); diff --git a/Questionable.Model/Questing/QuestStep.cs b/Questionable.Model/Questing/QuestStep.cs index fe7170d5..98127cde 100644 --- a/Questionable.Model/Questing/QuestStep.cs +++ b/Questionable.Model/Questing/QuestStep.cs @@ -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?> RequiredQuestVariables { get; set; } = new(); diff --git a/Questionable.Model/common-aethernetshard.json b/Questionable.Model/common-aethernetshard.json index a2af4209..15a85c5f 100644 --- a/Questionable.Model/common-aethernetshard.json +++ b/Questionable.Model/common-aethernetshard.json @@ -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", diff --git a/Questionable.Model/common-aetheryte.json b/Questionable.Model/common-aetheryte.json index 6aa50781..8b033a83 100644 --- a/Questionable.Model/common-aetheryte.json +++ b/Questionable.Model/common-aetheryte.json @@ -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", diff --git a/Questionable.Model/common-classjob.json b/Questionable.Model/common-classjob.json index 5a774939..e5e0d393 100644 --- a/Questionable.Model/common-classjob.json +++ b/Questionable.Model/common-classjob.json @@ -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", diff --git a/Questionable.Model/common-completionflags.json b/Questionable.Model/common-completionflags.json index eb77d70c..b7212b1d 100644 --- a/Questionable.Model/common-completionflags.json +++ b/Questionable.Model/common-completionflags.json @@ -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": { diff --git a/Questionable.Model/common-vector3.json b/Questionable.Model/common-vector3.json index cfae5637..028af1c7 100644 --- a/Questionable.Model/common-vector3.json +++ b/Questionable.Model/common-vector3.json @@ -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": { diff --git a/Questionable/Configuration.cs b/Questionable/Configuration.cs index 90c42bb5..74bb05e6 100644 --- a/Questionable/Configuration.cs +++ b/Questionable/Configuration.cs @@ -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 BlacklistedDutyCfcIds { get; set; } = []; } + internal sealed class SoloDutyConfiguration + { + public bool RunSoloInstancesWithBossMod { get; set; } + public HashSet WhitelistedSoloDutyCfcIds { get; set; } = []; + public HashSet BlacklistedSoloDutyCfcIds { get; set; } = []; + } + internal sealed class NotificationConfiguration { public bool Enabled { get; set; } = true; diff --git a/Questionable/Controller/QuestRegistry.cs b/Questionable/Controller/QuestRegistry.cs index 2c38b241..5e948761 100644 --- a/Questionable/Controller/QuestRegistry.cs +++ b/Questionable/Controller/QuestRegistry.cs @@ -27,6 +27,7 @@ internal sealed class QuestRegistry private readonly JsonSchemaValidator _jsonSchemaValidator; private readonly ILogger _logger; private readonly LeveData _leveData; + private readonly TerritoryData _territoryData; private readonly ICallGateProvider _reloadDataIpc; private readonly Dictionary _quests = []; @@ -34,7 +35,7 @@ internal sealed class QuestRegistry public QuestRegistry(IDalamudPluginInterface pluginInterface, QuestData questData, QuestValidator questValidator, JsonSchemaValidator jsonSchemaValidator, - ILogger logger, LeveData leveData) + ILogger 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("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); } } } diff --git a/Questionable/Controller/Steps/Common/SendNotification.cs b/Questionable/Controller/Steps/Common/SendNotification.cs index 8bb4fa80..b2d146f0 100644 --- a/Questionable/Controller/Steps/Common/SendNotification.cs +++ b/Questionable/Controller/Steps/Common/SendNotification.cs @@ -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, }; diff --git a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs index 4cd79cec..b8bf39fe 100644 --- a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs +++ b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs @@ -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 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 + internal sealed class StartSinglePlayerDutyExecutor : TaskExecutor { 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, 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; } diff --git a/Questionable/Controller/Steps/Shared/WaitAtEnd.cs b/Questionable/Controller/Steps/Shared/WaitAtEnd.cs index 59d108cd..1476ed3c 100644 --- a/Questionable/Controller/Steps/Shared/WaitAtEnd.cs +++ b/Questionable/Controller/Steps/Shared/WaitAtEnd.cs @@ -21,7 +21,8 @@ internal static class WaitAtEnd IClientState clientState, ICondition condition, TerritoryData territoryData, - AutoDutyIpc autoDutyIpc) + AutoDutyIpc autoDutyIpc, + BossModIpc bossModIpc) : ITaskFactory { public IEnumerable 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: diff --git a/Questionable/Data/JournalData.cs b/Questionable/Data/JournalData.cs index c2983fe5..80f7560b 100644 --- a/Questionable/Data/JournalData.cs +++ b/Questionable/Data/JournalData.cs @@ -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) diff --git a/Questionable/Data/TerritoryData.cs b/Questionable/Data/TerritoryData.cs index 820ae656..c57ada43 100644 --- a/Questionable/Data/TerritoryData.cs +++ b/Questionable/Data/TerritoryData.cs @@ -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 _dutyTerritories; private readonly ImmutableDictionary _instanceNames; private readonly ImmutableDictionary _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() + .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().GetRow(questBattleId).Order; + else + return dataManager.GetExcelSheet().GetRow(questBattleId).Unknown0; + } + public sealed record ContentFinderConditionData( uint ContentFinderConditionId, string Name, diff --git a/Questionable/External/AutoDutyIpc.cs b/Questionable/External/AutoDutyIpc.cs index 8d9a8482..67ea9fbd 100644 --- a/Questionable/External/AutoDutyIpc.cs +++ b/Questionable/External/AutoDutyIpc.cs @@ -31,7 +31,7 @@ internal sealed class AutoDutyIpc _stop = pluginInterface.GetIpcSubscriber("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) diff --git a/Questionable/External/BossModIpc.cs b/Questionable/External/BossModIpc.cs index d1d02f79..82a3de68 100644 --- a/Questionable/External/BossModIpc.cs +++ b/Questionable/External/BossModIpc.cs @@ -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 _getPreset; private readonly ICallGateSubscriber _createPreset; private readonly ICallGateSubscriber _setPreset; private readonly ICallGateSubscriber _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($"{Name}.Presets.Get"); _createPreset = pluginInterface.GetIpcSubscriber($"{Name}.Presets.Create"); _setPreset = pluginInterface.GetIpcSubscriber($"{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; + } } diff --git a/Questionable/External/QuestionableIpc.cs b/Questionable/External/QuestionableIpc.cs index 2b80d66a..0ff2b183 100644 --- a/Questionable/External/QuestionableIpc.cs +++ b/Questionable/External/QuestionableIpc.cs @@ -41,10 +41,10 @@ internal sealed class QuestionableIpc : IDisposable eventInfoComponent.GetCurrentlyActiveEventQuests().Select(q => q.ToString()).ToList()); _startQuest = pluginInterface.GetIpcProvider(IpcStartQuest); - _startQuest.RegisterFunc((string questId) => StartQuest(questController, questRegistry, questId, false)); + _startQuest.RegisterFunc((questId) => StartQuest(questController, questRegistry, questId, false)); _startSingleQuest = pluginInterface.GetIpcProvider(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(); diff --git a/Questionable/Model/QuestInfo.cs b/Questionable/Model/QuestInfo.cs index 5f261275..b7efde87 100644 --- a/Questionable/Model/QuestInfo.cs +++ b/Questionable/Model/QuestInfo.cs @@ -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 { - 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; } diff --git a/Questionable/Model/SatisfactionSupplyInfo.cs b/Questionable/Model/SatisfactionSupplyInfo.cs index 21c92936..b1bba4a4 100644 --- a/Questionable/Model/SatisfactionSupplyInfo.cs +++ b/Questionable/Model/SatisfactionSupplyInfo.cs @@ -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; } diff --git a/Questionable/QuestionablePlugin.cs b/Questionable/QuestionablePlugin.cs index 28d1f66b..ccd694af 100644 --- a/Questionable/QuestionablePlugin.cs +++ b/Questionable/QuestionablePlugin.cs @@ -311,6 +311,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(sp => sp.GetRequiredService()); } diff --git a/Questionable/Validation/EIssueType.cs b/Questionable/Validation/EIssueType.cs index 75512481..4d41f997 100644 --- a/Questionable/Validation/EIssueType.cs +++ b/Questionable/Validation/EIssueType.cs @@ -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 index 00000000..684e876b --- /dev/null +++ b/Questionable/Validation/Validators/UniqueSinglePlayerInstanceValidator.cs @@ -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 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}", + }; + } + } + } +} diff --git a/Questionable/Windows/QuestSelectionWindow.cs b/Questionable/Windows/QuestSelectionWindow.cs index a53b79e9..481c5f1a 100644 --- a/Questionable/Windows/QuestSelectionWindow.cs +++ b/Questionable/Windows/QuestSelectionWindow.cs @@ -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 index 00000000..2ddda36c --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file