]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
feat: #26107 uplink discounts for traitors (no nukies for now) (#26297)
authorFildrance <fildrance@gmail.com>
Thu, 5 Sep 2024 12:12:39 +0000 (15:12 +0300)
committerGitHub <noreply@github.com>
Thu, 5 Sep 2024 12:12:39 +0000 (22:12 +1000)
* feat: #26107 uplink discounts for traitors and nukies

* refactor: #26107 extracted discount label from price of StoreListingControl

* refactor: minor renaming

* refactor: parametrized adding discounts to uplink store

* fix: #26107 prevent exception on empty discountOptions

* feat: uplink now have 'Discounted' category which contains all discounted items on this session.

* after merge fixups

* rename discount categories according to common sense

* refactor: DiscountOptions is now optional (nullable) on ListingData

* add nullability check ignore for already checked listingData.DiscountOptions

* fix after merge store menu ui

* remove unused using

* final fix after merge conflicts

* [refactor]: #26107 fix variables naming in UplinkSystem

* fix: #26107 fix after merge

* refactor: #26107 now supports discountDownUntil on ListingItem, instead of % of discount

* feat: #26107 support multiple currency discount in store on side of discount message label

* refactor: #26107 extracted discounts initialization to separate system. StoreDiscountData are spread as array and not list now

* refactor: #26107 move more code from storesystem to StoreDiscountComponent

* refactor: #26107 separated StoreSystem and StoreDiscountSystem using events

* fix: #26107 placed not-nullable variable initialization in ListingData for tests

* refactor: #26107 minor renaming, xml-docs

* fix: #26107 changed most of discounts to be down to half price for balance purposes

* ids used in with discounts are now ProtoIds, dicountCategories are now prototypes, code with weights simplified

* decoupled storesystem and store discount system

* xml-docs

* refactor:  #26107 xml-doc for StoreDiscountSystem

* is now a thing (tmp)

* fix: compilation errors + StoreDiscountData.DiscountCategoryId

* refactor: rename ListingDataWithCostModifiers, fix all cost related code, enpittyfy performance, uglify uplink_catalog

* refactor: removed unused code, more StoreDiscountSystem docs, simplify code

* refactor: moved discount category logic to respective system, now creating ListingData c-tor clones all mutable fields as expected

* refactor: rename back (its not prototype)

* refactor: move ListingItemsInitializingEvent to file with handling logic

* refactor: comments for StoreBuyFinishedEvent handling, more logging

* refactor: moved StoreInitializedEvent, xml-doc

* refactor: simplify StoreDiscountSystem code  (reduce nesting) + xml-doc

* refactor: restore old listing data cost field name

* refactor: fix linter in uplink_catalog.yml

* refactor: xml-doc for ListingDataWithCostModifiers

* refactor: limit usage of ListingData in favour of ListingDataWithCostModifiers

* refactor: purged linq, removed custom datafield names, minor cleanup

* refactor: removed double-allocation on getting available listings

* refactor: StoreSystem.OnBuyRequest now uses component.FullListingsCatalog as reference point (as it was in original code)

* fix: minor discount categories on uplink items changes following design overview

* refactor: StoreBuyListingMessage now uses protoId and not whole object

* refactor: store refund and discount integration test, RefreshAllListings now translates previous cost modifiers to refreshed list, if state previous to refresh had any listing items

---------

Co-authored-by: pa.pecherskij <pa.pecherskij@interfax.ru>
23 files changed:
Content.Client/Store/Ui/StoreBoundUserInterface.cs
Content.Client/Store/Ui/StoreListingControl.xaml
Content.Client/Store/Ui/StoreListingControl.xaml.cs
Content.Client/Store/Ui/StoreMenu.xaml.cs
Content.IntegrationTests/Tests/StoreTests.cs [new file with mode: 0644]
Content.Server/GameTicking/Rules/TraitorRuleSystem.cs
Content.Server/Store/Conditions/BuyBeforeCondition.cs
Content.Server/Store/Systems/StoreSystem.Listings.cs
Content.Server/Store/Systems/StoreSystem.Refund.cs
Content.Server/Store/Systems/StoreSystem.Ui.cs
Content.Server/StoreDiscount/Systems/StoreDiscountSystem.cs [new file with mode: 0644]
Content.Server/Traitor/Uplink/Commands/AddUplinkCommand.cs
Content.Server/Traitor/Uplink/UplinkSystem.cs
Content.Shared/Store/Components/StoreComponent.cs
Content.Shared/Store/ListingPrototype.cs
Content.Shared/Store/StoreUi.cs
Content.Shared/StoreDiscount/Components/StoreDiscountComponent.cs [new file with mode: 0644]
Resources/Locale/en-US/administration/commands/add-uplink-command.ftl
Resources/Locale/en-US/store/categories.ftl
Resources/Locale/en-US/store/store.ftl
Resources/Prototypes/Catalog/discount_categories.yml [new file with mode: 0644]
Resources/Prototypes/Catalog/uplink_catalog.yml
Resources/Prototypes/Store/categories.yml

index 7ed67f7b5dd9fdfbad7475efc91d6a0c8abafc27..8c48258de00b3dac0bb2f1f0f320653f0c79f93f 100644 (file)
@@ -19,7 +19,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
     private string _search = string.Empty;
 
     [ViewVariables]
-    private HashSet<ListingData> _listings = new();
+    private HashSet<ListingDataWithCostModifiers> _listings = new();
 
     public StoreBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
     {
@@ -33,7 +33,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
 
         _menu.OnListingButtonPressed += (_, listing) =>
         {
-            SendMessage(new StoreBuyListingMessage(listing));
+            SendMessage(new StoreBuyListingMessage(listing.ID));
         };
 
         _menu.OnCategoryButtonPressed += (_, category) =>
@@ -68,6 +68,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
                 _listings = msg.Listings;
 
                 _menu?.UpdateBalance(msg.Balance);
+
                 UpdateListingsWithSearchFilter();
                 _menu?.SetFooterVisibility(msg.ShowFooter);
                 _menu?.UpdateRefund(msg.AllowRefund);
@@ -80,7 +81,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
         if (_menu == null)
             return;
 
-        var filteredListings = new HashSet<ListingData>(_listings);
+        var filteredListings = new HashSet<ListingDataWithCostModifiers>(_listings);
         if (!string.IsNullOrEmpty(_search))
         {
             filteredListings.RemoveWhere(listingData => !ListingLocalisationHelpers.GetLocalisedNameOrEntityName(listingData, _prototypeManager).Trim().ToLowerInvariant().Contains(_search) &&
index 12b4d7b5b3002c8edd4f55505875f376bc024870..3142f1cb06115a711233bde83895fc321a5f5005 100644 (file)
@@ -2,6 +2,8 @@
     <BoxContainer Margin="8,8,8,8" Orientation="Vertical">
         <BoxContainer Orientation="Horizontal">
             <Label Name="StoreItemName" HorizontalExpand="True" />
+            <Label Name="DiscountSubText"
+                   HorizontalAlignment="Right"/>
             <Button
                 Name="StoreItemBuyButton"
                 MinWidth="64"
index 030f07dc7ca95c0cee59f60312ba0deb88c6f745..20629104accf5f3486c39eb805c4303078a7f24a 100644 (file)
@@ -17,11 +17,12 @@ public sealed partial class StoreListingControl : Control
     [Dependency] private readonly IGameTiming _timing = default!;
     private readonly ClientGameTicker _ticker;
 
-    private readonly ListingData _data;
+    private readonly ListingDataWithCostModifiers _data;
 
     private readonly bool _hasBalance;
     private readonly string _price;
-    public StoreListingControl(ListingData data, string price, bool hasBalance, Texture? texture = null)
+    private readonly string _discount;
+    public StoreListingControl(ListingDataWithCostModifiers data, string price, string discount, bool hasBalance, Texture? texture = null)
     {
         IoCManager.InjectDependencies(this);
         RobustXamlLoader.Load(this);
@@ -31,6 +32,7 @@ public sealed partial class StoreListingControl : Control
         _data = data;
         _hasBalance = hasBalance;
         _price = price;
+        _discount = discount;
 
         StoreItemName.Text = ListingLocalisationHelpers.GetLocalisedNameOrEntityName(_data, _prototype);
         StoreItemDescription.SetMessage(ListingLocalisationHelpers.GetLocalisedDescriptionOrEntityDescription(_data, _prototype));
@@ -63,6 +65,7 @@ public sealed partial class StoreListingControl : Control
         }
         else
         {
+            DiscountSubText.Text = _discount;
             StoreItemBuyButton.Text = _price;
         }
     }
index 388b31291c19e05688de67b9501d1caac3596b65..ae38be1115ad7e125444092581d2f2d9d20737be 100644 (file)
@@ -1,4 +1,5 @@
 using System.Linq;
+using System.Text;
 using Content.Client.Actions;
 using Content.Client.Message;
 using Content.Shared.FixedPoint;
@@ -22,7 +23,7 @@ public sealed partial class StoreMenu : DefaultWindow
     private StoreWithdrawWindow? _withdrawWindow;
 
     public event EventHandler<string>? SearchTextUpdated;
-    public event Action<BaseButton.ButtonEventArgs, ListingData>? OnListingButtonPressed;
+    public event Action<BaseButton.ButtonEventArgs, ListingDataWithCostModifiers>? OnListingButtonPressed;
     public event Action<BaseButton.ButtonEventArgs, string>? OnCategoryButtonPressed;
     public event Action<BaseButton.ButtonEventArgs, string, int>? OnWithdrawAttempt;
     public event Action<BaseButton.ButtonEventArgs>? OnRefundAttempt;
@@ -30,7 +31,7 @@ public sealed partial class StoreMenu : DefaultWindow
     public Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Balance = new();
     public string CurrentCategory = string.Empty;
 
-    private List<ListingData> _cachedListings = new();
+    private List<ListingDataWithCostModifiers> _cachedListings = new();
 
     public StoreMenu()
     {
@@ -68,15 +69,17 @@ public sealed partial class StoreMenu : DefaultWindow
         WithdrawButton.Disabled = disabled;
     }
 
-    public void UpdateListing(List<ListingData> listings)
+    public void UpdateListing(List<ListingDataWithCostModifiers> listings)
     {
         _cachedListings = listings;
+
         UpdateListing();
     }
 
     public void UpdateListing()
     {
-        var sorted = _cachedListings.OrderBy(l => l.Priority).ThenBy(l => l.Cost.Values.Sum());
+        var sorted = _cachedListings.OrderBy(l => l.Priority)
+                                    .ThenBy(l => l.Cost.Values.Sum());
 
         // should probably chunk these out instead. to-do if this clogs the internet tubes.
         // maybe read clients prototypes instead?
@@ -114,13 +117,12 @@ public sealed partial class StoreMenu : DefaultWindow
         OnRefundAttempt?.Invoke(args);
     }
 
-    private void AddListingGui(ListingData listing)
+    private void AddListingGui(ListingDataWithCostModifiers listing)
     {
         if (!listing.Categories.Contains(CurrentCategory))
             return;
 
-        var listingPrice = listing.Cost;
-        var hasBalance = HasListingPrice(Balance, listingPrice);
+        var hasBalance = listing.CanBuyWith(Balance);
 
         var spriteSys = _entityManager.EntitySysManager.GetEntitySystem<SpriteSystem>();
 
@@ -143,29 +145,20 @@ public sealed partial class StoreMenu : DefaultWindow
             }
         }
 
-        var newListing = new StoreListingControl(listing, GetListingPriceString(listing), hasBalance, texture);
+        var listingInStock = GetListingPriceString(listing);
+        var discount = GetDiscountString(listing);
+
+        var newListing = new StoreListingControl(listing, listingInStock, discount, hasBalance, texture);
         newListing.StoreItemBuyButton.OnButtonDown += args
             => OnListingButtonPressed?.Invoke(args, listing);
 
         StoreListingsContainer.AddChild(newListing);
     }
 
