Make reagent dispensers gridinv-based instead of pseudo-listinv (#34205)

This simplifies the code and makes the experience of examining contents
easier without the reagent dispenser UI, as well as adding the possibility
for dispensers to have items of heterogeneous sizes in them, which would
allow configuring reagent dispensers to accept smaller containers such
as beakers or vials in order to allow for more types of smaller quantities
of reagents, or other flexibilities brought by using a standard storage
component.
This commit is contained in:
pathetic meowmeow
2025-05-09 23:49:05 -04:00
committed by GitHub
parent 942b2b4dcb
commit 5a0e0524ca
13 changed files with 154 additions and 216 deletions

View File

@@ -1,4 +1,5 @@
using Content.Shared.Chemistry;
using Content.Shared.Storage;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
@@ -9,15 +10,15 @@ namespace Content.Client.Chemistry.UI;
[GenerateTypedNameReferences]
public sealed partial class ReagentCardControl : Control
{
public string StorageSlotId { get; }
public Action<string>? OnPressed;
public Action<string>? OnEjectButtonPressed;
public ItemStorageLocation StorageLocation { get; }
public Action<ItemStorageLocation>? OnPressed;
public Action<ItemStorageLocation>? OnEjectButtonPressed;
public ReagentCardControl(ReagentInventoryItem item)
{
RobustXamlLoader.Load(this);
StorageSlotId = item.StorageSlotId;
StorageLocation = item.StorageLocation;
ColorPanel.PanelOverride = new StyleBoxFlat { BackgroundColor = item.ReagentColor };
ReagentNameLabel.Text = item.ReagentLabel;
FillLabel.Text = Loc.GetString("reagent-dispenser-window-quantity-label-text", ("quantity", item.Quantity));;
@@ -26,7 +27,7 @@ public sealed partial class ReagentCardControl : Control
if (item.Quantity == 0.0)
MainButton.Disabled = true;
MainButton.OnPressed += args => OnPressed?.Invoke(StorageSlotId);
EjectButton.OnPressed += args => OnEjectButtonPressed?.Invoke(StorageSlotId);
MainButton.OnPressed += args => OnPressed?.Invoke(StorageLocation);
EjectButton.OnPressed += args => OnEjectButtonPressed?.Invoke(StorageLocation);
}
}

View File

@@ -40,8 +40,8 @@ namespace Content.Client.Chemistry.UI
_window.AmountGrid.OnButtonPressed += s => SendMessage(new ReagentDispenserSetDispenseAmountMessage(s));
_window.OnDispenseReagentButtonPressed += (id) => SendMessage(new ReagentDispenserDispenseReagentMessage(id));
_window.OnEjectJugButtonPressed += (id) => SendMessage(new ItemSlotButtonPressedEvent(id));
_window.OnDispenseReagentButtonPressed += (location) => SendMessage(new ReagentDispenserDispenseReagentMessage(location));
_window.OnEjectJugButtonPressed += (location) => SendMessage(new ReagentDispenserEjectContainerMessage(location));
}
/// <summary>

View File

