]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Sentry turrets - Part 8: AI notifications (#35277)
authorchromiumboy <50505512+chromiumboy@users.noreply.github.com>
Fri, 8 Aug 2025 18:56:01 +0000 (13:56 -0500)
committerGitHub <noreply@github.com>
Fri, 8 Aug 2025 18:56:01 +0000 (21:56 +0300)
Content.Server/Chat/Systems/ChatNotificationSystem.cs [new file with mode: 0644]
Content.Server/Silicons/StationAi/StationAiSystem.cs
Content.Shared/Chat/Prototypes/ChatNotificationPrototype.cs [new file with mode: 0644]
Content.Shared/Intellicard/Components/IntellicardComponent.cs
Content.Shared/Silicons/StationAi/SharedStationAiSystem.cs
Content.Shared/Turrets/StationAiTurretComponent.cs [new file with mode: 0644]
Resources/Locale/en-US/silicons/station-ai.ftl
Resources/Locale/en-US/weapons/ranged/turrets.ftl
Resources/Prototypes/Chat/notifications.yml [new file with mode: 0644]
Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_energy.yml

diff --git a/Content.Server/Chat/Systems/ChatNotificationSystem.cs b/Content.Server/Chat/Systems/ChatNotificationSystem.cs
new file mode 100644 (file)
index 0000000..8cd3d54
--- /dev/null
@@ -0,0 +1,105 @@
+using Content.Server.Chat.Managers;
+using Content.Shared.Chat;
+using Content.Shared.Chat.Prototypes;
+using Content.Shared.Mind;
+using Content.Shared.Roles;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Chat.Systems;
+
+/// <summary>
+/// This system is used to notify specific players of the occurance of predefined events.
+/// </summary>
+public sealed partial class ChatNotificationSystem : EntitySystem
+{
+    [Dependency] private readonly IPrototypeManager _proto = default!;
+    [Dependency] private readonly IChatManager _chats = default!;
+    [Dependency] private readonly SharedMindSystem _mind = default!;
+    [Dependency] private readonly SharedRoleSystem _roles = default!;
+    [Dependency] private readonly IGameTiming _timing = default!;
+    [Dependency] private readonly ILogManager _logManager = default!;
+
+    private ISawmill _sawmill = default!;
+
+    // The following data does not need to be saved
+
+    // Local cache for rate limiting chat notifications by source
+    // (Recipient, ChatNotification) -> Dictionary<Source, next allowed TOA>
+    private readonly Dictionary<(EntityUid, ProtoId<ChatNotificationPrototype>), Dictionary<EntityUid, TimeSpan>> _chatNotificationsBySource = new();
+
+    // Local cache for rate limiting chat notifications by type
+    // (Recipient, ChatNotification) -> next allowed TOA
+    private readonly Dictionary<(EntityUid, ProtoId<ChatNotificationPrototype>), TimeSpan> _chatNotificationsByType = new();
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<ActorComponent, ChatNotificationEvent>(OnChatNotification);
+
+        _sawmill = _logManager.GetSawmill("chatnotification");
+    }
+
+    /// <summary>
+    /// Triggered when the specified player recieves a chat notification event.
+    /// </summary>
+    /// <param name="ent">The player receiving the chat notification.</param>
+    /// <param name="args">The chat notification event</param>
+    public void OnChatNotification(Entity<ActorComponent> ent, ref ChatNotificationEvent args)
+    {
+        if (!_proto.TryIndex(args.ChatNotification, out var chatNotification))
+        {
+            _sawmill.Warning("Attempted to index ChatNotificationPrototype " + args.ChatNotification + " but the prototype does not exist.");
+            return;
+        }
+
+        var source = args.Source;
+        var playerNotification = (ent, args.ChatNotification);
+
+        // Exit without notifying the player if we received a notification before the appropriate time has elasped
+
+        if (chatNotification.NotifyBySource)
+        {
+            if (!_chatNotificationsBySource.TryGetValue(playerNotification, out var trackedSources))
+                trackedSources = new();
+
+            trackedSources.TryGetValue(source, out var timeSpan);
+            trackedSources[source] = _timing.CurTime + chatNotification.NextDelay;
+
+            _chatNotificationsBySource[playerNotification] = trackedSources;
+
+            if (_timing.CurTime < timeSpan)
+                return;
+        }
+        else
+        {
+            _chatNotificationsByType.TryGetValue(playerNotification, out var timeSpan);
+            _chatNotificationsByType[playerNotification] = _timing.CurTime + chatNotification.NextDelay;
+
+            if (_timing.CurTime < timeSpan)
+                return;
+        }
+
+        var sourceName = args.SourceNameOverride ?? Name(source);
+        var userName = args.UserNameOverride ?? (args.User.HasValue ? Name(args.User.Value) : string.Empty);
+        var targetName = Name(ent);
+
+        var message = Loc.GetString(chatNotification.Message, ("source", sourceName), ("user", userName), ("target", targetName));
+        var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", message));
+
+        _chats.ChatMessageToOne(
+            ChatChannel.Notifications,
+            message,
+            wrappedMessage,
+            default,
+            false,
+            ent.Comp.PlayerSession.Channel,
+            colorOverride: chatNotification.Color
+        );
+
+        if (chatNotification.Sound != null && _mind.TryGetMind(ent, out var mindId, out _))
+            _roles.MindPlaySound(mindId, chatNotification.Sound);
+    }
+}
index 9b272a00f9e5e143cf04053af6722ef84d388dab..45b3dda4310f3b4fe395f8eeff6baaaf636093f1 100644 (file)
@@ -1,33 +1,32 @@
-using System.Linq;
-using Content.Server.Chat.Managers;
 using Content.Server.Chat.Systems;