-    public bool HasListingPrice(Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> currency, Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> price)
-    {
-        foreach (var type in price)
-        {
-            if (!currency.ContainsKey(type.Key))
-                return false;
-
-            if (currency[type.Key] < type.Value)
-                return false;
-        }
-        return true;
-    }
-
-    public string GetListingPriceString(ListingData listing)
+    private string GetListingPriceString(ListingDataWithCostModifiers listing)
     {
         var text = string.Empty;
+
         if (listing.Cost.Count < 1)
             text = Loc.GetString("store-currency-free");
         else
@@ -173,20 +166,72 @@ public sealed partial class StoreMenu : DefaultWindow
             foreach (var (type, amount) in listing.Cost)
             {
                 var currency = _prototypeManager.Index(type);
-                text += Loc.GetString("store-ui-price-display", ("amount", amount),
-                    ("currency", Loc.GetString(currency.DisplayName, ("amount", amount))));
+
+                text += Loc.GetString(
+                    "store-ui-price-display",
+                    ("amount", amount),
+                    ("currency", Loc.GetString(currency.DisplayName, ("amount", amount)))
+                );
             }
         }
 
         return text.TrimEnd();
     }
 
+    private string GetDiscountString(ListingDataWithCostModifiers listingDataWithCostModifiers)
+    {
+        string discountMessage;
+
+        if (!listingDataWithCostModifiers.IsCostModified)
+        {
+            return string.Empty;
+        }
+
+        var relativeModifiersSummary = listingDataWithCostModifiers.GetModifiersSummaryRelative();
+        if (relativeModifiersSummary.Count > 1)
+        {
+            var sb = new StringBuilder();
+            sb.Append('(');
+            foreach (var (currency, amount) in relativeModifiersSummary)
+            {
+                var currencyPrototype = _prototypeManager.Index(currency);
+                if (sb.Length != 0)
+                {
+                    sb.Append(", ");
+                }
+                var currentDiscountMessage = Loc.GetString(
+                    "store-ui-discount-display-with-currency",
+                    ("amount", amount.ToString("P0")),
+                    ("currency", Loc.GetString(currencyPrototype.DisplayName))
+                );
+                sb.Append(currentDiscountMessage);
+            }
+
+            sb.Append(')');
+            discountMessage = sb.ToString();
+        }
+        else
+        {
+            // if cost was modified - it should have diff relatively to original cost in 1 or more currency
+            // ReSharper disable once GenericEnumeratorNotDisposed Dictionary enumerator doesn't require dispose
+            var enumerator = relativeModifiersSummary.GetEnumerator();
+            enumerator.MoveNext();
+            var amount = enumerator.Current.Value;
+            discountMessage = Loc.GetString(
+                "store-ui-discount-display",
+                ("amount", (amount.ToString("P0")))
+            );
+        }
+
+        return discountMessage;
+    }
+
     private void ClearListings()
     {
         StoreListingsContainer.Children.Clear();
     }
 
-    public void PopulateStoreCategoryButtons(HashSet<ListingData> listings)
+    public void PopulateStoreCategoryButtons(HashSet<ListingDataWithCostModifiers> listings)
     {
         var allCategories = new List<StoreCategoryPrototype>();
         foreach (var listing in listings)
diff --git a/Content.IntegrationTests/Tests/StoreTests.cs b/Content.IntegrationTests/Tests/StoreTests.cs
new file mode 100644 (file)
index 0000000..877db1d
--- /dev/null
@@ -0,0 +1,160 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Content.Server.Store.Systems;
+using Content.Server.Traitor.Uplink;
+using Content.Shared.FixedPoint;
+using Content.Shared.Inventory;
+using Content.Shared.Store;
+using Content.Shared.Store.Components;
+using Content.Shared.StoreDiscount.Components;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.IntegrationTests.Tests;
+
+[TestFixture]
+public sealed class StoreTests
+{
+
+    [TestPrototypes]
+    private const string Prototypes = @"
+- type: entity
+  name: InventoryPdaDummy
+  id: InventoryPdaDummy
+  parent: BasePDA
+  components:
+  - type: Clothing
+    QuickEquip: false
+    slots:
+    - idcard
+  - type: Pda
+";
+    [Test]
+    public async Task StoreDiscountAndRefund()
+    {
+        await using var pair = await PoolManager.GetServerClient();
+        var server = pair.Server;
+
+        var testMap = await pair.CreateTestMap();
+        await server.WaitIdleAsync();
+
+        var serverRandom = server.ResolveDependency<IRobustRandom>();
+        serverRandom.SetSeed(534);
+
+        var entManager = server.ResolveDependency<IEntityManager>();
+
+        var mapSystem = server.System<SharedMapSystem>();
+        var prototypeManager = server.ProtoMan;
+
+        Assert.That(mapSystem.IsInitialized(testMap.MapId));
+
+
+        EntityUid human = default;
+        EntityUid uniform = default;
+        EntityUid pda = default;
+
+        var uplinkSystem = entManager.System<UplinkSystem>();
+
+        var listingPrototypes = prototypeManager.EnumeratePrototypes<ListingPrototype>()
+                                                .ToArray();
+
+        var coordinates = testMap.GridCoords;
+        await server.WaitAssertion(() =>
+        {
+            var invSystem = entManager.System<InventorySystem>();
+
+            human = entManager.SpawnEntity("HumanUniformDummy", coordinates);
+            uniform = entManager.SpawnEntity("UniformDummy", coordinates);
+            pda = entManager.SpawnEntity("InventoryPdaDummy", coordinates);
+
+            Assert.That(invSystem.TryEquip(human, uniform, "jumpsuit"));
+            Assert.That(invSystem.TryEquip(human, pda, "id"));
+
+            FixedPoint2 originalBalance = 20;
+            uplinkSystem.AddUplink(human, originalBalance, null, true);
+
+            var storeComponent = entManager.GetComponent<StoreComponent>(pda);
+            var discountComponent = entManager.GetComponent<StoreDiscountComponent>(pda);
+            Assert.That(
+                discountComponent.Discounts,
+                Has.Exactly(3).Items,
+                $"After applying discount total discounted items count was expected to be '3' "
+                + $"but was actually {discountComponent.Discounts.Count}- this can be due to discount "
+                + $"categories settings (maxItems, weight) not being realistically set, or default "
+                + $"discounted count being changed from '3' in StoreDiscountSystem.InitializeDiscounts."
+            );
+            var discountedListingItems = storeComponent.FullListingsCatalog
+                                                       .Where(x => x.IsCostModified)
+                                                       .OrderBy(x => x.ID)
+                                                       .ToArray();
+            Assert.That(discountComponent.Discounts
+                                         .Select(x => x.ListingId.Id),
+                Is.EquivalentTo(discountedListingItems.Select(x => x.ID)),
+                $"{nameof(StoreComponent)}.{nameof(StoreComponent.FullListingsCatalog)} does not contain all "
+                + $"items that are marked as discounted, or they don't have flag '{nameof(ListingDataWithCostModifiers.IsCostModified)}'"
+                + $"flag as 'true'. This marks the fact that cost modifier of discount is not applied properly!"
+            );
+
+            // Refund action requests re-generation of listing items so we will be re-acquiring items from component a lot of times.
+            var itemIds = discountedListingItems.Select(x => x.ID);
+            foreach (var itemId in itemIds)
+            {
+                Assert.Multiple(() =>
+                {
+                    storeComponent.RefundAllowed = true;
+
+                    var discountedListingItem = storeComponent.FullListingsCatalog.First(x => x.ID == itemId);
+                    var plainDiscountedCost = discountedListingItem.Cost[UplinkSystem.TelecrystalCurrencyPrototype];
+
+                    var prototype = listingPrototypes.First(x => x.ID == discountedListingItem.ID);
+
+                    var prototypeCost = prototype.Cost[UplinkSystem.TelecrystalCurrencyPrototype];
+                    var discountDownTo = prototype.DiscountDownTo[UplinkSystem.TelecrystalCurrencyPrototype];
+                    Assert.That(plainDiscountedCost.Value, Is.GreaterThanOrEqualTo(discountDownTo.Value), "Expected discounted cost to be greater then DiscountDownTo value.");
+                    Assert.That(plainDiscountedCost.Value, Is.LessThan(prototypeCost.Value), "Expected discounted cost to be lower then prototype cost.");
+
+
+                    var buyMsg = new StoreBuyListingMessage(discountedListingItem.ID){Actor = human};
+                    server.EntMan.EventBus.RaiseComponentEvent(pda, storeComponent, buyMsg);
+
+                    var newBalance = storeComponent.Balance[UplinkSystem.TelecrystalCurrencyPrototype];
+                    Assert.That(newBalance.Value, Is.EqualTo((originalBalance - plainDiscountedCost).Value), "Expected to have balance reduced by discounted cost");
+                    Assert.That(
+                        discountedListingItem.IsCostModified,
+                        Is.False,
+                        $"Expected item cost to not be modified after Buying discounted item."
+                    );
+                    var costAfterBuy = discountedListingItem.Cost[UplinkSystem.TelecrystalCurrencyPrototype];
+                    Assert.That(costAfterBuy.Value, Is.EqualTo(prototypeCost.Value), "Expected cost after discount refund to be equal to prototype cost.");
+
+                    var refundMsg = new StoreRequestRefundMessage { Actor = human };
+                    server.EntMan.EventBus.RaiseComponentEvent(pda, storeComponent, refundMsg);
+
+                    // get refreshed item after refund re-generated items
+                    discountedListingItem = storeComponent.FullListingsCatalog.First(x => x.ID == itemId);
+
+                    var afterRefundBalance = storeComponent.Balance[UplinkSystem.TelecrystalCurrencyPrototype];
+                    Assert.That(afterRefundBalance.Value, Is.EqualTo(originalBalance.Value), "Expected refund to return all discounted cost value.");
+                    Assert.That(
+                        discountComponent.Discounts.First(x => x.ListingId == discountedListingItem.ID).Count,
+                        Is.EqualTo(0),
+                        "Discounted count should still be zero even after refund."
+                    );
+
+                    Assert.That(
+                        discountedListingItem.IsCostModified,
+                        Is.False,
+                        $"Expected item cost to not be modified after Buying discounted item (even after refund was done)."
+                    );
+                    var costAfterRefund = discountedListingItem.Cost[UplinkSystem.TelecrystalCurrencyPrototype];
+                    Assert.That(costAfterRefund.Value, Is.EqualTo(prototypeCost.Value), "Expected cost after discount refund to be equal to prototype cost.");
+                });
+            }
+
+        });
+
+        await pair.CleanReturnAsync();
+    }
+}
index 0465e1bac4e4a5b86e7e292548428fe10364ed69..4e4191a51bfbef4bd6ab50d93e4bd9fe8cb1b8df 100644 (file)
@@ -94,7 +94,7 @@ public sealed class TraitorRuleSystem : GameRuleSystem<TraitorRuleComponent>
             // creadth: we need to create uplink for the antag.
             // PDA should be in place already
             var pda = _uplink.FindUplinkTarget(traitor);
-            if (pda == null || !_uplink.AddUplink(traitor, startingBalance))
+            if (pda == null || !_uplink.AddUplink(traitor, startingBalance, giveDiscounts: true))
                 return false;
 
             // Give traitors their codewords and uplink code to keep in their character info menu
index 3f0c2de2e18d30aaec5e92fe09a19dbc11331e54..fcfb5f92c9d07048245862a2e076d60f8a896f86 100644 (file)
@@ -1,5 +1,3 @@
-using Content.Server.Store.Components;
-using Content.Server.Store.Systems;
 using Content.Shared.Store;
 using Content.Shared.Store.Components;
 using Robust.Shared.Prototypes;
@@ -24,7 +22,7 @@ public sealed partial class BuyBeforeCondition : ListingCondition
         if (!args.EntityManager.TryGetComponent<StoreComponent>(args.StoreEntity, out var storeComp))
             return false;
 
-        var allListings = storeComp.Listings;
+        var allListings = storeComp.FullListingsCatalog;
 
         var purchasesFound = false;
 
index 10b53a7c94b8609a7722a338f5d95c69b1d211c9..4699ec7b669bf5950e88b5e76468f5dbf09c7d97 100644 (file)
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
 using Content.Shared.Store;
 using Content.Shared.Store.Components;
 using Robust.Shared.Prototypes;
@@ -13,25 +14,43 @@ public sealed partial class StoreSystem
     /// <param name="component">The store to refresh</param>
     public void RefreshAllListings(StoreComponent component)
     {
-        component.Listings = GetAllListings();
+        var previousState = component.FullListingsCatalog;
+        var newState = GetAllListings();
+        // if we refresh list with existing cost modifiers - they will be removed,
+        // need to restore them
+        if (previousState.Count != 0)
+        {
+            foreach (var previousStateListingItem in previousState)
+            {
+                if (!previousStateListingItem.IsCostModified
+                    || !TryGetListing(newState, previousStateListingItem.ID, out var found))
+                {
+                    continue;
+                }
+
+                foreach (var (modifierSourceId, costModifier) in previousStateListingItem.CostModifiersBySourceId)
+                {
+                    found.AddCostModifier(modifierSourceId, costModifier);
+                }
+            }
+        }
+
+        component.FullListingsCatalog = newState;
     }
 
     /// <summary>
     /// Gets all listings from a prototype.
     /// </summary>
     /// <returns>All the listings</returns>
-    public HashSet<ListingData> GetAllListings()
+    public HashSet<ListingDataWithCostModifiers> GetAllListings()
     {
-        var allListings = _proto.EnumeratePrototypes<ListingPrototype>();
-
-        var allData = new HashSet<ListingData>();
-
-        foreach (var listing in allListings)
+        var clones = new HashSet<ListingDataWithCostModifiers>();
+        foreach (var prototype in _proto.EnumeratePrototypes<ListingPrototype>())
         {
-            allData.Add((ListingData) listing.Clone());
+            clones.Add(new ListingDataWithCostModifiers(prototype));
         }
 
-        return allData;
+        return clones;
     }
 
     /// <summary>
@@ -39,7 +58,7 @@ public sealed partial class StoreSystem
     /// </summary>
     /// <param name="component">The store to add the listing to</param>
     /// <param name="listingId">The id of the listing</param>
-    /// <returns>Whetehr or not the listing was added successfully</returns>
+    /// <returns>Whether or not the listing was added successfully</returns>
     public bool TryAddListing(StoreComponent component, string listingId)
     {
         if (!_proto.TryIndex<ListingPrototype>(listingId, out var proto))
@@ -47,6 +66,7 @@ public sealed partial class StoreSystem
             Log.Error("Attempted to add invalid listing.");
             return false;
         }
+
         return TryAddListing(component, proto);
     }
 
