--- /dev/null
+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);
+ }
+}
-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)
}
}
+ 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;
}
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;
}
}
--- /dev/null
+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;
+}
/// </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;
}
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;
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;
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;
private EntityQuery<MapGridComponent> _gridQuery;
private static readonly EntProtoId DefaultAi = "StationAiBrain";
+ private readonly ProtoId<ChatNotificationPrototype> _downloadChatNotificationPrototype = "IntellicardDownload";
private const float MaxVisionMultiplier = 5f;
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)
_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)
--- /dev/null
+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
+{
+
+}
# 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
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
--- /dev/null
+- 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
components:
- type: AccessReader
access: [["StationAi"], ["ResearchDirector"]]
+ - type: StationAiTurret
- type: TurretTargetSettings
exemptAccessLevels:
- Borg