Sentry turrets - Part 8: AI notifications (#35277)

This commit is contained in:
chromiumboy
2025-08-08 13:56:01 -05:00
committed by GitHub
parent 1d21e13360
commit c3555af821
10 changed files with 291 additions and 72 deletions

View 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);
}
}

View 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))
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;
}
}

View 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;
}

View 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;
}

View 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)

View 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
{
}

View 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

View 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.
station-ai-turret-component-name = {$name} ({$address})
station-ai-turret-component-is-attacking-warning = {CAPITALIZE($source)} has engaged a hostile target.

View 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

View File

@@ -144,6 +144,7 @@
components:
- type: AccessReader
access: [["StationAi"], ["ResearchDirector"]]
- type: StationAiTurret
- type: TurretTargetSettings
exemptAccessLevels:
- Borg