StoreSystem.Ui.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. using System.Linq;
  2. using Content.Server.Actions;
  3. using Content.Server.Administration.Logs;
  4. using Content.Server.PDA.Ringer;
  5. using Content.Server.Stack;
  6. using Content.Server.Store.Components;
  7. using Content.Shared.Actions;
  8. using Content.Shared.Database;
  9. using Content.Shared.FixedPoint;
  10. using Content.Shared.Hands.EntitySystems;
  11. using Content.Shared.Mind;
  12. using Content.Shared.Store;
  13. using Content.Shared.Store.Components;
  14. using Content.Shared.UserInterface;
  15. using Robust.Server.GameObjects;
  16. using Robust.Shared.Audio.Systems;
  17. using Robust.Shared.Player;
  18. using Robust.Shared.Prototypes;
  19. namespace Content.Server.Store.Systems;
  20. public sealed partial class StoreSystem
  21. {
  22. [Dependency] private readonly IAdminLogManager _admin = default!;
  23. [Dependency] private readonly SharedHandsSystem _hands = default!;
  24. [Dependency] private readonly ActionsSystem _actions = default!;
  25. [Dependency] private readonly ActionContainerSystem _actionContainer = default!;
  26. [Dependency] private readonly ActionUpgradeSystem _actionUpgrade = default!;
  27. [Dependency] private readonly SharedMindSystem _mind = default!;
  28. [Dependency] private readonly SharedAudioSystem _audio = default!;
  29. [Dependency] private readonly StackSystem _stack = default!;
  30. [Dependency] private readonly UserInterfaceSystem _ui = default!;
  31. private void InitializeUi()
  32. {
  33. SubscribeLocalEvent<StoreComponent, StoreRequestUpdateInterfaceMessage>(OnRequestUpdate);
  34. SubscribeLocalEvent<StoreComponent, StoreBuyListingMessage>(OnBuyRequest);
  35. SubscribeLocalEvent<StoreComponent, StoreRequestWithdrawMessage>(OnRequestWithdraw);
  36. SubscribeLocalEvent<StoreComponent, StoreRequestRefundMessage>(OnRequestRefund);
  37. SubscribeLocalEvent<StoreComponent, RefundEntityDeletedEvent>(OnRefundEntityDeleted);
  38. }
  39. private void OnRefundEntityDeleted(Entity<StoreComponent> ent, ref RefundEntityDeletedEvent args)
  40. {
  41. ent.Comp.BoughtEntities.Remove(args.Uid);
  42. }
  43. /// <summary>
  44. /// Toggles the store Ui open and closed
  45. /// </summary>
  46. /// <param name="user">the person doing the toggling</param>
  47. /// <param name="storeEnt">the store being toggled</param>
  48. /// <param name="component"></param>
  49. public void ToggleUi(EntityUid user, EntityUid storeEnt, StoreComponent? component = null)
  50. {
  51. if (!Resolve(storeEnt, ref component))
  52. return;
  53. if (!TryComp<ActorComponent>(user, out var actor))
  54. return;
  55. if (!_ui.TryToggleUi(storeEnt, StoreUiKey.Key, actor.PlayerSession))
  56. return;
  57. UpdateUserInterface(user, storeEnt, component);
  58. }
  59. /// <summary>
  60. /// Closes the store UI for everyone, if it's open
  61. /// </summary>
  62. public void CloseUi(EntityUid uid, StoreComponent? component = null)
  63. {
  64. if (!Resolve(uid, ref component))
  65. return;
  66. _ui.CloseUi(uid, StoreUiKey.Key);
  67. }
  68. /// <summary>
  69. /// Updates the user interface for a store and refreshes the listings
  70. /// </summary>
  71. /// <param name="user">The person who if opening the store ui. Listings are filtered based on this.</param>
  72. /// <param name="store">The store entity itself</param>
  73. /// <param name="component">The store component being refreshed.</param>
  74. public void UpdateUserInterface(EntityUid? user, EntityUid store, StoreComponent? component = null)
  75. {
  76. if (!Resolve(store, ref component))
  77. return;
  78. //this is the person who will be passed into logic for all listing filtering.
  79. if (user != null) //if we have no "buyer" for this update, then don't update the listings
  80. {
  81. component.LastAvailableListings = GetAvailableListings(component.AccountOwner ?? user.Value, store, component)
  82. .ToHashSet();
  83. }
  84. //dictionary for all currencies, including 0 values for currencies on the whitelist
  85. Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> allCurrency = new();
  86. foreach (var supported in component.CurrencyWhitelist)
  87. {
  88. allCurrency.Add(supported, FixedPoint2.Zero);
  89. if (component.Balance.TryGetValue(supported, out var value))
  90. allCurrency[supported] = value;
  91. }
  92. // TODO: if multiple users are supposed to be able to interact with a single BUI & see different
  93. // stores/listings, this needs to use session specific BUI states.
  94. // only tell operatives to lock their uplink if it can be locked
  95. var showFooter = HasComp<RingerUplinkComponent>(store);
  96. var state = new StoreUpdateState(component.LastAvailableListings, allCurrency, showFooter, component.RefundAllowed);
  97. _ui.SetUiState(store, StoreUiKey.Key, state);
  98. }
  99. private void OnRequestUpdate(EntityUid uid, StoreComponent component, StoreRequestUpdateInterfaceMessage args)
  100. {
  101. UpdateUserInterface(args.Actor, GetEntity(args.Entity), component);
  102. }
  103. private void BeforeActivatableUiOpen(EntityUid uid, StoreComponent component, BeforeActivatableUIOpenEvent args)
  104. {
  105. UpdateUserInterface(args.User, uid, component);
  106. }
  107. /// <summary>
  108. /// Handles whenever a purchase was made.
  109. /// </summary>
  110. private void OnBuyRequest(EntityUid uid, StoreComponent component, StoreBuyListingMessage msg)
  111. {
  112. var listing = component.FullListingsCatalog.FirstOrDefault(x => x.ID.Equals(msg.Listing.Id));
  113. if (listing == null) //make sure this listing actually exists
  114. {
  115. Log.Debug("listing does not exist");
  116. return;
  117. }
  118. var buyer = msg.Actor;
  119. //verify that we can actually buy this listing and it wasn't added
  120. if (!ListingHasCategory(listing, component.Categories))
  121. return;
  122. //condition checking because why not
  123. if (listing.Conditions != null)
  124. {
  125. var args = new ListingConditionArgs(component.AccountOwner ?? GetBuyerMind(buyer), uid, listing, EntityManager);
  126. var conditionsMet = listing.Conditions.All(condition => condition.Condition(args));
  127. if (!conditionsMet)
  128. return;
  129. }
  130. //check that we have enough money
  131. var cost = listing.Cost;
  132. foreach (var (currency, amount) in cost)
  133. {
  134. if (!component.Balance.TryGetValue(currency, out var balance) || balance < amount)
  135. {
  136. return;
  137. }
  138. }
  139. if (!IsOnStartingMap(uid, component))
  140. DisableRefund(uid, component);
  141. //subtract the cash
  142. foreach (var (currency, amount) in cost)
  143. {
  144. component.Balance[currency] -= amount;
  145. component.BalanceSpent.TryAdd(currency, FixedPoint2.Zero);
  146. component.BalanceSpent[currency] += amount;
  147. }
  148. //spawn entity
  149. if (listing.ProductEntity != null)
  150. {
  151. var product = Spawn(listing.ProductEntity, Transform(buyer).Coordinates);
  152. _hands.PickupOrDrop(buyer, product);
  153. HandleRefundComp(uid, component, product);
  154. var xForm = Transform(product);
  155. if (xForm.ChildCount > 0)
  156. {
  157. var childEnumerator = xForm.ChildEnumerator;
  158. while (childEnumerator.MoveNext(out var child))
  159. {
  160. component.BoughtEntities.Add(child);
  161. }
  162. }
  163. }
  164. //give action
  165. if (!string.IsNullOrWhiteSpace(listing.ProductAction))
  166. {
  167. EntityUid? actionId;
  168. // I guess we just allow duplicate actions?
  169. // Allow duplicate actions and just have a single list buy for the buy-once ones.
  170. if (!_mind.TryGetMind(buyer, out var mind, out _))
  171. actionId = _actions.AddAction(buyer, listing.ProductAction);
  172. else
  173. actionId = _actionContainer.AddAction(mind, listing.ProductAction);
  174. // Add the newly bought action entity to the list of bought entities
  175. // And then add that action entity to the relevant product upgrade listing, if applicable
  176. if (actionId != null)
  177. {
  178. HandleRefundComp(uid, component, actionId.Value);
  179. if (listing.ProductUpgradeId != null)
  180. {
  181. foreach (var upgradeListing in component.FullListingsCatalog)
  182. {
  183. if (upgradeListing.ID == listing.ProductUpgradeId)
  184. {
  185. upgradeListing.ProductActionEntity = actionId.Value;
  186. break;
  187. }
  188. }
  189. }
  190. }
  191. }
  192. if (listing is { ProductUpgradeId: not null, ProductActionEntity: not null })
  193. {
  194. if (listing.ProductActionEntity != null)
  195. {
  196. component.BoughtEntities.Remove(listing.ProductActionEntity.Value);
  197. }
  198. if (!_actionUpgrade.TryUpgradeAction(listing.ProductActionEntity, out var upgradeActionId))
  199. {
  200. if (listing.ProductActionEntity != null)
  201. HandleRefundComp(uid, component, listing.ProductActionEntity.Value);
  202. return;
  203. }
  204. listing.ProductActionEntity = upgradeActionId;
  205. if (upgradeActionId != null)
  206. HandleRefundComp(uid, component, upgradeActionId.Value);
  207. }
  208. if (listing.ProductEvent != null)
  209. {
  210. if (!listing.RaiseProductEventOnUser)
  211. RaiseLocalEvent(listing.ProductEvent);
  212. else
  213. RaiseLocalEvent(buyer, listing.ProductEvent);
  214. }
  215. if (listing.DisableRefund)
  216. {
  217. component.RefundAllowed = false;
  218. }
  219. //log dat shit.
  220. _admin.Add(LogType.StorePurchase,
  221. LogImpact.Low,
  222. $"{ToPrettyString(buyer):player} purchased listing \"{ListingLocalisationHelpers.GetLocalisedNameOrEntityName(listing, _proto)}\" from {ToPrettyString(uid)}");
  223. listing.PurchaseAmount++; //track how many times something has been purchased
  224. _audio.PlayEntity(component.BuySuccessSound, msg.Actor, uid); //cha-ching!
  225. var buyFinished = new StoreBuyFinishedEvent
  226. {
  227. PurchasedItem = listing,
  228. StoreUid = uid
  229. };
  230. RaiseLocalEvent(ref buyFinished);
  231. UpdateUserInterface(buyer, uid, component);
  232. }
  233. /// <summary>
  234. /// Handles dispensing the currency you requested to be withdrawn.
  235. /// </summary>
  236. /// <remarks>
  237. /// This would need to be done should a currency with decimal values need to use it.
  238. /// not quite sure how to handle that
  239. /// </remarks>
  240. private void OnRequestWithdraw(EntityUid uid, StoreComponent component, StoreRequestWithdrawMessage msg)
  241. {
  242. if (msg.Amount <= 0)
  243. return;
  244. //make sure we have enough cash in the bank and we actually support this currency
  245. if (!component.Balance.TryGetValue(msg.Currency, out var currentAmount) || currentAmount < msg.Amount)
  246. return;
  247. //make sure a malicious client didn't send us random shit
  248. if (!_proto.TryIndex<CurrencyPrototype>(msg.Currency, out var proto))
  249. return;
  250. //we need an actually valid entity to spawn. This check has been done earlier, but just in case.
  251. if (proto.Cash == null || !proto.CanWithdraw)
  252. return;
  253. var buyer = msg.Actor;
  254. FixedPoint2 amountRemaining = msg.Amount;
  255. var coordinates = Transform(buyer).Coordinates;
  256. var sortedCashValues = proto.Cash.Keys.OrderByDescending(x => x).ToList();
  257. foreach (var value in sortedCashValues)
  258. {
  259. var cashId = proto.Cash[value];
  260. var amountToSpawn = (int) MathF.Floor((float) (amountRemaining / value));
  261. var ents = _stack.SpawnMultiple(cashId, amountToSpawn, coordinates);
  262. if (ents.FirstOrDefault() is {} ent)
  263. _hands.PickupOrDrop(buyer, ent);
  264. amountRemaining -= value * amountToSpawn;
  265. }
  266. component.Balance[msg.Currency] -= msg.Amount;
  267. UpdateUserInterface(buyer, uid, component);
  268. }
  269. private void OnRequestRefund(EntityUid uid, StoreComponent component, StoreRequestRefundMessage args)
  270. {
  271. // TODO: Remove guardian/holopara
  272. if (args.Actor is not { Valid: true } buyer)
  273. return;
  274. if (!IsOnStartingMap(uid, component))
  275. {
  276. DisableRefund(uid, component);
  277. UpdateUserInterface(buyer, uid, component);
  278. }
  279. if (!component.RefundAllowed || component.BoughtEntities.Count == 0)
  280. return;
  281. _admin.Add(LogType.StoreRefund, LogImpact.Low, $"{ToPrettyString(buyer):player} has refunded their purchases from {ToPrettyString(uid):store}");
  282. for (var i = component.BoughtEntities.Count - 1; i >= 0; i--)
  283. {
  284. var purchase = component.BoughtEntities[i];
  285. if (!Exists(purchase))
  286. continue;
  287. component.BoughtEntities.RemoveAt(i);
  288. if (_actions.TryGetActionData(purchase, out var actionComponent, logError: false))
  289. {
  290. _actionContainer.RemoveAction(purchase, actionComponent);
  291. }
  292. EntityManager.DeleteEntity(purchase);
  293. }
  294. component.BoughtEntities.Clear();
  295. foreach (var (currency, value) in component.BalanceSpent)
  296. {
  297. component.Balance[currency] += value;
  298. }
  299. // Reset store back to its original state
  300. RefreshAllListings(component);
  301. component.BalanceSpent = new();
  302. UpdateUserInterface(buyer, uid, component);
  303. }
  304. private void HandleRefundComp(EntityUid uid, StoreComponent component, EntityUid purchase)
  305. {
  306. component.BoughtEntities.Add(purchase);
  307. var refundComp = EnsureComp<StoreRefundComponent>(purchase);
  308. refundComp.StoreEntity = uid;
  309. refundComp.BoughtTime = _timing.CurTime;
  310. }
  311. private bool IsOnStartingMap(EntityUid store, StoreComponent component)
  312. {
  313. var xform = Transform(store);
  314. return component.StartingMap == xform.MapUid;
  315. }
  316. /// <summary>
  317. /// Disables refunds for this store
  318. /// </summary>
  319. public void DisableRefund(EntityUid store, StoreComponent? component = null)
  320. {
  321. if (!Resolve(store, ref component))
  322. return;
  323. component.RefundAllowed = false;
  324. }
  325. }
  326. /// <summary>
  327. /// Event of successfully finishing purchase in store (<see cref="StoreSystem"/>.
  328. /// </summary>
  329. /// <param name="StoreUid">EntityUid on which store is placed.</param>
  330. /// <param name="PurchasedItem">ListingItem that was purchased.</param>
  331. [ByRefEvent]
  332. public readonly record struct StoreBuyFinishedEvent(
  333. EntityUid StoreUid,
  334. ListingDataWithCostModifiers PurchasedItem
  335. );