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)
{
_menu.OnListingButtonPressed += (_, listing) =>
{
- SendMessage(new StoreBuyListingMessage(listing));
+ SendMessage(new StoreBuyListingMessage(listing.ID));
};
_menu.OnCategoryButtonPressed += (_, category) =>
_listings = msg.Listings;
_menu?.UpdateBalance(msg.Balance);
+
UpdateListingsWithSearchFilter();
_menu?.SetFooterVisibility(msg.ShowFooter);
_menu?.UpdateRefund(msg.AllowRefund);
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) &&
<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"
[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);
_data = data;
_hasBalance = hasBalance;
_price = price;
+ _discount = discount;
StoreItemName.Text = ListingLocalisationHelpers.GetLocalisedNameOrEntityName(_data, _prototype);
StoreItemDescription.SetMessage(ListingLocalisationHelpers.GetLocalisedDescriptionOrEntityDescription(_data, _prototype));
}
else
{
+ DiscountSubText.Text = _discount;
StoreItemBuyButton.Text = _price;
}
}
using System.Linq;
+using System.Text;
using Content.Client.Actions;
using Content.Client.Message;
using Content.Shared.FixedPoint;
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;
public Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Balance = new();
public string CurrentCategory = string.Empty;
- private List<ListingData> _cachedListings = new();
+ private List<ListingDataWithCostModifiers> _cachedListings = new();
public StoreMenu()
{
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?
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>();
}
}
- 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
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)
--- /dev/null
+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();
+ }
+}
// 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
-using Content.Server.Store.Components;
-using Content.Server.Store.Systems;
using Content.Shared.Store;
using Content.Shared.Store.Components;
using Robust.Shared.Prototypes;
if (!args.EntityManager.TryGetComponent<StoreComponent>(args.StoreEntity, out var storeComp))
return false;
- var allListings = storeComp.Listings;
+ var allListings = storeComp.FullListingsCatalog;
var purchasesFound = false;
+using System.Diagnostics.CodeAnalysis;
using Content.Shared.Store;
using Content.Shared.Store.Components;
using Robust.Shared.Prototypes;
/// <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>
/// </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))
Log.Error("Attempted to add invalid listing.");
return false;
}
+
return TryAddListing(component, proto);
}
/// <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>
/// <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>
/// <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();
}
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;
+ }
}
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);
//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
// 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);
}
/// </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
{
}
//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;
}
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
if (listing.ProductUpgradeId != null)
{
- foreach (var upgradeListing in component.Listings)
+ foreach (var upgradeListing in component.FullListingsCatalog)
{
if (upgradeListing.ID == listing.ProductUpgradeId)
{
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);
}
{
component.Balance[currency] += value;
}
+
// Reset store back to its original state
RefreshAllListings(component);
component.BalanceSpent = new();
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
+);
--- /dev/null
+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
+);
{
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;
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"));
}
+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;
/// </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)
_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;
{
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;
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;
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.
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;
/// </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!;
[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>
/// 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.
[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)
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) &&
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; }
+}
[Serializable, NetSerializable]
public sealed class StoreUpdateState : BoundUserInterfaceState
{
- public readonly HashSet<ListingData> Listings;
+ public readonly HashSet<ListingDataWithCostModifiers> Listings;
public readonly Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Balance;
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;
}
[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]
--- /dev/null
+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();
+}
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
store-category-job = Job
store-category-wearables = Wearables
store-category-pointless = Pointless
+store-discounted-items = Discounts
# Revenant
store-category-abilities = Abilities
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.
--- /dev/null
+- 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
name: uplink-pistol-viper-name
description: uplink-pistol-viper-desc
productEntity: WeaponPistolViper
+ discountCategory: rareDiscounts
+ discountDownTo:
+ Telecrystal: 2
cost:
Telecrystal: 3
categories:
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:
name: uplink-pistol-cobra-name
description: uplink-pistol-cobra-desc
productEntity: WeaponPistolCobra
+ discountCategory: rareDiscounts
+ discountDownTo:
+ Telecrystal: 2
cost:
Telecrystal: 4
categories:
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
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:
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:
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:
id: RevenantAbilities
name: store-category-abilities
+- type: storeCategory
+ id: DiscountedItems
+ name: store-discounted-items
+ priority: 200