Add experimental PurchaseItem step
authorLiza Carvelli <liza@carvel.li>
Sun, 22 Sep 2024 12:31:14 +0000 (14:31 +0200)
committerLiza Carvelli <liza@carvel.li>
Sun, 22 Sep 2024 12:31:14 +0000 (14:31 +0200)
16 files changed:
LLib
QuestPathGenerator/RoslynElements/QuestStepExtensions.cs
QuestPaths/3.x - Heavensward/Unlocks/Ishgard Restoration/3728_To Thaw a Frozen Heart.json
QuestPaths/quest-v1.json
Questionable.Model/Questing/Converter/InteractionTypeConverter.cs
Questionable.Model/Questing/EInteractionType.cs
Questionable.Model/Questing/PurchaseMenu.cs [new file with mode: 0644]
Questionable.Model/Questing/QuestStep.cs
Questionable/Controller/GameUi/InteractionUiController.cs
Questionable/Controller/GameUi/ShopController.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/Interact.cs
Questionable/Controller/Steps/Interactions/PurchaseItem.cs [new file with mode: 0644]
Questionable/Controller/Steps/TaskExecutor.cs
Questionable/Functions/ExcelFunctions.cs
Questionable/QuestionablePlugin.cs
Questionable/Windows/QuestComponents/CreationUtilsComponent.cs

diff --git a/LLib b/LLib
index 43c3dba112c202e2d0ff1a6909020c2b83e20dc3..e6e3a1f29715e2af4976dd7338ed2f09ae82c99c 160000 (submodule)
--- a/LLib
+++ b/LLib
@@ -1 +1 @@
-Subproject commit 43c3dba112c202e2d0ff1a6909020c2b83e20dc3
+Subproject commit e6e3a1f29715e2af4976dd7338ed2f09ae82c99c
index 6aca708aea96edafea4d79871840385a21518cc5..45f2a858ea61ba24a36361b7182a7b85950b5abd 100644 (file)
@@ -127,6 +127,8 @@ internal static class QuestStepExtensions
                                 .AsSyntaxNodeOrToken(),
                             AssignmentList(nameof(QuestStep.PointMenuChoices), step.PointMenuChoices)
                                 .AsSyntaxNodeOrToken(),
+                            Assignment(nameof(QuestStep.PurchaseMenu), step.PurchaseMenu, emptyStep.PurchaseMenu)
+                                .AsSyntaxNodeOrToken(),
                             Assignment(nameof(QuestStep.PickUpQuestId), step.PickUpQuestId,
                                     emptyStep.PickUpQuestId)
                                 .AsSyntaxNodeOrToken(),
index bef2725b27e07c533ae9fba03b3944c575a95d7d..98d6a07283b6bdcea816eec005aa57caf83c58b0 100644 (file)
             "Z": 150.92688
           },
           "TerritoryId": 886,
-          "InteractionType": "Instruction",
+          "InteractionType": "PurchaseItem",
+          "PurchaseMenu": {
+            "ExcelSheet": "GilShop",
+            "Key": 262151,
+            "$": "This isn't the correct shop id, but it's also unclear how you'd find out"
+          },
+          "ItemId": 5768,
+          "ItemCount": 2,
           "Comment": "Buy cream yellow dye",
           "AethernetShortcut": [
             "[Firmament] The New Nest",
index 4282b7c9b8257ca1295d251a1f3fa33820daa397..a29a43fa226df7205d6bc881933373d92cf78db6 100644 (file)
             "Combat",
             "UseItem",
             "EquipItem",
+            "PurchaseItem",
             "EquipRecommended",
             "Say",
             "Emote",
             ]
           }
         },
