StoreDiscountSystem.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. using System.Diagnostics.CodeAnalysis;
  2. using System.Linq;
  3. using Content.Server.Store.Systems;
  4. using Content.Shared.FixedPoint;
  5. using Content.Shared.Store;
  6. using Content.Shared.StoreDiscount.Components;
  7. using Robust.Shared.Prototypes;
  8. using Robust.Shared.Random;
  9. namespace Content.Server.StoreDiscount.Systems;
  10. /// <summary>
  11. /// Discount system that is part of <see cref="StoreSystem"/>.
  12. /// </summary>
  13. public sealed class StoreDiscountSystem : EntitySystem
  14. {
  15. [ValidatePrototypeId<StoreCategoryPrototype>]
  16. private const string DiscountedStoreCategoryPrototypeKey = "DiscountedItems";
  17. [Dependency] private readonly IRobustRandom _random = default!;
  18. [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
  19. /// <inheritdoc />
  20. public override void Initialize()
  21. {
  22. base.Initialize();
  23. SubscribeLocalEvent<StoreInitializedEvent>(OnStoreInitialized);
  24. SubscribeLocalEvent<StoreBuyFinishedEvent>(OnBuyFinished);
  25. }
  26. /// <summary> Decrements discounted item count, removes discount modifier and category, if counter reaches zero. </summary>
  27. private void OnBuyFinished(ref StoreBuyFinishedEvent ev)
  28. {
  29. var (storeId, purchasedItem) = ev;
  30. if (!TryComp<StoreDiscountComponent>(storeId, out var discountsComponent))
  31. {
  32. return;
  33. }
  34. // find and decrement discount count for item, if there is one.
  35. if (!TryGetDiscountData(discountsComponent.Discounts, purchasedItem, out var discountData) || discountData.Count == 0)
  36. {
  37. return;
  38. }
  39. discountData.Count--;
  40. if (discountData.Count > 0)
  41. {
  42. return;
  43. }
  44. // if there were discounts, but they are all bought up now - restore state: remove modifier and remove store category
  45. purchasedItem.RemoveCostModifier(discountData.DiscountCategory);
  46. purchasedItem.Categories.Remove(DiscountedStoreCategoryPrototypeKey);
  47. }
  48. /// <summary> Initialized discounts if required. </summary>
  49. private void OnStoreInitialized(ref StoreInitializedEvent ev)
  50. {
  51. if (!ev.UseDiscounts)
  52. {
  53. return;
  54. }
  55. var discountComponent = EnsureComp<StoreDiscountComponent>(ev.Store);
  56. var discounts = InitializeDiscounts(ev.Listings);
  57. ApplyDiscounts(ev.Listings, discounts);
  58. discountComponent.Discounts = discounts;
  59. }
  60. private IReadOnlyList<StoreDiscountData> InitializeDiscounts(
  61. IReadOnlyCollection<ListingDataWithCostModifiers> listings,
  62. int totalAvailableDiscounts = 6
  63. )
  64. {
  65. // Get list of categories with cumulative weights.
  66. // for example if we have categories with weights 2, 18 and 80
  67. // list of cumulative ones will be 2,20,100 (with 100 being total).
  68. // Then roll amount of unique listing items to be discounted under
  69. // each category, and after that - roll exact items in categories
  70. // and their cost
  71. var prototypes = _prototypeManager.EnumeratePrototypes<DiscountCategoryPrototype>();
  72. var categoriesWithCumulativeWeight = new CategoriesWithCumulativeWeightMap(prototypes);
  73. var uniqueListingItemCountByCategory = PickCategoriesToRoll(totalAvailableDiscounts, categoriesWithCumulativeWeight);
  74. return RollItems(listings, uniqueListingItemCountByCategory);
  75. }
  76. /// <summary>
  77. /// Roll <b>how many</b> unique listing items which discount categories going to have. This will be used later to then pick listing items
  78. /// to actually set discounts.
  79. /// </summary>
  80. /// <remarks>
  81. /// Not every discount category have equal chance to be rolled, and not every discount category even can be rolled.
  82. /// This step is important to distribute discounts properly (weighted) and with respect of
  83. /// category maxItems, and more importantly - to not roll same item multiple times on next step.
  84. /// </remarks>
  85. /// <param name="totalAvailableDiscounts">
  86. /// Total amount of different listing items to be discounted. Depending on <see cref="DiscountCategoryPrototype.MaxItems"/>
  87. /// there might be less discounts then <see cref="totalAvailableDiscounts"/>, but never more.
  88. /// </param>
  89. /// <param name="categoriesWithCumulativeWeightMap">
  90. /// Map of discount category cumulative weights by respective protoId of discount category.
  91. /// </param>
  92. /// <returns>Map: <b>count</b> of different listing items to be discounted, by discount category.</returns>
  93. private Dictionary<ProtoId<DiscountCategoryPrototype>, int> PickCategoriesToRoll(
  94. int totalAvailableDiscounts,
  95. CategoriesWithCumulativeWeightMap categoriesWithCumulativeWeightMap
  96. )
  97. {
  98. var chosenDiscounts = new Dictionary<ProtoId<DiscountCategoryPrototype>, int>();
  99. for (var i = 0; i < totalAvailableDiscounts; i++)
  100. {
  101. var discountCategory = categoriesWithCumulativeWeightMap.RollCategory(_random);
  102. if (discountCategory == null)
  103. {
  104. break;
  105. }
  106. // * if category was not previously picked - we mark it as picked 1 time
  107. // * if category was previously picked - we increment its 'picked' marker
  108. // * if category 'picked' marker going to exceed limit on category - we need to remove IT from further rolls
  109. int newDiscountCount;
  110. if (!chosenDiscounts.TryGetValue(discountCategory.ID, out var alreadySelectedCount))
  111. {
  112. newDiscountCount = 1;
  113. }
  114. else
  115. {
  116. newDiscountCount = alreadySelectedCount + 1;
  117. }
  118. chosenDiscounts[discountCategory.ID] = newDiscountCount;
  119. if (newDiscountCount >= discountCategory.MaxItems)
  120. {
  121. categoriesWithCumulativeWeightMap.Remove(discountCategory);
  122. }
  123. }
  124. return chosenDiscounts;
  125. }
  126. /// <summary>
  127. /// Rolls list of exact <see cref="ListingData"/> items to be discounted, and amount of currency to be discounted.
  128. /// </summary>
  129. /// <param name="listings">List of all available listing items from which discounted ones could be selected.</param>
  130. /// <param name="chosenDiscounts"></param>
  131. /// <returns>Collection of containers with rolled discount data.</returns>
  132. private IReadOnlyList<StoreDiscountData> RollItems(IEnumerable<ListingDataWithCostModifiers> listings, Dictionary<ProtoId<DiscountCategoryPrototype>, int> chosenDiscounts)
  133. {
  134. // To roll for discounts on items we: pick listing items that have values inside 'DiscountDownTo'.
  135. // then we iterate over discount categories that were chosen on previous step and pick unique set
  136. // of items from that exact category. Then we roll for their cost:
  137. // cost could be anything between DiscountDownTo value and actual item cost.
  138. var listingsByDiscountCategory = GroupDiscountableListingsByDiscountCategory(listings);
  139. var list = new List<StoreDiscountData>();
  140. foreach (var (discountCategory, itemsCount) in chosenDiscounts)
  141. {
  142. if (!listingsByDiscountCategory.TryGetValue(discountCategory, out var itemsForDiscount))
  143. {
  144. continue;
  145. }
  146. var chosen = _random.GetItems(itemsForDiscount, itemsCount, allowDuplicates: false);
  147. foreach (var listingData in chosen)
  148. {
  149. var cost = listingData.OriginalCost;
  150. var discountAmountByCurrencyId = RollItemCost(cost, listingData);
  151. var discountData = new StoreDiscountData
  152. {
  153. ListingId = listingData.ID,
  154. Count = 1,
  155. DiscountCategory = listingData.DiscountCategory!.Value,
  156. DiscountAmountByCurrency = discountAmountByCurrencyId
  157. };
  158. list.Add(discountData);
  159. }
  160. }
  161. return list;
  162. }
  163. /// <summary> Roll amount of each currency by which item cost should be reduced. </summary>
  164. /// <remarks>
  165. /// No point in confusing user with a fractional number, so we remove numbers after dot that were rolled.
  166. /// </remarks>
  167. private Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> RollItemCost(
  168. IReadOnlyDictionary<ProtoId<CurrencyPrototype>, FixedPoint2> originalCost,
  169. ListingDataWithCostModifiers listingData
  170. )
  171. {
  172. var discountAmountByCurrencyId = new Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2>(originalCost.Count);
  173. foreach (var (currency, amount) in originalCost)
  174. {
  175. if (!listingData.DiscountDownTo.TryGetValue(currency, out var discountUntilValue))
  176. {
  177. continue;
  178. }
  179. var discountUntilRolledValue = _random.NextDouble(discountUntilValue.Double(), amount.Double());
  180. var discountedCost = amount - Math.Floor(discountUntilRolledValue);
  181. // discount is negative modifier for cost
  182. discountAmountByCurrencyId.Add(currency.Id, -discountedCost);
  183. }
  184. return discountAmountByCurrencyId;
  185. }
  186. private void ApplyDiscounts(IReadOnlyList<ListingDataWithCostModifiers> listings, IReadOnlyCollection<StoreDiscountData> discounts)
  187. {
  188. foreach (var discountData in discounts)
  189. {
  190. if (discountData.Count <= 0)
  191. {
  192. continue;
  193. }
  194. ListingDataWithCostModifiers? found = null;
  195. for (var i = 0; i < listings.Count; i++)
  196. {
  197. var current = listings[i];
  198. if (current.ID == discountData.ListingId)
  199. {
  200. found = current;
  201. break;
  202. }
  203. }
  204. if (found == null)
  205. {
  206. Log.Warning($"Attempted to apply discount to listing item with {discountData.ListingId}, but found no such listing item.");
  207. return;
  208. }
  209. found.AddCostModifier(discountData.DiscountCategory, discountData.DiscountAmountByCurrency);
  210. found.Categories.Add(DiscountedStoreCategoryPrototypeKey);
  211. }
  212. }
  213. private static Dictionary<ProtoId<DiscountCategoryPrototype>, List<ListingDataWithCostModifiers>> GroupDiscountableListingsByDiscountCategory(
  214. IEnumerable<ListingDataWithCostModifiers> listings
  215. )
  216. {
  217. var listingsByDiscountCategory = new Dictionary<ProtoId<DiscountCategoryPrototype>, List<ListingDataWithCostModifiers>>();
  218. foreach (var listing in listings)
  219. {
  220. var category = listing.DiscountCategory;
  221. if (category == null || listing.DiscountDownTo.Count == 0)
  222. {
  223. continue;
  224. }
  225. if (!listingsByDiscountCategory.TryGetValue(category.Value, out var list))
  226. {
  227. list = new List<ListingDataWithCostModifiers>();
  228. listingsByDiscountCategory[category.Value] = list;
  229. }
  230. list.Add(listing);
  231. }
  232. return listingsByDiscountCategory;
  233. }
  234. private static bool TryGetDiscountData(
  235. IReadOnlyList<StoreDiscountData> discounts,
  236. ListingDataWithCostModifiers purchasedItem,
  237. [MaybeNullWhen(false)] out StoreDiscountData discountData
  238. )
  239. {
  240. for (var i = 0; i < discounts.Count; i++)
  241. {
  242. var current = discounts[i];
  243. if (current.ListingId == purchasedItem.ID)
  244. {
  245. discountData = current;
  246. return true;
  247. }
  248. }
  249. discountData = null!;
  250. return false;
  251. }
  252. /// <summary> Map for holding discount categories with their calculated cumulative weight. </summary>
  253. private sealed record CategoriesWithCumulativeWeightMap
  254. {
  255. private readonly List<DiscountCategoryPrototype> _categories;
  256. private readonly List<int> _weights;
  257. private int _totalWeight;
  258. /// <summary>
  259. /// Creates map, filtering out categories that could not be picked (no weight, no max items).
  260. /// Calculates cumulative weights by summing each next category weight with sum of all previous ones.
  261. /// </summary>
  262. public CategoriesWithCumulativeWeightMap(IEnumerable<DiscountCategoryPrototype> prototypes)
  263. {
  264. var asArray = prototypes.ToArray();
  265. _weights = new (asArray.Length);
  266. _categories = new(asArray.Length);
  267. var currentIndex = 0;
  268. _totalWeight = 0;
  269. for (var i = 0; i < asArray.Length; i++)
  270. {
  271. var category = asArray[i];
  272. if (category.MaxItems <= 0 || category.Weight <= 0)
  273. {
  274. continue;
  275. }
  276. _categories.Add(category);
  277. if (currentIndex == 0)
  278. {
  279. _totalWeight = category.Weight;
  280. }
  281. else
  282. {
  283. // cumulative weight of last discount category is total weight of all categories
  284. _totalWeight += category.Weight;
  285. }
  286. _weights.Add(_totalWeight);
  287. currentIndex++;
  288. }
  289. }
  290. /// <summary>
  291. /// Removes category and all of its effects on other items in map:
  292. /// decreases cumulativeWeight of every category that is following current one, and then
  293. /// reduces total cumulative count by that category weight, so it won't affect next rolls in any way.
  294. /// </summary>
  295. public void Remove(DiscountCategoryPrototype discountCategory)
  296. {
  297. var indexToRemove = _categories.IndexOf(discountCategory);
  298. if (indexToRemove == -1)
  299. {
  300. return;
  301. }
  302. for (var i = indexToRemove + 1; i < _categories.Count; i++)
  303. {
  304. _weights[i]-= discountCategory.Weight;
  305. }
  306. _totalWeight -= discountCategory.Weight;
  307. _categories.RemoveAt(indexToRemove);
  308. _weights.RemoveAt(indexToRemove);
  309. }
  310. /// <summary>
  311. /// Roll category respecting categories weight.
  312. /// </summary>
  313. /// <remarks>
  314. /// We rolled random point inside range of 0 and 'total weight' to pick category respecting category weights
  315. /// now we find index of category we rolled. If category cumulative weight is less than roll -
  316. /// we rolled other category, skip and try next.
  317. /// </remarks>
  318. /// <param name="random">Random number generator.</param>
  319. /// <returns>Rolled category, or null if no category could be picked based on current map state.</returns>
  320. public DiscountCategoryPrototype? RollCategory(IRobustRandom random)
  321. {
  322. var roll = random.Next(_totalWeight);
  323. for (int i = 0; i < _weights.Count; i++)
  324. {
  325. if (roll < _weights[i])
  326. {
  327. return _categories[i];
  328. }
  329. }
  330. return null;
  331. }
  332. }
  333. }
  334. /// <summary>
  335. /// Event of store being initialized.
  336. /// </summary>
  337. /// <param name="TargetUser">EntityUid of store entity owner.</param>
  338. /// <param name="Store">EntityUid of store entity.</param>
  339. /// <param name="UseDiscounts">Marker, if store should have discounts.</param>
  340. /// <param name="Listings">List of available listings items.</param>
  341. [ByRefEvent]
  342. public record struct StoreInitializedEvent(
  343. EntityUid TargetUser,
  344. EntityUid Store,
  345. bool UseDiscounts,
  346. IReadOnlyList<ListingDataWithCostModifiers> Listings
  347. );