@@ -56,9 +76,9 @@ public sealed partial class StoreSystem
     /// <param name="component">The store to add the listing to</param>
     /// <param name="listing">The listing</param>
     /// <returns>Whether or not the listing was add successfully</returns>
-    public bool TryAddListing(StoreComponent component, ListingData listing)
+    public bool TryAddListing(StoreComponent component, ListingPrototype listing)
     {
-        return component.Listings.Add(listing);
+        return component.FullListingsCatalog.Add(new ListingDataWithCostModifiers(listing));
     }
 
     /// <summary>
@@ -68,9 +88,9 @@ public sealed partial class StoreSystem
     /// <param name="store"></param>
     /// <param name="component">The store the listings are coming from.</param>
     /// <returns>The available listings.</returns>
-    public IEnumerable<ListingData> GetAvailableListings(EntityUid buyer, EntityUid store, StoreComponent component)
+    public IEnumerable<ListingDataWithCostModifiers> GetAvailableListings(EntityUid buyer, EntityUid store, StoreComponent component)
     {
-        return GetAvailableListings(buyer, component.Listings, component.Categories, store);
+        return GetAvailableListings(buyer, component.FullListingsCatalog, component.Categories, store);
     }
 
     /// <summary>
@@ -81,11 +101,12 @@ public sealed partial class StoreSystem
     /// <param name="categories">What categories to filter by.</param>
     /// <param name="storeEntity">The physial entity of the store. Can be null.</param>
     /// <returns>The available listings.</returns>
-    public IEnumerable<ListingData> GetAvailableListings(
+    public IEnumerable<ListingDataWithCostModifiers> GetAvailableListings(
         EntityUid buyer,
-        HashSet<ListingData>? listings,
+        IReadOnlyCollection<ListingDataWithCostModifiers>? listings,
         HashSet<ProtoId<StoreCategoryPrototype>> categories,
-        EntityUid? storeEntity = null)
+        EntityUid? storeEntity = null
+    )
     {
         listings ??= GetAllListings();
 
@@ -131,4 +152,19 @@ public sealed partial class StoreSystem
         }
         return false;
     }
+
+    private bool TryGetListing(IReadOnlyCollection<ListingDataWithCostModifiers> collection, string listingId, [MaybeNullWhen(false)] out ListingDataWithCostModifiers found)
+    {
+        foreach(var current in collection)
+        {
+            if (current.ID == listingId)
+            {
+                found = current;
+                return true;
+            }
+        }
+
+        found = null!;
+        return false;
+    }
 }
index 4e823582e6e871f0ef6b2fc7e9299f7cb18af354..04bd585ffcf75e8824fdca064c65858e99d2ad32 100644 (file)
@@ -16,7 +16,7 @@ public sealed partial class StoreSystem
 
     private void OnEntityRemoved(EntityUid uid, StoreRefundComponent component, EntRemovedFromContainerMessage args)
     {
-        if (component.StoreEntity == null || _actions.TryGetActionData(uid, out _) || !TryComp<StoreComponent>(component.StoreEntity.Value, out var storeComp))
+        if (component.StoreEntity == null || _actions.TryGetActionData(uid, out _, false) || !TryComp<StoreComponent>(component.StoreEntity.Value, out var storeComp))
             return;
 
         DisableRefund(component.StoreEntity.Value, storeComp);
index 983d68d0af735e056c0913b088f3f0d6cd9ea94c..247055c2a7fc8ab03988e6592dab7be687dfa794 100644 (file)
@@ -91,7 +91,8 @@ public sealed partial class StoreSystem
         //this is the person who will be passed into logic for all listing filtering.
         if (user != null) //if we have no "buyer" for this update, then don't update the listings
         {
-            component.LastAvailableListings = GetAvailableListings(component.AccountOwner ?? user.Value, store, component).ToHashSet();
+            component.LastAvailableListings = GetAvailableListings(component.AccountOwner ?? user.Value, store, component)
+                .ToHashSet();
         }
 
         //dictionary for all currencies, including 0 values for currencies on the whitelist
@@ -109,6 +110,7 @@ public sealed partial class StoreSystem
 
         // only tell operatives to lock their uplink if it can be locked
         var showFooter = HasComp<RingerUplinkComponent>(store);
+
         var state = new StoreUpdateState(component.LastAvailableListings, allCurrency, showFooter, component.RefundAllowed);
         _ui.SetUiState(store, StoreUiKey.Key, state);
     }