+        {
+          "if": {
+            "properties": {
+              "InteractionType": {
+                "const": "PurchaseItem"
+              }
+            }
+          },
+          "then": {
+            "properties": {
+              "ItemCount": {
+                "type": "integer"
+              },
+              "PurchaseMenu": {
+                "type": "object",
+                "description": "The text to use with /say",
+                "properties": {
+                  "ExcelSheet": {
+                    "type": "string"
+                  },
+                  "Key": {
+                    "type": [
+                      "string",
+                      "integer"
+                    ]
+                  }
+                },
+                "required": [
+                  "Key"
+                ]
+              }
+            },
+            "required": [
+              "ItemId",
+              "ItemCount"
+            ]
+          }
+        },
         {
           "if": {
             "properties": {
index 34320633c6ca11ad607ff3015ee0a8b8c2ebf4ea..aef951bf44bc491bac999ffc6e312d1ce79440d7 100644 (file)
@@ -16,6 +16,7 @@ public sealed class InteractionTypeConverter() : EnumConverter<EInteractionType>
         { EInteractionType.Combat, "Combat" },
         { EInteractionType.UseItem, "UseItem" },
         { EInteractionType.EquipItem, "EquipItem" },
+        { EInteractionType.PurchaseItem, "PurchaseItem" },
         { EInteractionType.EquipRecommended, "EquipRecommended" },
         { EInteractionType.Say, "Say" },
         { EInteractionType.Emote, "Emote" },
index d9942ef45fd484c67a68b6de79f5ff2a2311ab69..af23e90e7fc2fe021e75b8a42334d42912ef7419 100644 (file)
@@ -15,6 +15,7 @@ public enum EInteractionType
     Combat,
     UseItem,
     EquipItem,
+    PurchaseItem,
     EquipRecommended,
     Say,
     Emote,
diff --git a/Questionable.Model/Questing/PurchaseMenu.cs b/Questionable.Model/Questing/PurchaseMenu.cs
new file mode 100644 (file)
index 0000000..e0a2172
--- /dev/null
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+using Questionable.Model.Questing.Converter;
+
+namespace Questionable.Model.Questing;
+
+public sealed class PurchaseMenu
+{
+    public string? ExcelSheet { get; set; }
+
+    [JsonConverter(typeof(ExcelRefConverter))]
+    public ExcelRef? Key { get; set; }
+}
index da851c83426e0ecc9dd8a071aee6e891a2ff1461..c5626a5d64c0c2de1f69ec50a888f5078497256d 100644 (file)
@@ -78,6 +78,7 @@ public sealed class QuestStep
     public List<QuestWorkValue?> CompletionQuestVariablesFlags { get; set; } = [];
     public List<DialogueChoice> DialogueChoices { get; set; } = [];
     public List<uint> PointMenuChoices { get; set; } = [];
+    public PurchaseMenu? PurchaseMenu { get; set; }
 
     // TODO: Not implemented
     [JsonConverter(typeof(ElementIdConverter))]
index e94a5716124903a3256b7eb6e3341e412a963389..6db38b80f54e613f372535422ba9fc2d21ad5ca3 100644 (file)
@@ -46,6 +46,7 @@ internal sealed class InteractionUiController : IDisposable
     private readonly IGameGui _gameGui;
     private readonly ITargetManager _targetManager;
     private readonly IClientState _clientState;
+    private readonly ShopController _shopController;
     private readonly ILogger<InteractionUiController> _logger;
     private readonly Regex _returnRegex;
 
@@ -67,6 +68,7 @@ internal sealed class InteractionUiController : IDisposable
         ITargetManager targetManager,
         IPluginLog pluginLog,
         IClientState clientState,
+        ShopController shopController,
         ILogger<InteractionUiController> logger)
     {
         _addonLifecycle = addonLifecycle;
@@ -83,6 +85,7 @@ internal sealed class InteractionUiController : IDisposable
         _gameGui = gameGui;
         _targetManager = targetManager;
         _clientState = clientState;
+        _shopController = shopController;
         _logger = logger;
 
         _returnRegex = _dataManager.GetExcelSheet<Addon>()!.GetRow(196)!.GetRegex(addon => addon.Text, pluginLog)!;
@@ -334,7 +337,17 @@ internal sealed class InteractionUiController : IDisposable
                 if (step == null)
                     _logger.LogDebug("Ignoring current quest dialogue choices, no active step");
                 else
+                {
                     dialogueChoices.AddRange(step.DialogueChoices.Select(x => new DialogueChoiceInfo(quest, x)));
+                    if (step.PurchaseMenu != null)
+                        dialogueChoices.Add(new DialogueChoiceInfo(quest, new DialogueChoice
+                        {
+                            Type = EDialogChoiceType.List,
+                            ExcelSheet = step.PurchaseMenu.ExcelSheet,
+                            Prompt = null,
+                            Answer = step.PurchaseMenu.Key,
+                        }));
+                }
             }
 
             // add all travel dialogue choices
@@ -516,6 +529,13 @@ internal sealed class InteractionUiController : IDisposable
             return;
 
         _logger.LogTrace("Prompt: '{Prompt}'", actualPrompt);
+        if (_shopController.IsAutoBuyEnabled && _shopController.IsAwaitingYesNo)
+        {
+            addonSelectYesno->AtkUnitBase.FireCallbackInt(0);
+            _shopController.IsAwaitingYesNo = false;
+            return;
+        }
+
         var director = UIState.Instance()->DirectorTodo.Director;
         if (director != null &&
             director->Info.EventId.ContentId == EventHandlerType.GatheringLeveDirector &&
diff --git a/Questionable/Controller/GameUi/ShopController.cs b/Questionable/Controller/GameUi/ShopController.cs
new file mode 100644 (file)
index 0000000..611078e
--- /dev/null
@@ -0,0 +1,156 @@
+using System;
+using System.Linq;
+using System.Numerics;
+using Dalamud.Plugin.Services;
+using FFXIVClientStructs.FFXIV.Component.GUI;
+using LLib.GameUI;
+using LLib.Shop;
+using Microsoft.Extensions.Logging;
+using Questionable.Model.Questing;
+using Workshoppa.GameData.Shops;
+using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
+
+namespace Questionable.Controller.GameUi;
+
+internal sealed class ShopController : IDisposable, IShopWindow
+{
+    private readonly QuestController _questController;
+    private readonly IGameGui _gameGui;
+    private readonly IFramework _framework;
+    private readonly RegularShopBase _shop;
+    private readonly ILogger<ShopController> _logger;
+
+    public ShopController(QuestController questController, IGameGui gameGui, IAddonLifecycle addonLifecycle,
+        IFramework framework, ILogger<ShopController> logger, IPluginLog pluginLog)
+    {
+        _questController = questController;
+        _gameGui = gameGui;
+        _framework = framework;
+        _shop = new RegularShopBase(this, "Shop", pluginLog, gameGui, addonLifecycle);
+        _logger = logger;
+
+        _framework.Update += FrameworkUpdate;
+    }
+
+    public bool IsEnabled => _questController.IsRunning;
+    public bool IsOpen { get; set; }
+    public bool IsAutoBuyEnabled => _shop.AutoBuyEnabled;
+
+    public bool IsAwaitingYesNo
+    {
+        get { return _shop.IsAwaitingYesNo; }
+        set { _shop.IsAwaitingYesNo = value; }
+    }
+
+    public Vector2? Position { get; set; } // actual implementation doesn't matter, not a real window
+
+    public void Dispose()
+    {
+        _framework.Update -= FrameworkUpdate;
+        _shop.Dispose();
+    }
+
+    private void FrameworkUpdate(IFramework framework)
+    {
+        if (IsOpen && _shop.ItemForSale != null)
+        {
+            if (_shop.PurchaseState != null)
+            {
+                _shop.HandleNextPurchaseStep();
+            }
+            else
+            {
+                var currentStep = FindCurrentStep();
+                if (currentStep == null || currentStep.InteractionType != EInteractionType.PurchaseItem)
+                    return;
+
+                int missingItems = Math.Max(0,
+                    currentStep.ItemCount.GetValueOrDefault() - (int)_shop.ItemForSale.OwnedItems);
+                int toPurchase = Math.Min(_shop.GetMaxItemsToPurchase(), missingItems);
+                if (toPurchase > 0)
+                {
+                    _logger.LogDebug("Auto-buying {MissingItems} {ItemName}", missingItems, _shop.ItemForSale.ItemName);
+                    _shop.StartAutoPurchase(missingItems);
+                    _shop.HandleNextPurchaseStep();
+                }
+                else
+                    _shop.CancelAutoPurchase();
+            }
+        }
+    }
+
+    public int GetCurrencyCount() => _shop.GetItemCount(1); // TODO: support other currencies
+
+    private QuestStep? FindCurrentStep()
+    {
+        var currentQuest = _questController.CurrentQuest;
+        QuestSequence? currentSequence = currentQuest?.Quest.FindSequence(currentQuest.Sequence);
+        return currentSequence?.FindStep(currentQuest?.Step ?? 0);
+    }
+
+    public unsafe void UpdateShopStock(AtkUnitBase* addon)
+    {
+        var currentStep = FindCurrentStep();
+        if (currentStep == null || currentStep.InteractionType != EInteractionType.PurchaseItem)
+        {
+            _shop.ItemForSale = null;
+            return;
+        }
+
+        if (addon->AtkValuesCount != 625)
+        {
+            _logger.LogError("Unexpected amount of atkvalues for Shop addon ({AtkValueCount})", addon->AtkValuesCount);
+            _shop.ItemForSale = null;
+            return;
+        }
+
+        var atkValues = addon->AtkValues;
+
+        // Check if on 'Current Stock' tab?
+        if (atkValues[0].UInt != 0)
+        {
+            _shop.ItemForSale = null;
+            return;
+        }
+
+        uint itemCount = atkValues[2].UInt;
+        if (itemCount == 0)
+        {
+            _shop.ItemForSale = null;
+            return;
+        }
+
+        _shop.ItemForSale = Enumerable.Range(0, (int)itemCount)
+            .Select(i => new ItemForSale
+            {
+                Position = i,
+                ItemName = atkValues[14 + i].ReadAtkString(),
+                Price = atkValues[75 + i].UInt,
+                OwnedItems = atkValues[136 + i].UInt,
+                ItemId = atkValues[441 + i].UInt,
+            })
+            .FirstOrDefault(x => x.ItemId == currentStep.ItemId);
+    }
+
+    public unsafe void TriggerPurchase(AtkUnitBase* addonShop, int buyNow)
+    {
+        var buyItem = stackalloc AtkValue[]
+        {
+            new() { Type = ValueType.Int, Int = 0 },
+            new() { Type = ValueType.Int, Int = _shop.ItemForSale!.Position },
+            new() { Type = ValueType.Int, Int = buyNow },
+            new() { Type = 0, Int = 0 }
+        };
+        addonShop->FireCallback(4, buyItem);
+    }
+
+    public void SaveExternalPluginState()
+    {
+    }
+
+    public unsafe void RestoreExternalPluginState()
+    {
+        if (_gameGui.TryGetAddonByName("Shop", out AtkUnitBase* addonShop))
+            addonShop->FireCallbackInt(-1);
+    }
+}
index 1b7caea312c64d7b8754cd90b555d5c2512eb4f0..3cb137c46ea53b69d68e226831f8c4ab576914c6 100644 (file)
@@ -35,6 +35,11 @@ internal static class Interact
                 if (step.DataId == null)
                     yield break;
             }
+            else if (step.InteractionType == EInteractionType.PurchaseItem)
+            {
+                if (step.DataId == null)
+                    yield break;
+            }
             else if (step.InteractionType == EInteractionType.Snipe)
             {
                 if (!configuration.General.AutomaticallyCompleteSnipeTasks)
@@ -51,7 +56,8 @@ internal static class Interact
 
             yield return new Task(step.DataId.Value, quest, step.InteractionType,
                 step.TargetTerritoryId != null || quest.Id is SatisfactionSupplyNpcId ||
-                step.SkipConditions is { StepIf.Never: true }, step.PickUpItemId, step.SkipConditions?.StepIf);
+                step.SkipConditions is { StepIf.Never: true } || step.InteractionType == EInteractionType.PurchaseItem,
+                step.PickUpItemId, step.SkipConditions?.StepIf);
         }
     }
 