-using Content.Shared.Chat;
-using Content.Shared.Mind;
-using Content.Shared.Roles;
+using Content.Shared.Chat.Prototypes;
+using Content.Shared.DeviceNetwork.Components;
 using Content.Shared.Silicons.StationAi;
 using Content.Shared.StationAi;
-using Robust.Shared.Audio;
+using Content.Shared.Turrets;
+using Content.Shared.Weapons.Ranged.Events;
 using Robust.Shared.Map.Components;
 using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
 using static Content.Server.Chat.Systems.ChatSystem;
 
 namespace Content.Server.Silicons.StationAi;
 
 public sealed class StationAiSystem : SharedStationAiSystem
 {
-    [Dependency] private readonly IChatManager _chats = default!;
     [Dependency] private readonly EntityLookupSystem _lookup = default!;
     [Dependency] private readonly SharedTransformSystem _xforms = default!;
-    [Dependency] private readonly SharedMindSystem _mind = default!;
-    [Dependency] private readonly SharedRoleSystem _roles = default!;
 
-    private readonly HashSet<Entity<StationAiCoreComponent>> _ais = new();
+    private readonly HashSet<Entity<StationAiCoreComponent>> _stationAiCores = new();
+    private readonly ProtoId<ChatNotificationPrototype> _turretIsAttackingChatNotificationPrototype = "TurretIsAttacking";
+    private readonly ProtoId<ChatNotificationPrototype> _aiWireSnippedChatNotificationPrototype = "AiWireSnipped";
 
     public override void Initialize()
     {
         base.Initialize();
 
         SubscribeLocalEvent<ExpandICChatRecipientsEvent>(OnExpandICChatRecipients);
+        SubscribeLocalEvent<StationAiTurretComponent, AmmoShotEvent>(OnAmmoShot);
     }
 
     private void OnExpandICChatRecipients(ExpandICChatRecipientsEvent ev)
@@ -61,15 +60,33 @@ public sealed class StationAiSystem : SharedStationAiSystem
         }
     }
 
+    private void OnAmmoShot(Entity<StationAiTurretComponent> ent, ref AmmoShotEvent args)
+    {
+        var xform = Transform(ent);
+
+        if (!TryComp(xform.GridUid, out MapGridComponent? grid))
+            return;
+
+        var ais = GetStationAIs(xform.GridUid.Value);
+
+        foreach (var ai in ais)
+        {
+            var ev = new ChatNotificationEvent(_turretIsAttackingChatNotificationPrototype, ent);
+
+            if (TryComp<DeviceNetworkComponent>(ent, out var deviceNetwork))
+                ev.SourceNameOverride = Loc.GetString("station-ai-turret-component-name", ("name", Name(ent)), ("address", deviceNetwork.Address));
+
+            RaiseLocalEvent(ai, ref ev);
+        }
+    }
+
     public override bool SetVisionEnabled(Entity<StationAiVisionComponent> entity, bool enabled, bool announce = false)
     {
         if (!base.SetVisionEnabled(entity, enabled, announce))
             return false;
 
         if (announce)
-        {
             AnnounceSnip(entity.Owner);
-        }
 
         return true;
     }
