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

View File

@@ -22,6 +22,11 @@
MinWidth="64"
HorizontalAlignment="Right"
Text="{Loc 'store-ui-default-withdraw-text'}" />
<Button
Name="RefundButton"
MinWidth="64"
HorizontalAlignment="Right"
Text="Refund" />
</BoxContainer>
<PanelContainer VerticalExpand="True">
<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, int>? OnWithdrawAttempt;
public event Action<BaseButton.ButtonEventArgs>? OnRefreshButtonPressed;
public event Action<BaseButton.ButtonEventArgs>? OnRefundAttempt;
public Dictionary<string, FixedPoint2> 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;

View File

@@ -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<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
/// <summary>
/// The sound played to the buyer when a purchase is succesfully made.
@@ -78,3 +103,17 @@ public readonly record struct StoreAddedEvent;
/// </summary>
[ByRefEvent]
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.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<StoreComponent, StoreRequestUpdateInterfaceMessage>(OnRequestUpdate);
SubscribeLocalEvent<StoreComponent, StoreBuyListingMessage>(OnBuyRequest);
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>
@@ -98,7 +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);
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<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();
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)

View File

@@ -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<ListingData>, ICloneable
[DataField("productAction", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
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>
/// The event that is broadcast when the listing is purchased.
/// </summary>
@@ -105,6 +120,7 @@ public partial class ListingData : IEquatable<ListingData>, 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<ListingData>, ICloneable
Priority = Priority,
ProductEntity = ProductEntity,
ProductAction = ProductAction,
ProductUpgradeID = ProductUpgradeID,
ProductActionEntity = ProductActionEntity,
ProductEvent = ProductEvent,
PurchaseAmount = PurchaseAmount,
RestockTime = RestockTime,

View File

@@ -18,11 +18,14 @@ public sealed class StoreUpdateState : BoundUserInterfaceState
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;
Balance = balance;
ShowFooter = showFooter;
AllowRefund = allowRefund;
}
}
@@ -72,3 +75,12 @@ public sealed class StoreRequestWithdrawMessage : BoundUserInterfaceMessage
Amount = amount;
}
}
/// <summary>
/// Used when the refund button is pressed
/// </summary>
[Serializable, NetSerializable]
public sealed class StoreRequestRefundMessage : BoundUserInterfaceMessage
{
}