@@ -128,7 +130,7 @@ public sealed partial class StoreSystem
     /// </summary>
     private void OnBuyRequest(EntityUid uid, StoreComponent component, StoreBuyListingMessage msg)
     {
-        var listing = component.Listings.FirstOrDefault(x => x.Equals(msg.Listing));
+        var listing = component.FullListingsCatalog.FirstOrDefault(x => x.ID.Equals(msg.Listing.Id));
 
         if (listing == null) //make sure this listing actually exists
         {
@@ -153,9 +155,10 @@ public sealed partial class StoreSystem
         }
 
         //check that we have enough money
-        foreach (var currency in listing.Cost)
+        var cost = listing.Cost;
+        foreach (var (currency, amount) in cost)
         {
-            if (!component.Balance.TryGetValue(currency.Key, out var balance) || balance < currency.Value)
+            if (!component.Balance.TryGetValue(currency, out var balance) || balance < amount)
             {
                 return;
             }
@@ -165,13 +168,13 @@ public sealed partial class StoreSystem
             component.RefundAllowed = false;
 
         //subtract the cash
-        foreach (var (currency, value) in listing.Cost)
+        foreach (var (currency, amount) in cost)
         {
-            component.Balance[currency] -= value;
+            component.Balance[currency] -= amount;
 
             component.BalanceSpent.TryAdd(currency, FixedPoint2.Zero);
 
-            component.BalanceSpent[currency] += value;
+            component.BalanceSpent[currency] += amount;
         }
 
         //spawn entity
@@ -213,7 +216,7 @@ public sealed partial class StoreSystem
 
                 if (listing.ProductUpgradeId != null)
                 {
-                    foreach (var upgradeListing in component.Listings)
+                    foreach (var upgradeListing in component.FullListingsCatalog)
                     {
                         if (upgradeListing.ID == listing.ProductUpgradeId)
                         {
@@ -262,6 +265,13 @@ public sealed partial class StoreSystem
         listing.PurchaseAmount++; //track how many times something has been purchased
         _audio.PlayEntity(component.BuySuccessSound, msg.Actor, uid); //cha-ching!
 
+        var buyFinished = new StoreBuyFinishedEvent
+        {
+            PurchasedItem = listing,
+            StoreUid = uid
+        };
+        RaiseLocalEvent(ref buyFinished);
+
         UpdateUserInterface(buyer, uid, component);
     }
 
@@ -346,6 +356,7 @@ public sealed partial class StoreSystem
         {
             component.Balance[currency] += value;
         }
+
         // Reset store back to its original state
         RefreshAllListings(component);
         component.BalanceSpent = new();
@@ -376,3 +387,14 @@ public sealed partial class StoreSystem
         component.RefundAllowed = false;
     }
 }
+
+/// <summary>
+/// Event of successfully finishing purchase in store (<see cref="StoreSystem"/>.
+/// </summary>
+/// <param name="StoreUid">EntityUid on which store is placed.</param>
+/// <param name="PurchasedItem">ListingItem that was purchased.</param>
+[ByRefEvent]
+public readonly record struct StoreBuyFinishedEvent(
+    EntityUid StoreUid,
+    ListingDataWithCostModifiers PurchasedItem
+);
diff --git a/Content.Server/StoreDiscount/Systems/StoreDiscountSystem.cs b/Content.Server/StoreDiscount/Systems/StoreDiscountSystem.cs
new file mode 100644 (file)
index 0000000..9b4a048
--- /dev/null
@@ -0,0 +1,397 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Content.Server.Store.Systems;
+using Content.Shared.FixedPoint;
+using Content.Shared.Store;
+using Content.Shared.StoreDiscount.Components;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server.StoreDiscount.Systems;
+
+/// <summary>
+/// Discount system that is part of <see cref="StoreSystem"/>.
+/// </summary>
+public sealed class StoreDiscountSystem : EntitySystem
+{
+    [ValidatePrototypeId<StoreCategoryPrototype>]
+    private const string DiscountedStoreCategoryPrototypeKey = "DiscountedItems";
+
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+
+    /// <inheritdoc />
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<StoreInitializedEvent>(OnStoreInitialized);
+        SubscribeLocalEvent<StoreBuyFinishedEvent>(OnBuyFinished);
+    }
+
+    /// <summary> Decrements discounted item count, removes discount modifier and category, if counter reaches zero. </summary>
+    private void OnBuyFinished(ref StoreBuyFinishedEvent ev)
+    {
+        var (storeId, purchasedItem) = ev;
+        if (!TryComp<StoreDiscountComponent>(storeId, out var discountsComponent))
+        {
+            return;
+        }
+
+        // find and decrement discount count for item, if there is one.
+        if (!TryGetDiscountData(discountsComponent.Discounts, purchasedItem, out var discountData) || discountData.Count == 0)
+        {
+            return;
+        }
+
+        discountData.Count--;
+        if (discountData.Count > 0)
+        {
+            return;
+        }
+
+        // if there were discounts, but they are all bought up now - restore state: remove modifier and remove store category
+        purchasedItem.RemoveCostModifier(discountData.DiscountCategory);
+        purchasedItem.Categories.Remove(DiscountedStoreCategoryPrototypeKey);
+    }
+
+    /// <summary> Initialized discounts if required. </summary>
+    private void OnStoreInitialized(ref StoreInitializedEvent ev)
+    {
+        if (!ev.UseDiscounts)
+        {
+            return;
+        }
+
+        var discountComponent = EnsureComp<StoreDiscountComponent>(ev.Store);
+        var discounts = InitializeDiscounts(ev.Listings);
+        ApplyDiscounts(ev.Listings, discounts);
+        discountComponent.Discounts = discounts;
+    }
+
+    private IReadOnlyList<StoreDiscountData> InitializeDiscounts(
+        IReadOnlyCollection<ListingDataWithCostModifiers> listings,
+        int totalAvailableDiscounts = 3
+    )
+    {
+        // Get list of categories with cumulative weights.
+        // for example if we have categories with weights 2, 18 and 80
+        // list of cumulative ones will be 2,20,100 (with 100 being total).
+        // Then roll amount of unique listing items to be discounted under
+        // each category, and after that - roll exact items in categories
+        // and their cost
+
+        var prototypes = _prototypeManager.EnumeratePrototypes<DiscountCategoryPrototype>();
+        var categoriesWithCumulativeWeight = new CategoriesWithCumulativeWeightMap(prototypes);
+        var uniqueListingItemCountByCategory = PickCategoriesToRoll(totalAvailableDiscounts, categoriesWithCumulativeWeight);
+
+        return RollItems(listings, uniqueListingItemCountByCategory);
+    }
+
+    /// <summary>
+    /// Roll <b>how many</b> unique listing items which discount categories going to have. This will be used later to then pick listing items
+    /// to actually set discounts.
+    /// </summary>
+    /// <remarks>
+    /// Not every discount category have equal chance to be rolled, and not every discount category even can be rolled.
+    /// This step is important to distribute discounts properly (weighted) and with respect of
+    /// category maxItems, and more importantly - to not roll same item multiple times on next step.
+    /// </remarks>
+    /// <param name="totalAvailableDiscounts">
+    /// Total amount of different listing items to be discounted. Depending on <see cref="DiscountCategoryPrototype.MaxItems"/>
+    /// there might be less discounts then <see cref="totalAvailableDiscounts"/>, but never more.
+    /// </param>
+    /// <param name="categoriesWithCumulativeWeightMap">
+    /// Map of discount category cumulative weights by respective protoId of discount category.
+    /// </param>
+    /// <returns>Map: <b>count</b> of different listing items to be discounted, by discount category.</returns>
+    private Dictionary<ProtoId<DiscountCategoryPrototype>, int> PickCategoriesToRoll(
+        int totalAvailableDiscounts,
+        CategoriesWithCumulativeWeightMap categoriesWithCumulativeWeightMap
+    )
+    {
+        var chosenDiscounts = new Dictionary<ProtoId<DiscountCategoryPrototype>, int>();
+        for (var i = 0; i < totalAvailableDiscounts; i++)
+        {
+            var discountCategory = categoriesWithCumulativeWeightMap.RollCategory(_random);
+            if (discountCategory == null)
+            {
+                break;
+            }
+
+            // * if category was not previously picked - we mark it as picked 1 time
+            // * if category was previously picked - we increment its 'picked' marker
+            // * if category 'picked' marker going to exceed limit on category - we need to remove IT from further rolls
+            int newDiscountCount;
+            if (!chosenDiscounts.TryGetValue(discountCategory.ID, out var alreadySelectedCount))
+            {
+                newDiscountCount = 1;
+            }
+            else
+            {
+                newDiscountCount = alreadySelectedCount + 1;
+            }
+            chosenDiscounts[discountCategory.ID] = newDiscountCount;
+
+            if (newDiscountCount >= discountCategory.MaxItems)
+            {
+                categoriesWithCumulativeWeightMap.Remove(discountCategory);
+            }
+        }
+
+        return chosenDiscounts;
+    }
+
+    /// <summary>
+    /// Rolls list of exact <see cref="ListingData"/> items to be discounted, and amount of currency to be discounted.
+    /// </summary>
+    /// <param name="listings">List of all available listing items from which discounted ones could be selected.</param>
+    /// <param name="chosenDiscounts"></param>
+    /// <returns>Collection of containers with rolled discount data.</returns>
+    private IReadOnlyList<StoreDiscountData> RollItems(IEnumerable<ListingDataWithCostModifiers> listings, Dictionary<ProtoId<DiscountCategoryPrototype>, int> chosenDiscounts)
+    {
+        // To roll for discounts on items we: pick listing items that have values inside 'DiscountDownTo'.
+        // then we iterate over discount categories that were chosen on previous step and pick unique set
+        // of items from that exact category. Then we roll for their cost:
+        // cost could be anything between DiscountDownTo value and actual item cost.
+
+        var listingsByDiscountCategory = GroupDiscountableListingsByDiscountCategory(listings);
+
+        var list = new List<StoreDiscountData>();
+        foreach (var (discountCategory, itemsCount) in chosenDiscounts)
+        {
+            if (!listingsByDiscountCategory.TryGetValue(discountCategory, out var itemsForDiscount))
+            {
+                continue;
+            }
+
+            var chosen = _random.GetItems(itemsForDiscount, itemsCount, allowDuplicates: false);
+            foreach (var listingData in chosen)
+            {
+                var cost = listingData.OriginalCost;
+                var discountAmountByCurrencyId = RollItemCost(cost, listingData);
+
+                var discountData = new StoreDiscountData
+                {
+                    ListingId = listingData.ID,
+                    Count = 1,
+                    DiscountCategory = listingData.DiscountCategory!.Value,
+                    DiscountAmountByCurrency = discountAmountByCurrencyId
+                };
+                list.Add(discountData);
+            }
+        }
+
+        return list;
+    }
+
+    /// <summary> Roll amount of each currency by which item cost should be reduced. </summary>
+    /// <remarks>
+    /// No point in confusing user with a fractional number, so we remove numbers after dot that were rolled.
+    /// </remarks>
+    private Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> RollItemCost(
+        IReadOnlyDictionary<ProtoId<CurrencyPrototype>, FixedPoint2> originalCost,
+        ListingDataWithCostModifiers listingData
+    )
+    {
+        var discountAmountByCurrencyId = new Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2>(originalCost.Count);
+        foreach (var (currency, amount) in originalCost)
+        {
+            if (!listingData.DiscountDownTo.TryGetValue(currency, out var discountUntilValue))
+            {
+                continue;
+            }
+
+            var discountUntilRolledValue = _random.NextDouble(discountUntilValue.Double(), amount.Double());
+            var discountedCost = amount - Math.Floor(discountUntilRolledValue);
+
+            // discount is negative modifier for cost
+            discountAmountByCurrencyId.Add(currency.Id, -discountedCost);
+        }
+
+        return discountAmountByCurrencyId;
+    }
+
+    private void ApplyDiscounts(IReadOnlyList<ListingDataWithCostModifiers> listings, IReadOnlyCollection<StoreDiscountData> discounts)
+    {
+        foreach (var discountData in discounts)
+        {
+            if (discountData.Count <= 0)
+            {
+                continue;
+            }
+
+            ListingDataWithCostModifiers? found = null;
+            for (var i = 0; i < listings.Count; i++)
+            {
+                var current = listings[i];
+                if (current.ID == discountData.ListingId)
+                {
+                    found = current;
+                    break;
+                }
+            }
+
+            if (found == null)
+            {
+                Log.Warning($"Attempted to apply discount to listing item with {discountData.ListingId}, but found no such listing item.");
+                return;
+            }
+
+            found.AddCostModifier(discountData.DiscountCategory, discountData.DiscountAmountByCurrency);
+            found.Categories.Add(DiscountedStoreCategoryPrototypeKey);
+        }
+    }
+
+    private static Dictionary<ProtoId<DiscountCategoryPrototype>, List<ListingDataWithCostModifiers>> GroupDiscountableListingsByDiscountCategory(
+        IEnumerable<ListingDataWithCostModifiers> listings
+    )
+    {
+        var listingsByDiscountCategory = new Dictionary<ProtoId<DiscountCategoryPrototype>, List<ListingDataWithCostModifiers>>();
+        foreach (var listing in listings)
+        {
+            var category = listing.DiscountCategory;
+            if (category == null || listing.DiscountDownTo.Count == 0)
+            {
+                continue;
+            }
+
+            if (!listingsByDiscountCategory.TryGetValue(category.Value, out var list))
+            {
+                list = new List<ListingDataWithCostModifiers>();
+                listingsByDiscountCategory[category.Value] = list;
+            }
+
+            list.Add(listing);
+        }
+
+        return listingsByDiscountCategory;
+    }
+
+    private static bool TryGetDiscountData(
+        IReadOnlyList<StoreDiscountData> discounts,
+        ListingDataWithCostModifiers purchasedItem,
+        [MaybeNullWhen(false)] out StoreDiscountData discountData
+    )
+    {
+        for (var i = 0; i < discounts.Count; i++)
+        {
+            var current = discounts[i];
+            if (current.ListingId == purchasedItem.ID)
+            {
+                discountData = current;
+                return true;
+            }
+        }
+
+        discountData = null!;
+        return false;
+    }
+
+    /// <summary> Map for holding discount categories with their calculated cumulative weight.  </summary>
+    private sealed record CategoriesWithCumulativeWeightMap
+    {
+        private readonly List<DiscountCategoryPrototype> _categories;
+        private readonly List<int> _weights;
+        private int _totalWeight;
+
+        /// <summary>
+        /// Creates map, filtering out categories that could not be picked (no weight, no max items).
+        /// Calculates cumulative weights by summing each next category weight with sum of all previous ones.
+        /// </summary>
+        public CategoriesWithCumulativeWeightMap(IEnumerable<DiscountCategoryPrototype> prototypes)
+        {
+            var asArray = prototypes.ToArray();
+            _weights = new (asArray.Length);
+            _categories = new(asArray.Length);
+
+            var currentIndex = 0;
+            _totalWeight = 0;
+            for (var i = 0; i < asArray.Length; i++)
+            {
+                var category = asArray[i];
+                if (category.MaxItems <= 0 || category.Weight <= 0)
+                {
+                    continue;
+                }
+
+                _categories.Add(category);
+
+                if (currentIndex == 0)
+                {
+                    _totalWeight = category.Weight;
+                }
+                else
+                {
+                    // cumulative weight of last discount category is total weight of all categories
+                    _totalWeight += category.Weight;
+                }
+                _weights.Add(_totalWeight);
+
+                currentIndex++;
+            }
+        }
+
+        /// <summary>
+        /// Removes category and all of its effects on other items in map:
+        /// decreases cumulativeWeight of every category that is following current one, and then
+        /// reduces total cumulative count by that category weight, so it won't affect next rolls in any way.
+        /// </summary>
+        public void Remove(DiscountCategoryPrototype discountCategory)
+        {
+            var indexToRemove = _categories.IndexOf(discountCategory);
+            if (indexToRemove == -1)
+            {
+                return;
+            }
+
+            for (var i = indexToRemove + 1; i < _categories.Count; i++)
+            {
+                _weights[i]-= discountCategory.Weight;
+            }
+
+            _totalWeight -= discountCategory.Weight;
+            _categories.RemoveAt(indexToRemove);
+            _weights.RemoveAt(indexToRemove);
+        }
+
+        /// <summary>
+        /// Roll category respecting categories weight.
+        /// </summary>
+        /// <remarks>
+        /// We rolled random point inside range of 0 and 'total weight' to pick category respecting category weights
+        /// now we find index of category we rolled. If category cumulative weight is less than roll -
+        /// we rolled other category, skip and try next.
+        /// </remarks>
+        /// <param name="random">Random number generator.</param>
+        /// <returns>Rolled category, or null if no category could be picked based on current map state.</returns>
+        public DiscountCategoryPrototype? RollCategory(IRobustRandom random)
+        {
+            var roll = random.Next(_totalWeight);
+            for (int i = 0; i < _weights.Count; i++)
+            {
+                if (roll < _weights[i])
+                {
+                    return _categories[i];
+                }
+            }
+
+            return null;
+        }
+    }
+}
+
+/// <summary>
+/// Event of store being initialized.
+/// </summary>
+/// <param name="TargetUser">EntityUid of store entity owner.</param>
+/// <param name="Store">EntityUid of store entity.</param>
+/// <param name="UseDiscounts">Marker, if store should have discounts.</param>
+/// <param name="Listings">List of available listings items.</param>
+[ByRefEvent]
+public record struct StoreInitializedEvent(
+    EntityUid TargetUser,
+    EntityUid Store,
+    bool UseDiscounts,
+    IReadOnlyList<ListingDataWithCostModifiers> Listings
+);
index 63be36b360adb0c5e2fc7f73397dac70bd843daf..f5fde87c11fa4346e9e71448185df20efb855358 100644 (file)
@@ -28,13 +28,14 @@ namespace Content.Server.Traitor.Uplink.Commands
             {
                 1 => CompletionResult.FromHintOptions(CompletionHelper.SessionNames(), Loc.GetString("add-uplink-command-completion-1")),
                 2 => CompletionResult.FromHint(Loc.GetString("add-uplink-command-completion-2")),
+                3 => CompletionResult.FromHint(Loc.GetString("add-uplink-command-completion-3")),
                 _ => CompletionResult.Empty
             };
         }
 
         public void Execute(IConsoleShell shell, string argStr, string[] args)
         {
-            if (args.Length > 2)
+            if (args.Length > 3)
             {
                 shell.WriteError(Loc.GetString("shell-wrong-arguments-number"));
                 return;
@@ -82,9 +83,19 @@ namespace Content.Server.Traitor.Uplink.Commands
                 uplinkEntity = eUid;
             }
 
+            bool isDiscounted = false;
+            if (args.Length >= 3)
+            {
+                if (!bool.TryParse(args[2], out isDiscounted))
+                {
+                    shell.WriteLine(Loc.GetString("shell-invalid-bool"));
+                    return;
+                }
+            }
+
             // Finally add uplink
             var uplinkSys = _entManager.System<UplinkSystem>();
-            if (!uplinkSys.AddUplink(user, 20, uplinkEntity: uplinkEntity))
+            if (!uplinkSys.AddUplink(user, 20, uplinkEntity: uplinkEntity, giveDiscounts: isDiscounted))
             {
                 shell.WriteLine(Loc.GetString("add-uplink-command-error-2"));
             }
index 7c39f1ed666a00f9bebce701c699b27a5e188beb..ae809dc4d774c4459812a93da87b72af3252a924 100644 (file)
@@ -1,8 +1,9 @@
+using System.Linq;
 using Content.Server.Store.Systems;
+using Content.Server.StoreDiscount.Systems;
 using Content.Shared.Hands.EntitySystems;
 using Content.Shared.Inventory;
 using Content.Shared.PDA;
-using Content.Server.Store.Components;
 using Content.Shared.FixedPoint;
 using Content.Shared.Store;
 using Content.Shared.Store.Components;
@@ -23,21 +24,26 @@ namespace Content.Server.Traitor.Uplink
         /// </summary>
         /// <param name="user">The person who is getting the uplink</param>
         /// <param name="balance">The amount of currency on the uplink. If null, will just use the amount specified in the preset.</param>
-        /// <param name="uplinkPresetId">The id of the storepreset</param>
         /// <param name="uplinkEntity">The entity that will actually have the uplink functionality. Defaults to the PDA if null.</param>
+        /// <param name="giveDiscounts">Marker that enables discounts for uplink items.</param>
         /// <returns>Whether or not the uplink was added successfully</returns>
-        public bool AddUplink(EntityUid user, FixedPoint2? balance, EntityUid? uplinkEntity = null)
+        public bool AddUplink(
+            EntityUid user,
+            FixedPoint2? balance,
+            EntityUid? uplinkEntity = null,
+            bool giveDiscounts = false
+        )
         {
-            // Try to find target item
+            // Try to find target item if none passed
+            uplinkEntity ??= FindUplinkTarget(user);
             if (uplinkEntity == null)
             {
-                uplinkEntity = FindUplinkTarget(user);
-                if (uplinkEntity == null)
-                    return false;
+                return false;
             }
 
             EnsureComp<UplinkComponent>(uplinkEntity.Value);
             var store = EnsureComp<StoreComponent>(uplinkEntity.Value);
+
             store.AccountOwner = user;
             store.Balance.Clear();
             if (balance != null)
@@ -46,6 +52,14 @@ namespace Content.Server.Traitor.Uplink
                 _store.TryAddCurrency(new Dictionary<string, FixedPoint2> { { TelecrystalCurrencyPrototype, balance.Value } }, uplinkEntity.Value, store);
             }
 
+            var uplinkInitializedEvent = new StoreInitializedEvent(
+                TargetUser: user,
+                Store: uplinkEntity.Value,
+                UseDiscounts: giveDiscounts,
+                Listings: _store.GetAvailableListings(user, uplinkEntity.Value, store)
+                                .ToArray()
+            );
+            RaiseLocalEvent(ref uplinkInitializedEvent);
             // TODO add BUI. Currently can't be done outside of yaml -_-
 
             return true;
@@ -62,7 +76,8 @@ namespace Content.Server.Traitor.Uplink
             {
                 while (containerSlotEnumerator.MoveNext(out var pdaUid))
                 {
-                    if (!pdaUid.ContainedEntity.HasValue) continue;
+                    if (!pdaUid.ContainedEntity.HasValue)
+                        continue;
 
                     if (HasComp<PdaComponent>(pdaUid.ContainedEntity.Value) || HasComp<StoreComponent>(pdaUid.ContainedEntity.Value))
                         return pdaUid.ContainedEntity.Value;
index 223f507971715ab045b923d290a2d3b6f4ea0cfa..e5171dec418383761124f2603309b011d8f84cc0 100644 (file)
@@ -2,7 +2,6 @@ using Content.Shared.FixedPoint;
 using Robust.Shared.Audio;
 using Robust.Shared.GameStates;
 using Robust.Shared.Prototypes;
-using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
 
 namespace Content.Shared.Store.Components;
 
@@ -45,16 +44,16 @@ public sealed partial class StoreComponent : Component
     public EntityUid? AccountOwner = null;
 
     /// <summary>
-    /// All listings, including those that aren't available to the buyer
+    /// Cached list of listings items with modifiers.
     /// </summary>
     [DataField]
-    public HashSet<ListingData> Listings = new();
+    public HashSet<ListingDataWithCostModifiers> FullListingsCatalog = new();
 
     /// <summary>
     /// All available listings from the last time that it was checked.
     /// </summary>
     [ViewVariables]
-    public HashSet<ListingData> LastAvailableListings = new();
+    public HashSet<ListingDataWithCostModifiers> LastAvailableListings = new();
 
     /// <summary>
     ///     All current entities bought from this shop. Useful for keeping track of refunds and upgrades.
index 559c2a33bf5d9d243a7c0c38a3b2d4a56f0889b8..05ac5cc4cd5eb72ffa5512e2e47d816819f10139 100644 (file)
@@ -1,5 +1,7 @@
 using System.Linq;
 using Content.Shared.FixedPoint;
+using Content.Shared.Store.Components;
+using Content.Shared.StoreDiscount.Components;
 using Robust.Shared.Prototypes;
 using Robust.Shared.Serialization;
 using Robust.Shared.Utility;
@@ -13,8 +15,77 @@ namespace Content.Shared.Store;
 /// </summary>
 [Serializable, NetSerializable]
 [Virtual, DataDefinition]
-public partial class ListingData : IEquatable<ListingData>, ICloneable
+public partial class ListingData : IEquatable<ListingData>
 {
+    public ListingData()
+    {
+    }
+
+    public ListingData(ListingData other) : this(
+        other.Name,
+        other.DiscountCategory,
+        other.Description,
+        other.Conditions,
+        other.Icon,
+        other.Priority,
+        other.ProductEntity,
+        other.ProductAction,
+        other.ProductUpgradeId,
+        other.ProductActionEntity,
+        other.ProductEvent,
+        other.RaiseProductEventOnUser,
+        other.PurchaseAmount,
+        other.ID,
+        other.Categories,
+        other.OriginalCost,
+        other.RestockTime,
+        other.DiscountDownTo
+    )
+    {
+
+    }
+
+    public ListingData(
+        string? name,
+        ProtoId<DiscountCategoryPrototype>? discountCategory,
+        string? description,
+        List<ListingCondition>? conditions,
+        SpriteSpecifier? icon,
+        int priority,
+        EntProtoId? productEntity,
+        EntProtoId? productAction,
+        ProtoId<ListingPrototype>? productUpgradeId,
+        EntityUid? productActionEntity,
+        object? productEvent,
+        bool raiseProductEventOnUser,
+        int purchaseAmount,
+        string id,
+        HashSet<ProtoId<StoreCategoryPrototype>> categories,
+        IReadOnlyDictionary<ProtoId<CurrencyPrototype>, FixedPoint2> originalCost,
+        TimeSpan restockTime,
+        Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> dataDiscountDownTo
+    )
+    {
+        Name = name;
+        DiscountCategory = discountCategory;
+        Description = description;
+        Conditions = conditions?.ToList();
+        Icon = icon;
+        Priority = priority;
+        ProductEntity = productEntity;
+        ProductAction = productAction;
+        ProductUpgradeId = productUpgradeId;
+        ProductActionEntity = productActionEntity;
+        ProductEvent = productEvent;
+        RaiseProductEventOnUser = raiseProductEventOnUser;
+        PurchaseAmount = purchaseAmount;
+        ID = id;
+        Categories = categories.ToHashSet();
+        OriginalCost = originalCost;
+        RestockTime = restockTime;
+        DiscountDownTo = new Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2>(dataDiscountDownTo);
+    }
+
     [ViewVariables]
     [IdDataField]
     public string ID { get; private set; } = default!;
@@ -25,6 +96,12 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
     [DataField]
     public string? Name;
 
+    /// <summary>
+    /// Discount category for listing item. This marker describes chance of how often will item be discounted.
+    /// </summary>
+    [DataField]
+    public ProtoId<DiscountCategoryPrototype>? DiscountCategory;
+
     /// <summary>
     /// The description of the listing. If empty, uses the entity's description (if present)
     /// </summary>
@@ -35,13 +112,15 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
     /// The categories that this listing applies to. Used for filtering a listing for a store.
     /// </summary>
     [DataField]
-    public List<ProtoId<StoreCategoryPrototype>> Categories = new();
+    public HashSet<ProtoId<StoreCategoryPrototype>> Categories = new();
 
     /// <summary>
-    /// The cost of the listing. String represents the currency type while the FixedPoint2 represents the amount of that currency.
+    /// The original cost of the listing. FixedPoint2 represents the amount of that currency.
+    /// This fields should not be used for getting actual cost of item, as there could be
+    /// cost modifiers (due to discounts or surplus). Use Cost property on derived class instead.
     /// </summary>
     [DataField]
-    public Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Cost = new();
+    public IReadOnlyDictionary<ProtoId<CurrencyPrototype>, FixedPoint2> OriginalCost = new Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2>();
 
     /// <summary>
     /// Specific customizable conditions that determine whether or not the listing can be purchased.
@@ -109,6 +188,12 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
     [DataField]
     public TimeSpan RestockTime = TimeSpan.Zero;
 
+    /// <summary>
+    /// Options for discount - from max amount down to how much item costs can be cut by discount, absolute value.
+    /// </summary>
+    [DataField]
+    public Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> DiscountDownTo = new();
+
     public bool Equals(ListingData? listing)
     {
         if (listing == null)
@@ -132,7 +217,7 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
         if (!Categories.OrderBy(x => x).SequenceEqual(listing.Categories.OrderBy(x => x)))
             return false;
 
-        if (!Cost.OrderBy(x => x).SequenceEqual(listing.Cost.OrderBy(x => x)))
+        if (!OriginalCost.OrderBy(x => x).SequenceEqual(listing.OriginalCost.OrderBy(x => x)))
             return false;
 
         if ((Conditions != null && listing.Conditions != null) &&
@@ -142,38 +227,208 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
         return true;
     }
 
+}
+
+/// <summary>
+///     Defines a set item listing that is available in a store
+/// </summary>
+[Prototype("listing")]
+[Serializable, NetSerializable]
+[DataDefinition]
+public sealed partial class ListingPrototype : ListingData, IPrototype
+{
+    /// <summary> Setter/getter for item cost from prototype. </summary>
+    [DataField]
+    public IReadOnlyDictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Cost
+    {
+        get => OriginalCost;
+        set => OriginalCost = value;
+    }
+}
+
+/// <summary> Wrapper around <see cref="ListingData"/> that enables controller and centralized cost modification. </summary>
+/// <remarks>
+/// Server lifecycle of those objects is bound to <see cref="StoreComponent.FullListingsCatalog"/>, which is their local cache. To fix
+/// cost changes after server side change (for example, when all items with set discount are bought up) <see cref="ApplyAllModifiers"/> is called
+/// on changes.
+/// Client side lifecycle is possible due to modifiers and original cost being transferred fields and cost being calculated when needed. Modifiers changes
+/// should not (are not expected) be happening on client.
+/// </remarks>
+[Serializable, NetSerializable, DataDefinition]
+public sealed partial class ListingDataWithCostModifiers : ListingData
+{
+    private IReadOnlyDictionary<ProtoId<CurrencyPrototype>, FixedPoint2>? _costModified;
+
+    /// <summary>
+    /// Map of values, by which calculated cost should be modified, with modification sourceId.
+    /// Instead of modifying this field - use <see cref="RemoveCostModifier"/> and <see cref="AddCostModifier"/>
+    /// when possible.
+    /// </summary>
+    [DataField]
+    public Dictionary<string, Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2>> CostModifiersBySourceId = new();
+
+    /// <inheritdoc />
+    public ListingDataWithCostModifiers(ListingData listingData)
+        : base(
+            listingData.Name,
+            listingData.DiscountCategory,
+            listingData.Description,
+            listingData.Conditions,
+            listingData.Icon,
+            listingData.Priority,
+            listingData.ProductEntity,
+            listingData.ProductAction,
+            listingData.ProductUpgradeId,
+            listingData.ProductActionEntity,
+            listingData.ProductEvent,
+            listingData.RaiseProductEventOnUser,
+            listingData.PurchaseAmount,
+            listingData.ID,
+            listingData.Categories,
+            listingData.OriginalCost,
+            listingData.RestockTime,
+            listingData.DiscountDownTo
+        )
+    {
+    }
+
+    /// <summary> Marker, if cost of listing item have any modifiers. </summary>
+    public bool IsCostModified => CostModifiersBySourceId.Count > 0;
+
+    /// <summary> Cost of listing item after applying all available modifiers. </summary>
+    public IReadOnlyDictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Cost
+    {
+        get
+        {
+            return _costModified ??= CostModifiersBySourceId.Count == 0
+                ? new Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2>(OriginalCost)
+                : ApplyAllModifiers();
+        }
+    }
+
+    /// <summary> Add map with currencies and value by which cost should be modified when final value is calculated. </summary>
+    /// <param name="modifierSourceId">Id of modifier source. Can be used for removing modifier later.</param>
+    /// <param name="modifiers">Values for cost modification.</param>
+    public void AddCostModifier(string modifierSourceId, Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> modifiers)
+    {
+        CostModifiersBySourceId.Add(modifierSourceId, modifiers);
+        if (_costModified != null)
+        {
+            _costModified = ApplyAllModifiers();
+        }
+    }
+
+    /// <summary> Remove cost modifier with passed sourceId. </summary>
+    public void RemoveCostModifier(string modifierSourceId)
+    {
+        CostModifiersBySourceId.Remove(modifierSourceId);
+        if (_costModified != null)
+        {
+            _costModified = ApplyAllModifiers();
+        }
+    }
+
+    /// <summary> Check if listing item can be bought with passed balance. </summary>
+    public bool CanBuyWith(Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> balance)
+    {
+        foreach (var (currency, amount) in Cost)
+        {
+            if (!balance.ContainsKey(currency))
+                return false;
+
+            if (balance[currency] < amount)
+                return false;
+        }
+
+        return true;
+    }
+
     /// <summary>
-    /// Creates a unique instance of a listing. ALWAWYS USE THIS WHEN ENUMERATING LISTING PROTOTYPES
-    /// DON'T BE DUMB AND MODIFY THE PROTOTYPES
+    /// Gets percent of reduced/increased cost that modifiers give respective to <see cref="ListingData.OriginalCost"/>.
+    /// Percent values are numbers between 0 and 1.
     /// </summary>
-    /// <returns>A unique copy of the listing data.</returns>
-    public object Clone()
+    public IReadOnlyDictionary<ProtoId<CurrencyPrototype>, float> GetModifiersSummaryRelative()
     {
-        return new ListingData
+        var modifiersSummaryAbsoluteValues = CostModifiersBySourceId.Aggregate(
+            new Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2>(),
+            (accumulator, x) =>
+            {
+                foreach (var (currency, amount) in x.Value)
+                {
+                    accumulator.TryGetValue(currency, out var accumulatedAmount);
+                    accumulator[currency] = accumulatedAmount + amount;
+                }
+
+                return accumulator;
+            }
+        );
+        var relativeModifiedPercent = new Dictionary<ProtoId<CurrencyPrototype>, float>();
+        foreach (var (currency, discountAmount) in modifiersSummaryAbsoluteValues)
         {
-            ID = ID,
-            Name = Name,
-            Description = Description,
-            Categories = Categories,
-            Cost = Cost,
-            Conditions = Conditions,
-            Icon = Icon,
-            Priority = Priority,
-            ProductEntity = ProductEntity,
-            ProductAction = ProductAction,
-            ProductUpgradeId = ProductUpgradeId,
-            ProductActionEntity = ProductActionEntity,
-            ProductEvent = ProductEvent,
-            PurchaseAmount = PurchaseAmount,
-            RestockTime = RestockTime,
-        };
+            if (OriginalCost.TryGetValue(currency, out var originalAmount))
+            {
+                var discountPercent = (float)discountAmount.Value / originalAmount.Value;
+                relativeModifiedPercent.Add(currency, discountPercent);
+            }
+        }
+
+        return relativeModifiedPercent;
+
+    }
+
+    private Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> ApplyAllModifiers()
+    {
+        var dictionary = new Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2>(OriginalCost);
+        foreach (var (_, modifier) in CostModifiersBySourceId)
+        {
+            ApplyModifier(dictionary, modifier);
+        }
+
+        return dictionary;
+    }
+
+    private void ApplyModifier(
+        Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> applyTo,
+        IReadOnlyDictionary<ProtoId<CurrencyPrototype>, FixedPoint2> modifier
+    )
+    {
+        foreach (var (currency, modifyBy) in modifier)
+        {
+            if (applyTo.TryGetValue(currency, out var currentAmount))
+            {
+                var modifiedAmount = currentAmount + modifyBy;
+                if (modifiedAmount < 0)
+                {
+                    modifiedAmount = 0;
+                    // no negative cost allowed
+                }
+                applyTo[currency] = modifiedAmount;
+            }
+        }
     }
 }
 
 /// <summary>
-///     Defines a set item listing that is available in a store
+///     Defines set of rules for category of discounts -
+///     how <see cref="StoreDiscountComponent"/> will be filled by respective system.
 /// </summary>
-[Prototype("listing")]
-[Serializable, NetSerializable]
-[DataDefinition]
-public sealed partial class ListingPrototype : ListingData, IPrototype;
+[Prototype("discountCategory")]
+[DataDefinition, Serializable, NetSerializable]
+public sealed partial class DiscountCategoryPrototype : IPrototype
+{
+    [ViewVariables]
+    [IdDataField]
+    public string ID { get; private set; } = default!;
+
+    /// <summary>
+    /// Weight that sets chance to roll discount of that category.
+    /// </summary>
+    [DataField]
+    public int Weight { get; private set; }
+
+    /// <summary>
+    /// Maximum amount of items that are allowed to be picked from this category.
+    /// </summary>
+    [DataField]
+    public int? MaxItems { get; private set; }
+}
index 59cf1bbbc81825e9582db72e4f6cfb39c00000fb..d8cb9e6ca884b291a1bacfc45d6f2105be76eae3 100644 (file)
@@ -13,7 +13,7 @@ public enum StoreUiKey : byte
 [Serializable, NetSerializable]
 public sealed class StoreUpdateState : BoundUserInterfaceState
 {
-    public readonly HashSet<ListingData> Listings;
+    public readonly HashSet<ListingDataWithCostModifiers> Listings;
 
     public readonly Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Balance;
 
@@ -21,7 +21,7 @@ public sealed class StoreUpdateState : BoundUserInterfaceState
 
     public readonly bool AllowRefund;
 
-    public StoreUpdateState(HashSet<ListingData> listings, Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> balance, bool showFooter, bool allowRefund)
+    public StoreUpdateState(HashSet<ListingDataWithCostModifiers> listings, Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> balance, bool showFooter, bool allowRefund)
     {
         Listings = listings;
         Balance = balance;
@@ -37,14 +37,9 @@ public sealed class StoreRequestUpdateInterfaceMessage : BoundUserInterfaceMessa
 }
 
 [Serializable, NetSerializable]
-public sealed class StoreBuyListingMessage : BoundUserInterfaceMessage
+public sealed class StoreBuyListingMessage(ProtoId<ListingPrototype> listing) : BoundUserInterfaceMessage
 {
-    public ListingData Listing;
-
-    public StoreBuyListingMessage(ListingData listing)
-    {
-        Listing = listing;
-    }
+    public ProtoId<ListingPrototype> Listing = listing;
 }
 
 [Serializable, NetSerializable]
diff --git a/Content.Shared/StoreDiscount/Components/StoreDiscountComponent.cs b/Content.Shared/StoreDiscount/Components/StoreDiscountComponent.cs
new file mode 100644 (file)
index 0000000..3736b4d
--- /dev/null
@@ -0,0 +1,51 @@
+using Content.Shared.FixedPoint;
+using Content.Shared.Store;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.StoreDiscount.Components;
+
+/// <summary>
+/// Partner-component for adding discounts functionality to StoreSystem using StoreDiscountSystem.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class StoreDiscountComponent : Component
+{
+    /// <summary>
+    /// Discounts for items in <see cref="ListingData"/>.
+    /// </summary>
+    [ViewVariables, DataField]
+    public IReadOnlyList<StoreDiscountData> Discounts = Array.Empty<StoreDiscountData>();
+}
+
+/// <summary>
+/// Container for listing item discount state.
+/// </summary>
+[Serializable, NetSerializable, DataDefinition]
+public sealed partial class StoreDiscountData
+{
+    /// <summary>
+    /// Id of listing item to be discounted.
+    /// </summary>
+    [DataField(required: true)]
+    public ProtoId<ListingPrototype> ListingId;
+
+    /// <summary>
+    /// Amount of discounted items. Each buy will decrement this counter.
+    /// </summary>
+    [DataField]
+    public int Count;
+
+    /// <summary>
+    /// Discount category that provided this discount.
+    /// </summary>
+    [DataField(required: true)]
+    public ProtoId<DiscountCategoryPrototype> DiscountCategory;
+
+    /// <summary>
+    /// Map of currencies to flat amount of discount.
+    /// </summary>
+    [DataField]
+    public Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> DiscountAmountByCurrency = new();
+}
index 068f88dc5bee8d0a2755b0b3f674b72c0d405123..1a721c93f362476493bccfa0a5d7716f7fdebe29 100644 (file)
@@ -3,5 +3,6 @@ add-uplink-command-help = Usage: adduplink [username] [item-id]
 
 add-uplink-command-completion-1 = Username (defaults to self)
 add-uplink-command-completion-2 = Uplink uid (default to PDA)
+add-uplink-command-completion-3 = Is uplink discount enabled
 add-uplink-command-error-1 = Selected player doesn't control any entity
 add-uplink-command-error-2 = Failed to add uplink to the player
\ No newline at end of file
index 4ebeff3b23762923a8e46c6b5dfb3ae39813b851..64ed0b5c637aa3ab178f9dce9544d4c42239b971 100644 (file)
@@ -12,6 +12,7 @@ store-category-allies = Allies
 store-category-job = Job
 store-category-wearables = Wearables
 store-category-pointless = Pointless
+store-discounted-items = Discounts
 
 # Revenant
 store-category-abilities = Abilities
index 5c1a46339e723b6c701515af69a2cd79f3b5d45d..a0b349e6a2aa15945b4d62709a0d4b3847556a90 100644 (file)
@@ -2,6 +2,8 @@ store-ui-default-title = Store
 store-ui-default-withdraw-text = Withdraw
 store-ui-balance-display = {$currency}: {$amount}
 store-ui-price-display = {$amount} {$currency}
+store-ui-discount-display-with-currency =  {$amount} off on {$currency}
+store-ui-discount-display =  ({$amount} off!)
 store-ui-traitor-flavor = Copyright (C) NT -30643
 store-ui-traitor-warning = Operatives must lock their uplinks after use to avoid detection.
 
diff --git a/Resources/Prototypes/Catalog/discount_categories.yml b/Resources/Prototypes/Catalog/discount_categories.yml
new file mode 100644 (file)
index 0000000..5c512e9
--- /dev/null
@@ -0,0 +1,13 @@
+- type: discountCategory
+  id: rareDiscounts # Dirty-cheap items that are rarely used and can be discounted to 0-ish cost to encourage usage.
+  weight: 18
+  maxItems: 2
+
+- type: discountCategory
+  id: usualDiscounts # Cheap items that are used not very often.
+  weight: 80
+
+- type: discountCategory
+  id: veryRareDiscounts # Casually used items that are widely used but can be (rarely) discounted for epic lulz.
+  weight: 2
+  maxItems: 1
index 8954325eb14c1c58e8549ea58b6dee3e863fac0d..394a06516be848e4014038827f4e5ad1e3617e6a 100644 (file)
@@ -6,6 +6,9 @@
   name: uplink-pistol-viper-name
   description: uplink-pistol-viper-desc
   productEntity: WeaponPistolViper
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 3
   categories:
@@ -16,6 +19,9 @@
   name: uplink-revolver-python-name
   description: uplink-revolver-python-desc
   productEntity: WeaponRevolverPythonAP
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 8 # Originally was 13 TC but was not used due to high cost
   categories:
@@ -27,6 +33,9 @@
   name: uplink-pistol-cobra-name
   description: uplink-pistol-cobra-desc
   productEntity: WeaponPistolCobra
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
@@ -48,6 +57,9 @@
   name: uplink-esword-name
   description: uplink-esword-desc
   icon: { sprite: /Textures/Objects/Weapons/Melee/e_sword.rsi, state: icon }
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 4
   productEntity: EnergySword
   cost:
     Telecrystal: 8
@@ -60,6 +72,9 @@
   description: uplink-edagger-desc
   icon: { sprite: /Textures/Objects/Weapons/Melee/e_dagger.rsi, state: icon }
   productEntity: EnergyDaggerBox
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
@@ -71,6 +86,9 @@
   description: uplink-knives-kit-desc
   icon: { sprite: /Textures/Objects/Storage/boxicons.rsi, state: throwing_knives }
   productEntity: ThrowingKnivesKit
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 6
   categories:
@@ -81,6 +99,9 @@
   name: uplink-gloves-north-star-name
   description: uplink-gloves-north-star-desc
   productEntity: ClothingHandsGlovesNorthStar
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 8
   categories:
   name: uplink-disposable-turret-name
   description: uplink-disposable-turret-desc
   productEntity: ToolboxElectricalTurretFilled
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 6
   categories:
   description: uplink-eshield-desc
   icon: { sprite: /Textures/Objects/Weapons/Melee/e_shield.rsi, state: eshield-on }
   productEntity: EnergyShield
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 8
   categories:
   description: uplink-sniper-bundle-desc
   icon: { sprite: /Textures/Objects/Weapons/Guns/Snipers/heavy_sniper.rsi, state: base }
   productEntity: BriefcaseSyndieSniperBundleFilled
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 6
   cost:
     Telecrystal: 12
   categories:
   description: uplink-c20r-bundle-desc
   icon: { sprite: /Textures/Objects/Weapons/Guns/SMGs/c20r.rsi, state: icon }
   productEntity: ClothingBackpackDuffelSyndicateFilledSMG
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 10
   cost:
     Telecrystal: 17
   categories:
   description: uplink-buldog-bundle-desc
   icon: { sprite: /Textures/Objects/Weapons/Guns/Shotguns/bulldog.rsi, state: icon }
   productEntity: ClothingBackpackDuffelSyndicateFilledShotgun
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 12
   cost:
     Telecrystal: 20
   categories:
   description: uplink-grenade-launcher-bundle-desc
   icon: { sprite: /Textures/Objects/Weapons/Guns/Launchers/china_lake.rsi, state: icon }
   productEntity: ClothingBackpackDuffelSyndicateFilledGrenadeLauncher
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 20
   cost:
     Telecrystal: 25
   categories:
   description: uplink-l6-saw-bundle-desc
   icon: { sprite: /Textures/Objects/Weapons/Guns/LMGs/l6.rsi, state: icon }
   productEntity: ClothingBackpackDuffelSyndicateFilledLMG
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 24
   cost:
     Telecrystal: 30
   categories:
   name: uplink-explosive-grenade-name
   description: uplink-explosive-grenade-desc
   productEntity: ExGrenade
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-mini-bomb-name
   description: uplink-mini-bomb-desc
   productEntity: SyndieMiniBomb
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 6
   categories:
   name: uplink-supermatter-grenade-name
   description: uplink-supermatter-grenade-desc
   productEntity: SupermatterGrenade
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 6
   categories:
   name: uplink-whitehole-grenade-name
   description: uplink-whitehole-grenade-desc
   productEntity: WhiteholeGrenade
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 3
   categories:
   name: uplink-penguin-grenade-name
   description: uplink-penguin-grenade-desc
   productEntity: MobGrenadePenguin
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 5
   categories:
   name: uplink-c4-name
   description: uplink-c4-desc
   productEntity: C4
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-grenadier-rig-name
   description: uplink-grenadier-rig-desc
   productEntity: ClothingBeltMilitaryWebbingGrenadeFilled
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 6
   cost:
     Telecrystal: 12
   categories:
   name: uplink-c4-bundle-name
   description: uplink-c4-bundle-desc
   productEntity: ClothingBackpackDuffelSyndicateC4tBundle
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 8
   cost:
-    Telecrystal: 12 #you're buying bulk so its a 25% discount
+    Telecrystal: 12 #you're buying bulk so its a 25% discount, so no additional random discount over it
   categories:
   - UplinkExplosives
 
   name: uplink-emp-grenade-name
   description: uplink-emp-grenade-desc
   productEntity: EmpGrenade
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   description: uplink-exploding-pen-desc
   icon: { sprite: /Textures/Objects/Misc/pens.rsi, state: pen }
   productEntity: PenExplodingBox
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-cluster-grenade-name
   description: uplink-cluster-grenade-desc
   productEntity: ClusterGrenade
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 5
   cost:
     Telecrystal: 8
   categories:
   name: uplink-shrapnel-grenade-name
   description: uplink-shrapnel-grenade-desc
   productEntity: GrenadeShrapnel
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-incendiary-grenade-name
   description: uplink-incendiary-grenade-desc
   productEntity: GrenadeIncendiary
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-emp-kit-name
   description: uplink-emp-kit-desc
   productEntity: ElectricalDisruptionKit
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 6
   categories:
   description: uplink-hypopen-desc
   icon: { sprite: /Textures/Objects/Misc/pens.rsi, state: pen }
   productEntity: HypopenBox
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 6
   categories:
   description: uplink-hypodart-desc
   icon: { sprite: /Textures/Objects/Fun/Darts/dart_red.rsi, state: icon }
   productEntity: HypoDartBox
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   description: uplink-chemistry-kit-desc
   icon: { sprite: /Textures/Objects/Storage/boxicons.rsi, state: vials }
   productEntity: ChemicalSynthesisKit
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 4
   categories:
   name: uplink-nocturine-chemistry-bottle-name
   description: uplink-nocturine-chemistry-bottle-desc
   productEntity: NocturineChemistryBottle
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 6
   categories:
   name: uplink-combat-medkit-name
   description: uplink-combat-medkit-desc
   productEntity: MedkitCombatFilled
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 5
   categories:
   name: uplink-combat-medipen-name
   description: uplink-combat-medipen-desc
   productEntity: CombatMedipen
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-stimpack-name
   description: uplink-stimpack-desc
   productEntity: Stimpack
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-stimkit-name
   description: uplink-stimkit-desc
   productEntity: StimkitFilled
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 8
   cost:
     Telecrystal: 12
   categories:
   name: uplink-cigarettes-name
   description: uplink-cigarettes-desc
   productEntity: CigPackSyndicate
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-meds-bundle-name
   description: uplink-meds-bundle-desc
   productEntity: ClothingBackpackDuffelSyndicateMedicalBundleFilled
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 12
   cost:
     Telecrystal: 20
   categories:
   name: uplink-agent-id-card-name
   description: uplink-agent-id-card-desc
   productEntity: AgentIDCard
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 3
   categories:
   name: uplink-stealth-box-name
   description: uplink-stealth-box-desc
   productEntity: StealthBox
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 5
   categories:
   name: uplink-chameleon-projector-name
   description: uplink-chameleon-projector-desc
   productEntity: ChameleonProjector
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 7
   categories:
   description: uplink-encryption-key-desc
   icon: { sprite: /Textures/Objects/Devices/encryption_keys.rsi, state: synd_label }
   productEntity: BoxEncryptionKeySyndie # Two for the price of one
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-ultrabright-lantern-name
   description: uplink-ultrabright-lantern-desc
   productEntity: LanternFlash
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-bribe-name
   description: uplink-bribe-desc
   productEntity: BriefcaseSyndieLobbyingBundleFilled
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   description: uplink-decoy-kit-desc
   icon: { sprite: /Textures/Objects/Tools/Decoys/operative_decoy.rsi, state: folded }
   productEntity: ClothingBackpackDuffelSyndicateDecoyKitFilled
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 6
   categories:
   name: uplink-exploding-syndicate-bomb-fake-name
   description: uplink-exploding-syndicate-bomb-fake-desc
   productEntity: SyndicateBombFake
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 4
   categories:
   name: uplink-emag-name
   description: uplink-emag-desc
   productEntity: Emag
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 5
   cost:
     Telecrystal: 8
   categories:
   name: uplink-radio-jammer-name
   description: uplink-radio-jammer-desc
   productEntity: RadioJammer
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-syndicate-weapon-module-name
   description: uplink-syndicate-weapon-module-desc
   productEntity: BorgModuleSyndicateWeapon
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 5
   categories:
   description: uplink-syndicate-martyr-module-desc
   productEntity: BorgModuleMartyr
   icon: { sprite: /Textures/Objects/Specific/Robotics/borgmodule.rsi, state: syndicateborgbomb }
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-slipocalypse-clustersoap-name
   description: uplink-slipocalypse-clustersoap-desc
   productEntity: SlipocalypseClusterSoap
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-toolbox-name
   description: uplink-toolbox-desc
   productEntity: ToolboxSyndicateFilled
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-syndicate-jaws-of-life-name
   description: uplink-syndicate-jaws-of-life-desc
   productEntity: SyndicateJawsOfLife
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-duffel-surgery-name
   description: uplink-duffel-surgery-desc
   productEntity: ClothingBackpackDuffelSyndicateFilledMedical
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-power-sink-name
   description: uplink-power-sink-desc
   productEntity: PowerSink
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 8
   categories:
   name: uplink-singularity-beacon-name
   description: uplink-singularity-beacon-desc
   productEntity: SingularityBeacon
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 12
   categories:
   description: uplink-holopara-kit-desc
   icon: { sprite: /Textures/Objects/Misc/guardian_info.rsi, state: icon }
   productEntity: BoxHoloparasite
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 8
   cost:
     Telecrystal: 14
   categories:
   description: uplink-reinforcement-radio-traitor-desc
   productEntity: ReinforcementRadioSyndicate
   icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-urist }
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 7
   cost:
     Telecrystal: 14
   categories:
   description: uplink-reinforcement-radio-ancestor-desc
   productEntity: ReinforcementRadioSyndicateAncestor
   icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-ancestor }
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 6
   categories:
   description: uplink-reinforcement-radio-ancestor-desc
   productEntity: ReinforcementRadioSyndicateAncestorNukeops
   icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-ancestor }
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 6
   categories:
   name: uplink-carp-dehydrated-name
   description: uplink-carp-dehydrated-desc
   productEntity: DehydratedSpaceCarp
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   description: uplink-mobcat-microbomb-desc
   icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-syndicat }
   productEntity: ReinforcementRadioSyndicateSyndiCat
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 6
   categories:
   description: uplink-storage-implanter-desc
   icon: { sprite: /Textures/Clothing/Back/Backpacks/backpack.rsi, state: icon }
   productEntity: StorageImplanter
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 8
   categories:
   description: uplink-freedom-implanter-desc
   icon: { sprite: /Textures/Actions/Implants/implants.rsi, state: freedom }
   productEntity: FreedomImplanter
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 5
   categories:
   description: uplink-scram-implanter-desc
   icon: { sprite: /Textures/Structures/Specific/anomaly.rsi, state: anom4 }
   productEntity: ScramImplanter
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 6 # it's a gamble that may kill you easily so 6 TC per 2 uses, second one more of a backup
   categories:
   description: uplink-dna-scrambler-implanter-desc
   icon: { sprite: /Textures/Mobs/Species/Human/parts.rsi, state: full }
   productEntity: DnaScramblerImplanter
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 5
   categories:
   description: uplink-emp-implanter-desc
   icon: { sprite: /Textures/Objects/Magic/magicactions.rsi, state: shield }
   productEntity: EmpImplanter
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   description: uplink-uplink-implanter-desc
   icon: { sprite: /Textures/Objects/Devices/communication.rsi, state: old-radio }
   productEntity: UplinkImplanter
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-black-jetpack-name
   description: uplink-black-jetpack-desc
   productEntity: JetpackBlackFilled
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-voice-mask-name
   description: uplink-voice-mask-desc
   productEntity: ClothingMaskGasVoiceChameleon
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   description: uplink-chameleon-desc
   productEntity: ClothingBackpackChameleonFill
   icon: { sprite: /Textures/Clothing/Uniforms/Jumpsuit/rainbow.rsi, state: icon }
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-clothing-no-slips-shoes-name
   description: uplink-clothing-no-slips-shoes-desc
   productEntity: ClothingShoesChameleonNoSlips
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-clothing-thieving-gloves-name
   description: uplink-clothing-thieving-gloves-desc
   productEntity: ThievingGloves
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-clothing-outer-vest-web-name
   description: uplink-clothing-outer-vest-web-desc
   productEntity: ClothingOuterVestWeb
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 3
   categories:
   name: uplink-clothing-shoes-boots-mag-syndie-name
   description: uplink-clothing-shoes-boots-mag-syndie-desc
   productEntity: ClothingShoesBootsMagSyndie
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   description: uplink-eva-syndie-desc
   icon: { sprite: /Textures/Clothing/OuterClothing/Suits/eva_syndicate.rsi, state: icon }
   productEntity: ClothingBackpackDuffelSyndicateEVABundle
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   description: uplink-hardsuit-carp-desc
   icon: { sprite: /Textures/Clothing/OuterClothing/Suits/carpsuit.rsi, state: icon }
   productEntity: ClothingOuterHardsuitCarp
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   description: uplink-hardsuit-syndie-desc
   icon: { sprite: /Textures/Clothing/OuterClothing/Hardsuits/syndicate.rsi, state: icon }
   productEntity: ClothingBackpackDuffelSyndicateHardsuitBundle
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 4
   cost:
     Telecrystal: 8
   categories:
   description: uplink-hardsuit-syndieelite-desc
   icon: { sprite: /Textures/Clothing/OuterClothing/Hardsuits/syndieelite.rsi, state: icon }
   productEntity: ClothingBackpackDuffelSyndicateEliteHardsuitBundle
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 7
   cost:
     Telecrystal: 12
   categories:
   description: uplink-clothing-outer-hardsuit-juggernaut-desc
   icon: { sprite: /Textures/Structures/Storage/Crates/syndicate.rsi, state: icon }
   productEntity: CrateCybersunJuggernautBundle
