Sentry turrets - Part 8: AI notifications (#35277)
This commit is contained in:
105
Content.Server/Chat/Systems/ChatNotificationSystem.cs
Normal file
105
Content.Server/Chat/Systems/ChatNotificationSystem.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
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;
|
||||
|
||||
// TEST
|
||||
// filter = Filter.Broadcast();
|
||||
var ev = new ChatNotificationEvent(_aiWireSnippedChatNotificationPrototype, uid);
|
||||
|
||||
// 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));
|
||||
ev.SourceNameOverride = tile.ToString();
|
||||
|
||||
_chats.ChatMessageToMany(ChatChannel.Notifications, msg, msg, entity, false, true, filter.Recipients.Select(o => o.Channel));
|
||||
// Apparently there's no sound for this.
|
||||
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<EntityUid> GetStationAIs(EntityUid gridUid)
|
||||
{
|
||||
_stationAiCores.Clear();
|
||||
_lookup.GetChildEntities(gridUid, _stationAiCores);
|
||||
|
||||
var hashSet = new HashSet<EntityUid>();
|
||||
|
||||
foreach (var stationAiCore in _stationAiCores)
|
||||
{
|
||||
if (!TryGetHeld((stationAiCore, stationAiCore.Comp), out var insertedAi))
|
||||
continue;
|
||||
|
||||
hashSet.Add(insertedAi);
|
||||
}
|
||||
|
||||
return hashSet;
|
||||
}
|
||||
}
|
||||
|
||||
74
Content.Shared/Chat/Prototypes/ChatNotificationPrototype.cs
Normal file
74
Content.Shared/Chat/Prototypes/ChatNotificationPrototype.cs
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
12
Content.Shared/Turrets/StationAiTurretComponent.cs
Normal file
12
Content.Shared/Turrets/StationAiTurretComponent.cs
Normal file
@@ -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
|
||||
{
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
station-ai-turret-component-name = {$name} ({$address})
|
||||
station-ai-turret-component-is-attacking-warning = {CAPITALIZE($source)} has engaged a hostile target.
|
||||
21
Resources/Prototypes/Chat/notifications.yml
Normal file
21
Resources/Prototypes/Chat/notifications.yml
Normal file
@@ -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
|
||||
@@ -144,6 +144,7 @@
|
||||
components:
|
||||
- type: AccessReader
|
||||
access: [["StationAi"], ["ResearchDirector"]]
|
||||
- type: StationAiTurret
|
||||
- type: TurretTargetSettings
|
||||
exemptAccessLevels:
|
||||
- Borg
|
||||
|
||||
Reference in New Issue
Block a user