440 lines
13 KiB
C#
440 lines
13 KiB
C#
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<Item> _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<GrandCompanyItem> EmptyItems = new List<GrandCompanyItem>().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<PurchaseCompletedEventArgs>? 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<Item>();
|
|
_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<AtkUnitBase>("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<AtkUnitBase>("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<GrandCompanyItem> GetAvailableItems()
|
|
{
|
|
if (!IsOpen || !_gameGui.TryGetAddonByName<AtkUnitBase>("GrandCompanyExchange", out var addonPtr) || !LAddon.IsAddonReady(addonPtr))
|
|
{
|
|
return EmptyItems;
|
|
}
|
|
int atkValueInt = GetAtkValueInt(addonPtr, 1);
|
|
List<GrandCompanyItem> list = new List<GrandCompanyItem>(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);
|
|
}
|
|
}
|