@@ -2,6 +2,7 @@ using Content.Client.Stylesheets;
using Content.Client.UserInterface.Controls;
using Content.Shared.Chemistry;
using Content.Shared.Chemistry.Reagent;
using Content.Shared.Storage;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
@@ -18,8 +19,8 @@ namespace Content.Client.Chemistry.UI
{
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
public event Action<string>? OnDispenseReagentButtonPressed;
public event Action<string>? OnEjectJugButtonPressed;
public event Action<ItemStorageLocation>? OnDispenseReagentButtonPressed;
public event Action<ItemStorageLocation>? OnEjectJugButtonPressed;
/// <summary>
/// Create and initialize the dispenser UI client-side. Creates the basic layout,

View File

@@ -2,7 +2,6 @@ using Content.Shared.Whitelist;
using Content.Shared.Containers.ItemSlots;
using Content.Server.Chemistry.EntitySystems;
using Content.Shared.Chemistry;
using Content.Shared.Chemistry.Dispenser;
using Robust.Shared.Audio;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
@@ -15,47 +14,9 @@ namespace Content.Server.Chemistry.Components
[Access(typeof(ReagentDispenserSystem))]
public sealed partial class ReagentDispenserComponent : Component
{
/// <summary>
/// String with the pack name that stores the initial fill of the dispenser. The initial
/// fill is added to the dispenser on MapInit. Note that we don't use ContainerFill because
/// we have to generate the storage slots at MapInit first, then fill them.
/// </summary>
[DataField("pack", customTypeSerializer:typeof(PrototypeIdSerializer<ReagentDispenserInventoryPrototype>))]
[ViewVariables(VVAccess.ReadWrite)]
public string? PackPrototypeId = default!;
/// <summary>
/// Maximum number of internal storage slots. Dispenser can't store (or dispense) more than
/// this many chemicals (without unloading and reloading).
/// </summary>
[DataField("numStorageSlots")]
public int NumSlots = 25;
/// <summary>
/// For each created storage slot for the reagent containers being dispensed, apply this
/// entity whitelist. Makes sure weird containers don't fit in the dispenser and that beakers
/// don't accidentally get slotted into the source slots.
/// </summary>
[DataField]
public EntityWhitelist? StorageWhitelist;
[DataField]
public ItemSlot BeakerSlot = new();
/// <summary>
/// Prefix for automatically-generated slot name for storage, up to NumSlots.
/// </summary>
public static string BaseStorageSlotId = "ReagentDispenser-storageSlot";
/// <summary>
/// List of storage slots that were created at MapInit.
/// </summary>
[DataField]
public List<string> StorageSlotIds = new List<string>();
[DataField]
public List<ItemSlot> StorageSlots = new List<ItemSlot>();
[DataField("clickSound"), ViewVariables(VVAccess.ReadWrite)]
public SoundSpecifier ClickSound = new SoundPathSpecifier("/Audio/Machines/machine_switch.ogg");

View File

@@ -1,11 +1,12 @@
using System.Linq;
using Content.Server.Chemistry.Components;
using Content.Server.Chemistry.Containers.EntitySystems;
using Content.Shared.Chemistry;
using Content.Shared.Chemistry.Dispenser;
using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Containers.ItemSlots;
using Content.Shared.FixedPoint;
using Content.Shared.Nutrition.EntitySystems;
using Content.Shared.Storage.EntitySystems;
using JetBrains.Annotations;
using Robust.Server.Audio;
using Robust.Server.GameObjects;
@@ -13,6 +14,8 @@ using Robust.Shared.Audio;
using Robust.Shared.Containers;
using Robust.Shared.Prototypes;
using Content.Shared.Labels.Components;
using Content.Shared.Storage;
using Content.Server.Hands.Systems;
namespace Content.Server.Chemistry.EntitySystems
{
@@ -30,6 +33,7 @@ namespace Content.Server.Chemistry.EntitySystems
[Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly OpenableSystem _openable = default!;
[Dependency] private readonly HandsSystem _handsSystem = default!;
public override void Initialize()
{
@@ -37,12 +41,13 @@ namespace Content.Server.Chemistry.EntitySystems
SubscribeLocalEvent<ReagentDispenserComponent, ComponentStartup>(SubscribeUpdateUiState);
SubscribeLocalEvent<ReagentDispenserComponent, SolutionContainerChangedEvent>(SubscribeUpdateUiState);
SubscribeLocalEvent<ReagentDispenserComponent, EntInsertedIntoContainerMessage>(SubscribeUpdateUiState);
SubscribeLocalEvent<ReagentDispenserComponent, EntRemovedFromContainerMessage>(SubscribeUpdateUiState);
SubscribeLocalEvent<ReagentDispenserComponent, EntInsertedIntoContainerMessage>(SubscribeUpdateUiState, after: [typeof(SharedStorageSystem)]);
SubscribeLocalEvent<ReagentDispenserComponent, EntRemovedFromContainerMessage>(SubscribeUpdateUiState, after: [typeof(SharedStorageSystem)]);
SubscribeLocalEvent<ReagentDispenserComponent, BoundUIOpenedEvent>(SubscribeUpdateUiState);
SubscribeLocalEvent<ReagentDispenserComponent, ReagentDispenserSetDispenseAmountMessage>(OnSetDispenseAmountMessage);
SubscribeLocalEvent<ReagentDispenserComponent, ReagentDispenserDispenseReagentMessage>(OnDispenseReagentMessage);
SubscribeLocalEvent<ReagentDispenserComponent, ReagentDispenserEjectContainerMessage>(OnEjectReagentMessage);
SubscribeLocalEvent<ReagentDispenserComponent, ReagentDispenserClearContainerSolutionMessage>(OnClearContainerSolutionMessage);
SubscribeLocalEvent<ReagentDispenserComponent, MapInitEvent>(OnMapInit, before: new []{typeof(ItemSlotsSystem)});
@@ -82,32 +87,31 @@ namespace Content.Server.Chemistry.EntitySystems
private List<ReagentInventoryItem> GetInventory(Entity<ReagentDispenserComponent> reagentDispenser)
{
if (!TryComp<StorageComponent>(reagentDispenser.Owner, out var storage))
{
return [];
}
var inventory = new List<ReagentInventoryItem>();
for (var i = 0; i < reagentDispenser.Comp.NumSlots; i++)
foreach (var (storedContainer, storageLocation) in storage.StoredItems)
{
var storageSlotId = ReagentDispenserComponent.BaseStorageSlotId + i;
var storedContainer = _itemSlotsSystem.GetItemOrNull(reagentDispenser.Owner, storageSlotId);
// Set label from manually-applied label, or metadata if unavailable
string reagentLabel;
if (TryComp<LabelComponent>(storedContainer, out var label) && !string.IsNullOrEmpty(label.CurrentLabel))
reagentLabel = label.CurrentLabel;
else if (storedContainer != null)
reagentLabel = Name(storedContainer.Value);
else
continue;
reagentLabel = Name(storedContainer);
// Get volume remaining and color of solution
FixedPoint2 quantity = 0f;
var reagentColor = Color.White;
if (storedContainer != null && _solutionContainerSystem.TryGetDrainableSolution(storedContainer.Value, out _, out var sol))
if (_solutionContainerSystem.TryGetDrainableSolution(storedContainer, out _, out var sol))
{
quantity = sol.Volume;
reagentColor = sol.GetColor(_prototypeManager);
}
inventory.Add(new ReagentInventoryItem(storageSlotId, reagentLabel, quantity, reagentColor));
inventory.Add(new ReagentInventoryItem(storageLocation, reagentLabel, quantity, reagentColor));
}
return inventory;
@@ -122,8 +126,14 @@ namespace Content.Server.Chemistry.EntitySystems
private void OnDispenseReagentMessage(Entity<ReagentDispenserComponent> reagentDispenser, ref ReagentDispenserDispenseReagentMessage message)
{
if (!TryComp<StorageComponent>(reagentDispenser.Owner, out var storage))
{
return;
}
// Ensure that the reagent is something this reagent dispenser can dispense.
var storedContainer = _itemSlotsSystem.GetItemOrNull(reagentDispenser, message.SlotId);
var storageLocation = message.StorageLocation;
var storedContainer = storage.StoredItems.FirstOrDefault(kvp => kvp.Value == storageLocation).Key;
if (storedContainer == null)
return;
@@ -131,13 +141,13 @@ namespace Content.Server.Chemistry.EntitySystems
if (outputContainer is not { Valid: true } || !_solutionContainerSystem.TryGetFitsInDispenser(outputContainer.Value, out var solution, out _))
return;
if (_solutionContainerSystem.TryGetDrainableSolution(storedContainer.Value, out var src, out _) &&
if (_solutionContainerSystem.TryGetDrainableSolution(storedContainer, out var src, out _) &&
_solutionContainerSystem.TryGetRefillableSolution(outputContainer.Value, out var dst, out _))
{
// force open container, if applicable, to avoid confusing people on why it doesn't dispense
_openable.SetOpen(storedContainer.Value, true);
_openable.SetOpen(storedContainer, true);
_solutionTransferSystem.Transfer(reagentDispenser,
storedContainer.Value, src.Value,
storedContainer, src.Value,
outputContainer.Value, dst.Value,
(int)reagentDispenser.Comp.DispenseAmount);
}
@@ -146,6 +156,21 @@ namespace Content.Server.Chemistry.EntitySystems
ClickSound(reagentDispenser);
}
private void OnEjectReagentMessage(Entity<ReagentDispenserComponent> reagentDispenser, ref ReagentDispenserEjectContainerMessage message)
{
if (!TryComp<StorageComponent>(reagentDispenser.Owner, out var storage))
{
return;
}
var storageLocation = message.StorageLocation;
var storedContainer = storage.StoredItems.FirstOrDefault(kvp => kvp.Value == storageLocation).Key;
if (storedContainer == null)
return;
_handsSystem.TryPickupAnyHand(message.Actor, storedContainer);
}
private void OnClearContainerSolutionMessage(Entity<ReagentDispenserComponent> reagentDispenser, ref ReagentDispenserClearContainerSolutionMessage message)
{
var outputContainer = _itemSlotsSystem.GetItemOrNull(reagentDispenser, SharedReagentDispenser.OutputSlotName);
@@ -163,39 +188,11 @@ namespace Content.Server.Chemistry.EntitySystems
}
/// <summary>
/// Automatically generate storage slots for all NumSlots, and fill them with their initial chemicals.
/// The actual spawning of entities happens in ItemSlotsSystem's MapInit.
/// Initializes the beaker slot
/// </summary>
private void OnMapInit(EntityUid uid, ReagentDispenserComponent component, MapInitEvent args)
private void OnMapInit(Entity<ReagentDispenserComponent> ent, ref MapInitEvent args)
{
// Get list of pre-loaded containers
List<string> preLoad = new List<string>();
if (component.PackPrototypeId is not null
&& _prototypeManager.TryIndex(component.PackPrototypeId, out ReagentDispenserInventoryPrototype? packPrototype))
{
preLoad.AddRange(packPrototype.Inventory);
}
// Populate storage slots with base storage slot whitelist
for (var i = 0; i < component.NumSlots; i++)
{
var storageSlotId = ReagentDispenserComponent.BaseStorageSlotId + i;
ItemSlot storageComponent = new();
storageComponent.Whitelist = component.StorageWhitelist;
storageComponent.Swap = false;
storageComponent.EjectOnBreak = true;
// Check corresponding index in pre-loaded container (if exists) and set starting item
if (i < preLoad.Count)
storageComponent.StartingItem = preLoad[i];
component.StorageSlotIds.Add(storageSlotId);
component.StorageSlots.Add(storageComponent);
component.StorageSlots[i].Name = "Storage Slot " + (i+1);
_itemSlotsSystem.AddItemSlot(uid, component.StorageSlotIds[i], component.StorageSlots[i]);
}
_itemSlotsSystem.AddItemSlot(uid, SharedReagentDispenser.OutputSlotName, component.BeakerSlot);
_itemSlotsSystem.AddItemSlot(ent.Owner, SharedReagentDispenser.OutputSlotName, ent.Comp.BeakerSlot);
}
}
}

View File

@@ -1,23 +0,0 @@
using Content.Shared.Chemistry.Reagent;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
namespace Content.Shared.Chemistry.Dispenser
{
/// <summary>
/// Is simply a list of reagents defined in yaml. This can then be set as a
/// <see cref="SharedReagentDispenserComponent"/>s <c>pack</c> value (also in yaml),
/// to define which reagents it's able to dispense. Based off of how vending
/// machines define their inventory.
/// </summary>
[Serializable, NetSerializable, Prototype]
public sealed partial class ReagentDispenserInventoryPrototype : IPrototype
{
[DataField("inventory", customTypeSerializer: typeof(PrototypeIdListSerializer<EntityPrototype>))]
public List<string> Inventory = new();
[ViewVariables, IdDataField]
public string ID { get; private set; } = default!;
}
}

View File

@@ -1,5 +1,6 @@
using Content.Shared.Chemistry.Reagent;
using Content.Shared.FixedPoint;
using Content.Shared.Storage;
using Robust.Shared.Serialization;
namespace Content.Shared.Chemistry
@@ -66,11 +67,25 @@ namespace Content.Shared.Chemistry
[Serializable, NetSerializable]
public sealed class ReagentDispenserDispenseReagentMessage : BoundUserInterfaceMessage
{
public readonly string SlotId;
public readonly ItemStorageLocation StorageLocation;
public ReagentDispenserDispenseReagentMessage(string slotId)
public ReagentDispenserDispenseReagentMessage(ItemStorageLocation storageLocation)
{
SlotId = slotId;
StorageLocation = storageLocation;
}
}
/// <summary>
/// Message sent by the user interface to ask the reagent dispenser to eject a container
/// </summary>
[Serializable, NetSerializable]
public sealed class ReagentDispenserEjectContainerMessage : BoundUserInterfaceMessage
{
public readonly ItemStorageLocation StorageLocation;
public ReagentDispenserEjectContainerMessage(ItemStorageLocation storageLocation)
{
StorageLocation = storageLocation;
}
}
@@ -94,9 +109,9 @@ namespace Content.Shared.Chemistry
}
[Serializable, NetSerializable]
public sealed class ReagentInventoryItem(string storageSlotId, string reagentLabel, FixedPoint2 quantity, Color reagentColor)
public sealed class ReagentInventoryItem(ItemStorageLocation storageLocation, string reagentLabel, FixedPoint2 quantity, Color reagentColor)
{
public string StorageSlotId = storageSlotId;
public ItemStorageLocation StorageLocation = storageLocation;
public string ReagentLabel = reagentLabel;
public FixedPoint2 Quantity = quantity;
public Color ReagentColor = reagentColor;

View File

@@ -1,38 +0,0 @@
- type: reagentDispenserInventory
id: SodaDispenserInventory
inventory:
- DrinkCoconutWaterJug
- DrinkCoffeeJug
- DrinkColaBottleFull
- DrinkCreamCartonXL
- DrinkDrGibbJug
- DrinkEnergyDrinkJug
- DrinkGreenTeaJug
- DrinkIceJug
- DrinkJuiceLimeCartonXL
- DrinkJuiceOrangeCartonXL
- DrinkLemonLimeJug
- DrinkRootBeerJug
- DrinkSodaWaterBottleFull
- DrinkSpaceMountainWindBottleFull
- DrinkSpaceUpBottleFull
- DrinkSugarJug
- DrinkTeaJug
- DrinkTonicWaterBottleFull
- DrinkWaterMelonJuiceJug
- type: reagentDispenserInventory
id: BoozeDispenserInventory
inventory:
- DrinkAleBottleFullGrowler
- DrinkBeerGrowler
- DrinkCoffeeLiqueurBottleFull
- DrinkCognacBottleFull
- DrinkGinBottleFull
- DrinkMeadJug
- DrinkRumBottleFull
- DrinkTequilaBottleFull
- DrinkVermouthBottleFull
- DrinkVodkaBottleFull
- DrinkWhiskeyBottleFull
- DrinkWineBottleFull

View File

@@ -1,26 +0,0 @@
- type: reagentDispenserInventory
id: ChemDispenserStandardInventory
inventory:
- JugAluminium
- JugCarbon
- JugChlorine
- JugCopper
- JugEthanol
- JugFluorine
- JugSugar
- JugHydrogen
- JugIodine
- JugIron
- JugLithium
- JugMercury
- JugNitrogen
- JugOxygen
- JugPhosphorus
- JugPotassium
- JugRadium
- JugSilicon
- JugSodium
- JugSulfur
- type: reagentDispenserInventory
id: EmptyInventory

View File

@@ -32,6 +32,8 @@
interfaces:
enum.ReagentDispenserUiKey.Key:
type: ReagentDispenserBoundUserInterface
enum.StorageUiKey.Key:
type: StorageBoundUserInterface
- type: Anchorable
- type: Pullable
- type: Damageable
@@ -54,10 +56,11 @@
- !type:PlaySoundBehavior
sound:
collection: MetalGlassBreak
- type: Storage
maxItemSize: Normal
grid:
- 0,0,19,5
- type: ReagentDispenser
storageWhitelist:
tags:
- Bottle
beakerSlot:
whitelistFailPopup: reagent-dispenser-component-cannot-put-entity-message
whitelist:
@@ -70,6 +73,7 @@
machine_board: !type:Container
machine_parts: !type:Container
beakerSlot: !type:ContainerSlot
storagebase: !type:Container
- type: StaticPrice
price: 1000
- type: WiresPanel

View File

@@ -10,11 +10,24 @@
sprite: Structures/smalldispensers.rsi
drawdepth: SmallObjects
state: booze
- type: ReagentDispenser
storageWhitelist:
- type: Storage
whitelist:
tags:
- DrinkBottle
pack: BoozeDispenserInventory
- type: StorageFill
contents:
- id: DrinkAleBottleFullGrowler
- id: DrinkBeerGrowler
- id: DrinkCoffeeLiqueurBottleFull
- id: DrinkCognacBottleFull
- id: DrinkGinBottleFull
- id: DrinkMeadJug
- id: DrinkRumBottleFull
- id: DrinkTequilaBottleFull
- id: DrinkVermouthBottleFull
- id: DrinkVodkaBottleFull
- id: DrinkWhiskeyBottleFull
- id: DrinkWineBottleFull
- type: Transform
noRot: false
- type: Machine
@@ -31,8 +44,7 @@
suffix: Empty
parent: BoozeDispenser
components:
- type: ReagentDispenser
storageWhitelist:
- type: Storage
whitelist:
tags:
- DrinkBottle
pack: EmptyInventory

View File

@@ -1,7 +1,7 @@
- type: entity
id: ChemDispenser
id: ChemDispenserEmpty
name: chemical dispenser
suffix: Filled
suffix: Empty
parent: ReagentDispenserBase
description: An industrial grade chemical dispenser.
components:
@@ -9,11 +9,10 @@
sprite: Structures/dispensers.rsi
state: industrial-working
snapCardinals: true
- type: ReagentDispenser
storageWhitelist:
- type: Storage
whitelist:
tags:
- ChemDispensable
pack: ChemDispenserStandardInventory
- type: ApcPowerReceiver
- type: ExtensionCableReceiver
- type: Destructible
@@ -50,10 +49,31 @@
- MachineLayer
- type: entity
id: ChemDispenserEmpty
id: ChemDispenser
name: chemical dispenser
suffix: Empty
parent: ChemDispenser
suffix: Filled
parent: ChemDispenserEmpty
components:
- type: ReagentDispenser
pack: EmptyInventory
- type: StorageFill
contents:
- id: JugAluminium
- id: JugCarbon
- id: JugChlorine
- id: JugCopper
- id: JugEthanol
- id: JugFluorine
- id: JugSugar
- id: JugHydrogen
- id: JugIodine
- id: JugIron
- id: JugLithium
- id: JugMercury
- id: JugNitrogen
- id: JugOxygen
- id: JugPhosphorus
- id: JugPotassium
- id: JugRadium
- id: JugSilicon
- id: JugSodium
- id: JugSulfur

View File

@@ -10,11 +10,31 @@
sprite: Structures/smalldispensers.rsi
drawdepth: SmallObjects
state: soda
- type: ReagentDispenser
storageWhitelist:
- type: Storage
whitelist:
tags:
- DrinkBottle
pack: SodaDispenserInventory
- type: StorageFill
contents:
- id: DrinkCoconutWaterJug
- id: DrinkCoffeeJug
- id: DrinkColaBottleFull
- id: DrinkCreamCartonXL
- id: DrinkDrGibbJug
- id: DrinkEnergyDrinkJug
- id: DrinkGreenTeaJug
- id: DrinkIceJug
- id: DrinkJuiceLimeCartonXL
- id: DrinkJuiceOrangeCartonXL
- id: DrinkLemonLimeJug
- id: DrinkRootBeerJug
- id: DrinkSodaWaterBottleFull
- id: DrinkSpaceMountainWindBottleFull
- id: DrinkSpaceUpBottleFull
- id: DrinkSugarJug
- id: DrinkTeaJug
- id: DrinkTonicWaterBottleFull
- id: DrinkWaterMelonJuiceJug
- type: Transform
noRot: false
- type: Machine
@@ -28,9 +48,3 @@
parent: SodaDispenser
id: SodaDispenserEmpty
suffix: Empty
components:
- type: ReagentDispenser
storageWhitelist:
tags:
- DrinkBottle
pack: EmptyInventory