@@ -80,54 +97,59 @@ public sealed class StationAiSystem : SharedStationAiSystem
             return false;
 
         if (announce)
-        {
             AnnounceSnip(entity.Owner);
-        }
 
         return true;
     }
 
-    public override void AnnounceIntellicardUsage(EntityUid uid, SoundSpecifier? cue = null)
+    private void AnnounceSnip(EntityUid uid)
     {
-        if (!TryComp<ActorComponent>(uid, out var actor))
+        var xform = Transform(uid);
+
+        if (!TryComp(xform.GridUid, out MapGridComponent? grid))
             return;
 
-        var msg = Loc.GetString("ai-consciousness-download-warning");
-        var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", msg));
-        _chats.ChatMessageToOne(ChatChannel.Server, msg, wrappedMessage, default, false, actor.PlayerSession.Channel, colorOverride: Color.Red);
+        var ais = GetStationAIs(xform.GridUid.Value);
+
+        foreach (var ai in ais)
+        {
+            if (!StationAiCanDetectWireSnipping(ai))
+                continue;
+
+            var ev = new ChatNotificationEvent(_aiWireSnippedChatNotificationPrototype, uid);
+
+            var tile = Maps.LocalToTile(xform.GridUid.Value, grid, xform.Coordinates);
+            ev.SourceNameOverride = tile.ToString();
 
-        if (cue != null && _mind.TryGetMind(uid, out var mindId, out _))
-            _roles.MindPlaySound(mindId, cue);
+            RaiseLocalEvent(ai, ref ev);
+        }
     }
 
-    private void AnnounceSnip(EntityUid entity)
+    private bool StationAiCanDetectWireSnipping(EntityUid uid)
     {
-        var xform = Transform(entity);
+        // TODO: The ability to detect snipped AI interaction wires
+        // should be a MALF ability and/or a purchased upgrade rather
+        // than something available to the station AI by default.
+        // When these systems are added, add the appropriate checks here.
 
-        if (!TryComp(xform.GridUid, out MapGridComponent? grid))
-            return;
+        return false;
+    }
 
-        _ais.Clear();
-        _lookup.GetChildEntities(xform.GridUid.Value, _ais);
-        var filter = Filter.Empty();
+    public HashSet<EntityUid> GetStationAIs(EntityUid gridUid)
+    {
+        _stationAiCores.Clear();
+        _lookup.GetChildEntities(gridUid, _stationAiCores);
 
-        foreach (var ai in _ais)
-        {
-            // TODO: Filter API?
-            if (TryComp(ai.Owner, out ActorComponent? actorComp))
-            {
-                filter.AddPlayer(actorComp.PlayerSession);
-            }
-        }
+        var hashSet = new HashSet<EntityUid>();
 
-        // TEST
-        // filter = Filter.Broadcast();
+        foreach (var stationAiCore in _stationAiCores)
+        {
+            if (!TryGetHeld((stationAiCore, stationAiCore.Comp), out var insertedAi))
+                continue;
 
-        // No easy way to do chat notif embeds atm.
-        var tile = Maps.LocalToTile(xform.GridUid.Value, grid, xform.Coordinates);
-        var msg = Loc.GetString("ai-wire-snipped", ("coords", tile));
+            hashSet.Add(insertedAi);
+        }
 
-        _chats.ChatMessageToMany(ChatChannel.Notifications, msg, msg, entity, false, true, filter.Recipients.Select(o => o.Channel));
-        // Apparently there's no sound for this.
+        return hashSet;
     }
 }
