diff --git a/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs b/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs index c78a8923cd..8dd170e667 100644 --- a/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs +++ b/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs @@ -53,6 +53,9 @@ namespace Content.Server.Explosion.EntitySystems _adminLogger.Add(LogType.Trigger, LogImpact.High, $"A voice-trigger on {ToPrettyString(ent):entity} was triggered by {ToPrettyString(args.Source):speaker} speaking the key-phrase {component.KeyPhrase}."); Trigger(ent, args.Source); + + var voice = new VoiceTriggeredEvent(args.Source, message); + RaiseLocalEvent(ent, ref voice); } } @@ -137,3 +140,12 @@ namespace Content.Server.Explosion.EntitySystems } } } + + +/// +/// Raised when a voice trigger is activated, containing the message that triggered it. +/// +/// The EntityUid of the entity sending the message +/// The contents of the message +[ByRefEvent] +public readonly record struct VoiceTriggeredEvent(EntityUid Source, string? Message); diff --git a/Content.Server/VoiceTrigger/StorageVoiceControlComponent.cs b/Content.Server/VoiceTrigger/StorageVoiceControlComponent.cs new file mode 100644 index 0000000000..1689979d82 --- /dev/null +++ b/Content.Server/VoiceTrigger/StorageVoiceControlComponent.cs @@ -0,0 +1,19 @@ +using Content.Shared.Inventory; + +namespace Content.Server.VoiceTrigger; + +/// +/// Entities with this component, Containers, and TriggerOnVoiceComponent will insert any item or extract the spoken item after the TriggerOnVoiceComponent has been activated +/// +[RegisterComponent] +public sealed partial class StorageVoiceControlComponent : Component +{ + /// + /// Used to determine which slots the component can be used in. + /// + /// If not set, the component can be used anywhere, even while inside other containers. + /// + /// + [DataField] + public SlotFlags? AllowedSlots; +} diff --git a/Content.Server/VoiceTrigger/StorageVoiceControlSystem.cs b/Content.Server/VoiceTrigger/StorageVoiceControlSystem.cs new file mode 100644 index 0000000000..72e361bc58 --- /dev/null +++ b/Content.Server/VoiceTrigger/StorageVoiceControlSystem.cs @@ -0,0 +1,98 @@ +using Content.Server.Hands.Systems; +using Content.Server.Storage.EntitySystems; +using Content.Shared.Administration.Logs; +using Content.Shared.Database; +using Content.Shared.Hands.Components; +using Content.Shared.Inventory; +using Content.Shared.Popups; +using Content.Shared.Storage; +using Robust.Server.Containers; + +namespace Content.Server.VoiceTrigger; + +/// +/// Allows storages to be manipulated using voice commands. +/// +public sealed class StorageVoiceControlSystem : EntitySystem +{ + [Dependency] private readonly ContainerSystem _container = default!; + [Dependency] private readonly HandsSystem _hands = default!; + [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; + [Dependency] private readonly InventorySystem _inventory = default!; + [Dependency] private readonly SharedPopupSystem _popup = default!; + [Dependency] private readonly StorageSystem _storage = default!; + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(VoiceTriggered); + } + + private void VoiceTriggered(Entity ent, ref VoiceTriggeredEvent args) + { + // Check if the component has any slot restrictions via AllowedSlots + // If it has slot restrictions, check if the item is in a slot that is allowed + if (ent.Comp.AllowedSlots != null && _inventory.TryGetContainingSlot(ent.Owner, out var itemSlot) && + (itemSlot.SlotFlags & ent.Comp.AllowedSlots) == 0) + return; + + // Don't do anything if there is no message + if (args.Message == null) + return; + + // Get the storage component + if (!TryComp(ent, out var storage)) + return; + + // Get the hands component + if (!TryComp(args.Source, out var hands)) + return; + + // If the player has something in their hands, try to insert it into the storage + if (hands.ActiveHand != null && hands.ActiveHand.HeldEntity.HasValue) + { + // Disallow insertion and provide a reason why if the person decides to insert the item into itself + if (ent.Owner.Equals(hands.ActiveHand.HeldEntity.Value)) + { + _popup.PopupEntity(Loc.GetString("comp-storagevoicecontrol-self-insert", ("entity", hands.ActiveHand.HeldEntity.Value)), ent, args.Source); + return; + } + if (_storage.CanInsert(ent, hands.ActiveHand.HeldEntity.Value, out var failedReason)) + { + // We adminlog before insertion, otherwise the logger will attempt to pull info on an entity that no longer is present and throw an exception + _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(args.Source)} inserted {ToPrettyString(hands.ActiveHand.HeldEntity.Value)} into {ToPrettyString(ent)} via voice control"); + _storage.Insert(ent, hands.ActiveHand.HeldEntity.Value, out _); + return; + } + { + // Tell the player the reason why the item couldn't be inserted + if (failedReason == null) + return; + _popup.PopupEntity(Loc.GetString(failedReason), ent, args.Source); + _adminLogger.Add(LogType.Action, + LogImpact.Low, + $"{ToPrettyString(args.Source)} failed to insert {ToPrettyString(hands.ActiveHand.HeldEntity.Value)} into {ToPrettyString(ent)} via voice control"); + } + return; + } + + // If otherwise, we're retrieving an item, so check all the items currently in the attached storage + foreach (var item in storage.Container.ContainedEntities) + { + // Get the item's name + var itemName = MetaData(item).EntityName; + // The message doesn't match the item name the requestor requested, skip and move on to the next item + if (!args.Message.Contains(itemName, StringComparison.InvariantCultureIgnoreCase)) + continue; + + // We found the item we want, so draw it from storage and place it into the player's hands + if (storage.Container.ContainedEntities.Count != 0) + { + _container.RemoveEntity(ent, item); + _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(args.Source)} retrieved {ToPrettyString(item)} from {ToPrettyString(ent)} via voice control"); + _hands.TryPickup(args.Source, item, handsComp: hands); + break; + } + } + } +} diff --git a/Content.Shared/Storage/EntitySystems/SharedStorageSystem.cs b/Content.Shared/Storage/EntitySystems/SharedStorageSystem.cs index d2fbe26002..f523a6c8a0 100644 --- a/Content.Shared/Storage/EntitySystems/SharedStorageSystem.cs +++ b/Content.Shared/Storage/EntitySystems/SharedStorageSystem.cs @@ -282,7 +282,7 @@ public abstract class SharedStorageSystem : EntitySystem private void AddUiVerb(EntityUid uid, StorageComponent component, GetVerbsEvent args) { - if (!CanInteract(args.User, (uid, component), args.CanAccess && args.CanInteract)) + if (component.ShowVerb == false || !CanInteract(args.User, (uid, component), args.CanAccess && args.CanInteract)) return; // Does this player currently have the storage UI open? diff --git a/Content.Shared/Storage/StorageComponent.cs b/Content.Shared/Storage/StorageComponent.cs index c59f7ab00e..f772ad2022 100644 --- a/Content.Shared/Storage/StorageComponent.cs +++ b/Content.Shared/Storage/StorageComponent.cs @@ -146,6 +146,13 @@ namespace Content.Shared.Storage { Key, } + + /// + /// Allow or disallow showing the "open/close storage" verb. + /// This is desired on items that we don't want to be accessed by the player directly. + /// + [DataField] + public bool ShowVerb = true; } [Serializable, NetSerializable] diff --git a/Resources/Locale/en-US/components/storage-voice-control-component.ftl b/Resources/Locale/en-US/components/storage-voice-control-component.ftl new file mode 100644 index 0000000000..019b5ecfa1 --- /dev/null +++ b/Resources/Locale/en-US/components/storage-voice-control-component.ftl @@ -0,0 +1 @@ +comp-storagevoicecontrol-self-insert = You can't insert { THE($entity) } into itself! diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/security.yml b/Resources/Prototypes/Catalog/Fills/Lockers/security.yml index aab2a8af9a..dd9b602e00 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/security.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/security.yml @@ -122,7 +122,7 @@ contents: - id: ClothingEyesGlassesSecurity prob: 0.3 - - id: ClothingHeadHatFedoraBrown + - id: ClothingHeadHatDetGadget - id: ClothingNeckTieDet - id: ClothingOuterVestDetective - id: ClothingOuterCoatDetective diff --git a/Resources/Prototypes/Entities/Clothing/Head/specific.yml b/Resources/Prototypes/Entities/Clothing/Head/specific.yml index 636f922d85..1e2e55f55b 100644 --- a/Resources/Prototypes/Entities/Clothing/Head/specific.yml +++ b/Resources/Prototypes/Entities/Clothing/Head/specific.yml @@ -18,3 +18,34 @@ interfaces: enum.ChameleonUiKey.Key: type: ChameleonBoundUserInterface + +- type: entity + parent: ClothingHeadHatFedoraBrown + id: ClothingHeadHatDetGadget + name: go go hat + description: A novel hat with a built in toolkit. Automatically stores and retrieves items at the say of a phrase! + components: + - type: Tag + tags: [] # ignore "WhitelistChameleon" tag + - type: TriggerOnVoice + keyPhrase: "go go gadget" + listenRange: 0 + - type: ActiveListener + range: 0 + - type: StorageVoiceControl + allowedSlots: + - HEAD + - type: Storage + showVerb: false + grid: + - 0,0,6,3 + maxItemSize: Small + blacklist: + tags: + - HighRiskItem # no hiding objectives or trolling nukies + - FakeNukeDisk # no disk checking + - QuantumSpinInverter # avoid the morbillionth QSI bug + - type: ContainerContainer + containers: + storagebase: !type:Container + ents: [ ]