feat: #26107 uplink discounts for traitors (no nukies for now) (#26297)

* 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>
This commit is contained in:
Fildrance
2024-09-05 15:12:39 +03:00
committed by GitHub
parent 402f518c5e
commit a58252f45e
23 changed files with 1415 additions and 116 deletions

View File

@@ -19,7 +19,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
private string _search = string.Empty; private string _search = string.Empty;
[ViewVariables] [ViewVariables]
private HashSet<ListingData> _listings = new(); private HashSet<ListingDataWithCostModifiers> _listings = new();
public StoreBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) public StoreBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
{ {
@@ -33,7 +33,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
_menu.OnListingButtonPressed += (_, listing) => _menu.OnListingButtonPressed += (_, listing) =>
{ {
SendMessage(new StoreBuyListingMessage(listing)); SendMessage(new StoreBuyListingMessage(listing.ID));
}; };
_menu.OnCategoryButtonPressed += (_, category) => _menu.OnCategoryButtonPressed += (_, category) =>
@@ -68,6 +68,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
_listings = msg.Listings; _listings = msg.Listings;
_menu?.UpdateBalance(msg.Balance); _menu?.UpdateBalance(msg.Balance);
UpdateListingsWithSearchFilter(); UpdateListingsWithSearchFilter();
_menu?.SetFooterVisibility(msg.ShowFooter); _menu?.SetFooterVisibility(msg.ShowFooter);
_menu?.UpdateRefund(msg.AllowRefund); _menu?.UpdateRefund(msg.AllowRefund);
@@ -80,7 +81,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
if (_menu == null) if (_menu == null)
return; return;
var filteredListings = new HashSet<ListingData>(_listings); var filteredListings = new HashSet<ListingDataWithCostModifiers>(_listings);
if (!string.IsNullOrEmpty(_search)) if (!string.IsNullOrEmpty(_search))
{ {
filteredListings.RemoveWhere(listingData => !ListingLocalisationHelpers.GetLocalisedNameOrEntityName(listingData, _prototypeManager).Trim().ToLowerInvariant().Contains(_search) && filteredListings.RemoveWhere(listingData => !ListingLocalisationHelpers.GetLocalisedNameOrEntityName(listingData, _prototypeManager).Trim().ToLowerInvariant().Contains(_search) &&

View File

@@ -2,6 +2,8 @@
<BoxContainer Margin="8,8,8,8" Orientation="Vertical"> <BoxContainer Margin="8,8,8,8" Orientation="Vertical">
<BoxContainer Orientation="Horizontal"> <BoxContainer Orientation="Horizontal">
<Label Name="StoreItemName" HorizontalExpand="True" /> <Label Name="StoreItemName" HorizontalExpand="True" />
<Label Name="DiscountSubText"
HorizontalAlignment="Right"/>
<Button <Button
Name="StoreItemBuyButton" Name="StoreItemBuyButton"
MinWidth="64" MinWidth="64"

View File

@@ -17,11 +17,12 @@ public sealed partial class StoreListingControl : Control
[Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IGameTiming _timing = default!;
private readonly ClientGameTicker _ticker; private readonly ClientGameTicker _ticker;
private readonly ListingData _data; private readonly ListingDataWithCostModifiers _data;
private readonly bool _hasBalance; private readonly bool _hasBalance;
private readonly string _price; 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); IoCManager.InjectDependencies(this);
RobustXamlLoader.Load(this); RobustXamlLoader.Load(this);
@@ -31,6 +32,7 @@ public sealed partial class StoreListingControl : Control
_data = data; _data = data;
_hasBalance = hasBalance; _hasBalance = hasBalance;
_price = price; _price = price;
_discount = discount;
StoreItemName.Text = ListingLocalisationHelpers.GetLocalisedNameOrEntityName(_data, _prototype); StoreItemName.Text = ListingLocalisationHelpers.GetLocalisedNameOrEntityName(_data, _prototype);
StoreItemDescription.SetMessage(ListingLocalisationHelpers.GetLocalisedDescriptionOrEntityDescription(_data, _prototype)); StoreItemDescription.SetMessage(ListingLocalisationHelpers.GetLocalisedDescriptionOrEntityDescription(_data, _prototype));
@@ -63,6 +65,7 @@ public sealed partial class StoreListingControl : Control
} }
else else
{ {
DiscountSubText.Text = _discount;
StoreItemBuyButton.Text = _price; StoreItemBuyButton.Text = _price;
} }
} }

View File

@@ -1,4 +1,5 @@
using System.Linq; using System.Linq;
using System.Text;
using Content.Client.Actions; using Content.Client.Actions;
using Content.Client.Message; using Content.Client.Message;
using Content.Shared.FixedPoint; using Content.Shared.FixedPoint;
@@ -22,7 +23,7 @@ public sealed partial class StoreMenu : DefaultWindow
private StoreWithdrawWindow? _withdrawWindow; private StoreWithdrawWindow? _withdrawWindow;
public event EventHandler<string>? SearchTextUpdated; 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>? OnCategoryButtonPressed;
public event Action<BaseButton.ButtonEventArgs, string, int>? OnWithdrawAttempt; public event Action<BaseButton.ButtonEventArgs, string, int>? OnWithdrawAttempt;
public event Action<BaseButton.ButtonEventArgs>? OnRefundAttempt; public event Action<BaseButton.ButtonEventArgs>? OnRefundAttempt;
@@ -30,7 +31,7 @@ public sealed partial class StoreMenu : DefaultWindow
public Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Balance = new(); public Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Balance = new();
public string CurrentCategory = string.Empty; public string CurrentCategory = string.Empty;
private List<ListingData> _cachedListings = new(); private List<ListingDataWithCostModifiers> _cachedListings = new();
public StoreMenu() public StoreMenu()
{ {
@@ -68,15 +69,17 @@ public sealed partial class StoreMenu : DefaultWindow
WithdrawButton.Disabled = disabled; WithdrawButton.Disabled = disabled;
} }
public void UpdateListing(List<ListingData> listings) public void UpdateListing(List<ListingDataWithCostModifiers> listings)
{ {
_cachedListings = listings; _cachedListings = listings;
UpdateListing(); UpdateListing();
} }
public void 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. // should probably chunk these out instead. to-do if this clogs the internet tubes.
// maybe read clients prototypes instead? // maybe read clients prototypes instead?
@@ -114,13 +117,12 @@ public sealed partial class StoreMenu : DefaultWindow
OnRefundAttempt?.Invoke(args); OnRefundAttempt?.Invoke(args);
} }
private void AddListingGui(ListingData listing) private void AddListingGui(ListingDataWithCostModifiers listing)
{ {
if (!listing.Categories.Contains(CurrentCategory)) if (!listing.Categories.Contains(CurrentCategory))
return; return;
var listingPrice = listing.Cost; var hasBalance = listing.CanBuyWith(Balance);
var hasBalance = HasListingPrice(Balance, listingPrice);
var spriteSys = _entityManager.EntitySysManager.GetEntitySystem<SpriteSystem>(); 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 newListing.StoreItemBuyButton.OnButtonDown += args
=> OnListingButtonPressed?.Invoke(args, listing); => OnListingButtonPressed?.Invoke(args, listing);
StoreListingsContainer.AddChild(newListing); StoreListingsContainer.AddChild(newListing);
} }
public bool HasListingPrice(Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> currency, Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> price) private string GetListingPriceString(ListingDataWithCostModifiers listing)
{
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)
{ {
var text = string.Empty; var text = string.Empty;
if (listing.Cost.Count < 1) if (listing.Cost.Count < 1)
text = Loc.GetString("store-currency-free"); text = Loc.GetString("store-currency-free");
else else
@@ -173,20 +166,72 @@ public sealed partial class StoreMenu : DefaultWindow
foreach (var (type, amount) in listing.Cost) foreach (var (type, amount) in listing.Cost)
{ {
var currency = _prototypeManager.Index(type); 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(); 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() private void ClearListings()
{ {
StoreListingsContainer.Children.Clear(); StoreListingsContainer.Children.Clear();
} }
public void PopulateStoreCategoryButtons(HashSet<ListingData> listings) public void PopulateStoreCategoryButtons(HashSet<ListingDataWithCostModifiers> listings)
{ {
var allCategories = new List<StoreCategoryPrototype>(); var allCategories = new List<StoreCategoryPrototype>();
foreach (var listing in listings) foreach (var listing in listings)

View File

@@ -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();
}
}

View File

@@ -94,7 +94,7 @@ public sealed class TraitorRuleSystem : GameRuleSystem<TraitorRuleComponent>
// creadth: we need to create uplink for the antag. // creadth: we need to create uplink for the antag.
// PDA should be in place already // PDA should be in place already
var pda = _uplink.FindUplinkTarget(traitor); var pda = _uplink.FindUplinkTarget(traitor);
if (pda == null || !_uplink.AddUplink(traitor, startingBalance)) if (pda == null || !_uplink.AddUplink(traitor, startingBalance, giveDiscounts: true))
return false; return false;
// Give traitors their codewords and uplink code to keep in their character info menu // Give traitors their codewords and uplink code to keep in their character info menu

View File

@@ -1,5 +1,3 @@
using Content.Server.Store.Components;
using Content.Server.Store.Systems;
using Content.Shared.Store; using Content.Shared.Store;
using Content.Shared.Store.Components; using Content.Shared.Store.Components;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
@@ -24,7 +22,7 @@ public sealed partial class BuyBeforeCondition : ListingCondition
if (!args.EntityManager.TryGetComponent<StoreComponent>(args.StoreEntity, out var storeComp)) if (!args.EntityManager.TryGetComponent<StoreComponent>(args.StoreEntity, out var storeComp))
return false; return false;
var allListings = storeComp.Listings; var allListings = storeComp.FullListingsCatalog;
var purchasesFound = false; var purchasesFound = false;

View File

@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Content.Shared.Store; using Content.Shared.Store;
using Content.Shared.Store.Components; using Content.Shared.Store.Components;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
@@ -13,25 +14,43 @@ public sealed partial class StoreSystem
/// <param name="component">The store to refresh</param> /// <param name="component">The store to refresh</param>
public void RefreshAllListings(StoreComponent component) 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> /// <summary>
/// Gets all listings from a prototype. /// Gets all listings from a prototype.
/// </summary> /// </summary>
/// <returns>All the listings</returns> /// <returns>All the listings</returns>
public HashSet<ListingData> GetAllListings() public HashSet<ListingDataWithCostModifiers> GetAllListings()
{ {
var allListings = _proto.EnumeratePrototypes<ListingPrototype>(); var clones = new HashSet<ListingDataWithCostModifiers>();
foreach (var prototype in _proto.EnumeratePrototypes<ListingPrototype>())
var allData = new HashSet<ListingData>();
foreach (var listing in allListings)
{ {
allData.Add((ListingData) listing.Clone()); clones.Add(new ListingDataWithCostModifiers(prototype));
} }
return allData; return clones;
} }
/// <summary> /// <summary>
@@ -39,7 +58,7 @@ public sealed partial class StoreSystem
/// </summary> /// </summary>
/// <param name="component">The store to add the listing to</param> /// <param name="component">The store to add the listing to</param>
/// <param name="listingId">The id of the listing</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) public bool TryAddListing(StoreComponent component, string listingId)
{ {
if (!_proto.TryIndex<ListingPrototype>(listingId, out var proto)) if (!_proto.TryIndex<ListingPrototype>(listingId, out var proto))
@@ -47,6 +66,7 @@ public sealed partial class StoreSystem
Log.Error("Attempted to add invalid listing."); Log.Error("Attempted to add invalid listing.");
return false; return false;
} }
return TryAddListing(component, proto); 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="component">The store to add the listing to</param>
/// <param name="listing">The listing</param> /// <param name="listing">The listing</param>
/// <returns>Whether or not the listing was add successfully</returns> /// <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> /// <summary>
@@ -68,9 +88,9 @@ public sealed partial class StoreSystem
/// <param name="store"></param> /// <param name="store"></param>
/// <param name="component">The store the listings are coming from.</param> /// <param name="component">The store the listings are coming from.</param>
/// <returns>The available listings.</returns> /// <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> /// <summary>
@@ -81,11 +101,12 @@ public sealed partial class StoreSystem
/// <param name="categories">What categories to filter by.</param> /// <param name="categories">What categories to filter by.</param>
/// <param name="storeEntity">The physial entity of the store. Can be null.</param> /// <param name="storeEntity">The physial entity of the store. Can be null.</param>
/// <returns>The available listings.</returns> /// <returns>The available listings.</returns>
public IEnumerable<ListingData> GetAvailableListings( public IEnumerable<ListingDataWithCostModifiers> GetAvailableListings(
EntityUid buyer, EntityUid buyer,
HashSet<ListingData>? listings, IReadOnlyCollection<ListingDataWithCostModifiers>? listings,
HashSet<ProtoId<StoreCategoryPrototype>> categories, HashSet<ProtoId<StoreCategoryPrototype>> categories,
EntityUid? storeEntity = null) EntityUid? storeEntity = null
)
{ {
listings ??= GetAllListings(); listings ??= GetAllListings();
@@ -131,4 +152,19 @@ public sealed partial class StoreSystem
} }
return false; 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;
}
} }

View File

@@ -16,7 +16,7 @@ public sealed partial class StoreSystem
private void OnEntityRemoved(EntityUid uid, StoreRefundComponent component, EntRemovedFromContainerMessage args) 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; return;
DisableRefund(component.StoreEntity.Value, storeComp); DisableRefund(component.StoreEntity.Value, storeComp);

View File

@@ -91,7 +91,8 @@ public sealed partial class StoreSystem
//this is the person who will be passed into logic for all listing filtering. //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 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 //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 // only tell operatives to lock their uplink if it can be locked
var showFooter = HasComp<RingerUplinkComponent>(store); var showFooter = HasComp<RingerUplinkComponent>(store);
var state = new StoreUpdateState(component.LastAvailableListings, allCurrency, showFooter, component.RefundAllowed); var state = new StoreUpdateState(component.LastAvailableListings, allCurrency, showFooter, component.RefundAllowed);
_ui.SetUiState(store, StoreUiKey.Key, state); _ui.SetUiState(store, StoreUiKey.Key, state);
} }
@@ -128,7 +130,7 @@ public sealed partial class StoreSystem
/// </summary> /// </summary>
private void OnBuyRequest(EntityUid uid, StoreComponent component, StoreBuyListingMessage msg) 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 if (listing == null) //make sure this listing actually exists
{ {
@@ -153,9 +155,10 @@ public sealed partial class StoreSystem
} }
//check that we have enough money //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; return;
} }
@@ -165,13 +168,13 @@ public sealed partial class StoreSystem
component.RefundAllowed = false; component.RefundAllowed = false;
//subtract the cash //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.TryAdd(currency, FixedPoint2.Zero);
component.BalanceSpent[currency] += value; component.BalanceSpent[currency] += amount;
} }
//spawn entity //spawn entity
@@ -213,7 +216,7 @@ public sealed partial class StoreSystem
if (listing.ProductUpgradeId != null) if (listing.ProductUpgradeId != null)
{ {
foreach (var upgradeListing in component.Listings) foreach (var upgradeListing in component.FullListingsCatalog)
{ {
if (upgradeListing.ID == listing.ProductUpgradeId) if (upgradeListing.ID == listing.ProductUpgradeId)
{ {
@@ -262,6 +265,13 @@ public sealed partial class StoreSystem
listing.PurchaseAmount++; //track how many times something has been purchased listing.PurchaseAmount++; //track how many times something has been purchased
_audio.PlayEntity(component.BuySuccessSound, msg.Actor, uid); //cha-ching! _audio.PlayEntity(component.BuySuccessSound, msg.Actor, uid); //cha-ching!
var buyFinished = new StoreBuyFinishedEvent
{
PurchasedItem = listing,
StoreUid = uid
};
RaiseLocalEvent(ref buyFinished);
UpdateUserInterface(buyer, uid, component); UpdateUserInterface(buyer, uid, component);
} }
@@ -346,6 +356,7 @@ public sealed partial class StoreSystem
{ {
component.Balance[currency] += value; component.Balance[currency] += value;
} }
// Reset store back to its original state // Reset store back to its original state
RefreshAllListings(component); RefreshAllListings(component);
component.BalanceSpent = new(); component.BalanceSpent = new();
@@ -376,3 +387,14 @@ public sealed partial class StoreSystem
component.RefundAllowed = false; 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
);

View File

@@ -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
);

View File

@@ -28,13 +28,14 @@ namespace Content.Server.Traitor.Uplink.Commands
{ {
1 => CompletionResult.FromHintOptions(CompletionHelper.SessionNames(), Loc.GetString("add-uplink-command-completion-1")), 1 => CompletionResult.FromHintOptions(CompletionHelper.SessionNames(), Loc.GetString("add-uplink-command-completion-1")),
2 => CompletionResult.FromHint(Loc.GetString("add-uplink-command-completion-2")), 2 => CompletionResult.FromHint(Loc.GetString("add-uplink-command-completion-2")),
3 => CompletionResult.FromHint(Loc.GetString("add-uplink-command-completion-3")),
_ => CompletionResult.Empty _ => CompletionResult.Empty
}; };
} }
public void Execute(IConsoleShell shell, string argStr, string[] args) 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")); shell.WriteError(Loc.GetString("shell-wrong-arguments-number"));
return; return;
@@ -82,9 +83,19 @@ namespace Content.Server.Traitor.Uplink.Commands
uplinkEntity = eUid; 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 // Finally add uplink
var uplinkSys = _entManager.System<UplinkSystem>(); 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")); shell.WriteLine(Loc.GetString("add-uplink-command-error-2"));
} }

