From 09f8cb90785d8dba3cfe690dd6a7923ef094383a Mon Sep 17 00:00:00 2001 From: Liza Carvelli Date: Sun, 22 Sep 2024 14:31:14 +0200 Subject: [PATCH] Add experimental PurchaseItem step --- LLib | 2 +- .../RoslynElements/QuestStepExtensions.cs | 2 + .../3728_To Thaw a Frozen Heart.json | 9 +- QuestPaths/quest-v1.json | 39 +++++ .../Converter/InteractionTypeConverter.cs | 1 + .../Questing/EInteractionType.cs | 1 + Questionable.Model/Questing/PurchaseMenu.cs | 12 ++ Questionable.Model/Questing/QuestStep.cs | 1 + .../GameUi/InteractionUiController.cs | 20 +++ .../Controller/GameUi/ShopController.cs | 156 ++++++++++++++++++ .../Controller/Steps/Interactions/Interact.cs | 11 +- .../Steps/Interactions/PurchaseItem.cs | 22 +++ Questionable/Controller/Steps/TaskExecutor.cs | 3 +- Questionable/Functions/ExcelFunctions.cs | 5 + Questionable/QuestionablePlugin.cs | 2 + .../QuestComponents/CreationUtilsComponent.cs | 6 +- 16 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 Questionable.Model/Questing/PurchaseMenu.cs create mode 100644 Questionable/Controller/GameUi/ShopController.cs create mode 100644 Questionable/Controller/Steps/Interactions/PurchaseItem.cs diff --git a/LLib b/LLib index 43c3dba1..e6e3a1f2 160000 --- a/LLib +++ b/LLib @@ -1 +1 @@ -Subproject commit 43c3dba112c202e2d0ff1a6909020c2b83e20dc3 +Subproject commit e6e3a1f29715e2af4976dd7338ed2f09ae82c99c diff --git a/QuestPathGenerator/RoslynElements/QuestStepExtensions.cs b/QuestPathGenerator/RoslynElements/QuestStepExtensions.cs index 6aca708a..45f2a858 100644 --- a/QuestPathGenerator/RoslynElements/QuestStepExtensions.cs +++ b/QuestPathGenerator/RoslynElements/QuestStepExtensions.cs @@ -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(), diff --git a/QuestPaths/3.x - Heavensward/Unlocks/Ishgard Restoration/3728_To Thaw a Frozen Heart.json b/QuestPaths/3.x - Heavensward/Unlocks/Ishgard Restoration/3728_To Thaw a Frozen Heart.json index bef2725b..98d6a072 100644 --- a/QuestPaths/3.x - Heavensward/Unlocks/Ishgard Restoration/3728_To Thaw a Frozen Heart.json +++ b/QuestPaths/3.x - Heavensward/Unlocks/Ishgard Restoration/3728_To Thaw a Frozen Heart.json @@ -43,7 +43,14 @@ "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", diff --git a/QuestPaths/quest-v1.json b/QuestPaths/quest-v1.json index 4282b7c9..a29a43fa 100644 --- a/QuestPaths/quest-v1.json +++ b/QuestPaths/quest-v1.json @@ -119,6 +119,7 @@ "Combat", "UseItem", "EquipItem", + "PurchaseItem", "EquipRecommended", "Say", "Emote", @@ -707,6 +708,44 @@ ] } }, + { + "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": { diff --git a/Questionable.Model/Questing/Converter/InteractionTypeConverter.cs b/Questionable.Model/Questing/Converter/InteractionTypeConverter.cs index 34320633..aef951bf 100644 --- a/Questionable.Model/Questing/Converter/InteractionTypeConverter.cs +++ b/Questionable.Model/Questing/Converter/InteractionTypeConverter.cs @@ -16,6 +16,7 @@ public sealed class InteractionTypeConverter() : EnumConverter { EInteractionType.Combat, "Combat" }, { EInteractionType.UseItem, "UseItem" }, { EInteractionType.EquipItem, "EquipItem" }, + { EInteractionType.PurchaseItem, "PurchaseItem" }, { EInteractionType.EquipRecommended, "EquipRecommended" }, { EInteractionType.Say, "Say" }, { EInteractionType.Emote, "Emote" }, diff --git a/Questionable.Model/Questing/EInteractionType.cs b/Questionable.Model/Questing/EInteractionType.cs index d9942ef4..af23e90e 100644 --- a/Questionable.Model/Questing/EInteractionType.cs +++ b/Questionable.Model/Questing/EInteractionType.cs @@ -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 index 00000000..e0a2172b --- /dev/null +++ b/Questionable.Model/Questing/PurchaseMenu.cs @@ -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; } +} diff --git a/Questionable.Model/Questing/QuestStep.cs b/Questionable.Model/Questing/QuestStep.cs index da851c83..c5626a5d 100644 --- a/Questionable.Model/Questing/QuestStep.cs +++ b/Questionable.Model/Questing/QuestStep.cs @@ -78,6 +78,7 @@ public sealed class QuestStep public List CompletionQuestVariablesFlags { get; set; } = []; public List DialogueChoices { get; set; } = []; public List PointMenuChoices { get; set; } = []; + public PurchaseMenu? PurchaseMenu { get; set; } // TODO: Not implemented [JsonConverter(typeof(ElementIdConverter))] diff --git a/Questionable/Controller/GameUi/InteractionUiController.cs b/Questionable/Controller/GameUi/InteractionUiController.cs index e94a5716..6db38b80 100644 --- a/Questionable/Controller/GameUi/InteractionUiController.cs +++ b/Questionable/Controller/GameUi/InteractionUiController.cs @@ -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 _logger; private readonly Regex _returnRegex; @@ -67,6 +68,7 @@ internal sealed class InteractionUiController : IDisposable ITargetManager targetManager, IPluginLog pluginLog, IClientState clientState, + ShopController shopController, ILogger 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()!.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 index 00000000..611078e5 --- /dev/null +++ b/Questionable/Controller/GameUi/ShopController.cs @@ -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 _logger; + + public ShopController(QuestController questController, IGameGui gameGui, IAddonLifecycle addonLifecycle, + IFramework framework, ILogger 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); + } +} diff --git a/Questionable/Controller/Steps/Interactions/Interact.cs b/Questionable/Controller/Steps/Interactions/Interact.cs index 1b7caea3..3cb137c4 100644 --- a/Questionable/Controller/Steps/Interactions/Interact.cs +++ b/Questionable/Controller/Steps/Interactions/Interact.cs @@ -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 index 00000000..e0cf5961 --- /dev/null +++ b/Questionable/Controller/Steps/Interactions/PurchaseItem.cs @@ -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 + { + + } +} diff --git a/Questionable/Controller/Steps/TaskExecutor.cs b/Questionable/Controller/Steps/TaskExecutor.cs index 4f48b56b..e5b2c2e9 100644 --- a/Questionable/Controller/Steps/TaskExecutor.cs +++ b/Questionable/Controller/Steps/TaskExecutor.cs @@ -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 : 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() diff --git a/Questionable/Functions/ExcelFunctions.cs b/Questionable/Functions/ExcelFunctions.cs index cb0cf63e..e1278a59 100644 --- a/Questionable/Functions/ExcelFunctions.cs +++ b/Questionable/Functions/ExcelFunctions.cs @@ -91,6 +91,11 @@ internal sealed class ExcelFunctions var questRow = _dataManager.GetExcelSheet()!.GetRow(rowId); return questRow?.Unknown10; } + else if (excelSheet is "GilShop") + { + var questRow = _dataManager.GetExcelSheet()!.GetRow(rowId); + return questRow?.Name; + } else if (excelSheet is "ContentTalk" or null) { var questRow = _dataManager.GetExcelSheet()!.GetRow(rowId); diff --git a/Questionable/QuestionablePlugin.cs b/Questionable/QuestionablePlugin.cs index acee9943..a3731ed4 100644 --- a/Questionable/QuestionablePlugin.cs +++ b/Questionable/QuestionablePlugin.cs @@ -224,6 +224,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); @@ -284,6 +285,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin serviceProvider.GetRequiredService(); serviceProvider.GetRequiredService(); serviceProvider.GetRequiredService(); + serviceProvider.GetRequiredService(); serviceProvider.GetRequiredService(); serviceProvider.GetRequiredService(); serviceProvider.GetRequiredService().Enable(); diff --git a/Questionable/Windows/QuestComponents/CreationUtilsComponent.cs b/Questionable/Windows/QuestComponents/CreationUtilsComponent.cs index d8faefde..d1db774f 100644 --- a/Questionable/Windows/QuestComponents/CreationUtilsComponent.cs +++ b/Questionable/Windows/QuestComponents/CreationUtilsComponent.cs @@ -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 _logger; public CreationUtilsComponent( + QuestController questController, MovementController movementController, GameFunctions gameFunctions, QuestFunctions questFunctions, @@ -56,6 +58,7 @@ internal sealed class CreationUtilsComponent Configuration configuration, ILogger 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