From 71e0b01dbce051122f95560db0a290e111883302 Mon Sep 17 00:00:00 2001 From: Liza Carvelli Date: Fri, 21 Feb 2025 12:21:01 +0100 Subject: [PATCH] Add quest battle difficulty selection; UI tweaks --- .../MSQ-1/Gridania/445_Chasing Shadows.json | 6 +- .../MSQ/H-5.2/3765_A Sleep Disturbed.json | 4 ++ Questionable/Configuration.cs | 1 + .../GameUi/InteractionUiController.cs | 65 ++++++++++++++++++- .../SinglePlayerDutyConfigComponent.cs | 57 ++++++++++++---- 5 files changed, 118 insertions(+), 15 deletions(-) diff --git a/QuestPaths/2.x - A Realm Reborn/MSQ-1/Gridania/445_Chasing Shadows.json b/QuestPaths/2.x - A Realm Reborn/MSQ-1/Gridania/445_Chasing Shadows.json index d59a446c9..bb4aa6554 100644 --- a/QuestPaths/2.x - A Realm Reborn/MSQ-1/Gridania/445_Chasing Shadows.json +++ b/QuestPaths/2.x - A Realm Reborn/MSQ-1/Gridania/445_Chasing Shadows.json @@ -28,7 +28,11 @@ "Z": -309.55975 }, "TerritoryId": 148, - "InteractionType": "SinglePlayerDuty" + "InteractionType": "SinglePlayerDuty", + "BossModEnabled": false, + "BossModNotes": [ + "AI doesn't automatically target newly spawning adds and dies until after the boss died (tested on CNJ)" + ] } ] }, diff --git a/QuestPaths/5.x - Shadowbringers/MSQ/H-5.2/3765_A Sleep Disturbed.json b/QuestPaths/5.x - Shadowbringers/MSQ/H-5.2/3765_A Sleep Disturbed.json index 5cd92e470..49a098916 100644 --- a/QuestPaths/5.x - Shadowbringers/MSQ/H-5.2/3765_A Sleep Disturbed.json +++ b/QuestPaths/5.x - Shadowbringers/MSQ/H-5.2/3765_A Sleep Disturbed.json @@ -46,6 +46,10 @@ }, "TerritoryId": 817, "InteractionType": "SinglePlayerDuty", + "BossModEnabled": false, + "BossModNotes": [ + "Doesn't walk to the teleporter to finish the duty" + ], "Fly": true, "Comment": "A Sleep Disturbed (Opo-Opo, Wolf, Serpent)", "$": "The dialogue choices and data ids here are recycled", diff --git a/Questionable/Configuration.cs b/Questionable/Configuration.cs index a4126ed6f..07b20f4d0 100644 --- a/Questionable/Configuration.cs +++ b/Questionable/Configuration.cs @@ -45,6 +45,7 @@ internal sealed class Configuration : IPluginConfiguration internal sealed class SinglePlayerDutyConfiguration { public bool RunSoloInstancesWithBossMod { get; set; } + public byte RetryDifficulty { get; set; } = 2; public HashSet WhitelistedSinglePlayerDutyCfcIds { get; set; } = []; public HashSet BlacklistedSinglePlayerDutyCfcIds { get; set; } = []; } diff --git a/Questionable/Controller/GameUi/InteractionUiController.cs b/Questionable/Controller/GameUi/InteractionUiController.cs index 0c7e4d0d2..1825b4f79 100644 --- a/Questionable/Controller/GameUi/InteractionUiController.cs +++ b/Questionable/Controller/GameUi/InteractionUiController.cs @@ -47,6 +47,7 @@ internal sealed class InteractionUiController : IDisposable private readonly IClientState _clientState; private readonly ShopController _shopController; private readonly BossModIpc _bossModIpc; + private readonly Configuration _configuration; private readonly ILogger _logger; private readonly Regex _returnRegex; private readonly Regex _purchaseItemRegex; @@ -71,6 +72,7 @@ internal sealed class InteractionUiController : IDisposable IClientState clientState, ShopController shopController, BossModIpc bossModIpc, + Configuration configuration, ILogger logger) { _addonLifecycle = addonLifecycle; @@ -89,6 +91,7 @@ internal sealed class InteractionUiController : IDisposable _clientState = clientState; _shopController = shopController; _bossModIpc = bossModIpc; + _configuration = configuration; _logger = logger; _returnRegex = _dataManager.GetExcelSheet().GetRow(196).GetRegex(addon => addon.Text, pluginLog)!; @@ -98,6 +101,7 @@ internal sealed class InteractionUiController : IDisposable _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "CutSceneSelectString", CutsceneSelectStringPostSetup); _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "SelectIconString", SelectIconStringPostSetup); _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "SelectYesno", SelectYesnoPostSetup); + _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "DifficultySelectYesNo", DifficultySelectYesNoPostSetup); _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "PointMenu", PointMenuPostSetup); _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "HousingSelectBlock", HousingSelectBlockPostSetup); @@ -144,6 +148,12 @@ internal sealed class InteractionUiController : IDisposable SelectYesnoPostSetup(addonSelectYesno, true); } + if (_gameGui.TryGetAddonByName("DifficultySelectYesNo", out AtkUnitBase* addonDifficultySelectYesNo)) + { + _logger.LogInformation("DifficultySelectYesNo window is open"); + DifficultySelectYesNoPostSetup(addonDifficultySelectYesNo, true); + } + if (_gameGui.TryGetAddonByName("PointMenu", out AtkUnitBase* addonPointMenu)) { _logger.LogInformation("PointMenu is open"); @@ -669,8 +679,19 @@ internal sealed class InteractionUiController : IDisposable return true; } + if (CheckSinglePlayerDutyYesNo(quest.Id, step)) + { + addonSelectYesno->AtkUnitBase.FireCallbackInt(0); + return true; + } + + return false; + } + + private bool CheckSinglePlayerDutyYesNo(ElementId questId, QuestStep? step) + { if (step is { InteractionType: EInteractionType.SinglePlayerDuty } && - _bossModIpc.IsConfiguredToRunSoloInstance(quest.Id, step.SinglePlayerDutyIndex, step.BossModEnabled)) + _bossModIpc.IsConfiguredToRunSoloInstance(questId, step.SinglePlayerDutyIndex, step.BossModEnabled)) { // Most of these are yes/no dialogs "Duty calls, ...". // @@ -678,8 +699,7 @@ internal sealed class InteractionUiController : IDisposable // after you confirm 'Wait for Krile?'. However, if you fail that duty, you'll get a DifficultySelectYesNo. // DifficultySelectYesNo → [0, 2] for very easy - _logger.LogInformation("DefaultYesNo: probably Single Player Duty"); - addonSelectYesno->AtkUnitBase.FireCallbackInt(0); + _logger.LogInformation("SinglePlayerDutyYesNo: probably Single Player Duty"); return true; } @@ -716,6 +736,44 @@ internal sealed class InteractionUiController : IDisposable return false; } + + private unsafe void DifficultySelectYesNoPostSetup(AddonEvent type, AddonArgs args) + { + AtkUnitBase* addonDifficultySelectYesNo = (AtkUnitBase*)args.Addon; + DifficultySelectYesNoPostSetup(addonDifficultySelectYesNo, false); + } + + private unsafe void DifficultySelectYesNoPostSetup(AtkUnitBase* addonDifficultySelectYesNo, bool checkAllSteps) + { + var currentQuest = _questController.StartedQuest; + if (currentQuest == null) + return; + + var quest = currentQuest.Quest; + bool autoConfirm; + if (checkAllSteps) + { + var sequence = quest.FindSequence(currentQuest.Sequence); + autoConfirm = sequence != null && sequence.Steps.Any(step => CheckSinglePlayerDutyYesNo(quest.Id, step)); + } + else + { + var step = quest.FindSequence(currentQuest.Sequence)?.FindStep(currentQuest.Step); + autoConfirm = step != null && CheckSinglePlayerDutyYesNo(quest.Id, step); + } + + if (autoConfirm) + { + _logger.LogInformation("Confirming difficulty ({Difficulty}) for quest battle", _configuration.SinglePlayerDuties.RetryDifficulty); + var selectChoice = stackalloc AtkValue[] + { + new() { Type = ValueType.Int, Int = 0 }, + new() { Type = ValueType.Int, Int = _configuration.SinglePlayerDuties.RetryDifficulty } + }; + addonDifficultySelectYesNo->FireCallback(2, selectChoice); + } + } + private ushort? FindTargetTerritoryFromQuestStep(QuestController.QuestProgress currentQuest) { // this can be triggered either manually (in which case we should increase the step counter), or automatically @@ -888,6 +946,7 @@ internal sealed class InteractionUiController : IDisposable { _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "HousingSelectBlock", HousingSelectBlockPostSetup); _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "PointMenu", PointMenuPostSetup); + _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "DifficultySelectYesNo", DifficultySelectYesNoPostSetup); _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "SelectYesno", SelectYesnoPostSetup); _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "SelectIconString", SelectIconStringPostSetup); _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "CutSceneSelectString", CutsceneSelectStringPostSetup); diff --git a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs index 263a3b54d..07149deba 100644 --- a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs +++ b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs @@ -25,12 +25,6 @@ namespace Questionable.Windows.ConfigComponents; internal sealed class SinglePlayerDutyConfigComponent : ConfigComponent { - private readonly TerritoryData _territoryData; - private readonly QuestRegistry _questRegistry; - private readonly QuestData _questData; - private readonly IDataManager _dataManager; - private readonly ILogger _logger; - private static readonly List<(EClassJob ClassJob, string Name)> RoleQuestCategories = [ (EClassJob.Paladin, "Tank Role Quests"), @@ -40,6 +34,15 @@ internal sealed class SinglePlayerDutyConfigComponent : ConfigComponent (EClassJob.BlackMage, "Magical Ranged Role Quests"), ]; + private readonly string[] _retryDifficulties = ["Normal", "Easy", "Very Easy"]; + + private readonly TerritoryData _territoryData; + private readonly QuestRegistry _questRegistry; + private readonly QuestData _questData; + private readonly IDataManager _dataManager; + private readonly ILogger _logger; + private readonly List<(EClassJob ClassJob, int Category)> _sortedClassJobs; + private ImmutableDictionary> _startingCityBattles = ImmutableDictionary>.Empty; @@ -72,6 +75,13 @@ internal sealed class SinglePlayerDutyConfigComponent : ConfigComponent _questData = questData; _dataManager = dataManager; _logger = logger; + + _sortedClassJobs = dataManager.GetExcelSheet() + .Where(x => x is { RowId: > 0, UIPriority: < 100 }) + .Select(x => (ClassJob: (EClassJob)x.RowId, Priority: x.UIPriority)) + .OrderBy(x => x.Priority) + .Select(x => (x.ClassJob, x.Priority / 10)) + .ToList(); } public void Reload() @@ -256,8 +266,23 @@ internal sealed class SinglePlayerDutyConfigComponent : ConfigComponent Save(); } - ImGui.TextColored(ImGuiColors.DalamudRed, - "Work in Progress: For now, this will always use BossMod for combat."); + using (ImRaii.PushIndent(ImGui.GetFrameHeight() + ImGui.GetStyle().ItemInnerSpacing.X)) + { + ImGui.AlignTextToFramePadding(); + ImGui.TextColored(ImGuiColors.DalamudRed, + "Work in Progress: For now, this will always use BossMod for combat."); + + using (ImRaii.Disabled(!runSoloInstancesWithBossMod)) + { + int retryDifficulty = Configuration.SinglePlayerDuties.RetryDifficulty; + if (ImGui.Combo("Difficulty when retrying a quest battle", ref retryDifficulty, _retryDifficulties, + _retryDifficulties.Length)) + { + Configuration.SinglePlayerDuties.RetryDifficulty = (byte)retryDifficulty; + Save(); + } + } + } ImGui.Separator(); @@ -286,7 +311,7 @@ internal sealed class SinglePlayerDutyConfigComponent : ConfigComponent private void DrawMainScenarioConfigTable() { - using var tab = ImRaii.TabItem("MSQ###MSQ"); + using var tab = ImRaii.TabItem("Main Scenario Quests###MSQ"); if (!tab) return; @@ -323,10 +348,19 @@ internal sealed class SinglePlayerDutyConfigComponent : ConfigComponent if (!child) return; - foreach (EClassJob classJob in Enum.GetValues()) + int oldPriority = 0; + foreach (var (classJob, priority) in _sortedClassJobs) { if (_jobQuestBattles.TryGetValue(classJob, out var dutyInfos)) { + if (priority != oldPriority) + { + oldPriority = priority; + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + } + string jobName = classJob.ToFriendlyString(); if (classJob.IsClass()) jobName += $" / {classJob.AsJob().ToFriendlyString()}"; @@ -434,7 +468,8 @@ internal sealed class SinglePlayerDutyConfigComponent : ConfigComponent { using var _ = ImRaii.Tooltip(); - ImGui.TextColored(ImGuiColors.DalamudYellow, "While testing, the following issues have been found:"); + ImGui.TextColored(ImGuiColors.DalamudYellow, + "While testing, the following issues have been found:"); foreach (string note in dutyInfo.Notes) ImGui.BulletText(note); }