View File

@@ -1,8 +1,9 @@
using System.Linq;
using Content.Server.Store.Systems; using Content.Server.Store.Systems;
using Content.Server.StoreDiscount.Systems;
using Content.Shared.Hands.EntitySystems; using Content.Shared.Hands.EntitySystems;
using Content.Shared.Inventory; using Content.Shared.Inventory;
using Content.Shared.PDA; using Content.Shared.PDA;
using Content.Server.Store.Components;
using Content.Shared.FixedPoint; using Content.Shared.FixedPoint;
using Content.Shared.Store; using Content.Shared.Store;
using Content.Shared.Store.Components; using Content.Shared.Store.Components;
@@ -23,21 +24,26 @@ namespace Content.Server.Traitor.Uplink
/// </summary> /// </summary>
/// <param name="user">The person who is getting the uplink</param> /// <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="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="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> /// <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) if (uplinkEntity == null)
{ {
uplinkEntity = FindUplinkTarget(user); return false;
if (uplinkEntity == null)
return false;
} }
EnsureComp<UplinkComponent>(uplinkEntity.Value); EnsureComp<UplinkComponent>(uplinkEntity.Value);
var store = EnsureComp<StoreComponent>(uplinkEntity.Value); var store = EnsureComp<StoreComponent>(uplinkEntity.Value);
store.AccountOwner = user; store.AccountOwner = user;
store.Balance.Clear(); store.Balance.Clear();
if (balance != null) if (balance != null)
@@ -46,6 +52,14 @@ namespace Content.Server.Traitor.Uplink
_store.TryAddCurrency(new Dictionary<string, FixedPoint2> { { TelecrystalCurrencyPrototype, balance.Value } }, uplinkEntity.Value, store); _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 -_- // TODO add BUI. Currently can't be done outside of yaml -_-
return true; return true;
@@ -62,7 +76,8 @@ namespace Content.Server.Traitor.Uplink
{ {
while (containerSlotEnumerator.MoveNext(out var pdaUid)) 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)) if (HasComp<PdaComponent>(pdaUid.ContainedEntity.Value) || HasComp<StoreComponent>(pdaUid.ContainedEntity.Value))
return pdaUid.ContainedEntity.Value; return pdaUid.ContainedEntity.Value;

