* 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:
@@ -19,7 +19,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
|
||||
private string _search = string.Empty;
|
||||
|
||||
[ViewVariables]
|
||||
private HashSet<ListingData> _listings = new();
|
||||
private HashSet<ListingDataWithCostModifiers> _listings = new();
|
||||
|
||||
public StoreBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
|
||||
{
|
||||
@@ -33,7 +33,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
|
||||
|
||||
_menu.OnListingButtonPressed += (_, listing) =>
|
||||
{
|
||||
SendMessage(new StoreBuyListingMessage(listing));
|
||||
SendMessage(new StoreBuyListingMessage(listing.ID));
|
||||
};
|
||||
|
||||
_menu.OnCategoryButtonPressed += (_, category) =>
|
||||
@@ -68,6 +68,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
|
||||
_listings = msg.Listings;
|
||||
|
||||
_menu?.UpdateBalance(msg.Balance);
|
||||
|
||||
UpdateListingsWithSearchFilter();
|
||||
_menu?.SetFooterVisibility(msg.ShowFooter);
|
||||
_menu?.UpdateRefund(msg.AllowRefund);
|
||||
@@ -80,7 +81,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
|
||||
if (_menu == null)
|
||||
return;
|
||||
|
||||
var filteredListings = new HashSet<ListingData>(_listings);
|
||||
var filteredListings = new HashSet<ListingDataWithCostModifiers>(_listings);
|
||||
if (!string.IsNullOrEmpty(_search))
|
||||
{
|
||||
filteredListings.RemoveWhere(listingData => !ListingLocalisationHelpers.GetLocalisedNameOrEntityName(listingData, _prototypeManager).Trim().ToLowerInvariant().Contains(_search) &&
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<BoxContainer Margin="8,8,8,8" Orientation="Vertical">
|
||||
<BoxContainer Orientation="Horizontal">
|
||||
<Label Name="StoreItemName" HorizontalExpand="True" />
|
||||
<Label Name="DiscountSubText"
|
||||
HorizontalAlignment="Right"/>
|
||||
<Button
|
||||
Name="StoreItemBuyButton"
|
||||
MinWidth="64"
|
||||
|
||||
@@ -17,11 +17,12 @@ public sealed partial class StoreListingControl : Control
|
||||
[Dependency] private readonly IGameTiming _timing = default!;
|
||||
private readonly ClientGameTicker _ticker;
|
||||
|
||||
private readonly ListingData _data;
|
||||
private readonly ListingDataWithCostModifiers _data;
|
||||
|
||||
private readonly bool _hasBalance;
|
||||
private readonly string _price;
|
||||
public StoreListingControl(ListingData data, string price, bool hasBalance, Texture? texture = null)
|
||||
private readonly string _discount;
|
||||
public StoreListingControl(ListingDataWithCostModifiers data, string price, string discount, bool hasBalance, Texture? texture = null)
|
||||
{
|
||||
IoCManager.InjectDependencies(this);
|
||||
RobustXamlLoader.Load(this);
|
||||
@@ -31,6 +32,7 @@ public sealed partial class StoreListingControl : Control
|
||||
_data = data;
|
||||
_hasBalance = hasBalance;
|
||||
_price = price;
|
||||
_discount = discount;
|
||||
|
||||
StoreItemName.Text = ListingLocalisationHelpers.GetLocalisedNameOrEntityName(_data, _prototype);
|
||||
StoreItemDescription.SetMessage(ListingLocalisationHelpers.GetLocalisedDescriptionOrEntityDescription(_data, _prototype));
|
||||
@@ -63,6 +65,7 @@ public sealed partial class StoreListingControl : Control
|
||||
}
|
||||
else
|
||||
{
|
||||
DiscountSubText.Text = _discount;
|
||||
StoreItemBuyButton.Text = _price;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Content.Client.Actions;
|
||||
using Content.Client.Message;
|
||||
using Content.Shared.FixedPoint;
|
||||
@@ -22,7 +23,7 @@ public sealed partial class StoreMenu : DefaultWindow
|
||||
private StoreWithdrawWindow? _withdrawWindow;
|
||||
|
||||
public event EventHandler<string>? SearchTextUpdated;
|
||||
public event Action<BaseButton.ButtonEventArgs, ListingData>? OnListingButtonPressed;
|
||||
public event Action<BaseButton.ButtonEventArgs, ListingDataWithCostModifiers>? OnListingButtonPressed;
|
||||
public event Action<BaseButton.ButtonEventArgs, string>? OnCategoryButtonPressed;
|
||||
public event Action<BaseButton.ButtonEventArgs, string, int>? OnWithdrawAttempt;
|
||||
public event Action<BaseButton.ButtonEventArgs>? OnRefundAttempt;
|
||||
@@ -30,7 +31,7 @@ public sealed partial class StoreMenu : DefaultWindow
|
||||
public Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Balance = new();
|
||||
public string CurrentCategory = string.Empty;
|
||||
|
||||
private List<ListingData> _cachedListings = new();
|
||||
private List<ListingDataWithCostModifiers> _cachedListings = new();
|
||||
|
||||
public StoreMenu()
|
||||
{
|
||||
@@ -68,15 +69,17 @@ public sealed partial class StoreMenu : DefaultWindow
|
||||
WithdrawButton.Disabled = disabled;
|
||||
}
|
||||
|
||||
public void UpdateListing(List<ListingData> listings)
|
||||
public void UpdateListing(List<ListingDataWithCostModifiers> listings)
|
||||
{
|
||||
_cachedListings = listings;
|
||||
|
||||
UpdateListing();
|
||||
}
|
||||
|
||||
public void UpdateListing()
|
||||
{
|
||||
var sorted = _cachedListings.OrderBy(l => l.Priority).ThenBy(l => l.Cost.Values.Sum());
|
||||
var sorted = _cachedListings.OrderBy(l => l.Priority)
|
||||
.ThenBy(l => l.Cost.Values.Sum());
|
||||
|
||||
// should probably chunk these out instead. to-do if this clogs the internet tubes.
|
||||
// maybe read clients prototypes instead?
|
||||
@@ -114,13 +117,12 @@ public sealed partial class StoreMenu : DefaultWindow
|
||||
OnRefundAttempt?.Invoke(args);
|
||||
}
|
||||
|
||||
private void AddListingGui(ListingData listing)
|
||||
private void AddListingGui(ListingDataWithCostModifiers listing)
|
||||
{
|
||||
if (!listing.Categories.Contains(CurrentCategory))
|
||||
return;
|
||||
|
||||
var listingPrice = listing.Cost;
|
||||
var hasBalance = HasListingPrice(Balance, listingPrice);
|
||||
var hasBalance = listing.CanBuyWith(Balance);
|
||||
|
||||
var spriteSys = _entityManager.EntitySysManager.GetEntitySystem<SpriteSystem>();
|
||||
|
||||
@@ -143,29 +145,20 @@ public sealed partial class StoreMenu : DefaultWindow
|
||||
}
|
||||
}
|
||||
|
||||
var newListing = new StoreListingControl(listing, GetListingPriceString(listing), hasBalance, texture);
|
||||
var listingInStock = GetListingPriceString(listing);
|
||||
var discount = GetDiscountString(listing);
|
||||
|
||||
var newListing = new StoreListingControl(listing, listingInStock, discount, hasBalance, texture);
|
||||
newListing.StoreItemBuyButton.OnButtonDown += args
|
||||
=> OnListingButtonPressed?.Invoke(args, listing);
|
||||
|
||||
StoreListingsContainer.AddChild(newListing);
|
||||
}
|
||||
|
||||
public bool HasListingPrice(Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> currency, Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> price)
|
||||
{
|
||||
foreach (var type in price)
|
||||
{
|
||||
if (!currency.ContainsKey(type.Key))
|
||||
return false;
|
||||
|
||||
if (currency[type.Key] < type.Value)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public string GetListingPriceString(ListingData listing)
|
||||
private string GetListingPriceString(ListingDataWithCostModifiers listing)
|
||||
{
|
||||
var text = string.Empty;
|
||||
|
||||
if (listing.Cost.Count < 1)
|
||||
text = Loc.GetString("store-currency-free");
|
||||
else
|
||||
@@ -173,20 +166,72 @@ public sealed partial class StoreMenu : DefaultWindow
|
||||
foreach (var (type, amount) in listing.Cost)
|
||||
{
|
||||
var currency = _prototypeManager.Index(type);
|
||||
text += Loc.GetString("store-ui-price-display", ("amount", amount),
|
||||
("currency", Loc.GetString(currency.DisplayName, ("amount", amount))));
|
||||
|
||||
text += Loc.GetString(
|
||||
"store-ui-price-display",
|
||||
("amount", amount),
|
||||
("currency", Loc.GetString(currency.DisplayName, ("amount", amount)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return text.TrimEnd();
|
||||
}
|
||||
|
||||
private string GetDiscountString(ListingDataWithCostModifiers listingDataWithCostModifiers)
|
||||
{
|
||||
string discountMessage;
|
||||
|
||||
if (!listingDataWithCostModifiers.IsCostModified)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var relativeModifiersSummary = listingDataWithCostModifiers.GetModifiersSummaryRelative();
|
||||
if (relativeModifiersSummary.Count > 1)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append('(');
|
||||
foreach (var (currency, amount) in relativeModifiersSummary)
|
||||
{
|
||||
var currencyPrototype = _prototypeManager.Index(currency);
|
||||
if (sb.Length != 0)
|
||||
{
|
||||
sb.Append(", ");
|
||||
}
|
||||
var currentDiscountMessage = Loc.GetString(
|
||||
"store-ui-discount-display-with-currency",
|
||||
("amount", amount.ToString("P0")),
|
||||
("currency", Loc.GetString(currencyPrototype.DisplayName))
|
||||
);
|
||||
sb.Append(currentDiscountMessage);
|
||||
}
|
||||
|
||||
sb.Append(')');
|
||||
discountMessage = sb.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
// if cost was modified - it should have diff relatively to original cost in 1 or more currency
|
||||
// ReSharper disable once GenericEnumeratorNotDisposed Dictionary enumerator doesn't require dispose
|
||||
var enumerator = relativeModifiersSummary.GetEnumerator();
|
||||
enumerator.MoveNext();
|
||||
var amount = enumerator.Current.Value;
|
||||
discountMessage = Loc.GetString(
|
||||
"store-ui-discount-display",
|
||||
("amount", (amount.ToString("P0")))
|
||||
);
|
||||
}
|
||||
|
||||
return discountMessage;
|
||||
}
|
||||
|
||||
private void ClearListings()
|
||||
{
|
||||
StoreListingsContainer.Children.Clear();
|
||||
}
|
||||
|
||||
public void PopulateStoreCategoryButtons(HashSet<ListingData> listings)
|
||||
public void PopulateStoreCategoryButtons(HashSet<ListingDataWithCostModifiers> listings)
|
||||
{
|
||||
var allCategories = new List<StoreCategoryPrototype>();
|
||||
foreach (var listing in listings)
|
||||
|
||||
160
Content.IntegrationTests/Tests/StoreTests.cs
Normal file
160
Content.IntegrationTests/Tests/StoreTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ public sealed class TraitorRuleSystem : GameRuleSystem<TraitorRuleComponent>
|
||||
// creadth: we need to create uplink for the antag.
|
||||
// PDA should be in place already
|
||||
var pda = _uplink.FindUplinkTarget(traitor);
|
||||
if (pda == null || !_uplink.AddUplink(traitor, startingBalance))
|
||||
if (pda == null || !_uplink.AddUplink(traitor, startingBalance, giveDiscounts: true))
|
||||
return false;
|
||||
|
||||
// Give traitors their codewords and uplink code to keep in their character info menu
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using Content.Server.Store.Components;
|
||||
using Content.Server.Store.Systems;
|
||||
using Content.Shared.Store;
|
||||
using Content.Shared.Store.Components;
|
||||
using Robust.Shared.Prototypes;
|
||||
@@ -24,7 +22,7 @@ public sealed partial class BuyBeforeCondition : ListingCondition
|
||||
if (!args.EntityManager.TryGetComponent<StoreComponent>(args.StoreEntity, out var storeComp))
|
||||
return false;
|
||||
|
||||
var allListings = storeComp.Listings;
|
||||
var allListings = storeComp.FullListingsCatalog;
|
||||
|
||||
var purchasesFound = false;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Content.Shared.Store;
|
||||
using Content.Shared.Store.Components;
|
||||
using Robust.Shared.Prototypes;
|
||||
@@ -13,25 +14,43 @@ public sealed partial class StoreSystem
|
||||
/// <param name="component">The store to refresh</param>
|
||||
public void RefreshAllListings(StoreComponent component)
|
||||
{
|
||||
component.Listings = GetAllListings();
|
||||
var previousState = component.FullListingsCatalog;
|
||||
var newState = GetAllListings();
|
||||
// if we refresh list with existing cost modifiers - they will be removed,
|
||||
// need to restore them
|
||||
if (previousState.Count != 0)
|
||||
{
|
||||
foreach (var previousStateListingItem in previousState)
|
||||
{
|
||||
if (!previousStateListingItem.IsCostModified
|
||||
|| !TryGetListing(newState, previousStateListingItem.ID, out var found))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (modifierSourceId, costModifier) in previousStateListingItem.CostModifiersBySourceId)
|
||||
{
|
||||
found.AddCostModifier(modifierSourceId, costModifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component.FullListingsCatalog = newState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all listings from a prototype.
|
||||
/// </summary>
|
||||
/// <returns>All the listings</returns>
|
||||
public HashSet<ListingData> GetAllListings()
|
||||
public HashSet<ListingDataWithCostModifiers> GetAllListings()
|
||||
{
|
||||
var allListings = _proto.EnumeratePrototypes<ListingPrototype>();
|
||||
|
||||
var allData = new HashSet<ListingData>();
|
||||
|
||||
foreach (var listing in allListings)
|
||||
var clones = new HashSet<ListingDataWithCostModifiers>();
|
||||
foreach (var prototype in _proto.EnumeratePrototypes<ListingPrototype>())
|
||||
{
|
||||
allData.Add((ListingData) listing.Clone());
|
||||
clones.Add(new ListingDataWithCostModifiers(prototype));
|
||||
}
|
||||
|
||||
return allData;
|
||||
return clones;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -39,7 +58,7 @@ public sealed partial class StoreSystem
|
||||
/// </summary>
|
||||
/// <param name="component">The store to add the listing to</param>
|
||||
/// <param name="listingId">The id of the listing</param>
|
||||
/// <returns>Whetehr or not the listing was added successfully</returns>
|
||||
/// <returns>Whether or not the listing was added successfully</returns>
|
||||
public bool TryAddListing(StoreComponent component, string listingId)
|
||||
{
|
||||
if (!_proto.TryIndex<ListingPrototype>(listingId, out var proto))
|
||||
@@ -47,6 +66,7 @@ public sealed partial class StoreSystem
|
||||
Log.Error("Attempted to add invalid listing.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryAddListing(component, proto);
|
||||
}
|
||||
|
||||
@@ -56,9 +76,9 @@ public sealed partial class StoreSystem
|
||||
/// <param name="component">The store to add the listing to</param>
|
||||
/// <param name="listing">The listing</param>
|
||||
/// <returns>Whether or not the listing was add successfully</returns>
|
||||
public bool TryAddListing(StoreComponent component, ListingData listing)
|
||||
public bool TryAddListing(StoreComponent component, ListingPrototype listing)
|
||||
{
|
||||
return component.Listings.Add(listing);
|
||||
return component.FullListingsCatalog.Add(new ListingDataWithCostModifiers(listing));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -68,9 +88,9 @@ public sealed partial class StoreSystem
|
||||
/// <param name="store"></param>
|
||||
/// <param name="component">The store the listings are coming from.</param>
|
||||
/// <returns>The available listings.</returns>
|
||||
public IEnumerable<ListingData> GetAvailableListings(EntityUid buyer, EntityUid store, StoreComponent component)
|
||||
public IEnumerable<ListingDataWithCostModifiers> GetAvailableListings(EntityUid buyer, EntityUid store, StoreComponent component)
|
||||
{
|
||||
return GetAvailableListings(buyer, component.Listings, component.Categories, store);
|
||||
return GetAvailableListings(buyer, component.FullListingsCatalog, component.Categories, store);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,11 +101,12 @@ public sealed partial class StoreSystem
|
||||
/// <param name="categories">What categories to filter by.</param>
|
||||
/// <param name="storeEntity">The physial entity of the store. Can be null.</param>
|
||||
/// <returns>The available listings.</returns>
|
||||
public IEnumerable<ListingData> GetAvailableListings(
|
||||
public IEnumerable<ListingDataWithCostModifiers> GetAvailableListings(
|
||||
EntityUid buyer,
|
||||
HashSet<ListingData>? listings,
|
||||
IReadOnlyCollection<ListingDataWithCostModifiers>? listings,
|
||||
HashSet<ProtoId<StoreCategoryPrototype>> categories,
|
||||
EntityUid? storeEntity = null)
|
||||
EntityUid? storeEntity = null
|
||||
)
|
||||
{
|
||||
listings ??= GetAllListings();
|
||||
|
||||
@@ -131,4 +152,19 @@ public sealed partial class StoreSystem
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetListing(IReadOnlyCollection<ListingDataWithCostModifiers> collection, string listingId, [MaybeNullWhen(false)] out ListingDataWithCostModifiers found)
|
||||
{
|
||||
foreach(var current in collection)
|
||||
{
|
||||
if (current.ID == listingId)
|
||||
{
|
||||
found = current;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
found = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed partial class StoreSystem
|
||||
|
||||
private void OnEntityRemoved(EntityUid uid, StoreRefundComponent component, EntRemovedFromContainerMessage args)
|
||||
{
|
||||
if (component.StoreEntity == null || _actions.TryGetActionData(uid, out _) || !TryComp<StoreComponent>(component.StoreEntity.Value, out var storeComp))
|
||||
if (component.StoreEntity == null || _actions.TryGetActionData(uid, out _, false) || !TryComp<StoreComponent>(component.StoreEntity.Value, out var storeComp))
|
||||
return;
|
||||
|
||||
DisableRefund(component.StoreEntity.Value, storeComp);
|
||||
|
||||
@@ -91,7 +91,8 @@ public sealed partial class StoreSystem
|
||||
//this is the person who will be passed into logic for all listing filtering.
|
||||
if (user != null) //if we have no "buyer" for this update, then don't update the listings
|
||||
{
|
||||
component.LastAvailableListings = GetAvailableListings(component.AccountOwner ?? user.Value, store, component).ToHashSet();
|
||||
component.LastAvailableListings = GetAvailableListings(component.AccountOwner ?? user.Value, store, component)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
//dictionary for all currencies, including 0 values for currencies on the whitelist
|
||||
@@ -109,6 +110,7 @@ public sealed partial class StoreSystem
|
||||
|
||||
// only tell operatives to lock their uplink if it can be locked
|
||||
var showFooter = HasComp<RingerUplinkComponent>(store);
|
||||
|
||||
var state = new StoreUpdateState(component.LastAvailableListings, allCurrency, showFooter, component.RefundAllowed);
|
||||
_ui.SetUiState(store, StoreUiKey.Key, state);
|
||||
}
|
||||
@@ -128,7 +130,7 @@ public sealed partial class StoreSystem
|
||||
/// </summary>
|
||||
private void OnBuyRequest(EntityUid uid, StoreComponent component, StoreBuyListingMessage msg)
|
||||
{
|
||||
var listing = component.Listings.FirstOrDefault(x => x.Equals(msg.Listing));
|
||||
var listing = component.FullListingsCatalog.FirstOrDefault(x => x.ID.Equals(msg.Listing.Id));
|
||||
|
||||
if (listing == null) //make sure this listing actually exists
|
||||
{
|
||||
@@ -153,9 +155,10 @@ public sealed partial class StoreSystem
|
||||
}
|
||||
|
||||
//check that we have enough money
|
||||
foreach (var currency in listing.Cost)
|
||||
var cost = listing.Cost;
|
||||
foreach (var (currency, amount) in cost)
|
||||
{
|
||||
if (!component.Balance.TryGetValue(currency.Key, out var balance) || balance < currency.Value)
|
||||
if (!component.Balance.TryGetValue(currency, out var balance) || balance < amount)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -165,13 +168,13 @@ public sealed partial class StoreSystem
|
||||
component.RefundAllowed = false;
|
||||
|
||||
//subtract the cash
|
||||
foreach (var (currency, value) in listing.Cost)
|
||||
foreach (var (currency, amount) in cost)
|
||||
{
|
||||
component.Balance[currency] -= value;
|
||||
component.Balance[currency] -= amount;
|
||||
|
||||
component.BalanceSpent.TryAdd(currency, FixedPoint2.Zero);
|
||||
|
||||
component.BalanceSpent[currency] += value;
|
||||
component.BalanceSpent[currency] += amount;
|
||||
}
|
||||
|
||||
//spawn entity
|
||||
@@ -213,7 +216,7 @@ public sealed partial class StoreSystem
|
||||
|
||||
if (listing.ProductUpgradeId != null)
|
||||
{
|
||||
foreach (var upgradeListing in component.Listings)
|
||||
foreach (var upgradeListing in component.FullListingsCatalog)
|
||||
{
|
||||
if (upgradeListing.ID == listing.ProductUpgradeId)
|
||||
{
|
||||
@@ -262,6 +265,13 @@ public sealed partial class StoreSystem
|
||||
listing.PurchaseAmount++; //track how many times something has been purchased
|
||||
_audio.PlayEntity(component.BuySuccessSound, msg.Actor, uid); //cha-ching!
|
||||
|
||||
var buyFinished = new StoreBuyFinishedEvent
|
||||
{
|
||||
PurchasedItem = listing,
|
||||
StoreUid = uid
|
||||
};
|
||||
RaiseLocalEvent(ref buyFinished);
|
||||
|
||||
UpdateUserInterface(buyer, uid, component);
|
||||
}
|
||||
|
||||
@@ -346,6 +356,7 @@ public sealed partial class StoreSystem
|
||||
{
|
||||
component.Balance[currency] += value;
|
||||
}
|
||||
|
||||
// Reset store back to its original state
|
||||
RefreshAllListings(component);
|
||||
component.BalanceSpent = new();
|
||||
@@ -376,3 +387,14 @@ public sealed partial class StoreSystem
|
||||
component.RefundAllowed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event of successfully finishing purchase in store (<see cref="StoreSystem"/>.
|
||||
/// </summary>
|
||||
/// <param name="StoreUid">EntityUid on which store is placed.</param>
|
||||
/// <param name="PurchasedItem">ListingItem that was purchased.</param>
|
||||
[ByRefEvent]
|
||||
public readonly record struct StoreBuyFinishedEvent(
|
||||
EntityUid StoreUid,
|
||||
ListingDataWithCostModifiers PurchasedItem
|
||||
);
|
||||
|
||||
397
Content.Server/StoreDiscount/Systems/StoreDiscountSystem.cs
Normal file
397
Content.Server/StoreDiscount/Systems/StoreDiscountSystem.cs
Normal 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
|
||||
);
|
||||
@@ -28,13 +28,14 @@ namespace Content.Server.Traitor.Uplink.Commands
|
||||
{
|
||||
1 => CompletionResult.FromHintOptions(CompletionHelper.SessionNames(), Loc.GetString("add-uplink-command-completion-1")),
|
||||
2 => CompletionResult.FromHint(Loc.GetString("add-uplink-command-completion-2")),
|
||||
3 => CompletionResult.FromHint(Loc.GetString("add-uplink-command-completion-3")),
|
||||
_ => CompletionResult.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
if (args.Length > 2)
|
||||
if (args.Length > 3)
|
||||
{
|
||||
shell.WriteError(Loc.GetString("shell-wrong-arguments-number"));
|
||||
return;
|
||||
@@ -82,9 +83,19 @@ namespace Content.Server.Traitor.Uplink.Commands
|
||||
uplinkEntity = eUid;
|
||||
}
|
||||
|
||||
bool isDiscounted = false;
|
||||
if (args.Length >= 3)
|
||||
{
|
||||
if (!bool.TryParse(args[2], out isDiscounted))
|
||||
{
|
||||
shell.WriteLine(Loc.GetString("shell-invalid-bool"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Finally add uplink
|
||||
var uplinkSys = _entManager.System<UplinkSystem>();
|
||||
if (!uplinkSys.AddUplink(user, 20, uplinkEntity: uplinkEntity))
|
||||
if (!uplinkSys.AddUplink(user, 20, uplinkEntity: uplinkEntity, giveDiscounts: isDiscounted))
|
||||
{
|
||||
shell.WriteLine(Loc.GetString("add-uplink-command-error-2"));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Linq;
|
||||
using Content.Server.Store.Systems;
|
||||
using Content.Server.StoreDiscount.Systems;
|
||||
using Content.Shared.Hands.EntitySystems;
|
||||
using Content.Shared.Inventory;
|
||||
using Content.Shared.PDA;
|
||||
using Content.Server.Store.Components;
|
||||
using Content.Shared.FixedPoint;
|
||||
using Content.Shared.Store;
|
||||
using Content.Shared.Store.Components;
|
||||
@@ -23,21 +24,26 @@ namespace Content.Server.Traitor.Uplink
|
||||
/// </summary>
|
||||
/// <param name="user">The person who is getting the uplink</param>
|
||||
/// <param name="balance">The amount of currency on the uplink. If null, will just use the amount specified in the preset.</param>
|
||||
/// <param name="uplinkPresetId">The id of the storepreset</param>
|
||||
/// <param name="uplinkEntity">The entity that will actually have the uplink functionality. Defaults to the PDA if null.</param>
|
||||
/// <param name="giveDiscounts">Marker that enables discounts for uplink items.</param>
|
||||
/// <returns>Whether or not the uplink was added successfully</returns>
|
||||
public bool AddUplink(EntityUid user, FixedPoint2? balance, EntityUid? uplinkEntity = null)
|
||||
public bool AddUplink(
|
||||
EntityUid user,
|
||||
FixedPoint2? balance,
|
||||
EntityUid? uplinkEntity = null,
|
||||
bool giveDiscounts = false
|
||||
)
|
||||
{
|
||||
// Try to find target item
|
||||
// Try to find target item if none passed
|
||||
uplinkEntity ??= FindUplinkTarget(user);
|
||||
if (uplinkEntity == null)
|
||||
{
|
||||
uplinkEntity = FindUplinkTarget(user);
|
||||
if (uplinkEntity == null)
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureComp<UplinkComponent>(uplinkEntity.Value);
|
||||
var store = EnsureComp<StoreComponent>(uplinkEntity.Value);
|
||||
|
||||
store.AccountOwner = user;
|
||||
store.Balance.Clear();
|
||||
if (balance != null)
|
||||
@@ -46,6 +52,14 @@ namespace Content.Server.Traitor.Uplink
|
||||
_store.TryAddCurrency(new Dictionary<string, FixedPoint2> { { TelecrystalCurrencyPrototype, balance.Value } }, uplinkEntity.Value, store);
|
||||
}
|
||||
|
||||
var uplinkInitializedEvent = new StoreInitializedEvent(
|
||||
TargetUser: user,
|
||||
Store: uplinkEntity.Value,
|
||||
UseDiscounts: giveDiscounts,
|
||||
Listings: _store.GetAvailableListings(user, uplinkEntity.Value, store)
|
||||
.ToArray()
|
||||
);
|
||||
RaiseLocalEvent(ref uplinkInitializedEvent);
|
||||
// TODO add BUI. Currently can't be done outside of yaml -_-
|
||||
|
||||
return true;
|
||||
@@ -62,7 +76,8 @@ namespace Content.Server.Traitor.Uplink
|
||||
{
|
||||
while (containerSlotEnumerator.MoveNext(out var pdaUid))
|
||||
{
|
||||
if (!pdaUid.ContainedEntity.HasValue) continue;
|
||||
if (!pdaUid.ContainedEntity.HasValue)
|
||||
continue;
|
||||
|
||||
if (HasComp<PdaComponent>(pdaUid.ContainedEntity.Value) || HasComp<StoreComponent>(pdaUid.ContainedEntity.Value))
|
||||
return pdaUid.ContainedEntity.Value;
|
||||
|
||||
@@ -2,7 +2,6 @@ using Content.Shared.FixedPoint;
|
||||
using Robust.Shared.Audio;
|
||||
using Robust.Shared.GameStates;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
|
||||
|
||||
namespace Content.Shared.Store.Components;
|
||||
|
||||
@@ -45,16 +44,16 @@ public sealed partial class StoreComponent : Component
|
||||
public EntityUid? AccountOwner = null;
|
||||
|
||||
/// <summary>
|
||||
/// All listings, including those that aren't available to the buyer
|
||||
/// Cached list of listings items with modifiers.
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public HashSet<ListingData> Listings = new();
|
||||
public HashSet<ListingDataWithCostModifiers> FullListingsCatalog = new();
|
||||
|
||||
/// <summary>
|
||||
/// All available listings from the last time that it was checked.
|
||||
/// </summary>
|
||||
[ViewVariables]
|
||||
public HashSet<ListingData> LastAvailableListings = new();
|
||||
public HashSet<ListingDataWithCostModifiers> LastAvailableListings = new();
|
||||
|
||||
/// <summary>
|
||||
/// All current entities bought from this shop. Useful for keeping track of refunds and upgrades.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Linq;
|
||||
using Content.Shared.FixedPoint;
|
||||
using Content.Shared.Store.Components;
|
||||
using Content.Shared.StoreDiscount.Components;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Serialization;
|
||||
using Robust.Shared.Utility;
|
||||
@@ -13,8 +15,77 @@ namespace Content.Shared.Store;
|
||||
/// </summary>
|
||||
[Serializable, NetSerializable]
|
||||
[Virtual, DataDefinition]
|
||||
public partial class ListingData : IEquatable<ListingData>, ICloneable
|
||||
public partial class ListingData : IEquatable<ListingData>
|
||||
{
|
||||
public ListingData()
|
||||
{
|
||||
}
|
||||
|
||||
public ListingData(ListingData other) : this(
|
||||
other.Name,
|
||||
other.DiscountCategory,
|
||||
other.Description,
|
||||
other.Conditions,
|
||||
other.Icon,
|
||||
other.Priority,
|
||||
other.ProductEntity,
|
||||
other.ProductAction,
|
||||
other.ProductUpgradeId,
|
||||
other.ProductActionEntity,
|
||||
other.ProductEvent,
|
||||
other.RaiseProductEventOnUser,
|
||||
other.PurchaseAmount,
|
||||
other.ID,
|
||||
other.Categories,
|
||||
other.OriginalCost,
|
||||
other.RestockTime,
|
||||
other.DiscountDownTo
|
||||
)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public ListingData(
|
||||
string? name,
|
||||
ProtoId<DiscountCategoryPrototype>? discountCategory,
|
||||
string? description,
|
||||
List<ListingCondition>? conditions,
|
||||
SpriteSpecifier? icon,
|
||||
int priority,
|
||||
EntProtoId? productEntity,
|
||||
EntProtoId? productAction,
|
||||
ProtoId<ListingPrototype>? productUpgradeId,
|
||||
EntityUid? productActionEntity,
|
||||
object? productEvent,
|
||||
bool raiseProductEventOnUser,
|
||||
int purchaseAmount,
|
||||
string id,
|
||||
HashSet<ProtoId<StoreCategoryPrototype>> categories,
|
||||
IReadOnlyDictionary<ProtoId<CurrencyPrototype>, FixedPoint2> originalCost,
|
||||
TimeSpan restockTime,
|
||||
Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> dataDiscountDownTo
|
||||
)
|
||||
{
|
||||
Name = name;
|
||||
DiscountCategory = discountCategory;
|
||||
Description = description;
|
||||
Conditions = conditions?.ToList();
|
||||
Icon = icon;
|
||||
Priority = priority;
|
||||
ProductEntity = productEntity;
|
||||
ProductAction = productAction;
|
||||
ProductUpgradeId = productUpgradeId;
|
||||
ProductActionEntity = productActionEntity;
|
||||
ProductEvent = productEvent;
|
||||
RaiseProductEventOnUser = raiseProductEventOnUser;
|
||||
PurchaseAmount = purchaseAmount;
|
||||
ID = id;
|
||||
Categories = categories.ToHashSet();
|
||||
OriginalCost = originalCost;
|
||||
RestockTime = restockTime;
|
||||
DiscountDownTo = new Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2>(dataDiscountDownTo);
|
||||
}
|
||||
|
||||
[ViewVariables]
|
||||
[IdDataField]
|
||||
public string ID { get; private set; } = default!;
|
||||
@@ -25,6 +96,12 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
|
||||
[DataField]
|
||||
public string? Name;
|
||||
|
||||
/// <summary>
|
||||
/// Discount category for listing item. This marker describes chance of how often will item be discounted.
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public ProtoId<DiscountCategoryPrototype>? DiscountCategory;
|
||||
|
||||
/// <summary>
|
||||
/// The description of the listing. If empty, uses the entity's description (if present)
|
||||
/// </summary>
|
||||
@@ -35,13 +112,15 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
|
||||
/// The categories that this listing applies to. Used for filtering a listing for a store.
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public List<ProtoId<StoreCategoryPrototype>> Categories = new();
|
||||
public HashSet<ProtoId<StoreCategoryPrototype>> Categories = new();
|
||||
|
||||
/// <summary>
|
||||
/// The cost of the listing. String represents the currency type while the FixedPoint2 represents the amount of that currency.
|
||||
/// The original cost of the listing. FixedPoint2 represents the amount of that currency.
|
||||
/// This fields should not be used for getting actual cost of item, as there could be
|
||||
/// cost modifiers (due to discounts or surplus). Use Cost property on derived class instead.
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Cost = new();
|
||||
public IReadOnlyDictionary<ProtoId<CurrencyPrototype>, FixedPoint2> OriginalCost = new Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2>();
|
||||
|
||||
/// <summary>
|
||||
/// Specific customizable conditions that determine whether or not the listing can be purchased.
|
||||
@@ -109,6 +188,12 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
|
||||
[DataField]
|
||||
public TimeSpan RestockTime = TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Options for discount - from max amount down to how much item costs can be cut by discount, absolute value.
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> DiscountDownTo = new();
|
||||
|
||||
public bool Equals(ListingData? listing)
|
||||
{
|
||||
if (listing == null)
|
||||
@@ -132,7 +217,7 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
|
||||
if (!Categories.OrderBy(x => x).SequenceEqual(listing.Categories.OrderBy(x => x)))
|
||||
return false;
|
||||
|
||||
if (!Cost.OrderBy(x => x).SequenceEqual(listing.Cost.OrderBy(x => x)))
|
||||
if (!OriginalCost.OrderBy(x => x).SequenceEqual(listing.OriginalCost.OrderBy(x => x)))
|
||||
return false;
|
||||
|
||||
if ((Conditions != null && listing.Conditions != null) &&
|
||||
@@ -142,32 +227,6 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
|
||||
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>
|
||||
@@ -176,4 +235,200 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
|
||||
[Prototype("listing")]
|
||||
[Serializable, NetSerializable]
|
||||
[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; }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ public enum StoreUiKey : byte
|
||||
[Serializable, NetSerializable]
|
||||
public sealed class StoreUpdateState : BoundUserInterfaceState
|
||||
{
|
||||
public readonly HashSet<ListingData> Listings;
|
||||
public readonly HashSet<ListingDataWithCostModifiers> Listings;
|
||||
|
||||
public readonly Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> Balance;
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class StoreUpdateState : BoundUserInterfaceState
|
||||
|
||||
public readonly bool AllowRefund;
|
||||
|
||||
public StoreUpdateState(HashSet<ListingData> listings, Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> balance, bool showFooter, bool allowRefund)
|
||||
public StoreUpdateState(HashSet<ListingDataWithCostModifiers> listings, Dictionary<ProtoId<CurrencyPrototype>, FixedPoint2> balance, bool showFooter, bool allowRefund)
|
||||
{
|
||||
Listings = listings;
|
||||
Balance = balance;
|
||||
@@ -37,14 +37,9 @@ public sealed class StoreRequestUpdateInterfaceMessage : BoundUserInterfaceMessa
|
||||
}
|
||||
|
||||
[Serializable, NetSerializable]
|
||||
public sealed class StoreBuyListingMessage : BoundUserInterfaceMessage
|
||||
public sealed class StoreBuyListingMessage(ProtoId<ListingPrototype> listing) : BoundUserInterfaceMessage
|
||||
{
|
||||
public ListingData Listing;
|
||||
|
||||
public StoreBuyListingMessage(ListingData listing)
|
||||
{
|
||||
Listing = listing;
|
||||
}
|
||||
public ProtoId<ListingPrototype> Listing = listing;
|
||||
}
|
||||
|
||||
[Serializable, NetSerializable]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -3,5 +3,6 @@ add-uplink-command-help = Usage: adduplink [username] [item-id]
|
||||
|
||||
add-uplink-command-completion-1 = Username (defaults to self)
|
||||
add-uplink-command-completion-2 = Uplink uid (default to PDA)
|
||||
add-uplink-command-completion-3 = Is uplink discount enabled
|
||||
add-uplink-command-error-1 = Selected player doesn't control any entity
|
||||
add-uplink-command-error-2 = Failed to add uplink to the player
|
||||
@@ -12,6 +12,7 @@ store-category-allies = Allies
|
||||
store-category-job = Job
|
||||
store-category-wearables = Wearables
|
||||
store-category-pointless = Pointless
|
||||
store-discounted-items = Discounts
|
||||
|
||||
# Revenant
|
||||
store-category-abilities = Abilities
|
||||
|
||||
@@ -2,6 +2,8 @@ store-ui-default-title = Store
|
||||
store-ui-default-withdraw-text = Withdraw
|
||||
store-ui-balance-display = {$currency}: {$amount}
|
||||
store-ui-price-display = {$amount} {$currency}
|
||||
store-ui-discount-display-with-currency = {$amount} off on {$currency}
|
||||
store-ui-discount-display = ({$amount} off!)
|
||||
store-ui-traitor-flavor = Copyright (C) NT -30643
|
||||
store-ui-traitor-warning = Operatives must lock their uplinks after use to avoid detection.
|
||||
|
||||
|
||||
13
Resources/Prototypes/Catalog/discount_categories.yml
Normal file
13
Resources/Prototypes/Catalog/discount_categories.yml
Normal 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
|
||||
@@ -6,6 +6,9 @@
|
||||
name: uplink-pistol-viper-name
|
||||
description: uplink-pistol-viper-desc
|
||||
productEntity: WeaponPistolViper
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 3
|
||||
categories:
|
||||
@@ -16,6 +19,9 @@
|
||||
name: uplink-revolver-python-name
|
||||
description: uplink-revolver-python-desc
|
||||
productEntity: WeaponRevolverPythonAP
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 8 # Originally was 13 TC but was not used due to high cost
|
||||
categories:
|
||||
@@ -27,6 +33,9 @@
|
||||
name: uplink-pistol-cobra-name
|
||||
description: uplink-pistol-cobra-desc
|
||||
productEntity: WeaponPistolCobra
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -48,6 +57,9 @@
|
||||
name: uplink-esword-name
|
||||
description: uplink-esword-desc
|
||||
icon: { sprite: /Textures/Objects/Weapons/Melee/e_sword.rsi, state: icon }
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
productEntity: EnergySword
|
||||
cost:
|
||||
Telecrystal: 8
|
||||
@@ -60,6 +72,9 @@
|
||||
description: uplink-edagger-desc
|
||||
icon: { sprite: /Textures/Objects/Weapons/Melee/e_dagger.rsi, state: icon }
|
||||
productEntity: EnergyDaggerBox
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -71,6 +86,9 @@
|
||||
description: uplink-knives-kit-desc
|
||||
icon: { sprite: /Textures/Objects/Storage/boxicons.rsi, state: throwing_knives }
|
||||
productEntity: ThrowingKnivesKit
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -81,6 +99,9 @@
|
||||
name: uplink-gloves-north-star-name
|
||||
description: uplink-gloves-north-star-desc
|
||||
productEntity: ClothingHandsGlovesNorthStar
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 8
|
||||
categories:
|
||||
@@ -91,6 +112,9 @@
|
||||
name: uplink-disposable-turret-name
|
||||
description: uplink-disposable-turret-desc
|
||||
productEntity: ToolboxElectricalTurretFilled
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -107,6 +131,9 @@
|
||||
description: uplink-eshield-desc
|
||||
icon: { sprite: /Textures/Objects/Weapons/Melee/e_shield.rsi, state: eshield-on }
|
||||
productEntity: EnergyShield
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 8
|
||||
categories:
|
||||
@@ -123,6 +150,9 @@
|
||||
description: uplink-sniper-bundle-desc
|
||||
icon: { sprite: /Textures/Objects/Weapons/Guns/Snipers/heavy_sniper.rsi, state: base }
|
||||
productEntity: BriefcaseSyndieSniperBundleFilled
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 6
|
||||
cost:
|
||||
Telecrystal: 12
|
||||
categories:
|
||||
@@ -134,6 +164,9 @@
|
||||
description: uplink-c20r-bundle-desc
|
||||
icon: { sprite: /Textures/Objects/Weapons/Guns/SMGs/c20r.rsi, state: icon }
|
||||
productEntity: ClothingBackpackDuffelSyndicateFilledSMG
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 10
|
||||
cost:
|
||||
Telecrystal: 17
|
||||
categories:
|
||||
@@ -145,6 +178,9 @@
|
||||
description: uplink-buldog-bundle-desc
|
||||
icon: { sprite: /Textures/Objects/Weapons/Guns/Shotguns/bulldog.rsi, state: icon }
|
||||
productEntity: ClothingBackpackDuffelSyndicateFilledShotgun
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 12
|
||||
cost:
|
||||
Telecrystal: 20
|
||||
categories:
|
||||
@@ -156,6 +192,9 @@
|
||||
description: uplink-grenade-launcher-bundle-desc
|
||||
icon: { sprite: /Textures/Objects/Weapons/Guns/Launchers/china_lake.rsi, state: icon }
|
||||
productEntity: ClothingBackpackDuffelSyndicateFilledGrenadeLauncher
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 20
|
||||
cost:
|
||||
Telecrystal: 25
|
||||
categories:
|
||||
@@ -167,6 +206,9 @@
|
||||
description: uplink-l6-saw-bundle-desc
|
||||
icon: { sprite: /Textures/Objects/Weapons/Guns/LMGs/l6.rsi, state: icon }
|
||||
productEntity: ClothingBackpackDuffelSyndicateFilledLMG
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 24
|
||||
cost:
|
||||
Telecrystal: 30
|
||||
categories:
|
||||
@@ -179,6 +221,9 @@
|
||||
name: uplink-explosive-grenade-name
|
||||
description: uplink-explosive-grenade-desc
|
||||
productEntity: ExGrenade
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -209,6 +254,9 @@
|
||||
name: uplink-mini-bomb-name
|
||||
description: uplink-mini-bomb-desc
|
||||
productEntity: SyndieMiniBomb
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -219,6 +267,9 @@
|
||||
name: uplink-supermatter-grenade-name
|
||||
description: uplink-supermatter-grenade-desc
|
||||
productEntity: SupermatterGrenade
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -229,6 +280,9 @@
|
||||
name: uplink-whitehole-grenade-name
|
||||
description: uplink-whitehole-grenade-desc
|
||||
productEntity: WhiteholeGrenade
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 3
|
||||
categories:
|
||||
@@ -239,6 +293,9 @@
|
||||
name: uplink-penguin-grenade-name
|
||||
description: uplink-penguin-grenade-desc
|
||||
productEntity: MobGrenadePenguin
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 5
|
||||
categories:
|
||||
@@ -254,6 +311,9 @@
|
||||
name: uplink-c4-name
|
||||
description: uplink-c4-desc
|
||||
productEntity: C4
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -264,6 +324,9 @@
|
||||
name: uplink-grenadier-rig-name
|
||||
description: uplink-grenadier-rig-desc
|
||||
productEntity: ClothingBeltMilitaryWebbingGrenadeFilled
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 6
|
||||
cost:
|
||||
Telecrystal: 12
|
||||
categories:
|
||||
@@ -279,8 +342,11 @@
|
||||
name: uplink-c4-bundle-name
|
||||
description: uplink-c4-bundle-desc
|
||||
productEntity: ClothingBackpackDuffelSyndicateC4tBundle
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 8
|
||||
cost:
|
||||
Telecrystal: 12 #you're buying bulk so its a 25% discount
|
||||
Telecrystal: 12 #you're buying bulk so its a 25% discount, so no additional random discount over it
|
||||
categories:
|
||||
- UplinkExplosives
|
||||
|
||||
@@ -289,6 +355,9 @@
|
||||
name: uplink-emp-grenade-name
|
||||
description: uplink-emp-grenade-desc
|
||||
productEntity: EmpGrenade
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -300,6 +369,9 @@
|
||||
description: uplink-exploding-pen-desc
|
||||
icon: { sprite: /Textures/Objects/Misc/pens.rsi, state: pen }
|
||||
productEntity: PenExplodingBox
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -341,6 +413,9 @@
|
||||
name: uplink-cluster-grenade-name
|
||||
description: uplink-cluster-grenade-desc
|
||||
productEntity: ClusterGrenade
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 5
|
||||
cost:
|
||||
Telecrystal: 8
|
||||
categories:
|
||||
@@ -351,6 +426,9 @@
|
||||
name: uplink-shrapnel-grenade-name
|
||||
description: uplink-shrapnel-grenade-desc
|
||||
productEntity: GrenadeShrapnel
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -361,6 +439,9 @@
|
||||
name: uplink-incendiary-grenade-name
|
||||
description: uplink-incendiary-grenade-desc
|
||||
productEntity: GrenadeIncendiary
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -371,6 +452,9 @@
|
||||
name: uplink-emp-kit-name
|
||||
description: uplink-emp-kit-desc
|
||||
productEntity: ElectricalDisruptionKit
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -474,6 +558,9 @@
|
||||
description: uplink-hypopen-desc
|
||||
icon: { sprite: /Textures/Objects/Misc/pens.rsi, state: pen }
|
||||
productEntity: HypopenBox
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -485,6 +572,9 @@
|
||||
description: uplink-hypodart-desc
|
||||
icon: { sprite: /Textures/Objects/Fun/Darts/dart_red.rsi, state: icon }
|
||||
productEntity: HypoDartBox
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -496,6 +586,9 @@
|
||||
description: uplink-chemistry-kit-desc
|
||||
icon: { sprite: /Textures/Objects/Storage/boxicons.rsi, state: vials }
|
||||
productEntity: ChemicalSynthesisKit
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -526,6 +619,9 @@
|
||||
name: uplink-nocturine-chemistry-bottle-name
|
||||
description: uplink-nocturine-chemistry-bottle-desc
|
||||
productEntity: NocturineChemistryBottle
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -536,6 +632,9 @@
|
||||
name: uplink-combat-medkit-name
|
||||
description: uplink-combat-medkit-desc
|
||||
productEntity: MedkitCombatFilled
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 5
|
||||
categories:
|
||||
@@ -546,6 +645,9 @@
|
||||
name: uplink-combat-medipen-name
|
||||
description: uplink-combat-medipen-desc
|
||||
productEntity: CombatMedipen
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -556,6 +658,9 @@
|
||||
name: uplink-stimpack-name
|
||||
description: uplink-stimpack-desc
|
||||
productEntity: Stimpack
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -566,6 +671,9 @@
|
||||
name: uplink-stimkit-name
|
||||
description: uplink-stimkit-desc
|
||||
productEntity: StimkitFilled
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 8
|
||||
cost:
|
||||
Telecrystal: 12
|
||||
categories:
|
||||
@@ -576,6 +684,9 @@
|
||||
name: uplink-cigarettes-name
|
||||
description: uplink-cigarettes-desc
|
||||
productEntity: CigPackSyndicate
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -586,6 +697,9 @@
|
||||
name: uplink-meds-bundle-name
|
||||
description: uplink-meds-bundle-desc
|
||||
productEntity: ClothingBackpackDuffelSyndicateMedicalBundleFilled
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 12
|
||||
cost:
|
||||
Telecrystal: 20
|
||||
categories:
|
||||
@@ -607,6 +721,9 @@
|
||||
name: uplink-agent-id-card-name
|
||||
description: uplink-agent-id-card-desc
|
||||
productEntity: AgentIDCard
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 3
|
||||
categories:
|
||||
@@ -617,6 +734,9 @@
|
||||
name: uplink-stealth-box-name
|
||||
description: uplink-stealth-box-desc
|
||||
productEntity: StealthBox
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 5
|
||||
categories:
|
||||
@@ -627,6 +747,9 @@
|
||||
name: uplink-chameleon-projector-name
|
||||
description: uplink-chameleon-projector-desc
|
||||
productEntity: ChameleonProjector
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 7
|
||||
categories:
|
||||
@@ -638,6 +761,9 @@
|
||||
description: uplink-encryption-key-desc
|
||||
icon: { sprite: /Textures/Objects/Devices/encryption_keys.rsi, state: synd_label }
|
||||
productEntity: BoxEncryptionKeySyndie # Two for the price of one
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -679,6 +805,9 @@
|
||||
name: uplink-ultrabright-lantern-name
|
||||
description: uplink-ultrabright-lantern-desc
|
||||
productEntity: LanternFlash
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -689,6 +818,9 @@
|
||||
name: uplink-bribe-name
|
||||
description: uplink-bribe-desc
|
||||
productEntity: BriefcaseSyndieLobbyingBundleFilled
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -710,6 +842,9 @@
|
||||
description: uplink-decoy-kit-desc
|
||||
icon: { sprite: /Textures/Objects/Tools/Decoys/operative_decoy.rsi, state: folded }
|
||||
productEntity: ClothingBackpackDuffelSyndicateDecoyKitFilled
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -720,6 +855,9 @@
|
||||
name: uplink-exploding-syndicate-bomb-fake-name
|
||||
description: uplink-exploding-syndicate-bomb-fake-desc
|
||||
productEntity: SyndicateBombFake
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -732,6 +870,9 @@
|
||||
name: uplink-emag-name
|
||||
description: uplink-emag-desc
|
||||
productEntity: Emag
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 5
|
||||
cost:
|
||||
Telecrystal: 8
|
||||
categories:
|
||||
@@ -742,6 +883,9 @@
|
||||
name: uplink-radio-jammer-name
|
||||
description: uplink-radio-jammer-desc
|
||||
productEntity: RadioJammer
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -752,6 +896,9 @@
|
||||
name: uplink-syndicate-weapon-module-name
|
||||
description: uplink-syndicate-weapon-module-desc
|
||||
productEntity: BorgModuleSyndicateWeapon
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 5
|
||||
categories:
|
||||
@@ -763,6 +910,9 @@
|
||||
description: uplink-syndicate-martyr-module-desc
|
||||
productEntity: BorgModuleMartyr
|
||||
icon: { sprite: /Textures/Objects/Specific/Robotics/borgmodule.rsi, state: syndicateborgbomb }
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -783,6 +933,9 @@
|
||||
name: uplink-slipocalypse-clustersoap-name
|
||||
description: uplink-slipocalypse-clustersoap-desc
|
||||
productEntity: SlipocalypseClusterSoap
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -793,6 +946,9 @@
|
||||
name: uplink-toolbox-name
|
||||
description: uplink-toolbox-desc
|
||||
productEntity: ToolboxSyndicateFilled
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -803,6 +959,9 @@
|
||||
name: uplink-syndicate-jaws-of-life-name
|
||||
description: uplink-syndicate-jaws-of-life-desc
|
||||
productEntity: SyndicateJawsOfLife
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -813,6 +972,9 @@
|
||||
name: uplink-duffel-surgery-name
|
||||
description: uplink-duffel-surgery-desc
|
||||
productEntity: ClothingBackpackDuffelSyndicateFilledMedical
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -823,6 +985,9 @@
|
||||
name: uplink-power-sink-name
|
||||
description: uplink-power-sink-desc
|
||||
productEntity: PowerSink
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 8
|
||||
categories:
|
||||
@@ -876,6 +1041,9 @@
|
||||
name: uplink-singularity-beacon-name
|
||||
description: uplink-singularity-beacon-desc
|
||||
productEntity: SingularityBeacon
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 12
|
||||
categories:
|
||||
@@ -889,6 +1057,9 @@
|
||||
description: uplink-holopara-kit-desc
|
||||
icon: { sprite: /Textures/Objects/Misc/guardian_info.rsi, state: icon }
|
||||
productEntity: BoxHoloparasite
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 8
|
||||
cost:
|
||||
Telecrystal: 14
|
||||
categories:
|
||||
@@ -905,6 +1076,9 @@
|
||||
description: uplink-reinforcement-radio-traitor-desc
|
||||
productEntity: ReinforcementRadioSyndicate
|
||||
icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-urist }
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 7
|
||||
cost:
|
||||
Telecrystal: 14
|
||||
categories:
|
||||
@@ -953,6 +1127,9 @@
|
||||
description: uplink-reinforcement-radio-ancestor-desc
|
||||
productEntity: ReinforcementRadioSyndicateAncestor
|
||||
icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-ancestor }
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -969,6 +1146,9 @@
|
||||
description: uplink-reinforcement-radio-ancestor-desc
|
||||
productEntity: ReinforcementRadioSyndicateAncestorNukeops
|
||||
icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-ancestor }
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -984,6 +1164,9 @@
|
||||
name: uplink-carp-dehydrated-name
|
||||
description: uplink-carp-dehydrated-desc
|
||||
productEntity: DehydratedSpaceCarp
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -1000,6 +1183,9 @@
|
||||
description: uplink-mobcat-microbomb-desc
|
||||
icon: { sprite: Objects/Devices/communication.rsi, state: old-radio-syndicat }
|
||||
productEntity: ReinforcementRadioSyndicateSyndiCat
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -1027,6 +1213,9 @@
|
||||
description: uplink-storage-implanter-desc
|
||||
icon: { sprite: /Textures/Clothing/Back/Backpacks/backpack.rsi, state: icon }
|
||||
productEntity: StorageImplanter
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 8
|
||||
categories:
|
||||
@@ -1043,6 +1232,9 @@
|
||||
description: uplink-freedom-implanter-desc
|
||||
icon: { sprite: /Textures/Actions/Implants/implants.rsi, state: freedom }
|
||||
productEntity: FreedomImplanter
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 5
|
||||
categories:
|
||||
@@ -1054,6 +1246,9 @@
|
||||
description: uplink-scram-implanter-desc
|
||||
icon: { sprite: /Textures/Structures/Specific/anomaly.rsi, state: anom4 }
|
||||
productEntity: ScramImplanter
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 6 # it's a gamble that may kill you easily so 6 TC per 2 uses, second one more of a backup
|
||||
categories:
|
||||
@@ -1065,6 +1260,9 @@
|
||||
description: uplink-dna-scrambler-implanter-desc
|
||||
icon: { sprite: /Textures/Mobs/Species/Human/parts.rsi, state: full }
|
||||
productEntity: DnaScramblerImplanter
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 5
|
||||
categories:
|
||||
@@ -1076,6 +1274,9 @@
|
||||
description: uplink-emp-implanter-desc
|
||||
icon: { sprite: /Textures/Objects/Magic/magicactions.rsi, state: shield }
|
||||
productEntity: EmpImplanter
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -1143,6 +1344,9 @@
|
||||
description: uplink-uplink-implanter-desc
|
||||
icon: { sprite: /Textures/Objects/Devices/communication.rsi, state: old-radio }
|
||||
productEntity: UplinkImplanter
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -1179,6 +1383,9 @@
|
||||
name: uplink-black-jetpack-name
|
||||
description: uplink-black-jetpack-desc
|
||||
productEntity: JetpackBlackFilled
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -1189,6 +1396,9 @@
|
||||
name: uplink-voice-mask-name
|
||||
description: uplink-voice-mask-desc
|
||||
productEntity: ClothingMaskGasVoiceChameleon
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -1220,6 +1430,9 @@
|
||||
description: uplink-chameleon-desc
|
||||
productEntity: ClothingBackpackChameleonFill
|
||||
icon: { sprite: /Textures/Clothing/Uniforms/Jumpsuit/rainbow.rsi, state: icon }
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -1230,6 +1443,9 @@
|
||||
name: uplink-clothing-no-slips-shoes-name
|
||||
description: uplink-clothing-no-slips-shoes-desc
|
||||
productEntity: ClothingShoesChameleonNoSlips
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -1240,6 +1456,9 @@
|
||||
name: uplink-clothing-thieving-gloves-name
|
||||
description: uplink-clothing-thieving-gloves-desc
|
||||
productEntity: ThievingGloves
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -1250,6 +1469,9 @@
|
||||
name: uplink-clothing-outer-vest-web-name
|
||||
description: uplink-clothing-outer-vest-web-desc
|
||||
productEntity: ClothingOuterVestWeb
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 3
|
||||
categories:
|
||||
@@ -1260,6 +1482,9 @@
|
||||
name: uplink-clothing-shoes-boots-mag-syndie-name
|
||||
description: uplink-clothing-shoes-boots-mag-syndie-desc
|
||||
productEntity: ClothingShoesBootsMagSyndie
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -1271,6 +1496,9 @@
|
||||
description: uplink-eva-syndie-desc
|
||||
icon: { sprite: /Textures/Clothing/OuterClothing/Suits/eva_syndicate.rsi, state: icon }
|
||||
productEntity: ClothingBackpackDuffelSyndicateEVABundle
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -1282,6 +1510,9 @@
|
||||
description: uplink-hardsuit-carp-desc
|
||||
icon: { sprite: /Textures/Clothing/OuterClothing/Suits/carpsuit.rsi, state: icon }
|
||||
productEntity: ClothingOuterHardsuitCarp
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -1293,6 +1524,9 @@
|
||||
description: uplink-hardsuit-syndie-desc
|
||||
icon: { sprite: /Textures/Clothing/OuterClothing/Hardsuits/syndicate.rsi, state: icon }
|
||||
productEntity: ClothingBackpackDuffelSyndicateHardsuitBundle
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
cost:
|
||||
Telecrystal: 8
|
||||
categories:
|
||||
@@ -1320,6 +1554,9 @@
|
||||
description: uplink-hardsuit-syndieelite-desc
|
||||
icon: { sprite: /Textures/Clothing/OuterClothing/Hardsuits/syndieelite.rsi, state: icon }
|
||||
productEntity: ClothingBackpackDuffelSyndicateEliteHardsuitBundle
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 7
|
||||
cost:
|
||||
Telecrystal: 12
|
||||
categories:
|
||||
@@ -1331,6 +1568,9 @@
|
||||
description: uplink-clothing-outer-hardsuit-juggernaut-desc
|
||||
icon: { sprite: /Textures/Structures/Storage/Crates/syndicate.rsi, state: icon }
|
||||
productEntity: CrateCybersunJuggernautBundle
|
||||
discountCategory: veryRareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 8
|
||||
cost:
|
||||
Telecrystal: 12
|
||||
categories:
|
||||
@@ -1398,6 +1638,9 @@
|
||||
name: uplink-revolver-cap-gun-name
|
||||
description: uplink-revolver-cap-gun-desc
|
||||
productEntity: RevolverCapGun
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -1408,6 +1651,9 @@
|
||||
name: uplink-syndicate-stamp-name
|
||||
description: uplink-syndicate-stamp-desc
|
||||
productEntity: RubberStampSyndicate
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -1530,6 +1776,9 @@
|
||||
name: uplink-gatfruit-seeds-name
|
||||
description: uplink-gatfruit-seeds-desc
|
||||
productEntity: GatfruitSeeds
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -1544,6 +1793,9 @@
|
||||
name: uplink-rigged-boxing-gloves-name
|
||||
description: uplink-rigged-boxing-gloves-desc
|
||||
productEntity: ClothingHandsGlovesBoxingRigged
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -1558,6 +1810,9 @@
|
||||
name: uplink-rigged-boxing-gloves-name
|
||||
description: uplink-rigged-boxing-gloves-desc
|
||||
productEntity: ClothingHandsGlovesBoxingRigged
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -1572,6 +1827,9 @@
|
||||
name: uplink-necronomicon-name
|
||||
description: uplink-necronomicon-desc
|
||||
productEntity: BibleNecronomicon
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -1590,6 +1848,9 @@
|
||||
name: uplink-holy-hand-grenade-name
|
||||
description: uplink-holy-hand-grenade-desc
|
||||
productEntity: HolyHandGrenade
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 14
|
||||
cost:
|
||||
Telecrystal: 20
|
||||
categories:
|
||||
@@ -1604,6 +1865,9 @@
|
||||
name: uplink-revolver-cap-gun-fake-name
|
||||
description: uplink-revolver-cap-gun-fake-desc
|
||||
productEntity: RevolverCapGunFake
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 5
|
||||
cost:
|
||||
Telecrystal: 9
|
||||
categories:
|
||||
@@ -1620,6 +1884,9 @@
|
||||
description: uplink-banana-peel-explosive-desc
|
||||
icon: { sprite: Objects/Specific/Hydroponics/banana.rsi, state: peel }
|
||||
productEntity: TrashBananaPeelExplosiveUnarmed
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 1
|
||||
cost:
|
||||
Telecrystal: 2
|
||||
categories:
|
||||
@@ -1634,6 +1901,9 @@
|
||||
name: uplink-cluster-banana-peel-name
|
||||
description: uplink-cluster-banana-peel-desc
|
||||
productEntity: ClusterBananaPeel
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 3
|
||||
cost:
|
||||
Telecrystal: 6
|
||||
categories:
|
||||
@@ -1649,6 +1919,9 @@
|
||||
description: uplink-holoclown-kit-desc
|
||||
icon: { sprite: /Textures/Objects/Fun/figurines.rsi, state: holoclown }
|
||||
productEntity: BoxHoloclown
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 6
|
||||
cost:
|
||||
Telecrystal: 12
|
||||
categories:
|
||||
@@ -1662,6 +1935,9 @@
|
||||
id: uplinkHotPotato
|
||||
name: uplink-hot-potato-name
|
||||
description: uplink-hot-potato-desc
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
productEntity: HotPotato
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
@@ -1680,6 +1956,9 @@
|
||||
name: uplink-chimp-upgrade-kit-name
|
||||
description: uplink-chimp-upgrade-kit-desc
|
||||
productEntity: WeaponPistolCHIMPUpgradeKit
|
||||
discountCategory: usualDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 4
|
||||
categories:
|
||||
@@ -1694,6 +1973,9 @@
|
||||
name: uplink-proximity-mine-name
|
||||
description: uplink-proximity-mine-desc
|
||||
productEntity: WetFloorSignMineExplosive
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 5 # was 4, with my buff made it 5 to be closer to minibomb -panzer
|
||||
categories:
|
||||
@@ -1712,6 +1994,9 @@
|
||||
name: uplink-syndicate-sponge-box-name
|
||||
description: uplink-syndicate-sponge-box-desc
|
||||
icon: { sprite: Objects/Misc/monkeycube.rsi, state: box}
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 4
|
||||
productEntity: SyndicateSpongeBox
|
||||
cost:
|
||||
Telecrystal: 7
|
||||
@@ -1731,6 +2016,9 @@
|
||||
description: uplink-cane-blade-desc
|
||||
icon: { sprite: Objects/Weapons/Melee/cane.rsi, state: cane}
|
||||
productEntity: CaneSheathFilled
|
||||
discountCategory: rareDiscounts
|
||||
discountDownTo:
|
||||
Telecrystal: 2
|
||||
cost:
|
||||
Telecrystal: 5
|
||||
categories:
|
||||
|
||||
@@ -94,3 +94,7 @@
|
||||
id: RevenantAbilities
|
||||
name: store-category-abilities
|
||||
|
||||
- type: storeCategory
|
||||
id: DiscountedItems
|
||||
name: store-discounted-items
|
||||
priority: 200
|
||||
|
||||
Reference in New Issue
Block a user