@@ -147,7 +153,8 @@ internal static class Interact
             }
             else
             {
-                if (ProgressContext != null && (ProgressContext.WasSuccessful() || _interactionState == EInteractionState.InteractionConfirmed))
+                if (ProgressContext != null && (ProgressContext.WasSuccessful() ||
+                                                _interactionState == EInteractionState.InteractionConfirmed))
                     return ETaskResult.TaskComplete;
 
                 if (InteractionType == EInteractionType.Gather && condition[ConditionFlag.Gathering])
diff --git a/Questionable/Controller/Steps/Interactions/PurchaseItem.cs b/Questionable/Controller/Steps/Interactions/PurchaseItem.cs
new file mode 100644 (file)
index 0000000..e0cf596
--- /dev/null
@@ -0,0 +1,22 @@
+using Questionable.Model;
+using Questionable.Model.Questing;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class PurchaseItem
+{
+    internal sealed class Factory : SimpleTaskFactory
+    {
+        public override ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.PurchaseItem)
+                return null;
+            throw new System.NotImplementedException();
+        }
+    }
+
+    internal sealed class PurchaseRequest
+    {
+
+    }
+}
index 4f48b56b3cf93ba2b0275a5871c8da1008be8333..e5b2c2e95c85a195848d77e19f582198fa9b2fd6 100644 (file)
@@ -5,6 +5,7 @@ namespace Questionable.Controller.Steps;
 internal interface ITaskExecutor
 {
     ITask CurrentTask { get; }
+    public InteractionProgressContext? ProgressContext { get; }
 
     Type GetTaskType();
 
@@ -19,7 +20,7 @@ internal abstract class TaskExecutor<T> : ITaskExecutor
     where T : class, ITask
 {
     protected T Task { get; set; } = null!;
-    protected InteractionProgressContext? ProgressContext { get; set; }
+    public InteractionProgressContext? ProgressContext { get; set; }
     ITask ITaskExecutor.CurrentTask => Task;
 
     public bool WasInterrupted()
index cb0cf63ea46ab013767c6593f8d11d5aea9a2015..e1278a593baca2e5937740b7e3ef33a6ce4b1dae 100644 (file)
@@ -91,6 +91,11 @@ internal sealed class ExcelFunctions
             var questRow = _dataManager.GetExcelSheet<EventPathMove>()!.GetRow(rowId);
             return questRow?.Unknown10;
         }
+        else if (excelSheet is "GilShop")
+        {
+            var questRow = _dataManager.GetExcelSheet<GilShop>()!.GetRow(rowId);
+            return questRow?.Name;
+        }
         else if (excelSheet is "ContentTalk" or null)
         {
             var questRow = _dataManager.GetExcelSheet<ContentTalk>()!.GetRow(rowId);
index acee9943690a5a77ded647677b790cf8933a0b67..a3731ed49ceb4c14c2f7711385291a95f0015757 100644 (file)
@@ -224,6 +224,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddSingleton<CombatController>();
         serviceCollection.AddSingleton<GatheringController>();
         serviceCollection.AddSingleton<ContextMenuController>();
+        serviceCollection.AddSingleton<ShopController>();
 
         serviceCollection.AddSingleton<CraftworksSupplyController>();
         serviceCollection.AddSingleton<CreditsController>();
@@ -284,6 +285,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceProvider.GetRequiredService<CreditsController>();
         serviceProvider.GetRequiredService<HelpUiController>();
         serviceProvider.GetRequiredService<LeveUiController>();
+        serviceProvider.GetRequiredService<ShopController>();
         serviceProvider.GetRequiredService<QuestionableIpc>();
         serviceProvider.GetRequiredService<DalamudInitializer>();
         serviceProvider.GetRequiredService<AutoSnipeHandler>().Enable();
index d8faefdea8406b3d73f8e0b9181fbee6bf4468f2..d1db774f2fd5dfcd102189038fcd21cd415fdc4d 100644 (file)
@@ -29,6 +29,7 @@ namespace Questionable.Windows.QuestComponents;
 
 internal sealed class CreationUtilsComponent
 {
+    private readonly QuestController _questController;
     private readonly MovementController _movementController;
     private readonly GameFunctions _gameFunctions;
     private readonly QuestFunctions _questFunctions;
@@ -43,6 +44,7 @@ internal sealed class CreationUtilsComponent
     private readonly ILogger<CreationUtilsComponent> _logger;
 
     public CreationUtilsComponent(
+        QuestController questController,
         MovementController movementController,
         GameFunctions gameFunctions,
         QuestFunctions questFunctions,
@@ -56,6 +58,7 @@ internal sealed class CreationUtilsComponent
         Configuration configuration,
         ILogger<CreationUtilsComponent> logger)
     {
+        _questController = questController;
         _movementController = movementController;
         _gameFunctions = gameFunctions;
         _questFunctions = questFunctions;
@@ -154,13 +157,14 @@ internal sealed class CreationUtilsComponent
         }
 #endif
 
-#if false
+#if true
         unsafe
         {
             var actionManager = ActionManager.Instance();
             ImGui.Text(
                 $"A1: {actionManager->CastActionId} ({actionManager->LastUsedActionSequence} → {actionManager->LastHandledActionSequence})");
             ImGui.Text($"A2: {actionManager->CastTimeElapsed} / {actionManager->CastTimeTotal}");
+            ImGui.Text($"{_questController.TaskQueue.CurrentTaskExecutor?.ProgressContext}");
         }
 #endif