View File

@@ -2,7 +2,6 @@ using Content.Shared.FixedPoint;
using Robust.Shared.Audio; using Robust.Shared.Audio;
using Robust.Shared.GameStates; using Robust.Shared.GameStates;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Shared.Store.Components; namespace Content.Shared.Store.Components;
@@ -45,16 +44,16 @@ public sealed partial class StoreComponent : Component
public EntityUid? AccountOwner = null; public EntityUid? AccountOwner = null;
/// <summary> /// <summary>
/// All listings, including those that aren't available to the buyer /// Cached list of listings items with modifiers.
/// </summary> /// </summary>
[DataField] [DataField]
public HashSet<ListingData> Listings = new(); public HashSet<ListingDataWithCostModifiers> FullListingsCatalog = new();
/// <summary> /// <summary>
/// All available listings from the last time that it was checked. /// All available listings from the last time that it was checked.
/// </summary> /// </summary>
[ViewVariables] [ViewVariables]
public HashSet<ListingData> LastAvailableListings = new(); public HashSet<ListingDataWithCostModifiers> LastAvailableListings = new();
/// <summary> /// <summary>
/// All current entities bought from this shop. Useful for keeping track of refunds and upgrades. /// All current entities bought from this shop. Useful for keeping track of refunds and upgrades.

View File

@@ -1,5 +1,7 @@
using System.Linq; using System.Linq;
using Content.Shared.FixedPoint; using Content.Shared.FixedPoint;
using Content.Shared.Store.Components;
using Content.Shared.StoreDiscount.Components;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
using Robust.Shared.Serialization; using Robust.Shared.Serialization;
using Robust.Shared.Utility; using Robust.Shared.Utility;
@@ -13,8 +15,77 @@ namespace Content.Shared.Store;
/// </summary> /// </summary>
[Serializable, NetSerializable] [Serializable, NetSerializable]
[Virtual, DataDefinition] [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] [ViewVariables]
[IdDataField] [IdDataField]
public string ID { get; private set; } = default!; public string ID { get; private set; } = default!;
@@ -25,6 +96,12 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
[DataField] [DataField]
public string? Name; 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> /// <summary>
/// The description of the listing. If empty, uses the entity's description (if present) /// The description of the listing. If empty, uses the entity's description (if present)
/// </summary> /// </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. /// The categories that this listing applies to. Used for filtering a listing for a store.
/// </summary> /// </summary>
[DataField] [DataField]
public List<ProtoId<StoreCategoryPrototype>> Categories = new(); public HashSet<ProtoId<StoreCategoryPrototype>> Categories = new();
/// <summary> /// <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> /// </summary>
[DataField] [DataField]
public Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Cost = new(); public IReadOnlyDictionary<ProtoId<CurrencyPrototype>, FixedPoint2> OriginalCost = new Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2>();
/// <summary> /// <summary>
/// Specific customizable conditions that determine whether or not the listing can be purchased. /// 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] [DataField]
public TimeSpan RestockTime = TimeSpan.Zero; 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) public bool Equals(ListingData? listing)
{ {
if (listing == null) 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))) if (!Categories.OrderBy(x => x).SequenceEqual(listing.Categories.OrderBy(x => x)))
return false; 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; return false;
if ((Conditions != null && listing.Conditions != null) && if ((Conditions != null && listing.Conditions != null) &&
@@ -142,32 +227,6 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
return true; 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
/// </summary>
/// <returns>A unique copy of the listing data.</returns>
public object Clone()
{
return new ListingData
{
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,
};
}
} }
/// <summary> /// <summary>
@@ -176,4 +235,200 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
[Prototype("listing")] [Prototype("listing")]
[Serializable, NetSerializable] [Serializable, NetSerializable]
[DataDefinition] [DataDefinition]
public sealed partial class ListingPrototype : ListingData, IPrototype; 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>
/// Gets percent of reduced/increased cost that modifiers give respective to <see cref="ListingData.OriginalCost"/>.
/// Percent values are numbers between 0 and 1.
/// </summary>
public IReadOnlyDictionary<ProtoId<CurrencyPrototype>, float> GetModifiersSummaryRelative()
{
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)
{
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 set of rules for category of discounts -
/// how <see cref="StoreDiscountComponent"/> will be filled by respective system.
/// </summary>
[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; }
}

