Adds a refund button & action upgrades for stores (#24518)

* adds refunds to stores

* Adds method to check for starting map

* comments, datafields, some requested changes

* turns event into ref event

* Adds datafields

* Switches to entity terminating event

* Changes store entity to be nullable and checks if store is terminating to remove reference.

* Tryadd instead of containskey

* Adds a refund disable method, disables refund on bought ent container changes if not an action

* Removes datafield specification

* Readds missing using statement

* Removes unused using statements

* What the heck is listing data

---------

Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
This commit is contained in:
keronshb
2024-02-03 19:48:51 -05:00
committed by GitHub
parent c15b0691ec
commit 257909fd97
10 changed files with 318 additions and 6 deletions

View File

@@ -48,6 +48,11 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
{ {
SendMessage(new StoreRequestUpdateInterfaceMessage()); SendMessage(new StoreRequestUpdateInterfaceMessage());
}; };
_menu.OnRefundAttempt += (_) =>
{
SendMessage(new StoreRequestRefundMessage());
};
} }
protected override void UpdateState(BoundUserInterfaceState state) protected override void UpdateState(BoundUserInterfaceState state)
{ {
@@ -64,6 +69,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
_menu.UpdateListing(msg.Listings.ToList()); _menu.UpdateListing(msg.Listings.ToList());
_menu.SetFooterVisibility(msg.ShowFooter); _menu.SetFooterVisibility(msg.ShowFooter);
_menu.UpdateRefund(msg.AllowRefund);
break; break;
case StoreInitializeState msg: case StoreInitializeState msg:
_windowName = msg.Name; _windowName = msg.Name;

View File

@@ -22,6 +22,11 @@
MinWidth="64" MinWidth="64"
HorizontalAlignment="Right" HorizontalAlignment="Right"
Text="{Loc 'store-ui-default-withdraw-text'}" /> Text="{Loc 'store-ui-default-withdraw-text'}" />
<Button
Name="RefundButton"
MinWidth="64"
HorizontalAlignment="Right"
Text="Refund" />
</BoxContainer> </BoxContainer>
<PanelContainer VerticalExpand="True"> <PanelContainer VerticalExpand="True">
<PanelContainer.PanelOverride> <PanelContainer.PanelOverride>

View File

@@ -31,6 +31,7 @@ public sealed partial class StoreMenu : DefaultWindow
public event Action<BaseButton.ButtonEventArgs, string>? OnCategoryButtonPressed; public event Action<BaseButton.ButtonEventArgs, string>? OnCategoryButtonPressed;
public event Action<BaseButton.ButtonEventArgs, string, int>? OnWithdrawAttempt; public event Action<BaseButton.ButtonEventArgs, string, int>? OnWithdrawAttempt;
public event Action<BaseButton.ButtonEventArgs>? OnRefreshButtonPressed; public event Action<BaseButton.ButtonEventArgs>? OnRefreshButtonPressed;
public event Action<BaseButton.ButtonEventArgs>? OnRefundAttempt;
public Dictionary<string, FixedPoint2> Balance = new(); public Dictionary<string, FixedPoint2> Balance = new();
public string CurrentCategory = string.Empty; public string CurrentCategory = string.Empty;
@@ -44,6 +45,8 @@ public sealed partial class StoreMenu : DefaultWindow
WithdrawButton.OnButtonDown += OnWithdrawButtonDown; WithdrawButton.OnButtonDown += OnWithdrawButtonDown;
RefreshButton.OnButtonDown += OnRefreshButtonDown; RefreshButton.OnButtonDown += OnRefreshButtonDown;
RefundButton.OnButtonDown += OnRefundButtonDown;
if (Window != null) if (Window != null)
Window.Title = name; Window.Title = name;
} }
@@ -116,6 +119,11 @@ public sealed partial class StoreMenu : DefaultWindow
_withdrawWindow.OnWithdrawAttempt += OnWithdrawAttempt; _withdrawWindow.OnWithdrawAttempt += OnWithdrawAttempt;
} }
private void OnRefundButtonDown(BaseButton.ButtonEventArgs args)
{
OnRefundAttempt?.Invoke(args);
}
private void AddListingGui(ListingData listing) private void AddListingGui(ListingData listing)
{ {
if (!listing.Categories.Contains(CurrentCategory)) if (!listing.Categories.Contains(CurrentCategory))
@@ -262,6 +270,11 @@ public sealed partial class StoreMenu : DefaultWindow
_withdrawWindow?.Close(); _withdrawWindow?.Close();
} }
public void UpdateRefund(bool allowRefund)
{
RefundButton.Disabled = !allowRefund;
}
private sealed class StoreCategoryButton : Button private sealed class StoreCategoryButton : Button
{ {
public string? Id; public string? Id;

View File

@@ -1,6 +1,7 @@
using Content.Shared.FixedPoint; using Content.Shared.FixedPoint;
using Content.Shared.Store; using Content.Shared.Store;
using Robust.Shared.Audio; using Robust.Shared.Audio;
using Robust.Shared.Map;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; 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.Dictionary;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set;
@@ -59,6 +60,30 @@ public sealed partial class StoreComponent : Component
[ViewVariables] [ViewVariables]
public HashSet<ListingData> LastAvailableListings = new(); public HashSet<ListingData> LastAvailableListings = new();
/// <summary>
/// All current entities bought from this shop. Useful for keeping track of refunds and upgrades.
/// </summary>
[ViewVariables, DataField]
public List<EntityUid> BoughtEntities = new();
/// <summary>
/// The total balance spent in this store. Used for refunds.
/// </summary>
[ViewVariables, DataField]
public Dictionary<string, FixedPoint2> BalanceSpent = new();
/// <summary>
/// Controls if the store allows refunds
/// </summary>
[ViewVariables, DataField]
public bool RefundAllowed;
/// <summary>
/// The map the store was originally from, used to block refunds if the map is changed
/// </summary>
[DataField]
public EntityUid? StartingMap;
#region audio #region audio
/// <summary> /// <summary>
/// The sound played to the buyer when a purchase is succesfully made. /// The sound played to the buyer when a purchase is succesfully made.
@@ -78,3 +103,17 @@ public readonly record struct StoreAddedEvent;
/// </summary> /// </summary>
[ByRefEvent] [ByRefEvent]
public readonly record struct StoreRemovedEvent; public readonly record struct StoreRemovedEvent;
/// <summary>
/// Broadcast when an Entity with the <see cref="StoreRefundComponent"/> is deleted
/// </summary>
[ByRefEvent]
public readonly struct RefundEntityDeletedEvent
{
public EntityUid Uid { get; }
public RefundEntityDeletedEvent(EntityUid uid)
{
Uid = uid;
}
}

View File

@@ -0,0 +1,13 @@
using Content.Server.Store.Systems;
namespace Content.Server.Store.Components;
/// <summary>
/// Keeps track of entities bought from stores for refunds, especially useful if entities get deleted before they can be refunded.
/// </summary>
[RegisterComponent, Access(typeof(StoreSystem))]
public sealed partial class StoreRefundComponent : Component
{
[ViewVariables, DataField]
public EntityUid? StoreEntity;
}

View File

@@ -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<StoreComponent, EntityTerminatingEvent>(OnStoreTerminating);
SubscribeLocalEvent<StoreRefundComponent, EntityTerminatingEvent>(OnRefundTerminating);
SubscribeLocalEvent<StoreRefundComponent, EntRemovedFromContainerMessage>(OnEntityRemoved);
SubscribeLocalEvent<StoreRefundComponent, EntInsertedIntoContainerMessage>(OnEntityInserted);
}
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))
return;
DisableRefund(component.StoreEntity.Value, storeComp);
}
private void OnEntityInserted(EntityUid uid, StoreRefundComponent component, EntInsertedIntoContainerMessage args)
{
if (component.StoreEntity == null || _actions.TryGetActionData(uid, out _) || !TryComp<StoreComponent>(component.StoreEntity.Value, out var storeComp))
return;
DisableRefund(component.StoreEntity.Value, storeComp);
}
private void OnStoreTerminating(Entity<StoreComponent> ent, ref EntityTerminatingEvent args)
{
if (ent.Comp.BoughtEntities.Count <= 0)
return;
foreach (var boughtEnt in ent.Comp.BoughtEntities)
{
if (!TryComp<StoreRefundComponent>(boughtEnt, out var refundComp))
continue;
refundComp.StoreEntity = null;
}
}
private void OnRefundTerminating(Entity<StoreRefundComponent> ent, ref EntityTerminatingEvent args)
{
if (ent.Comp.StoreEntity == null)
return;
var ev = new RefundEntityDeletedEvent(ent);
RaiseLocalEvent(ent.Comp.StoreEntity.Value, ref ev);
}
}