+  discountCategory: veryRareDiscounts
+  discountDownTo:
+    Telecrystal: 8
   cost:
     Telecrystal: 12
   categories:
   name: uplink-revolver-cap-gun-name
   description: uplink-revolver-cap-gun-desc
   productEntity: RevolverCapGun
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-syndicate-stamp-name
   description: uplink-syndicate-stamp-desc
   productEntity: RubberStampSyndicate
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-gatfruit-seeds-name
   description: uplink-gatfruit-seeds-desc
   productEntity: GatfruitSeeds
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 6
   categories:
   name: uplink-rigged-boxing-gloves-name
   description: uplink-rigged-boxing-gloves-desc
   productEntity: ClothingHandsGlovesBoxingRigged
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 6
   categories:
   name: uplink-rigged-boxing-gloves-name
   description: uplink-rigged-boxing-gloves-desc
   productEntity: ClothingHandsGlovesBoxingRigged
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-necronomicon-name
   description: uplink-necronomicon-desc
   productEntity: BibleNecronomicon
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-holy-hand-grenade-name
   description: uplink-holy-hand-grenade-desc
   productEntity: HolyHandGrenade
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 14
   cost:
     Telecrystal: 20
   categories:
   name: uplink-revolver-cap-gun-fake-name
   description: uplink-revolver-cap-gun-fake-desc
   productEntity: RevolverCapGunFake
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 5
   cost:
     Telecrystal: 9
   categories:
   description: uplink-banana-peel-explosive-desc
   icon: { sprite: Objects/Specific/Hydroponics/banana.rsi, state: peel }
   productEntity: TrashBananaPeelExplosiveUnarmed
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 1
   cost:
     Telecrystal: 2
   categories:
   name: uplink-cluster-banana-peel-name
   description: uplink-cluster-banana-peel-desc
   productEntity: ClusterBananaPeel
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 3
   cost:
     Telecrystal: 6
   categories:
   description: uplink-holoclown-kit-desc
   icon: { sprite: /Textures/Objects/Fun/figurines.rsi, state: holoclown }
   productEntity: BoxHoloclown
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 6
   cost:
     Telecrystal: 12
   categories:
   id: uplinkHotPotato
   name: uplink-hot-potato-name
   description: uplink-hot-potato-desc
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   productEntity: HotPotato
   cost:
     Telecrystal: 4
   name: uplink-chimp-upgrade-kit-name
   description: uplink-chimp-upgrade-kit-desc
   productEntity: WeaponPistolCHIMPUpgradeKit
+  discountCategory: usualDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 4
   categories:
   name: uplink-proximity-mine-name
   description: uplink-proximity-mine-desc
   productEntity: WetFloorSignMineExplosive
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 5 # was 4, with my buff made it 5 to be closer to minibomb -panzer
   categories:
   name: uplink-syndicate-sponge-box-name
   description: uplink-syndicate-sponge-box-desc
   icon: { sprite:  Objects/Misc/monkeycube.rsi, state: box}
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 4
   productEntity: SyndicateSpongeBox
   cost:
     Telecrystal: 7
   description: uplink-cane-blade-desc
   icon: { sprite:  Objects/Weapons/Melee/cane.rsi, state: cane}
   productEntity: CaneSheathFilled
+  discountCategory: rareDiscounts
+  discountDownTo:
+    Telecrystal: 2
   cost:
     Telecrystal: 5
   categories:
index 6bd9756c3e94a467cc209c27326460bf8f18d33c..69cde10020f8bbdfa3544959948e8d2ddc5d5960 100644 (file)
@@ -94,3 +94,7 @@
   id: RevenantAbilities
   name: store-category-abilities
 
+- type: storeCategory
+  id: DiscountedItems
+  name: store-discounted-items
+  priority: 200