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