using System; using System.Collections.Generic; using System.Linq; using log4net; using ACE.Common; using ACE.Database; using ACE.Entity; using ACE.Entity.Enum; using ACE.Entity.Enum.Properties; using ACE.Entity.Models; using ACE.Server.Entity; using ACE.Server.Entity.Actions; using ACE.Server.Factories; using ACE.Server.Network.GameEvent.Events; using ACE.Server.Managers; namespace ACE.Server.WorldObjects { /// /// ** Buy Data Flow ** /// /// Player.HandleActionBuyItem -> Vendor.BuyItems_ValidateTransaction -> Player.FinalizeBuyTransaction -> Vendor.BuyItems_FinalTransaction /// /// public class Vendor : Creature { private static readonly ILog log = LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType); public readonly Dictionary DefaultItemsForSale = new Dictionary(); // unique items purchased from other players public readonly Dictionary UniqueItemsForSale = new Dictionary(); private bool inventoryloaded { get; set; } /// /// The last player who used this vendor /// private WorldObjectInfo lastPlayerInfo { get; set; } public uint? AlternateCurrency { get => GetProperty(PropertyDataId.AlternateCurrency); set { if (!value.HasValue) RemoveProperty(PropertyDataId.AlternateCurrency); else SetProperty(PropertyDataId.AlternateCurrency, value.Value); } } /// /// A new biota be created taking all of its values from weenie. /// public Vendor(Weenie weenie, ObjectGuid guid) : base(weenie, guid) { SetEphemeralValues(); } /// /// Restore a WorldObject from the database. /// public Vendor(Biota biota) : base(biota) { SetEphemeralValues(); } private void SetEphemeralValues() { ObjectDescriptionFlags |= ObjectDescriptionFlag.Vendor; if (!PropertyManager.GetBool("vendor_shop_uses_generator").Item) { GeneratorProfiles.RemoveAll(p => p.Biota.WhereCreate.HasFlag(RegenLocationType.Shop)); } OpenForBusiness = ValidateVendorRequirements(); } private bool ValidateVendorRequirements() { var success = true; var currencyWCID = AlternateCurrency ?? (uint)ACE.Entity.Enum.WeenieClassName.W_COINSTACK_CLASS; var currencyWeenie = DatabaseManager.World.GetCachedWeenie(currencyWCID); if (currencyWeenie == null) { var errorMsg = $"WCID {currencyWCID}{(AlternateCurrency.HasValue ? ", which comes from PropertyDataId.AlternateCurrency," : "")} is not found in the database, Vendor has been disabled as a result!"; log.Error($"[VENDOR] {Name} (0x{Guid}:{WeenieClassId}) Currency {errorMsg}"); success = false; } if (!MerchandiseItemTypes.HasValue) { log.Error($"[VENDOR] {Name} (0x{Guid}:{WeenieClassId}) MerchandiseItemTypes is NULL, Vendor has been disabled as a result!"); success = false; } if (!MerchandiseMinValue.HasValue) { log.Error($"[VENDOR] {Name} (0x{Guid}:{WeenieClassId}) MerchandiseMinValue is NULL, Vendor has been disabled as a result!"); success = false; } if (!MerchandiseMaxValue.HasValue) { log.Error($"[VENDOR] {Name} (0x{Guid}:{WeenieClassId}) MerchandiseMaxValue is NULL, Vendor has been disabled as a result!"); success = false; } if (!BuyPrice.HasValue) { log.Error($"[VENDOR] {Name} (0x{Guid}:{WeenieClassId}) BuyPrice is NULL, Vendor has been disabled as a result!"); success = false; } if (!SellPrice.HasValue) { log.Error($"[VENDOR] {Name} (0x{Guid}:{WeenieClassId}) SellPrice is NULL, Vendor has been disabled as a result!"); success = false; } return success; } /// /// Populates this vendor's DefaultItemsForSale /// private void LoadInventory() { if (inventoryloaded) return; var itemsForSale = new Dictionary<(uint weenieClassId, int paletteTemplate, double shade), uint>(); foreach (var item in Biota.PropertiesCreateList.Where(x => x.DestinationType == DestinationType.Shop)) LoadInventoryItem(itemsForSale, item.WeenieClassId, item.Palette, item.Shade, item.StackSize); //if (Biota.PropertiesGenerator != null && !PropertyManager.GetBool("vendor_shop_uses_generator").Item) //{ // foreach (var item in Biota.PropertiesGenerator.Where(x => x.WhereCreate.HasFlag(RegenLocationType.Shop))) // LoadInventoryItem(itemsForSale, item.WeenieClassId, (int?)item.PaletteId, item.Shade, item.StackSize); //} inventoryloaded = true; } private void LoadInventoryItem(Dictionary<(uint weenieClassId, int paletteTemplate, double shade), uint> itemsForSale, uint weenieClassId, int? palette, float? shade, int? stackSize) { //var itemProfile = (weenieClassId, palette ?? 0, shade ?? 0); // let's skip dupes if there are any //if (itemsForSale.ContainsKey(itemProfile)) // return; var wo = WorldObjectFactory.CreateNewWorldObject(weenieClassId); if (wo == null) return; if (palette > 0) wo.PaletteTemplate = palette; if (shade > 0) wo.Shade = shade; wo.ContainerId = Guid.Full; wo.CalculateObjDesc(); //itemsForSale.Add(itemProfile, wo.Guid.Full); wo.VendorShopCreateListStackSize = stackSize ?? -1; DefaultItemsForSale.Add(wo.Guid, wo); } public void AddDefaultItem(WorldObject item) { var existing = GetDefaultItemsByWcid(item.WeenieClassId); // add to existing stack? if (existing.Count > 0) { var stackLeft = existing.FirstOrDefault(i => (i.StackSize ?? 1) < (i.MaxStackSize ?? 1)); if (stackLeft != null) { stackLeft.SetStackSize((stackLeft.StackSize ?? 1) + 1); return; } } // create new item item.ContainerId = Guid.Full; item.CalculateObjDesc(); DefaultItemsForSale.Add(item.Guid, item); } /// /// Helper function to replace the previous 'AllItemsForSale' combiner /// While AllItemsForSale was a useful concept, it was only used in 2 places, and was inefficient /// public void forEachItem(Action action) { foreach (var kvp in DefaultItemsForSale) action(kvp.Value); foreach (var kvp in UniqueItemsForSale) action(kvp.Value); } public List GetDefaultItemsByWcid(uint wcid) { return DefaultItemsForSale.Values.Where(i => i.WeenieClassId == wcid).ToList(); } /// /// Searches the vendor's inventory for an item /// public bool TryGetItemForSale(ObjectGuid itemGuid, out WorldObject itemForSale) { return DefaultItemsForSale.TryGetValue(itemGuid, out itemForSale) || UniqueItemsForSale.TryGetValue(itemGuid, out itemForSale); } /// /// This is raised by Player.HandleActionUseItem. /// The item does not exist in the players possession. /// If the item was outside of range, the player will have been commanded to move using DoMoveTo before ActOnUse is called. /// When this is called, it should be assumed that the player is within range. /// public override void ActOnUse(WorldObject wo) { var player = wo as Player; if (player == null) return; if (player.IsBusy) { player.SendWeenieError(WeenieError.YoureTooBusy); return; } if (!OpenForBusiness || !ValidateVendorRequirements()) { // should there be some sort of feedback to player here? return; } var rotateTime = Rotate(player); // vendor rotates towards player // TODO: remove this when DelayManager is not forward propagating current tick time var actionChain = new ActionChain(); actionChain.AddDelaySeconds(0.001f); // force to run after rotate.EnqueueBroadcastAction actionChain.AddAction(this, LoadInventory); actionChain.AddDelaySeconds(rotateTime); actionChain.AddAction(this, () => ApproachVendor(player, VendorType.Open)); actionChain.EnqueueChain(); if (lastPlayerInfo == null) { var closeChain = new ActionChain(); closeChain.AddDelaySeconds(closeInterval); closeChain.AddAction(this, CheckClose); closeChain.EnqueueChain(); } lastPlayerInfo = new WorldObjectInfo(player); } private void PrepareResetToHome() { // Reset to Home position var resetInterval = ResetInterval ?? 300; ResetTimestamp = Time.GetFutureUnixTime(resetInterval); var autoResetTimer = new ActionChain(); autoResetTimer.AddDelaySeconds(resetInterval); autoResetTimer.AddAction(this, () => CheckResetToHome()); autoResetTimer.EnqueueChain(); } /// /// Sends the latest vendor inventory list to player, rotates vendor towards player, and performs the appropriate emote. /// /// The action performed by the player public void ApproachVendor(Player player, VendorType action = VendorType.Undef, uint altCurrencySpent = 0) { RotUniques(); player.Session.Network.EnqueueSend(new GameEventApproachVendor(player.Session, this, altCurrencySpent)); var rotateTime = Rotate(player); // vendor rotates to player if (action != VendorType.Undef) DoVendorEmote(action, player); player.LastOpenedContainerId = Guid; PrepareResetToHome(); } public void DoVendorEmote(VendorType vendorType, WorldObject player) { switch (vendorType) { case VendorType.Open: EmoteManager.DoVendorEmote(vendorType, player); break; case VendorType.Buy: // player buys item from vendor EmoteManager.DoVendorEmote(vendorType, player); break; case VendorType.Sell: // player sells item to vendor EmoteManager.DoVendorEmote(vendorType, player); break; default: log.Warn($"Vendor.DoVendorEmote - Encountered Unhandled VendorType {vendorType} for {Name} ({WeenieClassId})"); break; } } private const float closeInterval = 1.5f; /// /// After a player approaches a vendor, this is called every closeInterval seconds /// to see if the player is still within the UseRadius of the vendor. /// /// If the player has moved away, the vendor Close emote is called (waving goodbye, saying farewell) /// public void CheckClose() { if (lastPlayerInfo == null) return; var lastPlayer = lastPlayerInfo.TryGetWorldObject() as Player; if (lastPlayer == null) { lastPlayerInfo = null; return; } // handles player logging out at vendor if (lastPlayer.CurrentLandblock == null) { lastPlayerInfo = null; return; } var dist = GetCylinderDistance(lastPlayer); if (dist > UseRadius) { if (lastPlayer.LastOpenedContainerId == Guid) lastPlayer.LastOpenedContainerId = ObjectGuid.Invalid; EmoteManager.DoVendorEmote(VendorType.Close, lastPlayer); lastPlayerInfo = null; return; } var closeChain = new ActionChain(); closeChain.AddDelaySeconds(closeInterval); closeChain.AddAction(this, CheckClose); closeChain.EnqueueChain(); } public void CheckResetToHome() { if (Time.GetUnixTime() >= ResetTimestamp) { // are we already at home origin? if (Location.Pos.Equals(Home.Pos)) { // just turnto if required? if (!Location.Rotation.Equals(Home.Rotation)) { TurnTo(Home); } } else { MoveTo(Home, GetRunRate(), true, null, 1); } } } /// /// Creates world objects for generic items /// private List ItemProfileToWorldObjects(ItemProfile itemProfile) { var results = new List(); var remaining = itemProfile.Amount; while (remaining > 0) { var wo = WorldObjectFactory.CreateNewWorldObject(itemProfile.WeenieClassId); if (itemProfile.Palette != null) wo.PaletteTemplate = itemProfile.Palette; if (itemProfile.Shade != null) wo.Shade = itemProfile.Shade; if ((wo.MaxStackSize ?? 0) > 0) { // stackable var currentStackSize = Math.Min(remaining, wo.MaxStackSize.Value); wo.SetStackSize(currentStackSize); results.Add(wo); remaining -= currentStackSize; } else { // non-stackable wo.StackSize = null; results.Add(wo); remaining--; } } return results; } /// /// Handles validation for player buying items from vendor /// public bool BuyItems_ValidateTransaction(List itemProfiles, Player player) { // one difference between buy and sell currently // is that if *any* items in the buy transactions are detected as invalid, // we reject the entire transaction. // this seems to be the "safest" route, however in terms of player convenience // where only 1 item has an error from a large purchase set, // this might not be the most convenient for the player. var defaultItemProfiles = new List(); var uniqueItems = new List(); // find item profiles in default and unique items foreach (var itemProfile in itemProfiles) { if (!itemProfile.IsValidAmount) { // reject entire transaction immediately player.SendTransientError($"Invalid amount"); return false; } var itemGuid = new ObjectGuid(itemProfile.ObjectGuid); // check default items if (DefaultItemsForSale.TryGetValue(itemGuid, out var defaultItemForSale)) { itemProfile.WeenieClassId = defaultItemForSale.WeenieClassId; itemProfile.Palette = defaultItemForSale.PaletteTemplate; itemProfile.Shade = defaultItemForSale.Shade; defaultItemProfiles.Add(itemProfile); } // check unique items else if (UniqueItemsForSale.TryGetValue(itemGuid, out var uniqueItemForSale)) { uniqueItems.Add(uniqueItemForSale); } } // ensure player has enough free inventory slots / container slots / available burden to receive items var itemsToReceive = new ItemsToReceive(player); foreach (var defaultItemProfile in defaultItemProfiles) { itemsToReceive.Add(defaultItemProfile.WeenieClassId, defaultItemProfile.Amount); if (itemsToReceive.PlayerExceedsLimits) break; } if (!itemsToReceive.PlayerExceedsLimits) { foreach (var uniqueItem in uniqueItems) { itemsToReceive.Add(uniqueItem.WeenieClassId, uniqueItem.StackSize ?? 1); if (itemsToReceive.PlayerExceedsLimits) break; } } if (itemsToReceive.PlayerExceedsLimits) { if (itemsToReceive.PlayerExceedsAvailableBurden) player.Session.Network.EnqueueSend(new GameEventCommunicationTransientString(player.Session, "You are too encumbered to buy that!")); else if (itemsToReceive.PlayerOutOfInventorySlots) player.Session.Network.EnqueueSend(new GameEventCommunicationTransientString(player.Session, "You do not have enough pack space to buy that!")); else if (itemsToReceive.PlayerOutOfContainerSlots) player.Session.Network.EnqueueSend(new GameEventCommunicationTransientString(player.Session, "You do not have enough container slots to buy that!")); return false; } // ideally the creation of the wo's would be delayed even further, // and all validations would be performed on weenies beforehand // this would require: // - a forEach helper function to iterate through both defaultItemProfiles (ItemProfiles) and uniqueItems (WorldObjects), // so that 2 foreach iterators don't have to be written each time // - weenie to have more functions that mimic the functionality of WorldObject // create world objects for default items var defaultItems = new List(); foreach (var defaultItemProfile in defaultItemProfiles) defaultItems.AddRange(ItemProfileToWorldObjects(defaultItemProfile)); var purchaseItems = defaultItems.Concat(uniqueItems).ToList(); if (IsBusy && purchaseItems.Any(i => i.GetProperty(PropertyBool.VendorService) == true)) { player.SendWeenieErrorWithString(WeenieErrorWithString._IsTooBusyToAcceptGifts, Name); CleanupCreatedItems(defaultItems); return false; } // check uniques if (!player.CheckUniques(purchaseItems, this)) { CleanupCreatedItems(defaultItems); return false; } // calculate price uint totalPrice = 0; foreach (var item in purchaseItems) { var cost = GetSellCost(item); // detect rollover? totalPrice += cost; } // verify player has enough currency if (AlternateCurrency == null) { if (player.CoinValue < totalPrice) { CleanupCreatedItems(defaultItems); return false; } } else { var playerAltCurrency = player.GetNumInventoryItemsOfWCID(AlternateCurrency.Value); if (playerAltCurrency < totalPrice) { CleanupCreatedItems(defaultItems); return false; } } // everything is verified at this point // send transaction to player for further processing player.FinalizeBuyTransaction(this, defaultItems, uniqueItems, totalPrice); return true; } public uint GetSellCost(WorldObject item) => GetSellCost(item.Value, item.ItemType); public uint GetSellCost(Weenie item) => GetSellCost(item.GetValue(), item.GetItemType()); private uint GetSellCost(int? value, ItemType? itemType) { var sellRate = SellPrice ?? 1.0; if (itemType == ItemType.PromissoryNote) sellRate = 1.15; var cost = Math.Max(1, (uint)Math.Ceiling(((float)sellRate * (value ?? 0)) - 0.1)); return cost; } public int GetBuyCost(WorldObject item) => GetBuyCost(item.Value, item.ItemType); public int GetBuyCost(Weenie item) => GetBuyCost(item.GetValue(), item.GetItemType()); private int GetBuyCost(int? value, ItemType? itemType) { var buyRate = BuyPrice ?? 1; if (itemType == ItemType.PromissoryNote) buyRate = 1.0; var cost = Math.Max(1, (int)Math.Floor(((float)buyRate * (value ?? 0)) + 0.1)); return cost; } public int CalculatePayoutCoinAmount(Dictionary items) { var payout = 0; foreach (WorldObject item in items.Values) payout += GetBuyCost(item); return payout; } /// /// This will either add the item to the vendors temporary sellables, or destroy it. /// In both cases, the item will be removed from the database. /// The item should already have been removed from the players inventory /// public void ProcessItemsForPurchase(Player player, Dictionary items) { foreach (var item in items.Values) { var resellItem = true; // don't resell DestroyOnSell if (item.GetProperty(PropertyBool.DestroyOnSell) ?? false) resellItem = false; // don't resell Attuned items that can be sold if (item.Attuned == AttunedStatus.Attuned) resellItem = false; // don't resell stackables? if (item.MaxStackSize != null || item.MaxStructure != null) resellItem = false; if (resellItem) { item.ContainerId = Guid.Full; if (!UniqueItemsForSale.TryAdd(item.Guid, item)) { var sellItems = string.Join(", ", items.Values.Select(i => $"{i.Name} ({i.Guid})")); log.Error($"[VENDOR] {Name}.ProcessItemsForPurchase({player.Name}): duplicate item found, sell list: {sellItems}"); } item.SoldTimestamp = Time.GetUnixTime(); // verify no gap: even though the guid is technically free in the database at this point, // is it still marked as consumed in guid manager, and not marked as freed here? // if player repurchases item sometime later, we must ensure the guid is still marked as consumed for re-add // remove object from shard db, but keep a reference to it in memory // for DestroyOnSell items, these will effectively be destroyed immediately // for other items, if a player re-purchases, it will be added to the shard db again item.RemoveBiotaFromDatabase(); } else item.Destroy(); NumItemsBought++; } ApproachVendor(player, VendorType.Sell); } public void ApplyService(WorldObject item, Player target) { // verify -- players purchasing multiple services in 1 transaction, and IsBusy state? var spell = new Spell(item.SpellDID ?? 0); if (spell.NotFound) return; IsBusy = true; var preCastTime = PreCastMotion(target); var castChain = new ActionChain(); castChain.AddDelaySeconds(preCastTime); castChain.AddAction(this, () => { TryCastSpell(spell, target, this); PostCastMotion(); }); var postCastTime = GetPostCastTime(spell); castChain.AddDelaySeconds(postCastTime); castChain.AddAction(this, () => IsBusy = false); castChain.EnqueueChain(); NumServicesSold++; } /// /// Unique items in the vendor's inventory sold to the vendor by players /// expire after vendor_unique_rot_time seconds /// private void RotUniques() { List itemsToRemove = null; foreach (var uniqueItem in UniqueItemsForSale.Values) { var soldTime = uniqueItem.SoldTimestamp; if (soldTime == null) { log.Warn($"[VENDOR] Vendor {Name} has unique item {uniqueItem.Name} ({uniqueItem.Guid}) without a SoldTimestamp -- this shouldn't happen"); continue; // keep in list? } var rotTime = Time.GetDateTimeFromTimestamp(soldTime.Value); rotTime = rotTime.AddSeconds(PropertyManager.GetDouble("vendor_unique_rot_time", 300).Item); if (DateTime.UtcNow >= rotTime) { if (itemsToRemove == null) itemsToRemove = new List(); itemsToRemove.Add(uniqueItem); } } if (itemsToRemove != null) { foreach (var itemToRemove in itemsToRemove) { log.DebugFormat("[VENDOR] Vendor {0} has discontinued sale of {1} and removed it from its UniqueItemsForSale list.", Name, itemToRemove.Name); UniqueItemsForSale.Remove(itemToRemove.Guid); itemToRemove.Destroy(); // even though it has already been removed from the db at this point, we want to mark as freed in guid manager now } } } private static void CleanupCreatedItems(List createdItems) { foreach (var createdItem in createdItems) createdItem.Destroy(); } public bool OpenForBusiness { get => GetProperty(PropertyBool.OpenForBusiness) ?? true; set { if (value) RemoveProperty(PropertyBool.OpenForBusiness); else SetProperty(PropertyBool.OpenForBusiness, value); } } public int? MerchandiseItemTypes { get => GetProperty(PropertyInt.MerchandiseItemTypes); set { if (!value.HasValue) RemoveProperty(PropertyInt.MerchandiseItemTypes); else SetProperty(PropertyInt.MerchandiseItemTypes, value.Value); } } public int? MerchandiseMinValue { get => GetProperty(PropertyInt.MerchandiseMinValue); set { if (!value.HasValue) RemoveProperty(PropertyInt.MerchandiseMinValue); else SetProperty(PropertyInt.MerchandiseMinValue, value.Value); } } public int? MerchandiseMaxValue { get => GetProperty(PropertyInt.MerchandiseMaxValue); set { if (!value.HasValue) RemoveProperty(PropertyInt.MerchandiseMaxValue); else SetProperty(PropertyInt.MerchandiseMaxValue, value.Value); } } public double? BuyPrice { get => GetProperty(PropertyFloat.BuyPrice); set { if (!value.HasValue) RemoveProperty(PropertyFloat.BuyPrice); else SetProperty(PropertyFloat.BuyPrice, value.Value); } } public double? SellPrice { get => GetProperty(PropertyFloat.SellPrice); set { if (!value.HasValue) RemoveProperty(PropertyFloat.SellPrice); else SetProperty(PropertyFloat.SellPrice, value.Value); } } public bool? DealMagicalItems { get => GetProperty(PropertyBool.DealMagicalItems); set { if (!value.HasValue) RemoveProperty(PropertyBool.DealMagicalItems); else SetProperty(PropertyBool.DealMagicalItems, value.Value); } } public bool? VendorService { get => GetProperty(PropertyBool.VendorService); set { if (!value.HasValue) RemoveProperty(PropertyBool.VendorService); else SetProperty(PropertyBool.VendorService, value.Value); } } public int? VendorHappyMean { get => GetProperty(PropertyInt.VendorHappyMean); set { if (!value.HasValue) RemoveProperty(PropertyInt.VendorHappyMean); else SetProperty(PropertyInt.VendorHappyMean, value.Value); } } public int? VendorHappyVariance { get => GetProperty(PropertyInt.VendorHappyVariance); set { if (!value.HasValue) RemoveProperty(PropertyInt.VendorHappyVariance); else SetProperty(PropertyInt.VendorHappyVariance, value.Value); } } public int? VendorHappyMaxItems { get => GetProperty(PropertyInt.VendorHappyMaxItems); set { if (!value.HasValue) RemoveProperty(PropertyInt.VendorHappyMaxItems); else SetProperty(PropertyInt.VendorHappyMaxItems, value.Value); } } public int NumItemsSold { get => GetProperty(PropertyInt.NumItemsSold) ?? 0; set { if (value == 0) RemoveProperty(PropertyInt.NumItemsSold); else SetProperty(PropertyInt.NumItemsSold, value); } } public int NumItemsBought { get => GetProperty(PropertyInt.NumItemsBought) ?? 0; set { if (value == 0) RemoveProperty(PropertyInt.NumItemsBought); else SetProperty(PropertyInt.NumItemsBought, value); } } public int NumServicesSold { get => GetProperty(PropertyInt.NumServicesSold) ?? 0; set { if (value == 0) RemoveProperty(PropertyInt.NumServicesSold); else SetProperty(PropertyInt.NumServicesSold, value); } } public int MoneyIncome { get => GetProperty(PropertyInt.MoneyIncome) ?? 0; set { if (value == 0) RemoveProperty(PropertyInt.MoneyIncome); else SetProperty(PropertyInt.MoneyIncome, value); } } public int MoneyOutflow { get => GetProperty(PropertyInt.MoneyOutflow) ?? 0; set { if (value == 0) RemoveProperty(PropertyInt.MoneyOutflow); else SetProperty(PropertyInt.MoneyOutflow, value); } } } }