diff --git a/Content.Shared/Chat/Prototypes/ChatNotificationPrototype.cs b/Content.Shared/Chat/Prototypes/ChatNotificationPrototype.cs
new file mode 100644 (file)
index 0000000..5831516
--- /dev/null
@@ -0,0 +1,74 @@
+using Robust.Shared.Audio;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.Chat.Prototypes;
+
+/// <summary>
+/// A predefined notification used to warn a player of specific events.
+/// </summary>
+[Prototype("chatNotification")]
+public sealed partial class ChatNotificationPrototype : IPrototype
+{
+    [ViewVariables]
+    [IdDataField]
+    public string ID { get; private set; } = default!;
+
+    /// <summary>
+    /// The notification that the player receives.
+    /// </summary>
+    /// <remarks>
+    /// Use '{$source}', '{user}', and '{target}' in the fluent message
+    /// to insert the source, user, and target names respectively.
+    /// </remarks>
+    [DataField(required: true)]
+    public LocId Message = string.Empty;
+
+    /// <summary>
+    /// Font color for the notification.
+    /// </summary>
+    [DataField]
+    public Color Color = Color.White;
+
+    /// <summary>
+    /// Sound played upon receiving the notification.
+    /// </summary>
+    [DataField]
+    public SoundSpecifier? Sound;
+
+    /// <summary>
+    /// The period during which duplicate chat notifications are blocked after a player receives one.
+    /// Blocked notifications will never be delivered to the player.
+    /// </summary>
+    [DataField]
+    public TimeSpan NextDelay = TimeSpan.FromSeconds(10.0);
+
+    /// <summary>
+    /// Determines whether notification delays should be determined by the source
+    /// entity or by the notification prototype (i.e., individual notifications
+    /// vs grouping the notifications together).
+    /// </summary>
+    [DataField]
+    public bool NotifyBySource = false;
+}
+
+/// <summary>
+/// Raised when an specific player should be notified via a chat message of a predefined event occuring.
+/// </summary>
+/// <param name="ChatNotification">The prototype used to define the chat notification.</param>
+/// <param name="Source">The entity that the triggered the notification.</param>
+/// <param name="User">The entity that ultimately responsible for triggering the notification.</param>
+[ByRefEvent]
+public record ChatNotificationEvent(ProtoId<ChatNotificationPrototype> ChatNotification, EntityUid Source, EntityUid? User = null)
+{
+    /// <summary>
+    /// Set this variable if you want to change the name of the notification source
+    /// (if the name is included in the chat notification).
+    /// </summary>
+    public string? SourceNameOverride;
+
+    /// <summary>
+    /// Set this variable if you wish to change the name of the user who triggered the notification
+    /// (if the name is included in the chat notification).
+    /// </summary>
+    public string? UserNameOverride;
+}
index e27174977fb1f3d354abe2783e121d2ec358e224..5a299b1f80f422a66629cee30f5a2d1a8fa4621f 100644 (file)
@@ -20,20 +20,4 @@ public sealed partial class IntellicardComponent : Component
     /// </summary>
     [DataField, AutoNetworkedField]
     public int UploadTime = 3;
-
-    /// <summary>
-    /// The sound that plays for the AI
-    /// when they are being downloaded
-    /// </summary>
-    [DataField, AutoNetworkedField]
-    public SoundSpecifier? WarningSound = new SoundPathSpecifier("/Audio/Misc/notice2.ogg");
-
-    /// <summary>
-    /// The delay before allowing the warning to play again in seconds.
-    /// </summary>
-    [DataField, AutoNetworkedField]
-    public TimeSpan WarningDelay = TimeSpan.FromSeconds(8);
-
-    [ViewVariables]
-    public TimeSpan NextWarningAllowed = TimeSpan.Zero;
 }
index 265b46d24f58b6777b2ad3fdcde0e09851cac1ba..d76f16c44654f1819c3fabba3f6562e2be7c24fc 100644 (file)
@@ -1,6 +1,7 @@
 using Content.Shared.ActionBlocker;
 using Content.Shared.Actions;
 using Content.Shared.Administration.Managers;
+using Content.Shared.Chat.Prototypes;
 using Content.Shared.Containers.ItemSlots;
 using Content.Shared.Database;
 using Content.Shared.Doors.Systems;
@@ -17,7 +18,6 @@ using Content.Shared.Power;
 using Content.Shared.Power.EntitySystems;
 using Content.Shared.StationAi;
 using Content.Shared.Verbs;
-using Robust.Shared.Audio;
 using Robust.Shared.Audio.Systems;
 using Robust.Shared.Containers;
 using Robust.Shared.Map;
@@ -27,8 +27,8 @@ using Robust.Shared.Physics;
 using Robust.Shared.Prototypes;
 using Robust.Shared.Serialization;
 using Robust.Shared.Timing;
-using System.Diagnostics.CodeAnalysis;
 using Robust.Shared.Utility;
+using System.Diagnostics.CodeAnalysis;
 
 namespace Content.Shared.Silicons.StationAi;
 
@@ -70,6 +70,7 @@ public abstract partial class SharedStationAiSystem : EntitySystem
     private EntityQuery<MapGridComponent> _gridQuery;
 
     private static readonly EntProtoId DefaultAi = "StationAiBrain";
