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: [ ]