View File

@@ -4,11 +4,13 @@ using Content.Server.Administration.Logs;
using Content.Server.PDA.Ringer; using Content.Server.PDA.Ringer;
using Content.Server.Stack; using Content.Server.Stack;
using Content.Server.Store.Components; using Content.Server.Store.Components;
using Content.Shared.UserInterface; using Content.Shared.Actions;
using Content.Shared.Database; using Content.Shared.Database;
using Content.Shared.FixedPoint; using Content.Shared.FixedPoint;
using Content.Shared.Hands.EntitySystems; using Content.Shared.Hands.EntitySystems;
using Content.Shared.Mind;
using Content.Shared.Store; using Content.Shared.Store;
using Content.Shared.UserInterface;
using Robust.Server.GameObjects; using Robust.Server.GameObjects;
using Robust.Shared.Audio.Systems; using Robust.Shared.Audio.Systems;
using Robust.Shared.Player; using Robust.Shared.Player;
@@ -20,6 +22,9 @@ public sealed partial class StoreSystem
[Dependency] private readonly IAdminLogManager _admin = default!; [Dependency] private readonly IAdminLogManager _admin = default!;
[Dependency] private readonly SharedHandsSystem _hands = default!; [Dependency] private readonly SharedHandsSystem _hands = default!;
[Dependency] private readonly ActionsSystem _actions = 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 SharedAudioSystem _audio = default!;
[Dependency] private readonly StackSystem _stack = default!; [Dependency] private readonly StackSystem _stack = default!;
[Dependency] private readonly UserInterfaceSystem _ui = default!; [Dependency] private readonly UserInterfaceSystem _ui = default!;
@@ -29,6 +34,13 @@ public sealed partial class StoreSystem
SubscribeLocalEvent<StoreComponent, StoreRequestUpdateInterfaceMessage>(OnRequestUpdate); SubscribeLocalEvent<StoreComponent, StoreRequestUpdateInterfaceMessage>(OnRequestUpdate);
SubscribeLocalEvent<StoreComponent, StoreBuyListingMessage>(OnBuyRequest); SubscribeLocalEvent<StoreComponent, StoreBuyListingMessage>(OnBuyRequest);
SubscribeLocalEvent<StoreComponent, StoreRequestWithdrawMessage>(OnRequestWithdraw); SubscribeLocalEvent<StoreComponent, StoreRequestWithdrawMessage>(OnRequestWithdraw);
SubscribeLocalEvent<StoreComponent, StoreRequestRefundMessage>(OnRequestRefund);
SubscribeLocalEvent<StoreComponent, RefundEntityDeletedEvent>(OnRefundEntityDeleted);
}
private void OnRefundEntityDeleted(Entity<StoreComponent> ent, ref RefundEntityDeletedEvent args)
{
ent.Comp.BoughtEntities.Remove(args.Uid);
} }
/// <summary> /// <summary>
@@ -98,7 +110,7 @@ public sealed partial class StoreSystem
// only tell operatives to lock their uplink if it can be locked // only tell operatives to lock their uplink if it can be locked
var showFooter = HasComp<RingerUplinkComponent>(store); var showFooter = HasComp<RingerUplinkComponent>(store);
var state = new StoreUpdateState(component.LastAvailableListings, allCurrency, showFooter); var state = new StoreUpdateState(component.LastAvailableListings, allCurrency, showFooter, component.RefundAllowed);
_ui.SetUiState(ui, state); _ui.SetUiState(ui, state);
} }
@@ -118,6 +130,7 @@ public sealed partial class StoreSystem
private void OnBuyRequest(EntityUid uid, StoreComponent component, StoreBuyListingMessage msg) private void OnBuyRequest(EntityUid uid, StoreComponent component, StoreBuyListingMessage msg)
{ {
var listing = component.Listings.FirstOrDefault(x => x.Equals(msg.Listing)); var listing = component.Listings.FirstOrDefault(x => x.Equals(msg.Listing));
if (listing == null) //make sure this listing actually exists if (listing == null) //make sure this listing actually exists
{ {
Log.Debug("listing does not exist"); Log.Debug("listing does not exist");
@@ -149,10 +162,20 @@ public sealed partial class StoreSystem
return; return;
} }
} }
if (!IsOnStartingMap(uid, component))
component.RefundAllowed = false;
else
component.RefundAllowed = true;
//subtract the cash //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 //spawn entity
@@ -160,13 +183,71 @@ public sealed partial class StoreSystem
{ {
var product = Spawn(listing.ProductEntity, Transform(buyer).Coordinates); var product = Spawn(listing.ProductEntity, Transform(buyer).Coordinates);
_hands.PickupOrDrop(buyer, product); _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 //give action
if (!string.IsNullOrWhiteSpace(listing.ProductAction)) if (!string.IsNullOrWhiteSpace(listing.ProductAction))
{ {
EntityUid? actionId;
// I guess we just allow duplicate actions? // 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 //broadcast event
@@ -225,4 +306,71 @@ public sealed partial class StoreSystem
component.Balance[msg.Currency] -= msg.Amount; component.Balance[msg.Currency] -= msg.Amount;
UpdateUserInterface(buyer, uid, component); 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<StoreRefundComponent>(purchase);
refundComp.StoreEntity = uid;
}
private bool IsOnStartingMap(EntityUid store, StoreComponent component)
{
var xform = Transform(store);
return component.StartingMap == xform.MapUid;
}
/// <summary>
/// Disables refunds for this store
/// </summary>
public void DisableRefund(EntityUid store, StoreComponent? component = null)
{
if (!Resolve(store, ref component))
return;
component.RefundAllowed = false;
}
} }

View File

@@ -36,12 +36,14 @@ public sealed partial class StoreSystem : EntitySystem
InitializeUi(); InitializeUi();
InitializeCommand(); InitializeCommand();
InitializeRefund();
} }
private void OnMapInit(EntityUid uid, StoreComponent component, MapInitEvent args) private void OnMapInit(EntityUid uid, StoreComponent component, MapInitEvent args)
{ {
RefreshAllListings(component); RefreshAllListings(component);
InitializeFromPreset(component.Preset, uid, component); InitializeFromPreset(component.Preset, uid, component);
component.StartingMap = Transform(uid).MapUid;
} }
private void OnStartup(EntityUid uid, StoreComponent component, ComponentStartup args) private void OnStartup(EntityUid uid, StoreComponent component, ComponentStartup args)

