using System.Diagnostics.CodeAnalysis; using Content.Shared.ActionBlocker; using Content.Shared.Administration.Logs; using Content.Shared.Database; using Content.Shared.Destructible; using Content.Shared.Hands.Components; using Content.Shared.Hands.EntitySystems; using Content.Shared.Interaction; using Content.Shared.Interaction.Events; using Content.Shared.Popups; using Content.Shared.Verbs; using Content.Shared.Whitelist; using Robust.Shared.Audio.Systems; using Robust.Shared.Containers; using Robust.Shared.GameStates; using Robust.Shared.Utility; namespace Content.Shared.Containers.ItemSlots { /// /// A class that handles interactions related to inserting/ejecting items into/from an item slot. /// /// /// Note when using popups on entities with many slots with InsertOnInteract, EjectOnInteract or EjectOnUse: /// A single use will try to insert to/eject from every slot and generate a popup for each that fails. /// public sealed partial class ItemSlotsSystem : EntitySystem { [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; [Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!; [Dependency] private readonly SharedContainerSystem _containers = default!; [Dependency] private readonly SharedPopupSystem _popupSystem = default!; [Dependency] private readonly SharedHandsSystem _handsSystem = default!; [Dependency] private readonly SharedAudioSystem _audioSystem = default!; [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!; public override void Initialize() { base.Initialize(); InitializeLock(); SubscribeLocalEvent(OnMapInit); SubscribeLocalEvent(Oninitialize); SubscribeLocalEvent(OnInteractUsing); SubscribeLocalEvent(OnInteractHand); SubscribeLocalEvent(OnUseInHand); SubscribeLocalEvent>(AddAlternativeVerbs); SubscribeLocalEvent>(AddInteractionVerbsVerbs); SubscribeLocalEvent(OnBreak); SubscribeLocalEvent(OnBreak); SubscribeLocalEvent(GetItemSlotsState); SubscribeLocalEvent(HandleItemSlotsState); SubscribeLocalEvent(HandleButtonPressed); } #region ComponentManagement /// /// Spawn in starting items for any item slots that should have one. /// private void OnMapInit(EntityUid uid, ItemSlotsComponent itemSlots, MapInitEvent args) { foreach (var slot in itemSlots.Slots.Values) { if (slot.HasItem || string.IsNullOrEmpty(slot.StartingItem)) continue; var item = Spawn(slot.StartingItem, Transform(uid).Coordinates); if (slot.ContainerSlot != null) _containers.Insert(item, slot.ContainerSlot); } } /// /// Ensure item slots have containers. /// private void Oninitialize(EntityUid uid, ItemSlotsComponent itemSlots, ComponentInit args) { foreach (var (id, slot) in itemSlots.Slots) { slot.ContainerSlot = _containers.EnsureContainer(uid, id); } } /// /// Given a new item slot, store it in the and ensure the slot has an item /// container. /// public void AddItemSlot(EntityUid uid, string id, ItemSlot slot, ItemSlotsComponent? itemSlots = null) { itemSlots ??= EnsureComp(uid); DebugTools.AssertOwner(uid, itemSlots); if (itemSlots.Slots.TryGetValue(id, out var existing)) { if (existing.Local) Log.Error( $"Duplicate item slot key. Entity: {Comp(uid).EntityName} ({uid}), key: {id}"); else // server state takes priority slot.CopyFrom(existing); } slot.ContainerSlot = _containers.EnsureContainer(uid, id); itemSlots.Slots[id] = slot; Dirty(uid, itemSlots); } /// /// Remove an item slot. This should generally be called whenever a component that added a slot is being /// removed. /// public void RemoveItemSlot(EntityUid uid, ItemSlot slot, ItemSlotsComponent? itemSlots = null) { if (Terminating(uid) || slot.ContainerSlot == null) return; _containers.ShutdownContainer(slot.ContainerSlot); // Don't log missing resolves. when an entity has all of its components removed, the ItemSlotsComponent may // have been removed before some other component that added an item slot (and is now trying to remove it). if (!Resolve(uid, ref itemSlots, logMissing: false)) return; itemSlots.Slots.Remove(slot.ContainerSlot.ID); if (itemSlots.Slots.Count == 0) RemComp(uid, itemSlots); else Dirty(uid, itemSlots); } public bool TryGetSlot(EntityUid uid, string slotId, [NotNullWhen(true)] out ItemSlot? itemSlot, ItemSlotsComponent? component = null) { itemSlot = null; if (!Resolve(uid, ref component)) return false; return component.Slots.TryGetValue(slotId, out itemSlot); } #endregion #region Interactions /// /// Attempt to take an item from a slot, if any are set to EjectOnInteract. /// private void OnInteractHand(EntityUid uid, ItemSlotsComponent itemSlots, InteractHandEvent args) { if (args.Handled) return; foreach (var slot in itemSlots.Slots.Values) { if (!slot.EjectOnInteract || slot.Item == null || !CanEject(uid, args.User, slot, popup: args.User)) continue; args.Handled = true; TryEjectToHands(uid, slot, args.User, true); break; } } /// /// Attempt to eject an item from the first valid item slot. /// private void OnUseInHand(EntityUid uid, ItemSlotsComponent itemSlots, UseInHandEvent args) { if (args.Handled) return; foreach (var slot in itemSlots.Slots.Values) { if (!slot.EjectOnUse || slot.Item == null || !CanEject(uid, args.User, slot, popup: args.User)) continue; args.Handled = true; TryEjectToHands(uid, slot, args.User, true); break; } } /// /// Tries to insert a held item in any fitting item slot. If a valid slot already contains an item, it will /// swap it out and place the old one in the user's hand. /// /// /// This only handles the event if the user has an applicable entity that can be inserted. This allows for /// other interactions to still happen (e.g., open UI, or toggle-open), despite the user holding an item. /// Maybe this is undesirable. /// private void OnInteractUsing(EntityUid uid, ItemSlotsComponent itemSlots, InteractUsingEvent args) { if (args.Handled) return; if (!TryComp(args.User, out HandsComponent? hands)) return; if (itemSlots.Slots.Count == 0) return; // If any slot can be inserted into don't show popup. // If any whitelist passes, but slot is locked, then show locked. // If whitelist fails all, show whitelist fail. // valid, insertable slots (if any) var slots = new List(); string? whitelistFailPopup = null; string? lockedFailPopup = null; foreach (var slot in itemSlots.Slots.Values) { if (!slot.InsertOnInteract) continue; if (CanInsert(uid, args.Used, args.User, slot, slot.Swap)) { slots.Add(slot); } else { var allowed = CanInsertWhitelist(args.Used, slot); if (lockedFailPopup == null && slot.LockedFailPopup != null && allowed && slot.Locked) lockedFailPopup = slot.LockedFailPopup; if (whitelistFailPopup == null && slot.WhitelistFailPopup != null) whitelistFailPopup = slot.WhitelistFailPopup; } } if (slots.Count == 0) { // it's a bit weird that the popupMessage is stored with the item slots themselves, but in practice // the popup messages will just all be the same, so it's probably fine. // // doing a check to make sure that they're all the same or something is probably frivolous if (lockedFailPopup != null) _popupSystem.PopupClient(Loc.GetString(lockedFailPopup), uid, args.User); else if (whitelistFailPopup != null) _popupSystem.PopupClient(Loc.GetString(whitelistFailPopup), uid, args.User); return; } // Drop the held item onto the floor. Return if the user cannot drop. if (!_handsSystem.TryDrop(args.User, args.Used)) return; slots.Sort(SortEmpty); foreach (var slot in slots) { if (slot.Item != null) _handsSystem.TryPickupAnyHand(args.User, slot.Item.Value, handsComp: hands); Insert(uid, slot, args.Used, args.User, excludeUserAudio: true); if (slot.InsertSuccessPopup.HasValue) _popupSystem.PopupClient(Loc.GetString(slot.InsertSuccessPopup), uid, args.User); args.Handled = true; return; } } #endregion #region Insert /// /// Insert an item into a slot. This does not perform checks, so make sure to also use or just use instead. /// /// If true, will exclude the user when playing sound. Does nothing client-side. /// Useful for predicted interactions private void Insert(EntityUid uid, ItemSlot slot, EntityUid item, EntityUid? user, bool excludeUserAudio = false) { bool? inserted = slot.ContainerSlot != null ? _containers.Insert(item, slot.ContainerSlot) : null; // ContainerSlot automatically raises a directed EntInsertedIntoContainerMessage // Logging if (inserted != null && inserted.Value && user != null) _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(user.Value)} inserted {ToPrettyString(item)} into {slot.ContainerSlot?.ID + " slot of "}{ToPrettyString(uid)}"); _audioSystem.PlayPredicted(slot.InsertSound, uid, excludeUserAudio ? user : null); } /// /// Check whether a given item can be inserted into a slot. Unless otherwise specified, this will return /// false if the slot is already filled. /// public bool CanInsert(EntityUid uid, EntityUid usedUid, EntityUid? user, ItemSlot slot, bool swap = false) { if (slot.ContainerSlot == null) return false; if (slot.HasItem && (!swap || swap && !CanEject(uid, user, slot))) return false; if (!CanInsertWhitelist(usedUid, slot)) return false; if (slot.Locked) return false; var ev = new ItemSlotInsertAttemptEvent(uid, usedUid, user, slot); RaiseLocalEvent(uid, ref ev); RaiseLocalEvent(usedUid, ref ev); if (ev.Cancelled) { return false; } return _containers.CanInsert(usedUid, slot.ContainerSlot, assumeEmpty: swap); } private bool CanInsertWhitelist(EntityUid usedUid, ItemSlot slot) { if (_whitelistSystem.IsWhitelistFail(slot.Whitelist, usedUid) || _whitelistSystem.IsBlacklistPass(slot.Blacklist, usedUid)) return false; return true; } /// /// Tries to insert item into a specific slot. /// /// False if failed to insert item public bool TryInsert(EntityUid uid, string id, EntityUid item, EntityUid? user, ItemSlotsComponent? itemSlots = null, bool excludeUserAudio = false) { if (!Resolve(uid, ref itemSlots)) return false; if (!itemSlots.Slots.TryGetValue(id, out var slot)) return false; return TryInsert(uid, slot, item, user, excludeUserAudio: excludeUserAudio); } /// /// Tries to insert item into a specific slot. /// /// False if failed to insert item public bool TryInsert(EntityUid uid, ItemSlot slot, EntityUid item, EntityUid? user, bool excludeUserAudio = false) { if (!CanInsert(uid, item, user, slot)) return false; Insert(uid, slot, item, user, excludeUserAudio: excludeUserAudio); return true; } /// /// Tries to insert item into a specific slot from an entity's hand. /// Does not check action blockers. /// /// False if failed to insert item public bool TryInsertFromHand(EntityUid uid, ItemSlot slot, EntityUid user, HandsComponent? hands = null, bool excludeUserAudio = false) { if (!Resolve(user, ref hands, false)) return false; if (!_handsSystem.TryGetActiveItem((user, hands), out var held)) return false; if (!CanInsert(uid, held.Value, user, slot)) return false; // hands.Drop(item) checks CanDrop action blocker if (!_handsSystem.TryDrop(user, hands.ActiveHandId!)) return false; Insert(uid, slot, held.Value, user, excludeUserAudio: excludeUserAudio); return true; } /// /// Tries to insert an item into any empty slot. /// /// The entity that has the item slots. /// The item to be inserted. /// The entity performing the interaction. /// /// If true, will exclude the user when playing sound. Does nothing client-side. /// Useful for predicted interactions /// /// False if failed to insert item public bool TryInsertEmpty(Entity ent, EntityUid item, EntityUid? user, bool excludeUserAudio = false) { if (!Resolve(ent, ref ent.Comp, false)) return false; if (!TryGetAvailableSlot(ent, item, user, out var itemSlot, emptyOnly: true)) return false; if (user != null && !_handsSystem.TryDrop(user.Value, item)) return false; Insert(ent, itemSlot, item, user, excludeUserAudio: excludeUserAudio); return true; } /// /// Tries to get any slot that the can be inserted into. /// /// Entity that is being inserted into. /// Entity being inserted into . /// Entity inserting into . /// The ItemSlot on to insert into. /// True only returns slots that are empty. /// False returns any slot that is able to receive . /// True when a slot is found. Otherwise, false. public bool TryGetAvailableSlot(Entity ent, EntityUid item, Entity? userEnt, [NotNullWhen(true)] out ItemSlot? itemSlot, bool emptyOnly = false) { itemSlot = null; if (userEnt is { } user && Resolve(user, ref user.Comp) && _handsSystem.IsHolding(user, item)) { if (!_handsSystem.CanDrop(user, item)) return false; } if (!Resolve(ent, ref ent.Comp, false)) return false; var slots = new List(); foreach (var slot in ent.Comp.Slots.Values) { if (emptyOnly && slot.ContainerSlot?.ContainedEntity != null) continue; if (CanInsert(ent, item, userEnt, slot)) slots.Add(slot); } if (slots.Count == 0) return false; slots.Sort(SortEmpty); itemSlot = slots[0]; return true; } private static int SortEmpty(ItemSlot a, ItemSlot b) { var aEnt = a.ContainerSlot?.ContainedEntity; var bEnt = b.ContainerSlot?.ContainedEntity; if (aEnt == null && bEnt == null) return a.Priority.CompareTo(b.Priority); if (aEnt == null) return -1; return 1; } #endregion #region Eject /// /// Check whether an ejection from a given slot may happen. /// /// /// If a popup entity is given, this will generate a popup message if any are configured on the the item slot. /// public bool CanEject(EntityUid uid, EntityUid? user, ItemSlot slot, EntityUid? popup = null) { if (slot.Locked) { if (popup.HasValue && slot.LockedFailPopup.HasValue) _popupSystem.PopupClient(Loc.GetString(slot.LockedFailPopup), uid, popup.Value); return false; } if (slot.ContainerSlot?.ContainedEntity is not { } item) return false; var ev = new ItemSlotEjectAttemptEvent(uid, item, user, slot); RaiseLocalEvent(uid, ref ev); RaiseLocalEvent(item, ref ev); if (ev.Cancelled) return false; return _containers.CanRemove(item, slot.ContainerSlot); } /// /// Eject an item from a slot. This does not perform checks (e.g., is the slot locked?), so you should /// probably just use instead. /// /// If true, will exclude the user when playing sound. Does nothing client-side. /// Useful for predicted interactions private void Eject(EntityUid uid, ItemSlot slot, EntityUid item, EntityUid? user, bool excludeUserAudio = false) { bool? ejected = slot.ContainerSlot != null ? _containers.Remove(item, slot.ContainerSlot) : null; // ContainerSlot automatically raises a directed EntRemovedFromContainerMessage // Logging if (ejected != null && ejected.Value && user != null) _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(user.Value)} ejected {ToPrettyString(item)} from {slot.ContainerSlot?.ID + " slot of "}{ToPrettyString(uid)}"); _audioSystem.PlayPredicted(slot.EjectSound, uid, excludeUserAudio ? user : null); } /// /// Try to eject an item from a slot. /// /// False if item slot is locked or has no item inserted public bool TryEject(EntityUid uid, ItemSlot slot, EntityUid? user, [NotNullWhen(true)] out EntityUid? item, bool excludeUserAudio = false) { item = null; // This handles logic with the slot itself if (!CanEject(uid, user, slot)) return false; item = slot.Item; // This handles user logic if (user != null && item != null && !_actionBlockerSystem.CanPickup(user.Value, item.Value, showPopup: true)) return false; Eject(uid, slot, item!.Value, user, excludeUserAudio); return true; } /// /// Try to eject item from a slot. /// /// False if the id is not valid, the item slot is locked, or it has no item inserted public bool TryEject(EntityUid uid, string id, EntityUid? user, [NotNullWhen(true)] out EntityUid? item, ItemSlotsComponent? itemSlots = null, bool excludeUserAudio = false) { item = null; if (!Resolve(uid, ref itemSlots)) return false; if (!itemSlots.Slots.TryGetValue(id, out var slot)) return false; return TryEject(uid, slot, user, out item, excludeUserAudio); } /// /// Try to eject item from a slot directly into a user's hands. If they have no hands, the item will still /// be ejected onto the floor. /// /// /// False if the id is not valid, the item slot is locked, or it has no item inserted. True otherwise, even /// if the user has no hands. /// public bool TryEjectToHands(EntityUid uid, ItemSlot slot, EntityUid? user, bool excludeUserAudio = false) { if (!TryEject(uid, slot, user, out var item, excludeUserAudio)) return false; if (user != null) _handsSystem.PickupOrDrop(user.Value, item.Value); return true; } #endregion #region Verbs private void AddAlternativeVerbs(EntityUid uid, ItemSlotsComponent itemSlots, GetVerbsEvent args) { if (args.Hands == null || !args.CanAccess || !args.CanInteract) { return; } // Add the insert-item verbs if (args.Using != null && _actionBlockerSystem.CanDrop(args.User)) { var canInsertAny = false; foreach (var slot in itemSlots.Slots.Values) { // Disable slot insert if InsertOnInteract is true if (slot.InsertOnInteract || !CanInsert(uid, args.Using.Value, args.User, slot)) continue; var verbSubject = slot.Name != string.Empty ? Loc.GetString(slot.Name) : Name(args.Using.Value); AlternativeVerb verb = new() { IconEntity = GetNetEntity(args.Using), Act = () => Insert(uid, slot, args.Using.Value, args.User, excludeUserAudio: true) }; if (slot.InsertVerbText != null) { verb.Text = Loc.GetString(slot.InsertVerbText); verb.Icon = new SpriteSpecifier.Texture( new("/Textures/Interface/VerbIcons/insert.svg.192dpi.png")); } else if (slot.EjectOnInteract) { // Inserting/ejecting is a primary interaction for this entity. Instead of using the insert // category, we will use a single "Place " verb. verb.Text = Loc.GetString("place-item-verb-text", ("subject", verbSubject)); verb.Icon = new SpriteSpecifier.Texture( new("/Textures/Interface/VerbIcons/drop.svg.192dpi.png")); } else { verb.Category = VerbCategory.Insert; verb.Text = verbSubject; } verb.Priority = slot.Priority; args.Verbs.Add(verb); canInsertAny = true; } // If can insert then insert. Don't run eject verbs. if (canInsertAny) return; } // Add the eject-item verbs foreach (var slot in itemSlots.Slots.Values) { if (slot.EjectOnInteract || slot.DisableEject) // For this item slot, ejecting/inserting is a primary interaction. Instead of an eject category // alt-click verb, there will be a "Take item" primary interaction verb. continue; if (!CanEject(uid, args.User, slot)) continue; if (!_actionBlockerSystem.CanPickup(args.User, slot.Item!.Value)) continue; var verbSubject = slot.Name != string.Empty ? Loc.GetString(slot.Name) : Comp(slot.Item.Value).EntityName ?? string.Empty; AlternativeVerb verb = new() { IconEntity = GetNetEntity(slot.Item), Act = () => TryEjectToHands(uid, slot, args.User, excludeUserAudio: true) }; if (slot.EjectVerbText == null) { verb.Text = verbSubject; verb.Category = VerbCategory.Eject; } else { verb.Text = Loc.GetString(slot.EjectVerbText); } verb.Priority = slot.Priority; args.Verbs.Add(verb); } } private void AddInteractionVerbsVerbs(EntityUid uid, ItemSlotsComponent itemSlots, GetVerbsEvent args) { if (args.Hands == null || !args.CanAccess || !args.CanInteract) return; // If there are any slots that eject on left-click, add a "Take " verb. foreach (var slot in itemSlots.Slots.Values) { if (!slot.EjectOnInteract || !CanEject(uid, args.User, slot)) continue; if (!_actionBlockerSystem.CanPickup(args.User, slot.Item!.Value)) continue; var verbSubject = slot.Name != string.Empty ? Loc.GetString(slot.Name) : Name(slot.Item!.Value); InteractionVerb takeVerb = new() { IconEntity = GetNetEntity(slot.Item), Act = () => TryEjectToHands(uid, slot, args.User, excludeUserAudio: true) }; if (slot.EjectVerbText == null) takeVerb.Text = Loc.GetString("take-item-verb-text", ("subject", verbSubject)); else takeVerb.Text = Loc.GetString(slot.EjectVerbText); takeVerb.Priority = slot.Priority; args.Verbs.Add(takeVerb); } // Next, add the insert-item verbs if (args.Using == null || !_actionBlockerSystem.CanDrop(args.User)) return; foreach (var slot in itemSlots.Slots.Values) { if (!slot.InsertOnInteract || !CanInsert(uid, args.Using.Value, args.User, slot)) continue; var verbSubject = slot.Name != string.Empty ? Loc.GetString(slot.Name) : Name(args.Using.Value); InteractionVerb insertVerb = new() { IconEntity = GetNetEntity(args.Using), Act = () => Insert(uid, slot, args.Using.Value, args.User, excludeUserAudio: true) }; if (slot.InsertVerbText != null) { insertVerb.Text = Loc.GetString(slot.InsertVerbText); insertVerb.Icon = new SpriteSpecifier.Texture( new ResPath("/Textures/Interface/VerbIcons/insert.svg.192dpi.png")); } else if (slot.EjectOnInteract) { // Inserting/ejecting is a primary interaction for this entity. Instead of using the insert // category, we will use a single "Place " verb. insertVerb.Text = Loc.GetString("place-item-verb-text", ("subject", verbSubject)); insertVerb.Icon = new SpriteSpecifier.Texture( new ResPath("/Textures/Interface/VerbIcons/drop.svg.192dpi.png")); } else { insertVerb.Category = VerbCategory.Insert; insertVerb.Text = verbSubject; } insertVerb.Priority = slot.Priority; args.Verbs.Add(insertVerb); } } #endregion #region BUIs private void HandleButtonPressed(EntityUid uid, ItemSlotsComponent component, ItemSlotButtonPressedEvent args) { if (!component.Slots.TryGetValue(args.SlotId, out var slot)) return; if (args.TryEject && slot.HasItem) TryEjectToHands(uid, slot, args.Actor, true); else if (args.TryInsert && !slot.HasItem) TryInsertFromHand(uid, slot, args.Actor); } #endregion /// /// Eject items from (some) slots when the entity is destroyed. /// private void OnBreak(EntityUid uid, ItemSlotsComponent component, EntityEventArgs args) { foreach (var slot in component.Slots.Values) { if (slot.EjectOnBreak && slot.HasItem) { SetLock(uid, slot, false, component); TryEject(uid, slot, null, out var _); } } } /// /// Get the contents of some item slot. /// /// The item in the slot, or null if the slot is empty or the entity doesn't have an . public EntityUid? GetItemOrNull(EntityUid uid, string id, ItemSlotsComponent? itemSlots = null) { if (!Resolve(uid, ref itemSlots, logMissing: false)) return null; return itemSlots.Slots.GetValueOrDefault(id)?.Item; } /// /// Lock an item slot. This stops items from being inserted into or ejected from this slot. /// public void SetLock(EntityUid uid, string id, bool locked, ItemSlotsComponent? itemSlots = null) { if (!Resolve(uid, ref itemSlots)) return; if (!itemSlots.Slots.TryGetValue(id, out var slot)) return; SetLock(uid, slot, locked, itemSlots); } /// /// Lock an item slot. This stops items from being inserted into or ejected from this slot. /// public void SetLock(EntityUid uid, ItemSlot slot, bool locked, ItemSlotsComponent? itemSlots = null) { if (!Resolve(uid, ref itemSlots)) return; slot.Locked = locked; Dirty(uid, itemSlots); } /// /// Update the locked state of the managed item slots. /// /// /// Note that the slot's ContainerSlot performs its own networking, so we don't need to send information /// about the contained entity. /// private void HandleItemSlotsState(EntityUid uid, ItemSlotsComponent component, ref ComponentHandleState args) { if (args.Current is not ItemSlotsComponentState state) return; foreach (var (key, slot) in component.Slots) { if (!state.Slots.ContainsKey(key)) RemoveItemSlot(uid, slot, component); } foreach (var (serverKey, serverSlot) in state.Slots) { if (component.Slots.TryGetValue(serverKey, out var itemSlot)) { itemSlot.CopyFrom(serverSlot); itemSlot.ContainerSlot = _containers.EnsureContainer(uid, serverKey); } else { var slot = new ItemSlot(serverSlot); slot.Local = false; AddItemSlot(uid, serverKey, slot); } } } private void GetItemSlotsState(EntityUid uid, ItemSlotsComponent component, ref ComponentGetState args) { args.State = new ItemSlotsComponentState(component.Slots); } } }