using System; using Dalamud.Game.ClientState.Conditions; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game; using LLib; using LLib.Shop; using Microsoft.Extensions.Logging; using Questionable.Controller.Steps; using Questionable.Functions; namespace Questionable.Controller.CustomDelivery; internal sealed class VendorPurchaseExecutor(GameFunctions gameFunctions, IGameGui gameGui, ICondition condition, ILogger logger) : TaskExecutor() { private enum EState { InteractWithNpc, WaitForShopOrDialog, BuyItem, WaitForBuy, CloseShop, WaitForClose } private const long StateTimeoutMs = 15000L; private const long BuyConfirmationTimeoutMs = 2000L; private const int MaxBuyAttempts = 3; private EState _state; private Throttle _throttle; private int _purchaseIndex; private long _stateStartMs; private bool _failed; private int _countBeforeBuy; private int _buyAttempts; private bool _confirmationClicked; protected override bool Start() { _state = EState.InteractWithNpc; _throttle.Clear(); _purchaseIndex = 0; _stateStartMs = Environment.TickCount64; _failed = false; _countBeforeBuy = 0; _buyAttempts = 0; _confirmationClicked = false; return true; } public unsafe override ETaskResult Update() { long num = Environment.TickCount64 - _stateStartMs; if (_state != EState.WaitForBuy && num > 15000) { logger.LogWarning("Vendor purchase timed out in state {State} after {Elapsed}ms, retrying", _state, num); return ETaskResult.RetryStep; } switch (_state) { case EState.InteractWithNpc: if (!_throttle.TryReset(1.0)) { return ETaskResult.StillRunning; } if (gameFunctions.InteractWith(base.Task.VendorNpcId)) { logger.LogDebug("Interacted with vendor NPC {NpcId}", base.Task.VendorNpcId); SetState(EState.WaitForShopOrDialog); } return ETaskResult.StillRunning; case EState.WaitForShopOrDialog: if (VendorPurchaseActions.IsShopReady(gameGui)) { logger.LogDebug("Shop is ready, moving to BuyItem"); SetState(EState.BuyItem); } else if (VendorPurchaseActions.IsDialogReady(gameGui)) { if (!_throttle.TryReset(0.5)) { return ETaskResult.StillRunning; } if (VendorPurchaseActions.TrySelectDialogOption(gameGui, base.Task.DialogOptionIndex)) { logger.LogDebug("Selected dialog option {Index} to open shop", base.Task.DialogOptionIndex); } } return ETaskResult.StillRunning; case EState.BuyItem: { if (!_throttle.TryReset(0.5)) { return ETaskResult.StillRunning; } VendorPurchaseTask.PurchaseItem purchaseItem2 = base.Task.Purchases[_purchaseIndex]; int num2 = VendorPurchaseActions.FindItemDisplayIndex(gameGui, purchaseItem2.ItemId); if (num2 < 0) { logger.LogError("Item {ItemId} not found in shop display list", purchaseItem2.ItemId); _failed = true; SetState(EState.CloseShop); return ETaskResult.StillRunning; } _countBeforeBuy = InventoryManager.Instance()->GetInventoryItemCount(purchaseItem2.ItemId, isHq: false, checkEquipped: true, checkArmory: true, 0); if (VendorPurchaseActions.TryBuyItem(gameGui, num2, purchaseItem2.Quantity)) { _buyAttempts++; _confirmationClicked = false; logger.LogDebug("Submitted purchase for item {ItemId} (display index {DisplayIndex}) x{Quantity}, attempt {Attempt}/{MaxAttempts} ({Current}/{Total})", purchaseItem2.ItemId, num2, purchaseItem2.Quantity, _buyAttempts, 3, _purchaseIndex + 1, base.Task.Purchases.Count); SetState(EState.WaitForBuy); } return ETaskResult.StillRunning; } case EState.WaitForBuy: { VendorPurchaseTask.PurchaseItem purchaseItem = base.Task.Purchases[_purchaseIndex]; if (!_confirmationClicked && VendorPurchaseActions.TryConfirmPurchase(gameGui)) { _confirmationClicked = true; logger.LogDebug("Confirmed purchase of item {ItemId}", purchaseItem.ItemId); SetState(EState.WaitForBuy); return ETaskResult.StillRunning; } int inventoryItemCount = InventoryManager.Instance()->GetInventoryItemCount(purchaseItem.ItemId, isHq: false, checkEquipped: true, checkArmory: true, 0); if (inventoryItemCount <= _countBeforeBuy) { if (num < 2000) { return ETaskResult.StillRunning; } if (_buyAttempts < 3) { logger.LogWarning("Purchase of item {ItemId} was not reflected in inventory; retrying ({Attempt}/{MaxAttempts})", purchaseItem.ItemId, _buyAttempts + 1, 3); SetState(EState.BuyItem); return ETaskResult.StillRunning; } logger.LogError("Purchase of item {ItemId} failed after {Attempts} attempts", purchaseItem.ItemId, _buyAttempts); _failed = true; SetState(EState.CloseShop); return ETaskResult.StillRunning; } if (!_throttle.TryReset(0.5)) { return ETaskResult.StillRunning; } logger.LogDebug("Verified purchase of item {ItemId}: inventory {Before} -> {After}", purchaseItem.ItemId, _countBeforeBuy, inventoryItemCount); _purchaseIndex++; if (_purchaseIndex < base.Task.Purchases.Count) { _buyAttempts = 0; logger.LogDebug("Buying next item ({Index}/{Total})", _purchaseIndex + 1, base.Task.Purchases.Count); SetState(EState.BuyItem); } else { logger.LogDebug("All purchases complete, closing shop"); SetState(EState.CloseShop); } return ETaskResult.StillRunning; } case EState.CloseShop: if (!_throttle.TryReset(0.5)) { return ETaskResult.StillRunning; } if (VendorPurchaseActions.TryCloseShop(gameGui)) { logger.LogDebug("Closing shop"); SetState(EState.WaitForClose); } return ETaskResult.StillRunning; case EState.WaitForClose: if (VendorPurchaseActions.IsShopReady(gameGui)) { return ETaskResult.StillRunning; } if (VendorPurchaseActions.IsDialogReady(gameGui)) { if (!_throttle.TryReset(0.5)) { return ETaskResult.StillRunning; } VendorPurchaseActions.TrySelectDialogOption(gameGui, -1); logger.LogDebug("Dismissing post-shop dialog"); return ETaskResult.StillRunning; } if (!condition[ConditionFlag.OccupiedInEvent]) { if (_failed) { logger.LogWarning("Shop closed after failed purchase, retrying"); return ETaskResult.RetryStep; } logger.LogDebug("Shop closed, vendor purchase complete"); return ETaskResult.TaskComplete; } return ETaskResult.StillRunning; default: return ETaskResult.StillRunning; } } private void SetState(EState state) { _state = state; _stateStartMs = Environment.TickCount64; } public override bool ShouldInterruptOnDamage() { return false; } }