diff --git a/Content.Client/Store/Ui/StoreBoundUserInterface.cs b/Content.Client/Store/Ui/StoreBoundUserInterface.cs
index 6774ef35a0..b549918d7c 100644
--- a/Content.Client/Store/Ui/StoreBoundUserInterface.cs
+++ b/Content.Client/Store/Ui/StoreBoundUserInterface.cs
@@ -48,6 +48,11 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
{
SendMessage(new StoreRequestUpdateInterfaceMessage());
};
+
+ _menu.OnRefundAttempt += (_) =>
+ {
+ SendMessage(new StoreRequestRefundMessage());
+ };
}
protected override void UpdateState(BoundUserInterfaceState state)
{
@@ -64,6 +69,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
_menu.UpdateListing(msg.Listings.ToList());
_menu.SetFooterVisibility(msg.ShowFooter);
+ _menu.UpdateRefund(msg.AllowRefund);
break;
case StoreInitializeState msg:
_windowName = msg.Name;
diff --git a/Content.Client/Store/Ui/StoreMenu.xaml b/Content.Client/Store/Ui/StoreMenu.xaml
index a454e3e2b7..4b38352a44 100644
--- a/Content.Client/Store/Ui/StoreMenu.xaml
+++ b/Content.Client/Store/Ui/StoreMenu.xaml
@@ -22,6 +22,11 @@
MinWidth="64"
HorizontalAlignment="Right"
Text="{Loc 'store-ui-default-withdraw-text'}" />
+
diff --git a/Content.Client/Store/Ui/StoreMenu.xaml.cs b/Content.Client/Store/Ui/StoreMenu.xaml.cs
index d938dbfe54..5dc1ab246b 100644
--- a/Content.Client/Store/Ui/StoreMenu.xaml.cs
+++ b/Content.Client/Store/Ui/StoreMenu.xaml.cs
@@ -31,6 +31,7 @@ public sealed partial class StoreMenu : DefaultWindow
public event Action? OnCategoryButtonPressed;
public event Action? OnWithdrawAttempt;
public event Action? OnRefreshButtonPressed;
+ public event Action? OnRefundAttempt;
public Dictionary Balance = new();
public string CurrentCategory = string.Empty;
@@ -44,6 +45,8 @@ public sealed partial class StoreMenu : DefaultWindow
WithdrawButton.OnButtonDown += OnWithdrawButtonDown;
RefreshButton.OnButtonDown += OnRefreshButtonDown;
+ RefundButton.OnButtonDown += OnRefundButtonDown;
+
if (Window != null)
Window.Title = name;
}
@@ -116,6 +119,11 @@ public sealed partial class StoreMenu : DefaultWindow
_withdrawWindow.OnWithdrawAttempt += OnWithdrawAttempt;
}
+ private void OnRefundButtonDown(BaseButton.ButtonEventArgs args)
+ {
+ OnRefundAttempt?.Invoke(args);
+ }
+
private void AddListingGui(ListingData listing)
{
if (!listing.Categories.Contains(CurrentCategory))
@@ -262,6 +270,11 @@ public sealed partial class StoreMenu : DefaultWindow
_withdrawWindow?.Close();
}
+ public void UpdateRefund(bool allowRefund)
+ {
+ RefundButton.Disabled = !allowRefund;
+ }
+
private sealed class StoreCategoryButton : Button
{
public string? Id;
diff --git a/Content.Server/Store/Components/StoreComponent.cs b/Content.Server/Store/Components/StoreComponent.cs
index 54f8c9ee15..063e25fbf9 100644
--- a/Content.Server/Store/Components/StoreComponent.cs
+++ b/Content.Server/Store/Components/StoreComponent.cs
@@ -1,6 +1,7 @@
using Content.Shared.FixedPoint;
using Content.Shared.Store;
using Robust.Shared.Audio;
+using Robust.Shared.Map;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set;
@@ -59,6 +60,30 @@ public sealed partial class StoreComponent : Component
[ViewVariables]
public HashSet LastAvailableListings = new();
+ ///
+ /// All current entities bought from this shop. Useful for keeping track of refunds and upgrades.
+ ///
+ [ViewVariables, DataField]
+ public List BoughtEntities = new();
+
+ ///
+ /// The total balance spent in this store. Used for refunds.
+ ///
+ [ViewVariables, DataField]
+ public Dictionary BalanceSpent = new();
+
+ ///
+ /// Controls if the store allows refunds
+ ///
+ [ViewVariables, DataField]
+ public bool RefundAllowed;
+
+ ///
+ /// The map the store was originally from, used to block refunds if the map is changed
+ ///
+ [DataField]
+ public EntityUid? StartingMap;
+
#region audio
///
/// The sound played to the buyer when a purchase is succesfully made.
@@ -78,3 +103,17 @@ public readonly record struct StoreAddedEvent;
///
[ByRefEvent]
public readonly record struct StoreRemovedEvent;
+
+///
+/// Broadcast when an Entity with the is deleted
+///
+[ByRefEvent]
+public readonly struct RefundEntityDeletedEvent
+{
+ public EntityUid Uid { get; }
+
+ public RefundEntityDeletedEvent(EntityUid uid)
+ {
+ Uid = uid;
+ }
+}
diff --git a/Content.Server/Store/StoreRefundComponent.cs b/Content.Server/Store/StoreRefundComponent.cs
new file mode 100644
index 0000000000..1a6b17c5ea
--- /dev/null
+++ b/Content.Server/Store/StoreRefundComponent.cs
@@ -0,0 +1,13 @@
+using Content.Server.Store.Systems;
+
+namespace Content.Server.Store.Components;
+
+///
+/// Keeps track of entities bought from stores for refunds, especially useful if entities get deleted before they can be refunded.
+///
+[RegisterComponent, Access(typeof(StoreSystem))]
+public sealed partial class StoreRefundComponent : Component
+{
+ [ViewVariables, DataField]
+ public EntityUid? StoreEntity;
+}
diff --git a/Content.Server/Store/Systems/StoreSystem.Refund.cs b/Content.Server/Store/Systems/StoreSystem.Refund.cs
new file mode 100644
index 0000000000..c42c79475e
--- /dev/null
+++ b/Content.Server/Store/Systems/StoreSystem.Refund.cs
@@ -0,0 +1,56 @@
+using Content.Server.Actions;
+using Content.Server.Store.Components;
+using Content.Shared.Actions;
+using Robust.Shared.Containers;
+
+namespace Content.Server.Store.Systems;
+
+public sealed partial class StoreSystem
+{
+ private void InitializeRefund()
+ {
+ SubscribeLocalEvent(OnStoreTerminating);
+ SubscribeLocalEvent(OnRefundTerminating);
+ SubscribeLocalEvent(OnEntityRemoved);
+ SubscribeLocalEvent(OnEntityInserted);
+ }
+
+ private void OnEntityRemoved(EntityUid uid, StoreRefundComponent component, EntRemovedFromContainerMessage args)
+ {
+ if (component.StoreEntity == null || _actions.TryGetActionData(uid, out _) || !TryComp(component.StoreEntity.Value, out var storeComp))
+ return;
+
+ DisableRefund(component.StoreEntity.Value, storeComp);
+ }
+
+ private void OnEntityInserted(EntityUid uid, StoreRefundComponent component, EntInsertedIntoContainerMessage args)
+ {
+ if (component.StoreEntity == null || _actions.TryGetActionData(uid, out _) || !TryComp(component.StoreEntity.Value, out var storeComp))
+ return;
+
+ DisableRefund(component.StoreEntity.Value, storeComp);
+ }
+
+ private void OnStoreTerminating(Entity ent, ref EntityTerminatingEvent args)
+ {
+ if (ent.Comp.BoughtEntities.Count <= 0)
+ return;
+
+ foreach (var boughtEnt in ent.Comp.BoughtEntities)
+ {
+ if (!TryComp(boughtEnt, out var refundComp))
+ continue;
+
+ refundComp.StoreEntity = null;
+ }
+ }
+
+ private void OnRefundTerminating(Entity ent, ref EntityTerminatingEvent args)
+ {
+ if (ent.Comp.StoreEntity == null)
+ return;
+
+ var ev = new RefundEntityDeletedEvent(ent);
+ RaiseLocalEvent(ent.Comp.StoreEntity.Value, ref ev);
+ }
+}
diff --git a/Content.Server/Store/Systems/StoreSystem.Ui.cs b/Content.Server/Store/Systems/StoreSystem.Ui.cs
index 7599b08b3c..a7490fd27f 100644
--- a/Content.Server/Store/Systems/StoreSystem.Ui.cs
+++ b/Content.Server/Store/Systems/StoreSystem.Ui.cs
@@ -4,11 +4,13 @@ using Content.Server.Administration.Logs;
using Content.Server.PDA.Ringer;
using Content.Server.Stack;
using Content.Server.Store.Components;
-using Content.Shared.UserInterface;
+using Content.Shared.Actions;
using Content.Shared.Database;
using Content.Shared.FixedPoint;
using Content.Shared.Hands.EntitySystems;
+using Content.Shared.Mind;
using Content.Shared.Store;
+using Content.Shared.UserInterface;
using Robust.Server.GameObjects;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Player;
@@ -20,6 +22,9 @@ public sealed partial class StoreSystem
[Dependency] private readonly IAdminLogManager _admin = default!;
[Dependency] private readonly SharedHandsSystem _hands = default!;
[Dependency] private readonly ActionsSystem _actions = default!;
+ [Dependency] private readonly ActionContainerSystem _actionContainer = default!;
+ [Dependency] private readonly ActionUpgradeSystem _actionUpgrade = default!;
+ [Dependency] private readonly SharedMindSystem _mind = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly StackSystem _stack = default!;
[Dependency] private readonly UserInterfaceSystem _ui = default!;
@@ -29,6 +34,13 @@ public sealed partial class StoreSystem
SubscribeLocalEvent(OnRequestUpdate);
SubscribeLocalEvent(OnBuyRequest);
SubscribeLocalEvent(OnRequestWithdraw);
+ SubscribeLocalEvent(OnRequestRefund);
+ SubscribeLocalEvent(OnRefundEntityDeleted);
+ }
+
+ private void OnRefundEntityDeleted(Entity ent, ref RefundEntityDeletedEvent args)
+ {
+ ent.Comp.BoughtEntities.Remove(args.Uid);
}
///
@@ -98,7 +110,7 @@ public sealed partial class StoreSystem
// only tell operatives to lock their uplink if it can be locked
var showFooter = HasComp(store);
- var state = new StoreUpdateState(component.LastAvailableListings, allCurrency, showFooter);
+ var state = new StoreUpdateState(component.LastAvailableListings, allCurrency, showFooter, component.RefundAllowed);
_ui.SetUiState(ui, state);
}
@@ -118,6 +130,7 @@ public sealed partial class StoreSystem
private void OnBuyRequest(EntityUid uid, StoreComponent component, StoreBuyListingMessage msg)
{
var listing = component.Listings.FirstOrDefault(x => x.Equals(msg.Listing));
+
if (listing == null) //make sure this listing actually exists
{
Log.Debug("listing does not exist");
@@ -149,10 +162,20 @@ public sealed partial class StoreSystem
return;
}
}
+
+ if (!IsOnStartingMap(uid, component))
+ component.RefundAllowed = false;
+ else
+ component.RefundAllowed = true;
+
//subtract the cash
- foreach (var currency in listing.Cost)
+ foreach (var (currency, value) in listing.Cost)
{
- component.Balance[currency.Key] -= currency.Value;
+ component.Balance[currency] -= value;
+
+ component.BalanceSpent.TryAdd(currency, FixedPoint2.Zero);
+
+ component.BalanceSpent[currency] += value;
}
//spawn entity
@@ -160,13 +183,71 @@ public sealed partial class StoreSystem
{
var product = Spawn(listing.ProductEntity, Transform(buyer).Coordinates);
_hands.PickupOrDrop(buyer, product);
+
+ HandleRefundComp(uid, component, product);
+
+ var xForm = Transform(product);
+
+ if (xForm.ChildCount > 0)
+ {
+ var childEnumerator = xForm.ChildEnumerator;
+ while (childEnumerator.MoveNext(out var child))
+ {
+ component.BoughtEntities.Add(child);
+ }
+ }
}
//give action
if (!string.IsNullOrWhiteSpace(listing.ProductAction))
{
+ EntityUid? actionId;
// I guess we just allow duplicate actions?
- _actions.AddAction(buyer, listing.ProductAction);
+ // Allow duplicate actions and just have a single list buy for the buy-once ones.
+ if (!_mind.TryGetMind(buyer, out var mind, out _))
+ actionId = _actions.AddAction(buyer, listing.ProductAction);
+ else
+ actionId = _actionContainer.AddAction(mind, listing.ProductAction);
+
+ // Add the newly bought action entity to the list of bought entities
+ // And then add that action entity to the relevant product upgrade listing, if applicable
+ if (actionId != null)
+ {
+ HandleRefundComp(uid, component, actionId.Value);
+
+ if (listing.ProductUpgradeID != null)
+ {
+ foreach (var upgradeListing in component.Listings)
+ {
+ if (upgradeListing.ID == listing.ProductUpgradeID)
+ {
+ upgradeListing.ProductActionEntity = actionId.Value;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if (listing is { ProductUpgradeID: not null, ProductActionEntity: not null })
+ {
+ if (listing.ProductActionEntity != null)
+ {
+ component.BoughtEntities.Remove(listing.ProductActionEntity.Value);
+ }
+
+ if (!_actionUpgrade.TryUpgradeAction(listing.ProductActionEntity, out var upgradeActionId))
+ {
+ if (listing.ProductActionEntity != null)
+ HandleRefundComp(uid, component, listing.ProductActionEntity.Value);
+
+ return;
+ }
+
+ listing.ProductActionEntity = upgradeActionId;
+
+ if (upgradeActionId != null)
+ HandleRefundComp(uid, component, upgradeActionId.Value);
}
//broadcast event
@@ -225,4 +306,71 @@ public sealed partial class StoreSystem
component.Balance[msg.Currency] -= msg.Amount;
UpdateUserInterface(buyer, uid, component);
}
+
+ private void OnRequestRefund(EntityUid uid, StoreComponent component, StoreRequestRefundMessage args)
+ {
+ // TODO: Remove guardian/holopara
+
+ if (args.Session.AttachedEntity is not { Valid: true } buyer)
+ return;
+
+ if (!IsOnStartingMap(uid, component))
+ {
+ component.RefundAllowed = false;
+ UpdateUserInterface(buyer, uid, component);
+ }
+
+ if (!component.RefundAllowed || component.BoughtEntities.Count == 0)
+ return;
+
+ for (var i = component.BoughtEntities.Count; i >= 0; i--)
+ {
+ var purchase = component.BoughtEntities[i];
+
+ if (!Exists(purchase))
+ continue;
+
+ component.BoughtEntities.RemoveAt(i);
+
+ if (_actions.TryGetActionData(purchase, out var actionComponent))
+ {
+ _actionContainer.RemoveAction(purchase, actionComponent);
+ }
+
+ EntityManager.DeleteEntity(purchase);
+ }
+
+ foreach (var (currency, value) in component.BalanceSpent)
+ {
+ component.Balance[currency] += value;
+ }
+ // Reset store back to its original state
+ RefreshAllListings(component);
+ component.BalanceSpent = new();
+ UpdateUserInterface(buyer, uid, component);
+ }
+
+ private void HandleRefundComp(EntityUid uid, StoreComponent component, EntityUid purchase)
+ {
+ component.BoughtEntities.Add(purchase);
+ var refundComp = EnsureComp(purchase);
+ refundComp.StoreEntity = uid;
+ }
+
+ private bool IsOnStartingMap(EntityUid store, StoreComponent component)
+ {
+ var xform = Transform(store);
+ return component.StartingMap == xform.MapUid;
+ }
+
+ ///
+ /// Disables refunds for this store
+ ///
+ public void DisableRefund(EntityUid store, StoreComponent? component = null)
+ {
+ if (!Resolve(store, ref component))
+ return;
+
+ component.RefundAllowed = false;
+ }
}
diff --git a/Content.Server/Store/Systems/StoreSystem.cs b/Content.Server/Store/Systems/StoreSystem.cs
index 67fbd4faf5..8ce1f9bb83 100644
--- a/Content.Server/Store/Systems/StoreSystem.cs
+++ b/Content.Server/Store/Systems/StoreSystem.cs
@@ -36,12 +36,14 @@ public sealed partial class StoreSystem : EntitySystem
InitializeUi();
InitializeCommand();
+ InitializeRefund();
}
private void OnMapInit(EntityUid uid, StoreComponent component, MapInitEvent args)
{
RefreshAllListings(component);
InitializeFromPreset(component.Preset, uid, component);
+ component.StartingMap = Transform(uid).MapUid;
}
private void OnStartup(EntityUid uid, StoreComponent component, ComponentStartup args)
diff --git a/Content.Shared/Store/ListingPrototype.cs b/Content.Shared/Store/ListingPrototype.cs
index b0f72e6dfe..5dccc25337 100644
--- a/Content.Shared/Store/ListingPrototype.cs
+++ b/Content.Shared/Store/ListingPrototype.cs
@@ -2,6 +2,7 @@ using System.Linq;
using Content.Shared.FixedPoint;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.TypeSerializers.Implementations;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
@@ -77,6 +78,20 @@ public partial class ListingData : IEquatable, ICloneable
[DataField("productAction", customTypeSerializer: typeof(PrototypeIdSerializer))]
public string? ProductAction;
+ ///
+ /// The listing ID of the related upgrade listing. Can be used to link a to an
+ /// upgrade or to use standalone as an upgrade
+ ///
+ [DataField]
+ public ProtoId? ProductUpgradeID;
+
+ ///
+ /// Keeps track of the current action entity this is tied to, for action upgrades
+ ///
+ [DataField]
+ [NonSerialized]
+ public EntityUid? ProductActionEntity;
+
///
/// The event that is broadcast when the listing is purchased.
///
@@ -105,6 +120,7 @@ public partial class ListingData : IEquatable, ICloneable
Description != listing.Description ||
ProductEntity != listing.ProductEntity ||
ProductAction != listing.ProductAction ||
+ ProductActionEntity != listing.ProductActionEntity ||
ProductEvent != listing.ProductEvent ||
RestockTime != listing.RestockTime)
return false;
@@ -146,6 +162,8 @@ public partial class ListingData : IEquatable, ICloneable
Priority = Priority,
ProductEntity = ProductEntity,
ProductAction = ProductAction,
+ ProductUpgradeID = ProductUpgradeID,
+ ProductActionEntity = ProductActionEntity,
ProductEvent = ProductEvent,
PurchaseAmount = PurchaseAmount,
RestockTime = RestockTime,
diff --git a/Content.Shared/Store/StoreUi.cs b/Content.Shared/Store/StoreUi.cs
index a142cf4e4f..27a8ada185 100644
--- a/Content.Shared/Store/StoreUi.cs
+++ b/Content.Shared/Store/StoreUi.cs
@@ -18,11 +18,14 @@ public sealed class StoreUpdateState : BoundUserInterfaceState
public readonly bool ShowFooter;
- public StoreUpdateState(HashSet listings, Dictionary balance, bool showFooter)
+ public readonly bool AllowRefund;
+
+ public StoreUpdateState(HashSet listings, Dictionary balance, bool showFooter, bool allowRefund)
{
Listings = listings;
Balance = balance;
ShowFooter = showFooter;
+ AllowRefund = allowRefund;
}
}
@@ -72,3 +75,12 @@ public sealed class StoreRequestWithdrawMessage : BoundUserInterfaceMessage
Amount = amount;
}
}
+
+///
+/// Used when the refund button is pressed
+///
+[Serializable, NetSerializable]
+public sealed class StoreRequestRefundMessage : BoundUserInterfaceMessage
+{
+
+}