+    private readonly ProtoId<ChatNotificationPrototype> _downloadChatNotificationPrototype = "IntellicardDownload";
 
     private const float MaxVisionMultiplier = 5f;
 
@@ -287,10 +288,10 @@ public abstract partial class SharedStationAiSystem : EntitySystem
             return;
         }
 
-        if (TryGetHeld((args.Target.Value, targetHolder), out var held) && _timing.CurTime > intelliComp.NextWarningAllowed)
+        if (TryGetHeld((args.Target.Value, targetHolder), out var held))
         {
-            intelliComp.NextWarningAllowed = _timing.CurTime + intelliComp.WarningDelay;
-            AnnounceIntellicardUsage(held, intelliComp.WarningSound);
+            var ev = new ChatNotificationEvent(_downloadChatNotificationPrototype, args.Used, args.User);
+            RaiseLocalEvent(held, ref ev);
         }
 
         var doAfterArgs = new DoAfterArgs(EntityManager, args.User, cardHasAi ? intelliComp.UploadTime : intelliComp.DownloadTime, new IntellicardDoAfterEvent(), args.Target, ent.Owner)
@@ -528,8 +529,6 @@ public abstract partial class SharedStationAiSystem : EntitySystem
         _appearance.SetData(entity.Owner, StationAiVisualState.Key, state);
     }
 
-    public virtual void AnnounceIntellicardUsage(EntityUid uid, SoundSpecifier? cue = null) { }
-
     public virtual bool SetVisionEnabled(Entity<StationAiVisionComponent> entity, bool enabled, bool announce = false)
     {
         if (entity.Comp.Enabled == enabled)
diff --git a/Content.Shared/Turrets/StationAiTurretComponent.cs b/Content.Shared/Turrets/StationAiTurretComponent.cs
new file mode 100644 (file)
index 0000000..1251957
--- /dev/null
@@ -0,0 +1,12 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Turrets;
+
+/// <summary>
+/// This component designates a turret that is under the direct control of the station AI.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class StationAiTurretComponent : Component
+{
+
+}
index 81fee66ccbc960a9e1ba944dd66a4004bb4a626a..442782f9a137edd864e85990ed1553b10643095a 100644 (file)
@@ -1,5 +1,5 @@
 # General
-ai-wire-snipped = Wire has been cut at {$coords}.
+ai-wire-snipped = One of your systems' wires has been cut at {$source}.
 wire-name-ai-vision-light = AIV
 wire-name-ai-act-light = AIA
 station-ai-takeover = AI takeover
index 213599d92681088f63ed84881108ab1cab18f9f0..8a21647075a4b8f223885f6ef0b469fab92fe322 100644 (file)
@@ -9,4 +9,5 @@ deployable-turret-component-is-broken = The turret is heavily damaged and must b
 deployable-turret-component-cannot-access-wires = You can't reach the maintenance panel while the turret is active 
 
 # Turret notification for station AI
-station-ai-turret-is-attacking-warning = {CAPITALIZE($source)} has engaged a hostile target.
\ No newline at end of file
+station-ai-turret-component-name = {$name} ({$address})
+station-ai-turret-component-is-attacking-warning = {CAPITALIZE($source)} has engaged a hostile target.
\ No newline at end of file
diff --git a/Resources/Prototypes/Chat/notifications.yml b/Resources/Prototypes/Chat/notifications.yml
new file mode 100644 (file)
index 0000000..c1aee75
--- /dev/null
@@ -0,0 +1,21 @@
+- type: chatNotification
+  id: IntellicardDownload
+  message: ai-consciousness-download-warning
+  color: Red
+  sound: /Audio/Misc/notice2.ogg
+  nextDelay: 8
+
+- type: chatNotification
+  id: TurretIsAttacking
+  message: station-ai-turret-component-is-attacking-warning
+  color: Orange
+  sound: /Audio/Misc/notice2.ogg
+  nextDelay: 14
+  notifyBySource: true
+
+- type: chatNotification
+  id: AiWireSnipped
+  message: ai-wire-snipped
+  color: Pink
+  nextDelay: 12
+  notifyBySource: true
index 077d5dc5fd00a97afe919547cd5b3e3515fa5c52..40c67c898df3d8dc885ccc79148e8e8defb4c49f 100644 (file)
   components:
   - type: AccessReader
     access: [["StationAi"], ["ResearchDirector"]]
+  - type: StationAiTurret
   - type: TurretTargetSettings
     exemptAccessLevels:
     - Borg