View File

@@ -13,7 +13,7 @@ public enum StoreUiKey : byte
[Serializable, NetSerializable] [Serializable, NetSerializable]
public sealed class StoreUpdateState : BoundUserInterfaceState public sealed class StoreUpdateState : BoundUserInterfaceState
{ {
public readonly HashSet<ListingData> Listings; public readonly HashSet<ListingDataWithCostModifiers> Listings;
public readonly Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Balance; public readonly Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Balance;
@@ -21,7 +21,7 @@ public sealed class StoreUpdateState : BoundUserInterfaceState
public readonly bool AllowRefund; 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; Listings = listings;
Balance = balance; Balance = balance;
@@ -37,14 +37,9 @@ public sealed class StoreRequestUpdateInterfaceMessage : BoundUserInterfaceMessa
} }
[Serializable, NetSerializable] [Serializable, NetSerializable]
public sealed class StoreBuyListingMessage : BoundUserInterfaceMessage public sealed class StoreBuyListingMessage(ProtoId<ListingPrototype> listing) : BoundUserInterfaceMessage
{ {
public ListingData Listing; public ProtoId<ListingPrototype> Listing = listing;
public StoreBuyListingMessage(ListingData listing)
{
Listing = listing;
}
} }
[Serializable, NetSerializable] [Serializable, NetSerializable]

View File

@@ -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();
}

View 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-1 = Username (defaults to self)
add-uplink-command-completion-2 = Uplink uid (default to PDA) 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-1 = Selected player doesn't control any entity
add-uplink-command-error-2 = Failed to add uplink to the player add-uplink-command-error-2 = Failed to add uplink to the player

View File

@@ -12,6 +12,7 @@ store-category-allies = Allies
store-category-job = Job store-category-job = Job
store-category-wearables = Wearables store-category-wearables = Wearables
store-category-pointless = Pointless store-category-pointless = Pointless
store-discounted-items = Discounts
# Revenant # Revenant
store-category-abilities = Abilities store-category-abilities = Abilities

View File

@@ -2,6 +2,8 @@ store-ui-default-title = Store
store-ui-default-withdraw-text = Withdraw store-ui-default-withdraw-text = Withdraw
store-ui-balance-display = {$currency}: {$amount} store-ui-balance-display = {$currency}: {$amount}
store-ui-price-display = {$amount} {$currency} 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-flavor = Copyright (C) NT -30643
store-ui-traitor-warning = Operatives must lock their uplinks after use to avoid detection. store-ui-traitor-warning = Operatives must lock their uplinks after use to avoid detection.

View File

@@ -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

View File

