From e160746d42ddeaacf60169d231b0eac05acd9e5f Mon Sep 17 00:00:00 2001 From: Blair Date: Mon, 17 Jun 2024 03:40:48 +1000 Subject: [PATCH] Add MarketBoard service and associated interfaces, test and data widget (#1822) * Add MarketBoard service and associated interfaces, test and data widget * Dispose of events properly * Make listings readonly lists + provide internal list for internal use * Rename CatalogId to ItemId on interfaces, have kept CatalogId internally as it's technically correct * Removed RetainerOwnerId from the public interface * Removed NextCatalogId from the public interface * Updated test text * Null events in scoped service disposal --- Dalamud/Game/Marketboard/MarketBoard.cs | 165 ++++++++++ .../Game/Network/Internal/NetworkHandlers.cs | 37 ++- .../Structures/MarketBoardCurrentOfferings.cs | 156 ++++++++- .../Network/Structures/MarketBoardHistory.cs | 72 ++++- .../Network/Structures/MarketBoardPurchase.cs | 19 +- .../Structures/MarketBoardPurchaseHandler.cs | 34 +- .../Game/Network/Structures/MarketTaxRates.cs | 48 ++- .../Internal/Windows/Data/DataWindow.cs | 1 + .../Windows/Data/Widgets/MarketBoardWidget.cs | 302 ++++++++++++++++++ .../AgingSteps/MarketBoardAgingStep.cs | 259 +++++++++++++++ .../Windows/SelfTest/SelfTestWindow.cs | 1 + Dalamud/Plugin/Services/IMarketBoard.cs | 64 ++++ 12 files changed, 1131 insertions(+), 27 deletions(-) create mode 100644 Dalamud/Game/Marketboard/MarketBoard.cs create mode 100644 Dalamud/Interface/Internal/Windows/Data/Widgets/MarketBoardWidget.cs create mode 100644 Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/MarketBoardAgingStep.cs create mode 100644 Dalamud/Plugin/Services/IMarketBoard.cs diff --git a/Dalamud/Game/Marketboard/MarketBoard.cs b/Dalamud/Game/Marketboard/MarketBoard.cs new file mode 100644 index 000000000..2223cc1a8 --- /dev/null +++ b/Dalamud/Game/Marketboard/MarketBoard.cs @@ -0,0 +1,165 @@ +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Plugin.Services; + +namespace Dalamud.Game.MarketBoard; + +using Network.Internal; +using Network.Structures; + +/// +/// This class provides access to market board events +/// +[InterfaceVersion("1.0")] +[ServiceManager.EarlyLoadedService] +internal class MarketBoard : IInternalDisposableService, IMarketBoard +{ + [ServiceManager.ServiceDependency] + private readonly NetworkHandlers networkHandlers = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + [ServiceManager.ServiceConstructor] + public MarketBoard() + { + this.networkHandlers.MbHistoryObservable.Subscribe(this.OnMbHistory); + this.networkHandlers.MbPurchaseObservable.Subscribe(this.OnPurchase); + this.networkHandlers.MbOfferingsObservable.Subscribe(this.OnOfferings); + this.networkHandlers.MbPurchaseSentObservable.Subscribe(this.OnPurchaseSent); + this.networkHandlers.MbTaxesObservable.Subscribe(this.OnTaxRates); + } + + /// + public event IMarketBoard.HistoryReceivedDelegate? HistoryReceived; + + /// + public event IMarketBoard.ItemPurchasedDelegate? ItemPurchased; + + /// + public event IMarketBoard.OfferingsReceivedDelegate? OfferingsReceived; + + /// + public event IMarketBoard.PurchaseRequestedDelegate? PurchaseRequested; + + /// + public event IMarketBoard.TaxRatesReceivedDelegate? TaxRatesReceived; + + /// + public void DisposeService() + { + this.HistoryReceived = null; + this.ItemPurchased = null; + this.OfferingsReceived = null; + this.PurchaseRequested = null; + this.TaxRatesReceived = null; + } + + private void OnMbHistory(MarketBoardHistory marketBoardHistory) + { + this.HistoryReceived?.Invoke(marketBoardHistory); + } + + private void OnPurchase(MarketBoardPurchase marketBoardHistory) + { + this.ItemPurchased?.Invoke(marketBoardHistory); + } + + private void OnOfferings(MarketBoardCurrentOfferings currentOfferings) + { + this.OfferingsReceived?.Invoke(currentOfferings); + } + + private void OnPurchaseSent(MarketBoardPurchaseHandler purchaseHandler) + { + this.PurchaseRequested?.Invoke(purchaseHandler); + } + + private void OnTaxRates(MarketTaxRates taxRates) + { + this.TaxRatesReceived?.Invoke(taxRates); + } +} + +/// +/// Plugin scoped version of MarketBoard. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class MarketBoardPluginScoped : IInternalDisposableService, IMarketBoard +{ + [ServiceManager.ServiceDependency] + private readonly MarketBoard marketBoardService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + internal MarketBoardPluginScoped() + { + this.marketBoardService.HistoryReceived += this.OnHistoryReceived; + this.marketBoardService.ItemPurchased += this.OnItemPurchased; + this.marketBoardService.OfferingsReceived += this.OnOfferingsReceived; + this.marketBoardService.PurchaseRequested += this.OnPurchaseRequested; + this.marketBoardService.TaxRatesReceived += this.OnTaxRatesReceived; + } + + /// + public event IMarketBoard.HistoryReceivedDelegate? HistoryReceived; + + /// + public event IMarketBoard.ItemPurchasedDelegate? ItemPurchased; + + /// + public event IMarketBoard.OfferingsReceivedDelegate? OfferingsReceived; + + /// + public event IMarketBoard.PurchaseRequestedDelegate? PurchaseRequested; + + /// + public event IMarketBoard.TaxRatesReceivedDelegate? TaxRatesReceived; + + /// + void IInternalDisposableService.DisposeService() + { + this.marketBoardService.HistoryReceived -= this.OnHistoryReceived; + this.marketBoardService.ItemPurchased -= this.OnItemPurchased; + this.marketBoardService.OfferingsReceived -= this.OnOfferingsReceived; + this.marketBoardService.PurchaseRequested -= this.OnPurchaseRequested; + this.marketBoardService.TaxRatesReceived -= this.OnTaxRatesReceived; + + this.HistoryReceived = null; + this.ItemPurchased = null; + this.OfferingsReceived = null; + this.PurchaseRequested = null; + this.TaxRatesReceived = null; + } + + private void OnHistoryReceived(IMarketBoardHistory history) + { + this.HistoryReceived?.Invoke(history); + } + + private void OnItemPurchased(IMarketBoardPurchase purchase) + { + this.ItemPurchased?.Invoke(purchase); + } + + private void OnOfferingsReceived(IMarketBoardCurrentOfferings currentOfferings) + { + this.OfferingsReceived?.Invoke(currentOfferings); + } + + private void OnPurchaseRequested(IMarketBoardPurchaseHandler purchaseHandler) + { + this.PurchaseRequested?.Invoke(purchaseHandler); + } + + private void OnTaxRatesReceived(IMarketTaxRates taxRates) + { + this.TaxRatesReceived?.Invoke(taxRates); + } +} diff --git a/Dalamud/Game/Network/Internal/NetworkHandlers.cs b/Dalamud/Game/Network/Internal/NetworkHandlers.cs index f7091817c..0d08b90d9 100644 --- a/Dalamud/Game/Network/Internal/NetworkHandlers.cs +++ b/Dalamud/Game/Network/Internal/NetworkHandlers.cs @@ -209,6 +209,18 @@ internal unsafe class NetworkHandlers : IInternalDisposableService private event Action? MarketBoardPurchaseRequestSent; + public IObservable MbPurchaseObservable => this.mbPurchaseObservable; + + public IObservable MbHistoryObservable => this.mbHistoryObservable; + + public IObservable MbTaxesObservable => this.mbTaxesObservable; + + public IObservable MbItemRequestObservable => this.mbItemRequestObservable; + + public IObservable MbOfferingsObservable => this.mbOfferingsObservable; + + public IObservable MbPurchaseSentObservable => this.mbPurchaseSentObservable; + /// /// Disposes of managed and unmanaged resources. /// @@ -301,7 +313,7 @@ internal unsafe class NetworkHandlers : IInternalDisposableService private IObservable> OnMarketBoardListingsBatch( IObservable start) { - var offeringsObservable = this.mbOfferingsObservable.Publish().RefCount(); + var offeringsObservable = this.MbOfferingsObservable.Publish().RefCount(); void LogEndObserved(MarketBoardCurrentOfferings offerings) { @@ -315,7 +327,7 @@ internal unsafe class NetworkHandlers : IInternalDisposableService Log.Verbose( "Observed element of request {RequestId} with {NumListings} listings", offerings.RequestId, - offerings.ItemListings.Count); + offerings.InternalItemListings.Count); } IObservable UntilBatchEnd(MarketBoardItemRequest request) @@ -327,7 +339,7 @@ internal unsafe class NetworkHandlers : IInternalDisposableService } return offeringsObservable - .Where(offerings => offerings.ItemListings.All(l => l.CatalogId == request.CatalogId)) + .Where(offerings => offerings.InternalItemListings.All(l => l.CatalogId == request.CatalogId)) .Skip(totalPackets - 1) .Do(LogEndObserved); } @@ -343,7 +355,7 @@ internal unsafe class NetworkHandlers : IInternalDisposableService new List(), (agg, next) => { - agg.AddRange(next.ItemListings); + agg.AddRange(next.InternalItemListings); return agg; })); } @@ -351,14 +363,14 @@ internal unsafe class NetworkHandlers : IInternalDisposableService private IObservable> OnMarketBoardSalesBatch( IObservable start) { - var historyObservable = this.mbHistoryObservable.Publish().RefCount(); + var historyObservable = this.MbHistoryObservable.Publish().RefCount(); void LogHistoryObserved(MarketBoardHistory history) { Log.Verbose( "Observed history for item {CatalogId} with {NumSales} sales", history.CatalogId, - history.HistoryListings.Count); + history.InternalHistoryListings.Count); } IObservable UntilBatchEnd(MarketBoardItemRequest request) @@ -379,7 +391,7 @@ internal unsafe class NetworkHandlers : IInternalDisposableService new List(), (agg, next) => { - agg.AddRange(next.HistoryListings); + agg.AddRange(next.InternalHistoryListings); return agg; })); } @@ -394,7 +406,7 @@ internal unsafe class NetworkHandlers : IInternalDisposableService request.AmountToArrive); } - var startObservable = this.mbItemRequestObservable + var startObservable = this.MbItemRequestObservable .Where(request => request.Ok).Do(LogStartObserved) .Publish() .RefCount(); @@ -450,7 +462,7 @@ internal unsafe class NetworkHandlers : IInternalDisposableService private IDisposable HandleMarketTaxRates() { - return this.mbTaxesObservable + return this.MbTaxesObservable .Where(this.ShouldUpload) .SubscribeOn(ThreadPoolScheduler.Instance) .Subscribe( @@ -476,8 +488,8 @@ internal unsafe class NetworkHandlers : IInternalDisposableService private IDisposable HandleMarketBoardPurchaseHandler() { - return this.mbPurchaseSentObservable - .Zip(this.mbPurchaseObservable) + return this.MbPurchaseSentObservable + .Zip(this.MbPurchaseObservable) .Where(this.ShouldUpload) .SubscribeOn(ThreadPoolScheduler.Instance) .Subscribe( @@ -544,7 +556,8 @@ internal unsafe class NetworkHandlers : IInternalDisposableService { try { - if (eventId == 7 && responseId == 8) + // Event ID 0 covers the crystarium, 7 covers all other cities + if (eventId is 7 or 0 && responseId == 8) this.MarketBoardTaxesReceived?.InvokeSafely((nint)args); } catch (Exception ex) diff --git a/Dalamud/Game/Network/Structures/MarketBoardCurrentOfferings.cs b/Dalamud/Game/Network/Structures/MarketBoardCurrentOfferings.cs index fc782cd51..324a00d65 100644 --- a/Dalamud/Game/Network/Structures/MarketBoardCurrentOfferings.cs +++ b/Dalamud/Game/Network/Structures/MarketBoardCurrentOfferings.cs @@ -7,7 +7,7 @@ namespace Dalamud.Game.Network.Structures; /// /// This class represents the current market board offerings from a game network packet. /// -public class MarketBoardCurrentOfferings +public class MarketBoardCurrentOfferings : IMarketBoardCurrentOfferings { private MarketBoardCurrentOfferings() { @@ -16,7 +16,10 @@ public class MarketBoardCurrentOfferings /// /// Gets the list of individual item listings. /// - public List ItemListings { get; } = new(); + IReadOnlyList IMarketBoardCurrentOfferings.ItemListings => this.InternalItemListings; + + internal List InternalItemListings { get; set; } = new List(); + /// /// Gets the listing end index. @@ -45,6 +48,8 @@ public class MarketBoardCurrentOfferings using var stream = new UnmanagedMemoryStream((byte*)dataPtr.ToPointer(), 1544); using var reader = new BinaryReader(stream); + var listings = new List(); + for (var i = 0; i < 10; i++) { var listingEntry = new MarketBoardItemListing(); @@ -64,6 +69,8 @@ public class MarketBoardCurrentOfferings reader.ReadUInt16(); // durability reader.ReadUInt16(); // spiritbond + var materiaList = new List(); + for (var materiaIndex = 0; materiaIndex < 5; materiaIndex++) { var materiaVal = reader.ReadUInt16(); @@ -74,9 +81,11 @@ public class MarketBoardCurrentOfferings }; if (materiaEntry.MateriaId != 0) - listingEntry.Materia.Add(materiaEntry); + materiaList.Add(materiaEntry); } + listingEntry.Materia = materiaList; + reader.ReadUInt16(); reader.ReadUInt32(); @@ -92,9 +101,10 @@ public class MarketBoardCurrentOfferings reader.ReadUInt32(); if (listingEntry.CatalogId != 0) - output.ItemListings.Add(listingEntry); + listings.Add(listingEntry); } + output.InternalItemListings = listings; output.ListingIndexEnd = reader.ReadByte(); output.ListingIndexStart = reader.ReadByte(); output.RequestId = reader.ReadUInt16(); @@ -105,7 +115,7 @@ public class MarketBoardCurrentOfferings /// /// This class represents the current market board offering of a single item from the network packet. /// - public class MarketBoardItemListing + public class MarketBoardItemListing : IMarketBoardItemListing { /// /// Initializes a new instance of the class. @@ -119,6 +129,9 @@ public class MarketBoardCurrentOfferings /// public ulong ArtisanId { get; internal set; } + /// + public uint ItemId => this.CatalogId; + /// /// Gets the catalog ID. /// @@ -147,7 +160,7 @@ public class MarketBoardCurrentOfferings /// /// Gets the list of materia attached to this item. /// - public List Materia { get; } = new(); + public IReadOnlyList Materia { get; internal set; } = new List(); /// /// Gets the amount of attached materia. @@ -202,7 +215,7 @@ public class MarketBoardCurrentOfferings /// /// This represents the materia slotted to an . /// - public class ItemMateria + public class ItemMateria : IItemMateria { /// /// Initializes a new instance of the class. @@ -223,3 +236,132 @@ public class MarketBoardCurrentOfferings } } } + +/// +/// An interface that represents the current market board offerings. +/// +public interface IMarketBoardCurrentOfferings +{ + /// + /// Gets the list of individual item listings. + /// + IReadOnlyList ItemListings { get; } + + /// + /// Gets the listing end index. + /// + int ListingIndexEnd { get; } + + /// + /// Gets the listing start index. + /// + int ListingIndexStart { get; } + + /// + /// Gets the request ID. + /// + int RequestId { get; } +} + +/// +/// An interface that represents the current market board offering of a single item from the . +/// +public interface IMarketBoardItemListing +{ + /// + /// Gets the artisan ID. + /// + ulong ArtisanId { get; } + + /// + /// Gets the item ID. + /// + uint ItemId { get; } + + /// + /// Gets a value indicating whether the item is HQ. + /// + bool IsHq { get; } + + /// + /// Gets the item quantity. + /// + uint ItemQuantity { get; } + + /// + /// Gets the time this offering was last reviewed. + /// + DateTime LastReviewTime { get; } + + /// + /// Gets the listing ID. + /// + ulong ListingId { get; } + + /// + /// Gets the list of materia attached to this item. + /// + IReadOnlyList Materia { get; } + + /// + /// Gets the amount of attached materia. + /// + int MateriaCount { get; } + + /// + /// Gets a value indicating whether this item is on a mannequin. + /// + bool OnMannequin { get; } + + /// + /// Gets the player name. + /// + string PlayerName { get; } + + /// + /// Gets the price per unit. + /// + uint PricePerUnit { get; } + + /// + /// Gets the city ID of the retainer selling the item. + /// + int RetainerCityId { get; } + + /// + /// Gets the ID of the retainer selling the item. + /// + ulong RetainerId { get; } + + /// + /// Gets the name of the retainer. + /// + string RetainerName { get; } + + /// + /// Gets the stain or applied dye of the item. + /// + int StainId { get; } + + /// + /// Gets the total tax. + /// + uint TotalTax { get; } +} + +/// +/// An interface that represents the materia slotted to an . +/// +public interface IItemMateria +{ + /// + /// Gets the materia index. + /// + int Index { get; } + + /// + /// Gets the materia ID. + /// + int MateriaId { get; } +} + diff --git a/Dalamud/Game/Network/Structures/MarketBoardHistory.cs b/Dalamud/Game/Network/Structures/MarketBoardHistory.cs index 148c321db..fd1011bca 100644 --- a/Dalamud/Game/Network/Structures/MarketBoardHistory.cs +++ b/Dalamud/Game/Network/Structures/MarketBoardHistory.cs @@ -7,7 +7,7 @@ namespace Dalamud.Game.Network.Structures; /// /// This class represents the market board history from a game network packet. /// -public class MarketBoardHistory +public class MarketBoardHistory : IMarketBoardHistory { /// /// Initializes a new instance of the class. @@ -26,10 +26,17 @@ public class MarketBoardHistory /// public uint CatalogId2 { get; private set; } + public uint ItemId => this.CatalogId; + /// - /// Gets the list of individual item history listings. + /// Gets the list of individual item listings. /// - public List HistoryListings { get; } = new(); + IReadOnlyList IMarketBoardHistory.HistoryListings => this.InternalHistoryListings; + + /// + /// Gets or sets a list of individual item listings. + /// + internal List InternalHistoryListings { get; set; } = new List(); /// /// Read a object from memory. @@ -53,6 +60,7 @@ public class MarketBoardHistory return output; } + var historyListings = new List(); for (var i = 0; i < 20; i++) { var listingEntry = new MarketBoardHistoryListing @@ -69,19 +77,21 @@ public class MarketBoardHistory listingEntry.BuyerName = Encoding.UTF8.GetString(reader.ReadBytes(33)).TrimEnd('\u0000'); listingEntry.NextCatalogId = reader.ReadUInt32(); - output.HistoryListings.Add(listingEntry); + historyListings.Add(listingEntry); if (listingEntry.NextCatalogId == 0) break; } + output.InternalHistoryListings = historyListings; + return output; } /// /// This class represents the market board history of a single item from the network packet. /// - public class MarketBoardHistoryListing + public class MarketBoardHistoryListing : IMarketBoardHistoryListing { /// /// Initializes a new instance of the class. @@ -126,3 +136,55 @@ public class MarketBoardHistory public uint SalePrice { get; internal set; } } } + +/// +/// An interface that represents the market board history from the game. +/// +public interface IMarketBoardHistory +{ + /// + /// Gets the item ID. + /// + uint ItemId { get; } + + /// + /// Gets the list of individual item history listings. + /// + IReadOnlyList HistoryListings { get; } +} + +/// +/// An interface that represents the market board history of a single item from . +/// +public interface IMarketBoardHistoryListing +{ + /// + /// Gets the buyer's name. + /// + string BuyerName { get; } + + /// + /// Gets a value indicating whether the item is HQ. + /// + bool IsHq { get; } + + /// + /// Gets a value indicating whether the item is on a mannequin. + /// + bool OnMannequin { get; } + + /// + /// Gets the time of purchase. + /// + DateTime PurchaseTime { get; } + + /// + /// Gets the quantity. + /// + uint Quantity { get; } + + /// + /// Gets the sale price. + /// + uint SalePrice { get; } +} diff --git a/Dalamud/Game/Network/Structures/MarketBoardPurchase.cs b/Dalamud/Game/Network/Structures/MarketBoardPurchase.cs index 62d104cff..4221188e4 100644 --- a/Dalamud/Game/Network/Structures/MarketBoardPurchase.cs +++ b/Dalamud/Game/Network/Structures/MarketBoardPurchase.cs @@ -6,7 +6,7 @@ namespace Dalamud.Game.Network.Structures; /// Represents market board purchase information. This message is received from the /// server when a purchase is made at a market board. /// -public class MarketBoardPurchase +public class MarketBoardPurchase : IMarketBoardPurchase { private MarketBoardPurchase() { @@ -41,3 +41,20 @@ public class MarketBoardPurchase return output; } } + +/// +/// An interface that represents market board purchase information. This message is received from the +/// server when a purchase is made at a market board. +/// +public interface IMarketBoardPurchase +{ + /// + /// Gets the item ID of the item that was purchased. + /// + uint CatalogId { get; } + + /// + /// Gets the quantity of the item that was purchased. + /// + uint ItemQuantity { get; } +} diff --git a/Dalamud/Game/Network/Structures/MarketBoardPurchaseHandler.cs b/Dalamud/Game/Network/Structures/MarketBoardPurchaseHandler.cs index 783e62cda..3fadc70b5 100644 --- a/Dalamud/Game/Network/Structures/MarketBoardPurchaseHandler.cs +++ b/Dalamud/Game/Network/Structures/MarketBoardPurchaseHandler.cs @@ -6,7 +6,7 @@ namespace Dalamud.Game.Network.Structures; /// Represents market board purchase information. This message is sent from the /// client when a purchase is made at a market board. /// -public class MarketBoardPurchaseHandler +public class MarketBoardPurchaseHandler : IMarketBoardPurchaseHandler { private MarketBoardPurchaseHandler() { @@ -59,3 +59,35 @@ public class MarketBoardPurchaseHandler return output; } } + +/// +/// An interface that represents market board purchase information. This message is sent from the +/// client when a purchase is made at a market board. +/// +public interface IMarketBoardPurchaseHandler +{ + /// + /// Gets the object ID of the retainer associated with the sale. + /// + ulong RetainerId { get; } + + /// + /// Gets the object ID of the item listing. + /// + ulong ListingId { get; } + + /// + /// Gets the item ID of the item that was purchased. + /// + uint CatalogId { get; } + + /// + /// Gets the quantity of the item that was purchased. + /// + uint ItemQuantity { get; } + + /// + /// Gets the unit price of the item. + /// + uint PricePerUnit { get; } +} diff --git a/Dalamud/Game/Network/Structures/MarketTaxRates.cs b/Dalamud/Game/Network/Structures/MarketTaxRates.cs index 42e1d8cce..d5802f106 100644 --- a/Dalamud/Game/Network/Structures/MarketTaxRates.cs +++ b/Dalamud/Game/Network/Structures/MarketTaxRates.cs @@ -6,7 +6,7 @@ namespace Dalamud.Game.Network.Structures; /// This class represents the "Result Dialog" packet. This is also used e.g. for reduction results, but we only care about tax rates. /// We can do that by checking the "Category" field. /// -public class MarketTaxRates +public class MarketTaxRates : IMarketTaxRates { private MarketTaxRates() { @@ -100,3 +100,49 @@ public class MarketTaxRates }; } } + +/// +/// An interface that represents the tax rates received by the client when interacting with a retainer vocate. +/// +public interface IMarketTaxRates +{ + /// + /// Gets the category of this ResultDialog packet. + /// + uint Category { get; } + + /// + /// Gets the tax rate in Limsa Lominsa. + /// + uint LimsaLominsaTax { get; } + + /// + /// Gets the tax rate in Gridania. + /// + uint GridaniaTax { get; } + + /// + /// Gets the tax rate in Ul'dah. + /// + uint UldahTax { get; } + + /// + /// Gets the tax rate in Ishgard. + /// + uint IshgardTax { get; } + + /// + /// Gets the tax rate in Kugane. + /// + uint KuganeTax { get; } + + /// + /// Gets the tax rate in the Crystarium. + /// + uint CrystariumTax { get; } + + /// + /// Gets the tax rate in the Crystarium. + /// + uint SharlayanTax { get; } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs index 951d3d91c..8ac49aef4 100644 --- a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs +++ b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs @@ -43,6 +43,7 @@ internal class DataWindow : Window, IDisposable new IconBrowserWidget(), new ImGuiWidget(), new KeyStateWidget(), + new MarketBoardWidget(), new NetworkMonitorWidget(), new ObjectTableWidget(), new PartyListWidget(), diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/MarketBoardWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/MarketBoardWidget.cs new file mode 100644 index 000000000..73d7c7e84 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/MarketBoardWidget.cs @@ -0,0 +1,302 @@ +using System.Collections.Concurrent; + +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; + +using ImGuiNET; + +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; + +using System.Globalization; + +using Game.MarketBoard; +using Game.Network.Structures; + +/// +/// Widget to display market board events. +/// +internal class MarketBoardWidget : IDataWindowWidget +{ + private readonly ConcurrentQueue<(IMarketBoardHistory MarketBoardHistory, IMarketBoardHistoryListing Listing)> marketBoardHistoryQueue = new(); + private readonly ConcurrentQueue<(IMarketBoardCurrentOfferings MarketBoardCurrentOfferings, IMarketBoardItemListing Listing)> marketBoardCurrentOfferingsQueue = new(); + private readonly ConcurrentQueue marketBoardPurchasesQueue = new(); + private readonly ConcurrentQueue marketBoardPurchaseRequestsQueue = new(); + private readonly ConcurrentQueue marketTaxRatesQueue = new(); + + private bool trackMarketBoard; + private int trackedEvents; + + /// Finalizes an instance of the class. + ~MarketBoardWidget() + { + if (this.trackMarketBoard) + { + this.trackMarketBoard = false; + var marketBoard = Service.GetNullable(); + if (marketBoard != null) + { + marketBoard.HistoryReceived -= this.MarketBoardHistoryReceived; + marketBoard.OfferingsReceived -= this.MarketBoardOfferingsReceived; + marketBoard.ItemPurchased -= this.MarketBoardItemPurchased; + marketBoard.PurchaseRequested -= this.MarketBoardPurchaseRequested; + marketBoard.TaxRatesReceived -= this.TaxRatesReceived; + } + } + } + + /// + public string[]? CommandShortcuts { get; init; } = { "marketboard" }; + + /// + public string DisplayName { get; init; } = "Market Board"; + + /// + public bool Ready { get; set; } + + /// + public void Load() + { + this.trackMarketBoard = false; + this.trackedEvents = 0; + this.marketBoardHistoryQueue.Clear(); + this.marketBoardPurchaseRequestsQueue.Clear(); + this.marketBoardPurchasesQueue.Clear(); + this.marketTaxRatesQueue.Clear(); + this.marketBoardCurrentOfferingsQueue.Clear(); + this.Ready = true; + } + + /// + public void Draw() + { + var marketBoard = Service.Get(); + if (ImGui.Checkbox("Track MarketBoard Events", ref this.trackMarketBoard)) + { + if (this.trackMarketBoard) + { + marketBoard.HistoryReceived += this.MarketBoardHistoryReceived; + marketBoard.OfferingsReceived += this.MarketBoardOfferingsReceived; + marketBoard.ItemPurchased += this.MarketBoardItemPurchased; + marketBoard.PurchaseRequested += this.MarketBoardPurchaseRequested; + marketBoard.TaxRatesReceived += this.TaxRatesReceived; + } + else + { + marketBoard.HistoryReceived -= this.MarketBoardHistoryReceived; + marketBoard.OfferingsReceived -= this.MarketBoardOfferingsReceived; + marketBoard.ItemPurchased -= this.MarketBoardItemPurchased; + marketBoard.PurchaseRequested -= this.MarketBoardPurchaseRequested; + marketBoard.TaxRatesReceived -= this.TaxRatesReceived; + } + } + + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X / 2); + if (ImGui.DragInt("Stored Number of Events", ref this.trackedEvents, 0.1f, 1, 512)) + { + this.trackedEvents = Math.Clamp(this.trackedEvents, 1, 512); + } + + if (ImGui.Button("Clear Stored Events")) + { + this.marketBoardHistoryQueue.Clear(); + } + + using (var tabBar = ImRaii.TabBar("marketTabs")) + { + if (tabBar) + { + using (var tabItem = ImRaii.TabItem("History")) + { + if (tabItem) + { + ImGuiTable.DrawTable(string.Empty, this.marketBoardHistoryQueue, this.DrawMarketBoardHistory, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg, "Item ID", "Quantity", "Is HQ?", "Sale Price", "Buyer Name", "Purchase Time"); + } + } + + using (var tabItem = ImRaii.TabItem("Offerings")) + { + if (tabItem) + { + ImGuiTable.DrawTable(string.Empty, this.marketBoardCurrentOfferingsQueue, this.DrawMarketBoardCurrentOfferings, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg, "Item ID", "Quantity", "Is HQ?", "Price Per Unit", "Buyer Name", "Retainer Name", "Last Review Time"); + } + } + + using (var tabItem = ImRaii.TabItem("Purchases")) + { + if (tabItem) + { + ImGuiTable.DrawTable(string.Empty, this.marketBoardPurchasesQueue, this.DrawMarketBoardPurchases, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg, "Item ID", "Quantity"); + } + } + + using (var tabItem = ImRaii.TabItem("Purchase Requests")) + { + if (tabItem) + { + ImGuiTable.DrawTable(string.Empty, this.marketBoardPurchaseRequestsQueue, this.DrawMarketBoardPurchaseRequests, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg, "Item ID", "Quantity", "Price Per Unit", "Listing ID", "Retainer ID"); + } + } + + using (var tabItem = ImRaii.TabItem("Taxes")) + { + if (tabItem) + { + ImGuiTable.DrawTable(string.Empty, this.marketTaxRatesQueue, this.DrawMarketTaxRates, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg, "Uldah", "Limsa Lominsa", "Gridania", "Ishgard", "Kugane", "Crystarium", "Sharlayan"); + } + } + } + } + } + + private void TaxRatesReceived(IMarketTaxRates marketTaxRates) + { + this.marketTaxRatesQueue.Enqueue(marketTaxRates); + + while (this.marketTaxRatesQueue.Count > this.trackedEvents) + { + this.marketTaxRatesQueue.TryDequeue(out _); + } + } + + private void MarketBoardPurchaseRequested(IMarketBoardPurchaseHandler marketBoardPurchaseHandler) + { + this.marketBoardPurchaseRequestsQueue.Enqueue(marketBoardPurchaseHandler); + + while (this.marketBoardPurchaseRequestsQueue.Count > this.trackedEvents) + { + this.marketBoardPurchaseRequestsQueue.TryDequeue(out _); + } + } + + private void MarketBoardItemPurchased(IMarketBoardPurchase marketBoardPurchase) + { + this.marketBoardPurchasesQueue.Enqueue(marketBoardPurchase); + + while (this.marketBoardPurchasesQueue.Count > this.trackedEvents) + { + this.marketBoardPurchasesQueue.TryDequeue(out _); + } + } + + private void MarketBoardOfferingsReceived(IMarketBoardCurrentOfferings marketBoardCurrentOfferings) + { + foreach (var listing in marketBoardCurrentOfferings.ItemListings) + { + this.marketBoardCurrentOfferingsQueue.Enqueue((marketBoardCurrentOfferings, listing)); + } + + while (this.marketBoardCurrentOfferingsQueue.Count > this.trackedEvents) + { + this.marketBoardCurrentOfferingsQueue.TryDequeue(out _); + } + } + + private void MarketBoardHistoryReceived(IMarketBoardHistory marketBoardHistory) + { + foreach (var listing in marketBoardHistory.HistoryListings) + { + this.marketBoardHistoryQueue.Enqueue((marketBoardHistory, listing)); + } + + while (this.marketBoardHistoryQueue.Count > this.trackedEvents) + { + this.marketBoardHistoryQueue.TryDequeue(out _); + } + } + + private void DrawMarketBoardHistory((IMarketBoardHistory History, IMarketBoardHistoryListing Listing) data) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.History.ItemId.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.Quantity.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.IsHq.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.SalePrice.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.BuyerName); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.PurchaseTime.ToString(CultureInfo.InvariantCulture)); + } + + private void DrawMarketBoardCurrentOfferings((IMarketBoardCurrentOfferings MarketBoardCurrentOfferings, IMarketBoardItemListing Listing) data) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.ItemId.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.ItemQuantity.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.IsHq.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.PricePerUnit.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.PlayerName); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.RetainerName); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.Listing.LastReviewTime.ToString(CultureInfo.InvariantCulture)); + } + + private void DrawMarketBoardPurchases(IMarketBoardPurchase data) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.CatalogId.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.ItemQuantity.ToString()); + } + + private void DrawMarketBoardPurchaseRequests(IMarketBoardPurchaseHandler data) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.CatalogId.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.ItemQuantity.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.PricePerUnit.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.ListingId.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.RetainerId.ToString()); + } + + private void DrawMarketTaxRates(IMarketTaxRates data) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.UldahTax.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.LimsaLominsaTax.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.GridaniaTax.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.IshgardTax.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.KuganeTax.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.CrystariumTax.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.SharlayanTax.ToString()); + } +} diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/MarketBoardAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/MarketBoardAgingStep.cs new file mode 100644 index 000000000..97768e3c8 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/MarketBoardAgingStep.cs @@ -0,0 +1,259 @@ +namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps; + +using System.Globalization; +using System.Linq; + +using Game.MarketBoard; +using Game.Network.Structures; + +using ImGuiNET; + +/// +/// Tests the various market board events +/// +internal class MarketBoardAgingStep : IAgingStep +{ + private SubStep currentSubStep; + private bool eventsSubscribed; + + private IMarketBoardHistoryListing? historyListing; + private IMarketBoardItemListing? itemListing; + private IMarketTaxRates? marketTaxRate; + private IMarketBoardPurchaseHandler? marketBoardPurchaseRequest; + private IMarketBoardPurchase? marketBoardPurchase; + + private enum SubStep + { + History, + Offerings, + PurchaseRequests, + Purchases, + Taxes, + Done, + } + + /// + public string Name => "Test MarketBoard"; + + /// + public SelfTestStepResult RunStep() + { + if (!this.eventsSubscribed) + { + this.SubscribeToEvents(); + } + + ImGui.Text($"Testing: {this.currentSubStep.ToString()}"); + + switch (this.currentSubStep) + { + case SubStep.History: + + if (this.historyListing == null) + { + ImGui.Text("Goto a Market Board. Open any item that has historical sale listings."); + } + else + { + ImGui.Text("Does one of the historical sales match this information?"); + ImGui.Separator(); + ImGui.Text($"Quantity: {this.historyListing.Quantity.ToString()}"); + ImGui.Text($"Buyer: {this.historyListing.BuyerName}"); + ImGui.Text($"Sale Price: {this.historyListing.SalePrice.ToString()}"); + ImGui.Text($"Purchase Time: {this.historyListing.PurchaseTime.ToString(CultureInfo.InvariantCulture)}"); + ImGui.Separator(); + if (ImGui.Button("Looks Correct / Skip")) + { + this.currentSubStep++; + } + + ImGui.SameLine(); + if (ImGui.Button("No")) + { + return SelfTestStepResult.Fail; + } + } + + break; + case SubStep.Offerings: + + if (this.itemListing == null) + { + ImGui.Text("Goto a Market Board. Open any item that has sale listings."); + } + else + { + ImGui.Text("Does one of the sales match this information?"); + ImGui.Separator(); + ImGui.Text($"Quantity: {this.itemListing.ItemQuantity.ToString()}"); + ImGui.Text($"Price Per Unit: {this.itemListing.PricePerUnit}"); + ImGui.Text($"Retainer Name: {this.itemListing.RetainerName}"); + ImGui.Text($"Is HQ?: {(this.itemListing.IsHq ? "Yes" : "No")}"); + ImGui.Separator(); + if (ImGui.Button("Looks Correct / Skip")) + { + this.currentSubStep++; + } + + ImGui.SameLine(); + if (ImGui.Button("No")) + { + return SelfTestStepResult.Fail; + } + } + + break; + case SubStep.PurchaseRequests: + if (this.marketBoardPurchaseRequest == null) + { + ImGui.Text("Goto a Market Board. Purchase any item, the cheapest you can find."); + } + else + { + ImGui.Text("Does this information match the purchase you made? This is testing the request to the server."); + ImGui.Separator(); + ImGui.Text($"Quantity: {this.marketBoardPurchaseRequest.ItemQuantity.ToString()}"); + ImGui.Text($"Item ID: {this.marketBoardPurchaseRequest.CatalogId}"); + ImGui.Text($"Price Per Unit: {this.marketBoardPurchaseRequest.PricePerUnit}"); + ImGui.Separator(); + if (ImGui.Button("Looks Correct / Skip")) + { + this.currentSubStep++; + } + + ImGui.SameLine(); + if (ImGui.Button("No")) + { + return SelfTestStepResult.Fail; + } + } + + break; + case SubStep.Purchases: + if (this.marketBoardPurchase == null) + { + ImGui.Text("Goto a Market Board. Purchase any item, the cheapest you can find."); + } + else + { + ImGui.Text("Does this information match the purchase you made? This is testing the response from the server."); + ImGui.Separator(); + ImGui.Text($"Quantity: {this.marketBoardPurchase.ItemQuantity.ToString()}"); + ImGui.Text($"Item ID: {this.marketBoardPurchase.CatalogId}"); + ImGui.Separator(); + if (ImGui.Button("Looks Correct / Skip")) + { + this.currentSubStep++; + } + + ImGui.SameLine(); + if (ImGui.Button("No")) + { + return SelfTestStepResult.Fail; + } + } + + break; + case SubStep.Taxes: + if (this.marketTaxRate == null) + { + ImGui.Text("Goto a Retainer Vocate and talk to then. Click the 'View market tax rates' menu item."); + } + else + { + ImGui.Text("Does this market tax rate information look correct?"); + ImGui.Separator(); + ImGui.Text($"Uldah: {this.marketTaxRate.UldahTax.ToString()}"); + ImGui.Text($"Gridania: {this.marketTaxRate.GridaniaTax.ToString()}"); + ImGui.Text($"Limsa Lominsa: {this.marketTaxRate.LimsaLominsaTax.ToString()}"); + ImGui.Text($"Ishgard: {this.marketTaxRate.IshgardTax.ToString()}"); + ImGui.Text($"Kugane: {this.marketTaxRate.KuganeTax.ToString()}"); + ImGui.Text($"Crystarium: {this.marketTaxRate.CrystariumTax.ToString()}"); + ImGui.Text($"Sharlayan: {this.marketTaxRate.SharlayanTax.ToString()}"); + ImGui.Separator(); + if (ImGui.Button("Looks Correct / Skip")) + { + this.currentSubStep++; + } + + ImGui.SameLine(); + if (ImGui.Button("No")) + { + return SelfTestStepResult.Fail; + } + } + break; + case SubStep.Done: + return SelfTestStepResult.Pass; + default: + throw new ArgumentOutOfRangeException(); + } + + return SelfTestStepResult.Waiting; + } + + /// + public void CleanUp() + { + this.currentSubStep = SubStep.History; + this.historyListing = null; + this.marketTaxRate = null; + this.marketBoardPurchase = null; + this.marketBoardPurchaseRequest = null; + this.itemListing = null; + this.UnsubscribeFromEvents(); + } + + private void SubscribeToEvents() + { + var marketBoard = Service.Get(); + marketBoard.HistoryReceived += this.OnHistoryReceived; + marketBoard.OfferingsReceived += this.OnOfferingsReceived; + marketBoard.ItemPurchased += this.OnItemPurchased; + marketBoard.PurchaseRequested += this.OnPurchaseRequested; + marketBoard.TaxRatesReceived += this.OnTaxRatesReceived; + this.eventsSubscribed = true; + } + + private void UnsubscribeFromEvents() + { + var marketBoard = Service.Get(); + marketBoard.HistoryReceived -= this.OnHistoryReceived; + marketBoard.OfferingsReceived -= this.OnOfferingsReceived; + marketBoard.ItemPurchased -= this.OnItemPurchased; + marketBoard.PurchaseRequested -= this.OnPurchaseRequested; + marketBoard.TaxRatesReceived -= this.OnTaxRatesReceived; + this.eventsSubscribed = false; + } + + private void OnTaxRatesReceived(IMarketTaxRates marketTaxRates) + { + this.marketTaxRate = marketTaxRates; + } + + private void OnPurchaseRequested(IMarketBoardPurchaseHandler marketBoardPurchaseHandler) + { + this.marketBoardPurchaseRequest = marketBoardPurchaseHandler; + } + + private void OnItemPurchased(IMarketBoardPurchase purchase) + { + this.marketBoardPurchase = purchase; + } + + private void OnOfferingsReceived(IMarketBoardCurrentOfferings marketBoardCurrentOfferings) + { + if (marketBoardCurrentOfferings.ItemListings.Count != 0) + { + this.itemListing = marketBoardCurrentOfferings.ItemListings.First(); + } + } + + private void OnHistoryReceived(IMarketBoardHistory marketBoardHistory) + { + if (marketBoardHistory.HistoryListings.Count != 0) + { + this.historyListing = marketBoardHistory.HistoryListings.First(); + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs b/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs index 8f4a59843..e3172d5c2 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs @@ -44,6 +44,7 @@ internal class SelfTestWindow : Window new HandledExceptionAgingStep(), new DutyStateAgingStep(), new GameConfigAgingStep(), + new MarketBoardAgingStep(), new LogoutEventAgingStep(), }; diff --git a/Dalamud/Plugin/Services/IMarketBoard.cs b/Dalamud/Plugin/Services/IMarketBoard.cs new file mode 100644 index 000000000..9126dfcc7 --- /dev/null +++ b/Dalamud/Plugin/Services/IMarketBoard.cs @@ -0,0 +1,64 @@ +namespace Dalamud.Plugin.Services; + +using Game.Network.Structures; + +/// +/// Provides access to market board related events as the client receives/sends them. +/// +public interface IMarketBoard +{ + /// + /// A delegate type used with the event. + /// + /// The historical listings for a particular item on the market board. + public delegate void HistoryReceivedDelegate(IMarketBoardHistory history); + + /// + /// A delegate type used with the event. + /// + /// The item that has been purchased. + public delegate void ItemPurchasedDelegate(IMarketBoardPurchase purchase); + + /// + /// A delegate type used with the event. + /// + /// The current offerings for a particular item on the market board. + public delegate void OfferingsReceivedDelegate(IMarketBoardCurrentOfferings currentOfferings); + + /// + /// A delegate type used with the event. + /// + /// The details about the item being purchased. + public delegate void PurchaseRequestedDelegate(IMarketBoardPurchaseHandler purchaseRequested); + + /// + /// A delegate type used with the event. + /// + /// The new tax rates. + public delegate void TaxRatesReceivedDelegate(IMarketTaxRates taxRates); + + /// + /// Event that fires when historical sale listings are received for a specific item on the market board. + /// + public event HistoryReceivedDelegate HistoryReceived; + + /// + /// Event that fires when a item is purchased on the market board. + /// + public event ItemPurchasedDelegate ItemPurchased; + + /// + /// Event that fires when current offerings are received for a specific item on the market board. + /// + public event OfferingsReceivedDelegate OfferingsReceived; + + /// + /// Event that fires when a player requests to purchase an item from the market board. + /// + public event PurchaseRequestedDelegate PurchaseRequested; + + /// + /// Event that fires when the client receives new tax rates. These events only occur when accessing a retainer vocate and requesting the tax rates. + /// + public event TaxRatesReceivedDelegate TaxRatesReceived; +}