View File

@@ -2,6 +2,7 @@ using System.Linq;
using Content.Shared.FixedPoint; using Content.Shared.FixedPoint;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
using Robust.Shared.Serialization; 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;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
@@ -77,6 +78,20 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
[DataField("productAction", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))] [DataField("productAction", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string? ProductAction; public string? ProductAction;
/// <summary>
/// The listing ID of the related upgrade listing. Can be used to link a <see cref="ProductAction"/> to an
/// upgrade or to use standalone as an upgrade
/// </summary>
[DataField]
public ProtoId<ListingPrototype>? ProductUpgradeID;
/// <summary>
/// Keeps track of the current action entity this is tied to, for action upgrades
/// </summary>
[DataField]
[NonSerialized]
public EntityUid? ProductActionEntity;
/// <summary> /// <summary>
/// The event that is broadcast when the listing is purchased. /// The event that is broadcast when the listing is purchased.
/// </summary> /// </summary>
@@ -105,6 +120,7 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
Description != listing.Description || Description != listing.Description ||
ProductEntity != listing.ProductEntity || ProductEntity != listing.ProductEntity ||
ProductAction != listing.ProductAction || ProductAction != listing.ProductAction ||
ProductActionEntity != listing.ProductActionEntity ||
ProductEvent != listing.ProductEvent || ProductEvent != listing.ProductEvent ||
RestockTime != listing.RestockTime) RestockTime != listing.RestockTime)
return false; return false;
@@ -146,6 +162,8 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
Priority = Priority, Priority = Priority,
ProductEntity = ProductEntity, ProductEntity = ProductEntity,
ProductAction = ProductAction, ProductAction = ProductAction,
ProductUpgradeID = ProductUpgradeID,
ProductActionEntity = ProductActionEntity,
ProductEvent = ProductEvent, ProductEvent = ProductEvent,
PurchaseAmount = PurchaseAmount, PurchaseAmount = PurchaseAmount,
RestockTime = RestockTime, RestockTime = RestockTime,

View File

@@ -18,11 +18,14 @@ public sealed class StoreUpdateState : BoundUserInterfaceState
public readonly bool ShowFooter; public readonly bool ShowFooter;
public StoreUpdateState(HashSet<ListingData> listings, Dictionary<string, FixedPoint2> balance, bool showFooter) public readonly bool AllowRefund;
public StoreUpdateState(HashSet<ListingData> listings, Dictionary<string, FixedPoint2> balance, bool showFooter, bool allowRefund)
{ {
Listings = listings; Listings = listings;
Balance = balance; Balance = balance;
ShowFooter = showFooter; ShowFooter = showFooter;
AllowRefund = allowRefund;
} }
} }
@@ -72,3 +75,12 @@ public sealed class StoreRequestWithdrawMessage : BoundUserInterfaceMessage
Amount = amount; Amount = amount;
} }
} }
/// <summary>
/// Used when the refund button is pressed
/// </summary>
[Serializable, NetSerializable]
public sealed class StoreRequestRefundMessage : BoundUserInterfaceMessage
{
}