diff --git a/Content.Server/Chat/Systems/ChatNotificationSystem.cs b/Content.Server/Chat/Systems/ChatNotificationSystem.cs
new file mode 100644
index 0000000000..8cd3d54e80
--- /dev/null
+++ b/Content.Server/Chat/Systems/ChatNotificationSystem.cs
@@ -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;
+
+///
+/// This system is used to notify specific players of the occurance of predefined events.
+///
+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
+ private readonly Dictionary<(EntityUid, ProtoId), Dictionary> _chatNotificationsBySource = new();
+
+ // Local cache for rate limiting chat notifications by type
+ // (Recipient, ChatNotification) -> next allowed TOA
+ private readonly Dictionary<(EntityUid, ProtoId), TimeSpan> _chatNotificationsByType = new();
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnChatNotification);
+
+ _sawmill = _logManager.GetSawmill("chatnotification");
+ }
+
+ ///
+ /// Triggered when the specified player recieves a chat notification event.
+ ///
+ /// The player receiving the chat notification.
+ /// The chat notification event
+ public void OnChatNotification(Entity 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);
+ }
+}
diff --git a/Content.Server/Silicons/StationAi/StationAiSystem.cs b/Content.Server/Silicons/StationAi/StationAiSystem.cs
index 9b272a00f9..45b3dda431 100644
--- a/Content.Server/Silicons/StationAi/StationAiSystem.cs
+++ b/Content.Server/Silicons/StationAi/StationAiSystem.cs
@@ -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> _ais = new();
+ private readonly HashSet> _stationAiCores = new();
+ private readonly ProtoId _turretIsAttackingChatNotificationPrototype = "TurretIsAttacking";
+ private readonly ProtoId _aiWireSnippedChatNotificationPrototype = "AiWireSnipped";
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent(OnExpandICChatRecipients);
+ SubscribeLocalEvent(OnAmmoShot);
}
private void OnExpandICChatRecipients(ExpandICChatRecipientsEvent ev)
@@ -61,15 +60,33 @@ public sealed class StationAiSystem : SharedStationAiSystem
}
}
+ private void OnAmmoShot(Entity 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(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 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(uid, out var actor))
- 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);
-
- if (cue != null && _mind.TryGetMind(uid, out var mindId, out _))
- _roles.MindPlaySound(mindId, cue);
- }
-
- private void AnnounceSnip(EntityUid entity)
- {
- var xform = Transform(entity);
+ var xform = Transform(uid);
if (!TryComp(xform.GridUid, out MapGridComponent? grid))
return;
- _ais.Clear();
- _lookup.GetChildEntities(xform.GridUid.Value, _ais);
- var filter = Filter.Empty();
+ var ais = GetStationAIs(xform.GridUid.Value);
- foreach (var ai in _ais)
+ foreach (var ai in ais)
{
- // TODO: Filter API?
- if (TryComp(ai.Owner, out ActorComponent? actorComp))
- {
- filter.AddPlayer(actorComp.PlayerSession);
- }
+ if (!StationAiCanDetectWireSnipping(ai))
+ continue;
+
+ var ev = new ChatNotificationEvent(_aiWireSnippedChatNotificationPrototype, uid);
+
+ var tile = Maps.LocalToTile(xform.GridUid.Value, grid, xform.Coordinates);
+ ev.SourceNameOverride = tile.ToString();
+
+ RaiseLocalEvent(ai, ref ev);
+ }
+ }
+
+ private bool StationAiCanDetectWireSnipping(EntityUid uid)
+ {
+ // 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.
+
+ return false;
+ }
+
+ public HashSet GetStationAIs(EntityUid gridUid)
+ {
+ _stationAiCores.Clear();
+ _lookup.GetChildEntities(gridUid, _stationAiCores);
+
+ var hashSet = new HashSet();
+
+ foreach (var stationAiCore in _stationAiCores)
+ {
+ if (!TryGetHeld((stationAiCore, stationAiCore.Comp), out var insertedAi))
+ continue;
+
+ hashSet.Add(insertedAi);
}
- // TEST
- // filter = Filter.Broadcast();
-
- // 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));
-
- _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
index 0000000000..58315161a5
--- /dev/null
+++ b/Content.Shared/Chat/Prototypes/ChatNotificationPrototype.cs
@@ -0,0 +1,74 @@
+using Robust.Shared.Audio;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.Chat.Prototypes;
+
+///
+/// A predefined notification used to warn a player of specific events.
+///
+[Prototype("chatNotification")]
+public sealed partial class ChatNotificationPrototype : IPrototype
+{
+ [ViewVariables]
+ [IdDataField]
+ public string ID { get; private set; } = default!;
+
+ ///
+ /// The notification that the player receives.
+ ///
+ ///
+ /// Use '{$source}', '{user}', and '{target}' in the fluent message
+ /// to insert the source, user, and target names respectively.
+ ///
+ [DataField(required: true)]
+ public LocId Message = string.Empty;
+
+ ///
+ /// Font color for the notification.
+ ///
+ [DataField]
+ public Color Color = Color.White;
+
+ ///
+ /// Sound played upon receiving the notification.
+ ///
+ [DataField]
+ public SoundSpecifier? Sound;
+
+ ///
+ /// The period during which duplicate chat notifications are blocked after a player receives one.
+ /// Blocked notifications will never be delivered to the player.
+ ///
+ [DataField]
+ public TimeSpan NextDelay = TimeSpan.FromSeconds(10.0);
+
+ ///
+ /// 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).
+ ///
+ [DataField]
+ public bool NotifyBySource = false;
+}
+
+///
+/// Raised when an specific player should be notified via a chat message of a predefined event occuring.
+///
+/// The prototype used to define the chat notification.
+/// The entity that the triggered the notification.
+/// The entity that ultimately responsible for triggering the notification.
+[ByRefEvent]
+public record ChatNotificationEvent(ProtoId ChatNotification, EntityUid Source, EntityUid? User = null)
+{
+ ///
+ /// Set this variable if you want to change the name of the notification source
+ /// (if the name is included in the chat notification).
+ ///
+ public string? SourceNameOverride;
+
+ ///
+ /// 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).
+ ///
+ public string? UserNameOverride;
+}
diff --git a/Content.Shared/Intellicard/Components/IntellicardComponent.cs b/Content.Shared/Intellicard/Components/IntellicardComponent.cs
index e27174977f..5a299b1f80 100644
--- a/Content.Shared/Intellicard/Components/IntellicardComponent.cs
+++ b/Content.Shared/Intellicard/Components/IntellicardComponent.cs
@@ -20,20 +20,4 @@ public sealed partial class IntellicardComponent : Component
///
[DataField, AutoNetworkedField]
public int UploadTime = 3;
-
- ///
- /// The sound that plays for the AI
- /// when they are being downloaded
- ///
- [DataField, AutoNetworkedField]
- public SoundSpecifier? WarningSound = new SoundPathSpecifier("/Audio/Misc/notice2.ogg");
-
- ///
- /// The delay before allowing the warning to play again in seconds.
- ///
- [DataField, AutoNetworkedField]
- public TimeSpan WarningDelay = TimeSpan.FromSeconds(8);
-
- [ViewVariables]
- public TimeSpan NextWarningAllowed = TimeSpan.Zero;
}
diff --git a/Content.Shared/Silicons/StationAi/SharedStationAiSystem.cs b/Content.Shared/Silicons/StationAi/SharedStationAiSystem.cs
index 265b46d24f..d76f16c446 100644
--- a/Content.Shared/Silicons/StationAi/SharedStationAiSystem.cs
+++ b/Content.Shared/Silicons/StationAi/SharedStationAiSystem.cs
@@ -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 _gridQuery;
private static readonly EntProtoId DefaultAi = "StationAiBrain";
+ private readonly ProtoId _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 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
index 0000000000..125195787e
--- /dev/null
+++ b/Content.Shared/Turrets/StationAiTurretComponent.cs
@@ -0,0 +1,12 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Turrets;
+
+///
+/// This component designates a turret that is under the direct control of the station AI.
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class StationAiTurretComponent : Component
+{
+
+}
diff --git a/Resources/Locale/en-US/silicons/station-ai.ftl b/Resources/Locale/en-US/silicons/station-ai.ftl
index 81fee66ccb..442782f9a1 100644
--- a/Resources/Locale/en-US/silicons/station-ai.ftl
+++ b/Resources/Locale/en-US/silicons/station-ai.ftl
@@ -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
diff --git a/Resources/Locale/en-US/weapons/ranged/turrets.ftl b/Resources/Locale/en-US/weapons/ranged/turrets.ftl
index 213599d926..8a21647075 100644
--- a/Resources/Locale/en-US/weapons/ranged/turrets.ftl
+++ b/Resources/Locale/en-US/weapons/ranged/turrets.ftl
@@ -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
index 0000000000..c1aee755c6
--- /dev/null
+++ b/Resources/Prototypes/Chat/notifications.yml
@@ -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
diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_energy.yml b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_energy.yml
index 077d5dc5fd..40c67c898d 100644
--- a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_energy.yml
+++ b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_energy.yml
@@ -144,6 +144,7 @@
components:
- type: AccessReader
access: [["StationAi"], ["ResearchDirector"]]
+ - type: StationAiTurret
- type: TurretTargetSettings
exemptAccessLevels:
- Borg