@@ -6,6 +6,9 @@
name: uplink-pistol-viper-name name: uplink-pistol-viper-name
description: uplink-pistol-viper-desc description: uplink-pistol-viper-desc
productEntity: WeaponPistolViper productEntity: WeaponPistolViper
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 3 Telecrystal: 3
categories: categories:
@@ -16,6 +19,9 @@
name: uplink-revolver-python-name name: uplink-revolver-python-name
description: uplink-revolver-python-desc description: uplink-revolver-python-desc
productEntity: WeaponRevolverPythonAP productEntity: WeaponRevolverPythonAP
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 8 # Originally was 13 TC but was not used due to high cost Telecrystal: 8 # Originally was 13 TC but was not used due to high cost
categories: categories:
@@ -27,6 +33,9 @@
name: uplink-pistol-cobra-name name: uplink-pistol-cobra-name
description: uplink-pistol-cobra-desc description: uplink-pistol-cobra-desc
productEntity: WeaponPistolCobra productEntity: WeaponPistolCobra
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -48,6 +57,9 @@
name: uplink-esword-name name: uplink-esword-name
description: uplink-esword-desc description: uplink-esword-desc
icon: { sprite: /Textures/Objects/Weapons/Melee/e_sword.rsi, state: icon } icon: { sprite: /Textures/Objects/Weapons/Melee/e_sword.rsi, state: icon }
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 4
productEntity: EnergySword productEntity: EnergySword
cost: cost:
Telecrystal: 8 Telecrystal: 8
@@ -60,6 +72,9 @@
description: uplink-edagger-desc description: uplink-edagger-desc
icon: { sprite: /Textures/Objects/Weapons/Melee/e_dagger.rsi, state: icon } icon: { sprite: /Textures/Objects/Weapons/Melee/e_dagger.rsi, state: icon }
productEntity: EnergyDaggerBox productEntity: EnergyDaggerBox
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -71,6 +86,9 @@
description: uplink-knives-kit-desc description: uplink-knives-kit-desc
icon: { sprite: /Textures/Objects/Storage/boxicons.rsi, state: throwing_knives } icon: { sprite: /Textures/Objects/Storage/boxicons.rsi, state: throwing_knives }
productEntity: ThrowingKnivesKit productEntity: ThrowingKnivesKit
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -81,6 +99,9 @@
name: uplink-gloves-north-star-name name: uplink-gloves-north-star-name
description: uplink-gloves-north-star-desc description: uplink-gloves-north-star-desc
productEntity: ClothingHandsGlovesNorthStar productEntity: ClothingHandsGlovesNorthStar
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 8 Telecrystal: 8
categories: categories:
@@ -91,6 +112,9 @@
name: uplink-disposable-turret-name name: uplink-disposable-turret-name
description: uplink-disposable-turret-desc description: uplink-disposable-turret-desc
productEntity: ToolboxElectricalTurretFilled productEntity: ToolboxElectricalTurretFilled
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -107,6 +131,9 @@
description: uplink-eshield-desc description: uplink-eshield-desc
icon: { sprite: /Textures/Objects/Weapons/Melee/e_shield.rsi, state: eshield-on } icon: { sprite: /Textures/Objects/Weapons/Melee/e_shield.rsi, state: eshield-on }
productEntity: EnergyShield productEntity: EnergyShield
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 8 Telecrystal: 8
categories: categories:
@@ -123,6 +150,9 @@
description: uplink-sniper-bundle-desc description: uplink-sniper-bundle-desc
icon: { sprite: /Textures/Objects/Weapons/Guns/Snipers/heavy_sniper.rsi, state: base } icon: { sprite: /Textures/Objects/Weapons/Guns/Snipers/heavy_sniper.rsi, state: base }
productEntity: BriefcaseSyndieSniperBundleFilled productEntity: BriefcaseSyndieSniperBundleFilled
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 6
cost: cost:
Telecrystal: 12 Telecrystal: 12
categories: categories:
@@ -134,6 +164,9 @@
description: uplink-c20r-bundle-desc description: uplink-c20r-bundle-desc
icon: { sprite: /Textures/Objects/Weapons/Guns/SMGs/c20r.rsi, state: icon } icon: { sprite: /Textures/Objects/Weapons/Guns/SMGs/c20r.rsi, state: icon }
productEntity: ClothingBackpackDuffelSyndicateFilledSMG productEntity: ClothingBackpackDuffelSyndicateFilledSMG
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 10
cost: cost:
Telecrystal: 17 Telecrystal: 17
categories: categories:
@@ -145,6 +178,9 @@
description: uplink-buldog-bundle-desc description: uplink-buldog-bundle-desc
icon: { sprite: /Textures/Objects/Weapons/Guns/Shotguns/bulldog.rsi, state: icon } icon: { sprite: /Textures/Objects/Weapons/Guns/Shotguns/bulldog.rsi, state: icon }
productEntity: ClothingBackpackDuffelSyndicateFilledShotgun productEntity: ClothingBackpackDuffelSyndicateFilledShotgun
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 12
cost: cost:
Telecrystal: 20 Telecrystal: 20
categories: categories:
@@ -156,6 +192,9 @@
description: uplink-grenade-launcher-bundle-desc description: uplink-grenade-launcher-bundle-desc
icon: { sprite: /Textures/Objects/Weapons/Guns/Launchers/china_lake.rsi, state: icon } icon: { sprite: /Textures/Objects/Weapons/Guns/Launchers/china_lake.rsi, state: icon }
productEntity: ClothingBackpackDuffelSyndicateFilledGrenadeLauncher productEntity: ClothingBackpackDuffelSyndicateFilledGrenadeLauncher
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 20
cost: cost:
Telecrystal: 25 Telecrystal: 25
categories: categories:
@@ -167,6 +206,9 @@
description: uplink-l6-saw-bundle-desc description: uplink-l6-saw-bundle-desc
icon: { sprite: /Textures/Objects/Weapons/Guns/LMGs/l6.rsi, state: icon } icon: { sprite: /Textures/Objects/Weapons/Guns/LMGs/l6.rsi, state: icon }
productEntity: ClothingBackpackDuffelSyndicateFilledLMG productEntity: ClothingBackpackDuffelSyndicateFilledLMG
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 24
cost: cost:
Telecrystal: 30 Telecrystal: 30
categories: categories:
@@ -179,6 +221,9 @@
name: uplink-explosive-grenade-name name: uplink-explosive-grenade-name
description: uplink-explosive-grenade-desc description: uplink-explosive-grenade-desc
productEntity: ExGrenade productEntity: ExGrenade
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -209,6 +254,9 @@
name: uplink-mini-bomb-name name: uplink-mini-bomb-name
description: uplink-mini-bomb-desc description: uplink-mini-bomb-desc
productEntity: SyndieMiniBomb productEntity: SyndieMiniBomb
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -219,6 +267,9 @@
name: uplink-supermatter-grenade-name name: uplink-supermatter-grenade-name
description: uplink-supermatter-grenade-desc description: uplink-supermatter-grenade-desc
productEntity: SupermatterGrenade productEntity: SupermatterGrenade
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -229,6 +280,9 @@
name: uplink-whitehole-grenade-name name: uplink-whitehole-grenade-name
description: uplink-whitehole-grenade-desc description: uplink-whitehole-grenade-desc
productEntity: WhiteholeGrenade productEntity: WhiteholeGrenade
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 3 Telecrystal: 3
categories: categories:
@@ -239,6 +293,9 @@
name: uplink-penguin-grenade-name name: uplink-penguin-grenade-name
description: uplink-penguin-grenade-desc description: uplink-penguin-grenade-desc
productEntity: MobGrenadePenguin productEntity: MobGrenadePenguin
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 5 Telecrystal: 5
categories: categories:
@@ -254,6 +311,9 @@
name: uplink-c4-name name: uplink-c4-name
description: uplink-c4-desc description: uplink-c4-desc
productEntity: C4 productEntity: C4
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -264,6 +324,9 @@
name: uplink-grenadier-rig-name name: uplink-grenadier-rig-name
description: uplink-grenadier-rig-desc description: uplink-grenadier-rig-desc
productEntity: ClothingBeltMilitaryWebbingGrenadeFilled productEntity: ClothingBeltMilitaryWebbingGrenadeFilled
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 6
cost: cost:
Telecrystal: 12 Telecrystal: 12
categories: categories:
@@ -279,8 +342,11 @@
name: uplink-c4-bundle-name name: uplink-c4-bundle-name
description: uplink-c4-bundle-desc description: uplink-c4-bundle-desc
productEntity: ClothingBackpackDuffelSyndicateC4tBundle productEntity: ClothingBackpackDuffelSyndicateC4tBundle
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 8
cost: 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: categories:
- UplinkExplosives - UplinkExplosives
@@ -289,6 +355,9 @@
name: uplink-emp-grenade-name name: uplink-emp-grenade-name
description: uplink-emp-grenade-desc description: uplink-emp-grenade-desc
productEntity: EmpGrenade productEntity: EmpGrenade
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -300,6 +369,9 @@
description: uplink-exploding-pen-desc description: uplink-exploding-pen-desc
icon: { sprite: /Textures/Objects/Misc/pens.rsi, state: pen } icon: { sprite: /Textures/Objects/Misc/pens.rsi, state: pen }
productEntity: PenExplodingBox productEntity: PenExplodingBox
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -341,6 +413,9 @@
name: uplink-cluster-grenade-name name: uplink-cluster-grenade-name
description: uplink-cluster-grenade-desc description: uplink-cluster-grenade-desc
productEntity: ClusterGrenade productEntity: ClusterGrenade
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 5
cost: cost:
Telecrystal: 8 Telecrystal: 8
categories: categories:
@@ -351,6 +426,9 @@
name: uplink-shrapnel-grenade-name name: uplink-shrapnel-grenade-name
description: uplink-shrapnel-grenade-desc description: uplink-shrapnel-grenade-desc
productEntity: GrenadeShrapnel productEntity: GrenadeShrapnel
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -361,6 +439,9 @@
name: uplink-incendiary-grenade-name name: uplink-incendiary-grenade-name
description: uplink-incendiary-grenade-desc description: uplink-incendiary-grenade-desc
productEntity: GrenadeIncendiary productEntity: GrenadeIncendiary
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -371,6 +452,9 @@
name: uplink-emp-kit-name name: uplink-emp-kit-name
description: uplink-emp-kit-desc description: uplink-emp-kit-desc
productEntity: ElectricalDisruptionKit productEntity: ElectricalDisruptionKit
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -474,6 +558,9 @@
description: uplink-hypopen-desc description: uplink-hypopen-desc
icon: { sprite: /Textures/Objects/Misc/pens.rsi, state: pen } icon: { sprite: /Textures/Objects/Misc/pens.rsi, state: pen }
productEntity: HypopenBox productEntity: HypopenBox
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -485,6 +572,9 @@
description: uplink-hypodart-desc description: uplink-hypodart-desc
icon: { sprite: /Textures/Objects/Fun/Darts/dart_red.rsi, state: icon } icon: { sprite: /Textures/Objects/Fun/Darts/dart_red.rsi, state: icon }
productEntity: HypoDartBox productEntity: HypoDartBox
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -496,6 +586,9 @@
description: uplink-chemistry-kit-desc description: uplink-chemistry-kit-desc
icon: { sprite: /Textures/Objects/Storage/boxicons.rsi, state: vials } icon: { sprite: /Textures/Objects/Storage/boxicons.rsi, state: vials }
productEntity: ChemicalSynthesisKit productEntity: ChemicalSynthesisKit
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -526,6 +619,9 @@
name: uplink-nocturine-chemistry-bottle-name name: uplink-nocturine-chemistry-bottle-name
description: uplink-nocturine-chemistry-bottle-desc description: uplink-nocturine-chemistry-bottle-desc
productEntity: NocturineChemistryBottle productEntity: NocturineChemistryBottle
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -536,6 +632,9 @@
name: uplink-combat-medkit-name name: uplink-combat-medkit-name
description: uplink-combat-medkit-desc description: uplink-combat-medkit-desc
productEntity: MedkitCombatFilled productEntity: MedkitCombatFilled
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 5 Telecrystal: 5
categories: categories:
@@ -546,6 +645,9 @@
name: uplink-combat-medipen-name name: uplink-combat-medipen-name
description: uplink-combat-medipen-desc description: uplink-combat-medipen-desc
productEntity: CombatMedipen productEntity: CombatMedipen
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -556,6 +658,9 @@
name: uplink-stimpack-name name: uplink-stimpack-name
description: uplink-stimpack-desc description: uplink-stimpack-desc
productEntity: Stimpack productEntity: Stimpack
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -566,6 +671,9 @@
name: uplink-stimkit-name name: uplink-stimkit-name
description: uplink-stimkit-desc description: uplink-stimkit-desc
productEntity: StimkitFilled productEntity: StimkitFilled
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 8
cost: cost:
Telecrystal: 12 Telecrystal: 12
categories: categories:
@@ -576,6 +684,9 @@
name: uplink-cigarettes-name name: uplink-cigarettes-name
description: uplink-cigarettes-desc description: uplink-cigarettes-desc
productEntity: CigPackSyndicate productEntity: CigPackSyndicate
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -586,6 +697,9 @@
name: uplink-meds-bundle-name name: uplink-meds-bundle-name
description: uplink-meds-bundle-desc description: uplink-meds-bundle-desc
productEntity: ClothingBackpackDuffelSyndicateMedicalBundleFilled productEntity: ClothingBackpackDuffelSyndicateMedicalBundleFilled
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 12
cost: cost:
Telecrystal: 20 Telecrystal: 20
categories: categories:
@@ -607,6 +721,9 @@
name: uplink-agent-id-card-name name: uplink-agent-id-card-name
description: uplink-agent-id-card-desc description: uplink-agent-id-card-desc
productEntity: AgentIDCard productEntity: AgentIDCard
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 3 Telecrystal: 3
categories: categories:
@@ -617,6 +734,9 @@
name: uplink-stealth-box-name name: uplink-stealth-box-name
description: uplink-stealth-box-desc description: uplink-stealth-box-desc
productEntity: StealthBox productEntity: StealthBox
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 5 Telecrystal: 5
categories: categories:
@@ -627,6 +747,9 @@
name: uplink-chameleon-projector-name name: uplink-chameleon-projector-name
description: uplink-chameleon-projector-desc description: uplink-chameleon-projector-desc
productEntity: ChameleonProjector productEntity: ChameleonProjector
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 7 Telecrystal: 7
categories: categories:
@@ -638,6 +761,9 @@
description: uplink-encryption-key-desc description: uplink-encryption-key-desc
icon: { sprite: /Textures/Objects/Devices/encryption_keys.rsi, state: synd_label } icon: { sprite: /Textures/Objects/Devices/encryption_keys.rsi, state: synd_label }
productEntity: BoxEncryptionKeySyndie # Two for the price of one productEntity: BoxEncryptionKeySyndie # Two for the price of one
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -679,6 +805,9 @@
name: uplink-ultrabright-lantern-name name: uplink-ultrabright-lantern-name
description: uplink-ultrabright-lantern-desc description: uplink-ultrabright-lantern-desc
productEntity: LanternFlash productEntity: LanternFlash
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -689,6 +818,9 @@
name: uplink-bribe-name name: uplink-bribe-name
description: uplink-bribe-desc description: uplink-bribe-desc
productEntity: BriefcaseSyndieLobbyingBundleFilled productEntity: BriefcaseSyndieLobbyingBundleFilled
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -710,6 +842,9 @@
description: uplink-decoy-kit-desc description: uplink-decoy-kit-desc
icon: { sprite: /Textures/Objects/Tools/Decoys/operative_decoy.rsi, state: folded } icon: { sprite: /Textures/Objects/Tools/Decoys/operative_decoy.rsi, state: folded }
productEntity: ClothingBackpackDuffelSyndicateDecoyKitFilled productEntity: ClothingBackpackDuffelSyndicateDecoyKitFilled
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -720,6 +855,9 @@
name: uplink-exploding-syndicate-bomb-fake-name name: uplink-exploding-syndicate-bomb-fake-name
description: uplink-exploding-syndicate-bomb-fake-desc description: uplink-exploding-syndicate-bomb-fake-desc
productEntity: SyndicateBombFake productEntity: SyndicateBombFake
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -732,6 +870,9 @@
name: uplink-emag-name name: uplink-emag-name
description: uplink-emag-desc description: uplink-emag-desc
productEntity: Emag productEntity: Emag
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 5
cost: cost:
Telecrystal: 8 Telecrystal: 8
categories: categories:
@@ -742,6 +883,9 @@
name: uplink-radio-jammer-name name: uplink-radio-jammer-name
description: uplink-radio-jammer-desc description: uplink-radio-jammer-desc
productEntity: RadioJammer productEntity: RadioJammer
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -752,6 +896,9 @@
name: uplink-syndicate-weapon-module-name name: uplink-syndicate-weapon-module-name
description: uplink-syndicate-weapon-module-desc description: uplink-syndicate-weapon-module-desc
productEntity: BorgModuleSyndicateWeapon productEntity: BorgModuleSyndicateWeapon
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 5 Telecrystal: 5
categories: categories:
@@ -763,6 +910,9 @@
description: uplink-syndicate-martyr-module-desc description: uplink-syndicate-martyr-module-desc
productEntity: BorgModuleMartyr productEntity: BorgModuleMartyr
icon: { sprite: /Textures/Objects/Specific/Robotics/borgmodule.rsi, state: syndicateborgbomb } icon: { sprite: /Textures/Objects/Specific/Robotics/borgmodule.rsi, state: syndicateborgbomb }
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -783,6 +933,9 @@
name: uplink-slipocalypse-clustersoap-name name: uplink-slipocalypse-clustersoap-name
description: uplink-slipocalypse-clustersoap-desc description: uplink-slipocalypse-clustersoap-desc
productEntity: SlipocalypseClusterSoap productEntity: SlipocalypseClusterSoap
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -793,6 +946,9 @@
name: uplink-toolbox-name name: uplink-toolbox-name
description: uplink-toolbox-desc description: uplink-toolbox-desc
productEntity: ToolboxSyndicateFilled productEntity: ToolboxSyndicateFilled
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -803,6 +959,9 @@
name: uplink-syndicate-jaws-of-life-name name: uplink-syndicate-jaws-of-life-name
description: uplink-syndicate-jaws-of-life-desc description: uplink-syndicate-jaws-of-life-desc
productEntity: SyndicateJawsOfLife productEntity: SyndicateJawsOfLife
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -813,6 +972,9 @@
name: uplink-duffel-surgery-name name: uplink-duffel-surgery-name
description: uplink-duffel-surgery-desc description: uplink-duffel-surgery-desc
productEntity: ClothingBackpackDuffelSyndicateFilledMedical productEntity: ClothingBackpackDuffelSyndicateFilledMedical
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -823,6 +985,9 @@
name: uplink-power-sink-name name: uplink-power-sink-name
description: uplink-power-sink-desc description: uplink-power-sink-desc
productEntity: PowerSink productEntity: PowerSink
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 8 Telecrystal: 8
categories: categories:
@@ -876,6 +1041,9 @@
name: uplink-singularity-beacon-name name: uplink-singularity-beacon-name
description: uplink-singularity-beacon-desc description: uplink-singularity-beacon-desc
productEntity: SingularityBeacon productEntity: SingularityBeacon
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 12 Telecrystal: 12
categories: categories:
@@ -889,6 +1057,9 @@
description: uplink-holopara-kit-desc description: uplink-holopara-kit-desc
icon: { sprite: /Textures/Objects/Misc/guardian_info.rsi, state: icon } icon: { sprite: /Textures/Objects/Misc/guardian_info.rsi, state: icon }
productEntity: BoxHoloparasite productEntity: BoxHoloparasite
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 8
cost: cost:
Telecrystal: 14 Telecrystal: 14
categories: categories:
@@ -905,6 +1076,9 @@
description: uplink-reinforcement-radio-traitor-desc description: uplink-reinforcement-radio-traitor-desc
productEntity: ReinforcementRadioSyndicate productEntity: ReinforcementRadioSyndicate
icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-urist } icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-urist }
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 7
cost: cost:
Telecrystal: 14 Telecrystal: 14
categories: categories:
@@ -953,6 +1127,9 @@
description: uplink-reinforcement-radio-ancestor-desc description: uplink-reinforcement-radio-ancestor-desc
productEntity: ReinforcementRadioSyndicateAncestor productEntity: ReinforcementRadioSyndicateAncestor
icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-ancestor } icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-ancestor }
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -969,6 +1146,9 @@
description: uplink-reinforcement-radio-ancestor-desc description: uplink-reinforcement-radio-ancestor-desc
productEntity: ReinforcementRadioSyndicateAncestorNukeops productEntity: ReinforcementRadioSyndicateAncestorNukeops
icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-ancestor } icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-ancestor }
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -984,6 +1164,9 @@
name: uplink-carp-dehydrated-name name: uplink-carp-dehydrated-name
description: uplink-carp-dehydrated-desc description: uplink-carp-dehydrated-desc
productEntity: DehydratedSpaceCarp productEntity: DehydratedSpaceCarp
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -1000,6 +1183,9 @@
description: uplink-mobcat-microbomb-desc description: uplink-mobcat-microbomb-desc
icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-syndicat } icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-syndicat }
productEntity: ReinforcementRadioSyndicateSyndiCat productEntity: ReinforcementRadioSyndicateSyndiCat
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -1027,6 +1213,9 @@
description: uplink-storage-implanter-desc description: uplink-storage-implanter-desc
icon: { sprite: /Textures/Clothing/Back/Backpacks/backpack.rsi, state: icon } icon: { sprite: /Textures/Clothing/Back/Backpacks/backpack.rsi, state: icon }
productEntity: StorageImplanter productEntity: StorageImplanter
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 8 Telecrystal: 8
categories: categories:
@@ -1043,6 +1232,9 @@
description: uplink-freedom-implanter-desc description: uplink-freedom-implanter-desc
icon: { sprite: /Textures/Actions/Implants/implants.rsi, state: freedom } icon: { sprite: /Textures/Actions/Implants/implants.rsi, state: freedom }
productEntity: FreedomImplanter productEntity: FreedomImplanter
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 5 Telecrystal: 5
categories: categories:
@@ -1054,6 +1246,9 @@
description: uplink-scram-implanter-desc description: uplink-scram-implanter-desc
icon: { sprite: /Textures/Structures/Specific/anomaly.rsi, state: anom4 } icon: { sprite: /Textures/Structures/Specific/anomaly.rsi, state: anom4 }
productEntity: ScramImplanter productEntity: ScramImplanter
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 6 # it's a gamble that may kill you easily so 6 TC per 2 uses, second one more of a backup Telecrystal: 6 # it's a gamble that may kill you easily so 6 TC per 2 uses, second one more of a backup
categories: categories:
@@ -1065,6 +1260,9 @@
description: uplink-dna-scrambler-implanter-desc description: uplink-dna-scrambler-implanter-desc
icon: { sprite: /Textures/Mobs/Species/Human/parts.rsi, state: full } icon: { sprite: /Textures/Mobs/Species/Human/parts.rsi, state: full }
productEntity: DnaScramblerImplanter productEntity: DnaScramblerImplanter
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 5 Telecrystal: 5
categories: categories:
@@ -1076,6 +1274,9 @@
description: uplink-emp-implanter-desc description: uplink-emp-implanter-desc
icon: { sprite: /Textures/Objects/Magic/magicactions.rsi, state: shield } icon: { sprite: /Textures/Objects/Magic/magicactions.rsi, state: shield }
productEntity: EmpImplanter productEntity: EmpImplanter
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -1143,6 +1344,9 @@
description: uplink-uplink-implanter-desc description: uplink-uplink-implanter-desc
icon: { sprite: /Textures/Objects/Devices/communication.rsi, state: old-radio } icon: { sprite: /Textures/Objects/Devices/communication.rsi, state: old-radio }
productEntity: UplinkImplanter productEntity: UplinkImplanter
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -1179,6 +1383,9 @@
name: uplink-black-jetpack-name name: uplink-black-jetpack-name
description: uplink-black-jetpack-desc description: uplink-black-jetpack-desc
productEntity: JetpackBlackFilled productEntity: JetpackBlackFilled
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -1189,6 +1396,9 @@
name: uplink-voice-mask-name name: uplink-voice-mask-name
description: uplink-voice-mask-desc description: uplink-voice-mask-desc
productEntity: ClothingMaskGasVoiceChameleon productEntity: ClothingMaskGasVoiceChameleon
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -1220,6 +1430,9 @@
description: uplink-chameleon-desc description: uplink-chameleon-desc
productEntity: ClothingBackpackChameleonFill productEntity: ClothingBackpackChameleonFill
icon: { sprite: /Textures/Clothing/Uniforms/Jumpsuit/rainbow.rsi, state: icon } icon: { sprite: /Textures/Clothing/Uniforms/Jumpsuit/rainbow.rsi, state: icon }
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -1230,6 +1443,9 @@
name: uplink-clothing-no-slips-shoes-name name: uplink-clothing-no-slips-shoes-name
description: uplink-clothing-no-slips-shoes-desc description: uplink-clothing-no-slips-shoes-desc
productEntity: ClothingShoesChameleonNoSlips productEntity: ClothingShoesChameleonNoSlips
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -1240,6 +1456,9 @@
name: uplink-clothing-thieving-gloves-name name: uplink-clothing-thieving-gloves-name
description: uplink-clothing-thieving-gloves-desc description: uplink-clothing-thieving-gloves-desc
productEntity: ThievingGloves productEntity: ThievingGloves
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -1250,6 +1469,9 @@
name: uplink-clothing-outer-vest-web-name name: uplink-clothing-outer-vest-web-name
description: uplink-clothing-outer-vest-web-desc description: uplink-clothing-outer-vest-web-desc
productEntity: ClothingOuterVestWeb productEntity: ClothingOuterVestWeb
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 3 Telecrystal: 3
categories: categories:
@@ -1260,6 +1482,9 @@
name: uplink-clothing-shoes-boots-mag-syndie-name name: uplink-clothing-shoes-boots-mag-syndie-name
description: uplink-clothing-shoes-boots-mag-syndie-desc description: uplink-clothing-shoes-boots-mag-syndie-desc
productEntity: ClothingShoesBootsMagSyndie productEntity: ClothingShoesBootsMagSyndie
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -1271,6 +1496,9 @@
description: uplink-eva-syndie-desc description: uplink-eva-syndie-desc
icon: { sprite: /Textures/Clothing/OuterClothing/Suits/eva_syndicate.rsi, state: icon } icon: { sprite: /Textures/Clothing/OuterClothing/Suits/eva_syndicate.rsi, state: icon }
productEntity: ClothingBackpackDuffelSyndicateEVABundle productEntity: ClothingBackpackDuffelSyndicateEVABundle
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -1282,6 +1510,9 @@
description: uplink-hardsuit-carp-desc description: uplink-hardsuit-carp-desc
icon: { sprite: /Textures/Clothing/OuterClothing/Suits/carpsuit.rsi, state: icon } icon: { sprite: /Textures/Clothing/OuterClothing/Suits/carpsuit.rsi, state: icon }
productEntity: ClothingOuterHardsuitCarp productEntity: ClothingOuterHardsuitCarp
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -1293,6 +1524,9 @@
description: uplink-hardsuit-syndie-desc description: uplink-hardsuit-syndie-desc
icon: { sprite: /Textures/Clothing/OuterClothing/Hardsuits/syndicate.rsi, state: icon } icon: { sprite: /Textures/Clothing/OuterClothing/Hardsuits/syndicate.rsi, state: icon }
productEntity: ClothingBackpackDuffelSyndicateHardsuitBundle productEntity: ClothingBackpackDuffelSyndicateHardsuitBundle
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 4
cost: cost:
Telecrystal: 8 Telecrystal: 8
categories: categories:
@@ -1320,6 +1554,9 @@
description: uplink-hardsuit-syndieelite-desc description: uplink-hardsuit-syndieelite-desc
icon: { sprite: /Textures/Clothing/OuterClothing/Hardsuits/syndieelite.rsi, state: icon } icon: { sprite: /Textures/Clothing/OuterClothing/Hardsuits/syndieelite.rsi, state: icon }
productEntity: ClothingBackpackDuffelSyndicateEliteHardsuitBundle productEntity: ClothingBackpackDuffelSyndicateEliteHardsuitBundle
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 7
cost: cost:
Telecrystal: 12 Telecrystal: 12
categories: categories:
@@ -1331,6 +1568,9 @@
description: uplink-clothing-outer-hardsuit-juggernaut-desc description: uplink-clothing-outer-hardsuit-juggernaut-desc
icon: { sprite: /Textures/Structures/Storage/Crates/syndicate.rsi, state: icon } icon: { sprite: /Textures/Structures/Storage/Crates/syndicate.rsi, state: icon }
productEntity: CrateCybersunJuggernautBundle productEntity: CrateCybersunJuggernautBundle
discountCategory: veryRareDiscounts
discountDownTo:
Telecrystal: 8
cost: cost:
Telecrystal: 12 Telecrystal: 12
categories: categories:
@@ -1398,6 +1638,9 @@
name: uplink-revolver-cap-gun-name name: uplink-revolver-cap-gun-name
description: uplink-revolver-cap-gun-desc description: uplink-revolver-cap-gun-desc
productEntity: RevolverCapGun productEntity: RevolverCapGun
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -1408,6 +1651,9 @@
name: uplink-syndicate-stamp-name name: uplink-syndicate-stamp-name
description: uplink-syndicate-stamp-desc description: uplink-syndicate-stamp-desc
productEntity: RubberStampSyndicate productEntity: RubberStampSyndicate
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -1530,6 +1776,9 @@
name: uplink-gatfruit-seeds-name name: uplink-gatfruit-seeds-name
description: uplink-gatfruit-seeds-desc description: uplink-gatfruit-seeds-desc
productEntity: GatfruitSeeds productEntity: GatfruitSeeds
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -1544,6 +1793,9 @@
name: uplink-rigged-boxing-gloves-name name: uplink-rigged-boxing-gloves-name
description: uplink-rigged-boxing-gloves-desc description: uplink-rigged-boxing-gloves-desc
productEntity: ClothingHandsGlovesBoxingRigged productEntity: ClothingHandsGlovesBoxingRigged
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -1558,6 +1810,9 @@
name: uplink-rigged-boxing-gloves-name name: uplink-rigged-boxing-gloves-name
description: uplink-rigged-boxing-gloves-desc description: uplink-rigged-boxing-gloves-desc
productEntity: ClothingHandsGlovesBoxingRigged productEntity: ClothingHandsGlovesBoxingRigged
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -1572,6 +1827,9 @@
name: uplink-necronomicon-name name: uplink-necronomicon-name
description: uplink-necronomicon-desc description: uplink-necronomicon-desc
productEntity: BibleNecronomicon productEntity: BibleNecronomicon
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -1590,6 +1848,9 @@
name: uplink-holy-hand-grenade-name name: uplink-holy-hand-grenade-name
description: uplink-holy-hand-grenade-desc description: uplink-holy-hand-grenade-desc
productEntity: HolyHandGrenade productEntity: HolyHandGrenade
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 14
cost: cost:
Telecrystal: 20 Telecrystal: 20
categories: categories:
@@ -1604,6 +1865,9 @@
name: uplink-revolver-cap-gun-fake-name name: uplink-revolver-cap-gun-fake-name
description: uplink-revolver-cap-gun-fake-desc description: uplink-revolver-cap-gun-fake-desc
productEntity: RevolverCapGunFake productEntity: RevolverCapGunFake
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 5
cost: cost:
Telecrystal: 9 Telecrystal: 9
categories: categories:
@@ -1620,6 +1884,9 @@
description: uplink-banana-peel-explosive-desc description: uplink-banana-peel-explosive-desc
icon: { sprite: Objects/Specific/Hydroponics/banana.rsi, state: peel } icon: { sprite: Objects/Specific/Hydroponics/banana.rsi, state: peel }
productEntity: TrashBananaPeelExplosiveUnarmed productEntity: TrashBananaPeelExplosiveUnarmed
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 1
cost: cost:
Telecrystal: 2 Telecrystal: 2
categories: categories:
@@ -1634,6 +1901,9 @@
name: uplink-cluster-banana-peel-name name: uplink-cluster-banana-peel-name
description: uplink-cluster-banana-peel-desc description: uplink-cluster-banana-peel-desc
productEntity: ClusterBananaPeel productEntity: ClusterBananaPeel
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 3
cost: cost:
Telecrystal: 6 Telecrystal: 6
categories: categories:
@@ -1649,6 +1919,9 @@
description: uplink-holoclown-kit-desc description: uplink-holoclown-kit-desc
icon: { sprite: /Textures/Objects/Fun/figurines.rsi, state: holoclown } icon: { sprite: /Textures/Objects/Fun/figurines.rsi, state: holoclown }
productEntity: BoxHoloclown productEntity: BoxHoloclown
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 6
cost: cost:
Telecrystal: 12 Telecrystal: 12
categories: categories:
@@ -1662,6 +1935,9 @@
id: uplinkHotPotato id: uplinkHotPotato
name: uplink-hot-potato-name name: uplink-hot-potato-name
description: uplink-hot-potato-desc description: uplink-hot-potato-desc
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
productEntity: HotPotato productEntity: HotPotato
cost: cost:
Telecrystal: 4 Telecrystal: 4
@@ -1680,6 +1956,9 @@
name: uplink-chimp-upgrade-kit-name name: uplink-chimp-upgrade-kit-name
description: uplink-chimp-upgrade-kit-desc description: uplink-chimp-upgrade-kit-desc
productEntity: WeaponPistolCHIMPUpgradeKit productEntity: WeaponPistolCHIMPUpgradeKit
discountCategory: usualDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 4 Telecrystal: 4
categories: categories:
@@ -1694,6 +1973,9 @@
name: uplink-proximity-mine-name name: uplink-proximity-mine-name
description: uplink-proximity-mine-desc description: uplink-proximity-mine-desc
productEntity: WetFloorSignMineExplosive productEntity: WetFloorSignMineExplosive
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 5 # was 4, with my buff made it 5 to be closer to minibomb -panzer Telecrystal: 5 # was 4, with my buff made it 5 to be closer to minibomb -panzer
categories: categories:
@@ -1712,6 +1994,9 @@
name: uplink-syndicate-sponge-box-name name: uplink-syndicate-sponge-box-name
description: uplink-syndicate-sponge-box-desc description: uplink-syndicate-sponge-box-desc
icon: { sprite: Objects/Misc/monkeycube.rsi, state: box} icon: { sprite: Objects/Misc/monkeycube.rsi, state: box}
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 4
productEntity: SyndicateSpongeBox productEntity: SyndicateSpongeBox
cost: cost:
Telecrystal: 7 Telecrystal: 7
@@ -1731,6 +2016,9 @@
description: uplink-cane-blade-desc description: uplink-cane-blade-desc
icon: { sprite: Objects/Weapons/Melee/cane.rsi, state: cane} icon: { sprite: Objects/Weapons/Melee/cane.rsi, state: cane}
productEntity: CaneSheathFilled productEntity: CaneSheathFilled
discountCategory: rareDiscounts
discountDownTo:
Telecrystal: 2
cost: cost:
Telecrystal: 5 Telecrystal: 5
categories: categories:

View File

@@ -94,3 +94,7 @@
id: RevenantAbilities id: RevenantAbilities
name: store-category-abilities name: store-category-abilities
- type: storeCategory
id: DiscountedItems
name: store-discounted-items
priority: 200