using System; using System.Collections.Generic; using System.Collections.ObjectModel; using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Component.GUI; using LLib.GameUI; using LLib.Inventory; using LLib.Shop.Model; using Lumina.Excel; using Lumina.Excel.Sheets; namespace LLib.Shop; public sealed class GrandCompanyShop : IDisposable { private sealed class GrandCompanyPurchaseRequest { public required uint ItemId { get; init; } public required int RankTabIndex { get; init; } public required int CategoryTabIndex { get; init; } public required int DesiredQuantity { get; init; } public int InitialCount { get; set; } = -1; } private const string AddonName = "GrandCompanyExchange"; private const string SelectYesNoAddonName = "SelectYesno"; private readonly IPluginLog _pluginLog; private readonly IGameGui _gameGui; private readonly IAddonLifecycle _addonLifecycle; private readonly ExcelSheet _itemSheet; private const int MinPollIntervalMs = 100; private const int MaxWaitMs = 3000; private const int MaxPurchaseQuantity = 99; private const int AtkItemCountIndex = 1; private const int AtkSealCostBase = 67; private const int AtkIconIdBase = 167; private const int AtkItemIdBase = 317; private static readonly ReadOnlyCollection EmptyItems = new List().AsReadOnly(); private int _navigationStep; private long _lastActionMs; private bool _awaitingAddonReady; private bool _yesNoArmed; private GrandCompanyPurchaseRequest? _pendingPurchase; private int _itemCountBeforePurchase; public bool IsOpen { get; private set; } public bool IsPurchaseInProgress => _pendingPurchase != null; public bool IsAwaitingConfirmation { get; private set; } public event EventHandler? PurchaseCompleted; public event EventHandler? ShopOpened; public event EventHandler? ShopClosed; public GrandCompanyShop(IPluginLog pluginLog, IGameGui gameGui, IAddonLifecycle addonLifecycle, IDataManager dataManager) { ArgumentNullException.ThrowIfNull(pluginLog, "pluginLog"); ArgumentNullException.ThrowIfNull(gameGui, "gameGui"); ArgumentNullException.ThrowIfNull(addonLifecycle, "addonLifecycle"); ArgumentNullException.ThrowIfNull(dataManager, "dataManager"); _pluginLog = pluginLog; _gameGui = gameGui; _addonLifecycle = addonLifecycle; _itemSheet = dataManager.GetExcelSheet(); _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "GrandCompanyExchange", OnShopPostSetup); _addonLifecycle.RegisterListener(AddonEvent.PreFinalize, "GrandCompanyExchange", OnShopPreFinalize); _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "SelectYesno", OnSelectYesNoPostSetup); _addonLifecycle.RegisterListener(AddonEvent.PostUpdate, "SelectYesno", OnSelectYesNoPostUpdate); } private void OnShopPostSetup(AddonEvent type, AddonArgs args) { IsOpen = true; _navigationStep = 0; _pluginLog.Debug("[GCShop] Shop opened"); this.ShopOpened?.Invoke(this, EventArgs.Empty); } private void OnShopPreFinalize(AddonEvent type, AddonArgs args) { uint? num = _pendingPurchase?.ItemId; IsOpen = false; ResetPurchaseState(); _pluginLog.Debug("[GCShop] Shop closed"); this.ShopClosed?.Invoke(this, EventArgs.Empty); if (num.HasValue) { this.PurchaseCompleted?.Invoke(this, new PurchaseCompletedEventArgs(num.Value, success: false)); } } private void OnSelectYesNoPostSetup(AddonEvent type, AddonArgs args) { if (_pendingPurchase != null && IsAwaitingConfirmation) { _yesNoArmed = true; } } private unsafe void OnSelectYesNoPostUpdate(AddonEvent type, AddonArgs args) { if (_pendingPurchase != null && IsAwaitingConfirmation && _yesNoArmed) { AtkUnitBase* address = (AtkUnitBase*)args.Addon.Address; if (LAddon.IsAddonReady(address)) { _yesNoArmed = false; _pluginLog.Information("[GCShop] Confirming purchase dialog"); AddonCallback.ClickYes(address); address->Close(fireCallback: true); IsAwaitingConfirmation = false; _lastActionMs = Environment.TickCount64; } } } public bool StartPurchase(uint itemId, int rankTabIndex, int categoryTabIndex, int quantity) { if (!IsOpen || _pendingPurchase != null) { return false; } _pendingPurchase = new GrandCompanyPurchaseRequest { ItemId = itemId, RankTabIndex = rankTabIndex, CategoryTabIndex = categoryTabIndex, DesiredQuantity = quantity }; _navigationStep = 0; _lastActionMs = 0L; _itemCountBeforePurchase = -1; _pluginLog.Information($"[GCShop] Starting purchase of {quantity}x item {itemId}"); return true; } public void CancelPurchase() { uint? num = _pendingPurchase?.ItemId; ResetPurchaseState(); if (num.HasValue) { this.PurchaseCompleted?.Invoke(this, new PurchaseCompletedEventArgs(num.Value, success: false)); } } public unsafe bool ProcessPurchase() { if (_pendingPurchase == null || !IsOpen) { return false; } InventoryManager* ptr = InventoryManager.Instance(); if (ptr != null && _navigationStep == 0 && _itemCountBeforePurchase == -1) { int inventoryItemCount = ptr->GetInventoryItemCount(_pendingPurchase.ItemId, isHq: false, checkEquipped: true, checkArmory: true, 0); if (inventoryItemCount >= _pendingPurchase.DesiredQuantity) { _pluginLog.Information($"[GCShop] Already have {inventoryItemCount}x item {_pendingPurchase.ItemId} (need {_pendingPurchase.DesiredQuantity})"); uint itemId = _pendingPurchase.ItemId; ResetPurchaseState(); this.PurchaseCompleted?.Invoke(this, new PurchaseCompletedEventArgs(itemId, success: true)); return true; } } if (!_gameGui.TryGetAddonByName("GrandCompanyExchange", out var addonPtr) || !LAddon.IsAddonReady(addonPtr)) { return false; } if (_awaitingAddonReady) { long num = Environment.TickCount64 - _lastActionMs; if (num < 100) { return false; } if (num >= 3000) { _pluginLog.Warning($"[GCShop] Addon did not become ready within {3000}ms, cancelling purchase"); CancelPurchase(); return false; } if (_navigationStep == 2 && GetAtkValueInt(addonPtr, 1) <= 0) { return false; } _awaitingAddonReady = false; } switch (_navigationStep) { case 0: AddonCallback.Fire(addonPtr, 1, _pendingPurchase.RankTabIndex); _navigationStep++; _lastActionMs = Environment.TickCount64; _awaitingAddonReady = true; break; case 1: AddonCallback.Fire(addonPtr, 2, _pendingPurchase.CategoryTabIndex); _navigationStep++; _lastActionMs = Environment.TickCount64; _awaitingAddonReady = true; break; case 2: { GrandCompanyItem grandCompanyItem = FindItemInShop(addonPtr, _pendingPurchase.ItemId); if (grandCompanyItem != null) { InventoryManager* ptr2 = InventoryManager.Instance(); int num2 = ((ptr2 != null) ? ptr2->GetInventoryItemCount(_pendingPurchase.ItemId, isHq: false, checkEquipped: true, checkArmory: true, 0) : 0); if (_pendingPurchase.InitialCount == -1) { _pendingPurchase.InitialCount = num2; } _itemCountBeforePurchase = num2; int num3 = _pendingPurchase.DesiredQuantity - (num2 - _pendingPurchase.InitialCount); int num4 = ((grandCompanyItem.SealCost != 0) ? (GetCompanySeals() / (int)grandCompanyItem.SealCost) : 0); int maxHoldableQuantity = InventoryHelper.GetMaxHoldableQuantity(_pendingPurchase.ItemId, _itemSheet); int num5 = Math.Min(Math.Min(num3, 99), Math.Min(num4, maxHoldableQuantity)); if (num5 <= 0) { _pluginLog.Warning($"[GCShop] Cannot buy item {_pendingPurchase.ItemId}: remaining={num3}, affordable={num4}, holdable={maxHoldableQuantity}"); CancelPurchase(); } else { _pluginLog.Information($"[GCShop] Found item {grandCompanyItem.ItemId} at index {grandCompanyItem.Index}, purchasing {num5}x (count before: {num2})"); FirePurchaseCallback(addonPtr, grandCompanyItem, num5); IsAwaitingConfirmation = true; _navigationStep++; _lastActionMs = Environment.TickCount64; } } else { _pluginLog.Warning($"[GCShop] Item {_pendingPurchase.ItemId} not found in shop"); CancelPurchase(); } break; } case 3: return WaitForPurchaseCompletion(); } return false; } public unsafe void CloseShop() { if (IsOpen && _gameGui.TryGetAddonByName("GrandCompanyExchange", out var addonPtr)) { addonPtr->Close(fireCallback: true); } } public unsafe static int GetCompanySeals() { InventoryManager* ptr = InventoryManager.Instance(); PlayerState* ptr2 = PlayerState.Instance(); if (ptr == null || ptr2 == null) { return 0; } return (int)ptr->GetCompanySeals(ptr2->GrandCompany); } public unsafe ReadOnlyCollection GetAvailableItems() { if (!IsOpen || !_gameGui.TryGetAddonByName("GrandCompanyExchange", out var addonPtr) || !LAddon.IsAddonReady(addonPtr)) { return EmptyItems; } int atkValueInt = GetAtkValueInt(addonPtr, 1); List list = new List(atkValueInt); for (int i = 0; i < atkValueInt; i++) { int atkValueInt2 = GetAtkValueInt(addonPtr, 317 + i); if (atkValueInt2 > 0) { list.Add(new GrandCompanyItem { Index = i, ItemId = (uint)atkValueInt2, IconId = (uint)GetAtkValueInt(addonPtr, 167 + i), SealCost = (uint)GetAtkValueInt(addonPtr, 67 + i) }); } } return list.AsReadOnly(); } private unsafe static GrandCompanyItem? FindItemInShop(AtkUnitBase* addon, uint targetItemId) { int atkValueInt = GetAtkValueInt(addon, 1); if (atkValueInt <= 0) { return null; } for (int i = 0; i < atkValueInt; i++) { int atkValueInt2 = GetAtkValueInt(addon, 317 + i); if (atkValueInt2 == (int)targetItemId) { return new GrandCompanyItem { Index = i, ItemId = (uint)atkValueInt2, IconId = (uint)GetAtkValueInt(addon, 167 + i), SealCost = (uint)GetAtkValueInt(addon, 67 + i) }; } } return null; } private unsafe bool WaitForPurchaseCompletion() { long num = Environment.TickCount64 - _lastActionMs; if (IsAwaitingConfirmation) { if (num >= 3000) { _pluginLog.Warning("[GCShop] Confirmation dialog was not handled in time, cancelling purchase"); CancelPurchase(); } return false; } InventoryManager* ptr = InventoryManager.Instance(); if (ptr != null && ptr->GetInventoryItemCount(_pendingPurchase.ItemId, isHq: false, checkEquipped: true, checkArmory: true, 0) > _itemCountBeforePurchase) { int num2 = ptr->GetInventoryItemCount(_pendingPurchase.ItemId, isHq: false, checkEquipped: true, checkArmory: true, 0) - _pendingPurchase.InitialCount; if (num2 >= _pendingPurchase.DesiredQuantity) { _pluginLog.Information($"[GCShop] Purchase of {num2}x {_pendingPurchase.ItemId} completed"); uint itemId = _pendingPurchase.ItemId; int quantityPurchased = num2; ResetPurchaseState(); this.PurchaseCompleted?.Invoke(this, new PurchaseCompletedEventArgs(itemId, success: true, quantityPurchased)); return true; } _pluginLog.Information($"[GCShop] Batch complete ({num2}/{_pendingPurchase.DesiredQuantity}), starting next batch"); _navigationStep = 2; _lastActionMs = Environment.TickCount64; _awaitingAddonReady = true; return false; } if (num >= 3000) { _pluginLog.Warning($"[GCShop] Item count did not increase within {3000}ms after confirmation"); CancelPurchase(); } return false; } private void ResetPurchaseState() { _pendingPurchase = null; IsAwaitingConfirmation = false; _yesNoArmed = false; _awaitingAddonReady = false; _navigationStep = 0; _itemCountBeforePurchase = -1; } private unsafe void FirePurchaseCallback(AtkUnitBase* addon, GrandCompanyItem item, int quantity) { AtkValue* ptr = stackalloc AtkValue[9]; ptr->Type = AtkValueType.Int; ptr->Int = 0; ptr[1].Type = AtkValueType.Int; ptr[1].Int = item.Index; ptr[2].Type = AtkValueType.Int; ptr[2].Int = quantity; ptr[3].Type = AtkValueType.Int; ptr[3].Int = 0; ptr[4].Type = AtkValueType.Int; ptr[4].Int = 0; ptr[5].Type = AtkValueType.Int; ptr[5].Int = 0; ptr[6].Type = AtkValueType.UInt; ptr[6].UInt = item.ItemId; ptr[7].Type = AtkValueType.UInt; ptr[7].UInt = item.IconId; ptr[8].Type = AtkValueType.UInt; ptr[8].UInt = item.SealCost; addon->FireCallback(9u, ptr); } private unsafe static int GetAtkValueInt(AtkUnitBase* addon, int index) { if (addon == null || addon->AtkValues == null || index < 0 || index >= addon->AtkValuesCount) { return -1; } AtkValue atkValue = addon->AtkValues[index]; return atkValue.Type switch { AtkValueType.Int => atkValue.Int, AtkValueType.UInt => (int)atkValue.UInt, AtkValueType.Bool => (atkValue.Byte != 0) ? 1 : 0, _ => -1, }; } public void Dispose() { _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "GrandCompanyExchange", OnShopPostSetup); _addonLifecycle.UnregisterListener(AddonEvent.PreFinalize, "GrandCompanyExchange", OnShopPreFinalize); _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "SelectYesno", OnSelectYesNoPostSetup); _addonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "SelectYesno", OnSelectYesNoPostUpdate); } }