From 15667824d381369fc9b0719b4bb78c53d583d561 Mon Sep 17 00:00:00 2001 From: Mnemotechnician <69920617+mnemotechnician@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:18:17 +0300 Subject: [PATCH 1/6] Allow changing the custom examine of other entities --- .../_Floof/Examine/CustomExamineSystem.cs | 2 +- .../_Floof/Examine/CustomExamineSystem.cs | 26 +--- .../_Floof/Examine/CustomExamineComponent.cs | 14 +- .../_Floof/Examine/CustomExamineSystem.cs | 121 +++++++++++++++++- .../Examine/SetCustomExamineDoAfterEvent.cs | 24 ++++ .../en-US/_Floof/examine/custom-examine.ftl | 4 + 6 files changed, 158 insertions(+), 33 deletions(-) create mode 100644 Content.Shared/_Floof/Examine/SetCustomExamineDoAfterEvent.cs diff --git a/Content.Client/_Floof/Examine/CustomExamineSystem.cs b/Content.Client/_Floof/Examine/CustomExamineSystem.cs index da1f813d1d3..1a6a27d5372 100644 --- a/Content.Client/_Floof/Examine/CustomExamineSystem.cs +++ b/Content.Client/_Floof/Examine/CustomExamineSystem.cs @@ -88,7 +88,7 @@ private void OpenUi(EntityUid target) SubtleData = data.subtleData, Target = GetNetEntity(target) }; - RaiseNetworkEvent(ev); + RaisePredictiveEvent(ev); }; } diff --git a/Content.Server/_Floof/Examine/CustomExamineSystem.cs b/Content.Server/_Floof/Examine/CustomExamineSystem.cs index 46616ecac74..e6d241e9e03 100644 --- a/Content.Server/_Floof/Examine/CustomExamineSystem.cs +++ b/Content.Server/_Floof/Examine/CustomExamineSystem.cs @@ -3,27 +3,5 @@ namespace Content.Server._Floof.Examine; - -public sealed class CustomExamineSystem : SharedCustomExamineSystem -{ - public override void Initialize() - { - base.Initialize(); - SubscribeNetworkEvent(OnSetCustomExamineMessage); - } - - private void OnSetCustomExamineMessage(SetCustomExamineMessage msg, EntitySessionEventArgs args) - { - var target = GetEntity(msg.Target); - if (!CanChangeExamine(args.SenderSession, target)) - return; - - var comp = EnsureComp(target); - - TrimData(ref msg.PublicData, ref msg.SubtleData); - comp.PublicData = msg.PublicData; - comp.SubtleData = msg.SubtleData; - - Dirty(target, comp); - } -} +/// Abstract class implementation. This used to contain the SetData logic, but now it's predicted. +public sealed class CustomExamineSystem : SharedCustomExamineSystem { } diff --git a/Content.Shared/_Floof/Examine/CustomExamineComponent.cs b/Content.Shared/_Floof/Examine/CustomExamineComponent.cs index 6dcd9b1c257..851e79e093c 100644 --- a/Content.Shared/_Floof/Examine/CustomExamineComponent.cs +++ b/Content.Shared/_Floof/Examine/CustomExamineComponent.cs @@ -1,3 +1,4 @@ +using System.Collections; using Robust.Shared.GameStates; using Robust.Shared.Serialization; @@ -34,7 +35,7 @@ public sealed partial class CustomExamineComponent : Component } [DataDefinition, Serializable, NetSerializable] -public partial struct CustomExamineData +public partial struct CustomExamineData : IEquatable { [DataField] public string? Content; @@ -59,4 +60,15 @@ public partial struct CustomExamineData /// [DataField] public TimeSpan LastUpdate; + + // God bless Rider for generating this + public bool Equals(CustomExamineData other) => + Content == other.Content + && VisibilityRange == other.VisibilityRange + && ExpireTime.Equals(other.ExpireTime) + && RequiresConsent == other.RequiresConsent + && LastUpdate.Equals(other.LastUpdate); + + public override bool Equals(object? obj) => obj is CustomExamineData other && Equals(other); + public override int GetHashCode() => HashCode.Combine(Content, VisibilityRange, ExpireTime, RequiresConsent, LastUpdate); } diff --git a/Content.Shared/_Floof/Examine/CustomExamineSystem.cs b/Content.Shared/_Floof/Examine/CustomExamineSystem.cs index d9f1f95d69d..3628df76c00 100644 --- a/Content.Shared/_Floof/Examine/CustomExamineSystem.cs +++ b/Content.Shared/_Floof/Examine/CustomExamineSystem.cs @@ -1,11 +1,15 @@ -using System.Linq; -using System.Text.RegularExpressions; +using Content.Server._Floof.Examine; using Content.Shared._Common.Consent; using Content.Shared._Floof.Util; using Content.Shared.ActionBlocker; using Content.Shared.Administration.Managers; +using Content.Shared.Chat; +using Content.Shared.DoAfter; using Content.Shared.Examine; +using Content.Shared.Ghost; using Content.Shared.Interaction; +using Content.Shared.Popups; +using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Timing; @@ -22,6 +26,9 @@ public abstract class SharedCustomExamineSystem : EntitySystem /// Max length of any content field, INCLUDING markup. public static int AbsolutelyMaxLength = 1024; + /// The time it takes to update the custom examine of another entity. + public static TimeSpan SlowCustomExamineChangeDuration = TimeSpan.FromSeconds(3); + private static readonly string[] AllowedTags = // This sucks, shared markup when [ "bolditalic", @@ -35,15 +42,22 @@ public abstract class SharedCustomExamineSystem : EntitySystem "language", ]; - [Dependency] private readonly SharedConsentSystem _consent = default!; - [Dependency] private readonly ExamineSystemShared _examine = default!; [Dependency] private readonly ISharedAdminManager _admin = default!; [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly INetManager _net = default!; + [Dependency] private readonly SharedConsentSystem _consent = default!; + [Dependency] private readonly ExamineSystemShared _examine = default!; [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!; + [Dependency] private readonly SharedInteractionSystem _interactions = default!; + [Dependency] private readonly SharedDoAfterSystem _doAfters = default!; + [Dependency] private readonly SharedPopupSystem _popups = default!; public override void Initialize() { SubscribeLocalEvent(OnExamined); + + SubscribeAllEvent(OnSetCustomExamineMessage); + SubscribeLocalEvent(OnSetExamineDoAfter); } private void OnExamined(Entity ent, ref ExaminedEvent args) @@ -80,12 +94,91 @@ private void OnExamined(Entity ent, ref ExaminedEvent ar } } - protected bool CanChangeExamine(ICommonSession actor, EntityUid examinee) + private void OnSetCustomExamineMessage(SetCustomExamineMessage msg, EntitySessionEventArgs args) { - return actor.AttachedEntity == examinee && _actionBlocker.CanConsciouslyPerformAction(examinee) - || _admin.IsAdmin(actor); + var target = GetEntity(msg.Target); + + // If custom examine data is the same as previous, don't bother + if (TryComp(target, out var oldExamine) + && oldExamine.PublicData.Equals(msg.PublicData) + && oldExamine.SubtleData.Equals(msg.SubtleData) + ) + return; + + + if (CanInstantlyChangeExamine(args.SenderSession, target)) + { + SetData(msg.PublicData, msg.SubtleData, target); + return; + } + + if (CanSlowlyChangeExamine(args.SenderSession, target)) + { + var user = args.SenderSession.AttachedEntity!.Value; // CanSlowlyChangeExamine ensures its not null + var doAfterArgs = new DoAfterArgs() + { + DuplicateCondition = DuplicateConditions.SameEvent, + BlockDuplicate = true, // We're predicting the event, so CancelDuplicate would probably cause mispredicts + BreakOnDamage = true, + BreakOnMove = true, + BreakOnWeightlessMove = true, + NeedHand = false, + RequireCanInteract = true, + Target = target, + User = user, + Delay = SlowCustomExamineChangeDuration, + Event = new SetCustomExamineDoAfterEvent(msg.PublicData, msg.SubtleData), + Broadcast = true, // No component to listen on + }; + + if (_doAfters.TryStartDoAfter(doAfterArgs)) + return; + } + + // I wanted to use a chat message here, but it feels kinda wack + // On the other hand popups can easily be obscured by the custom examine window. + if (_net.IsServer) + _popups.PopupEntity(Loc.GetString("custom-examine-cant-change-data"), target, args.SenderSession, PopupType.Medium); } + private void OnSetExamineDoAfter(ref SetCustomExamineDoAfterEvent args) + { + if (args.Cancelled || args.Handled || args.Target is not {} target) + return; + + // Sanity check + if (!CanSlowlyChangeExamine(args.User, target)) + return; + + SetData(args.PublicData, args.SubtleData, target); + // Small popup to let other players know what happened + _popups.PopupEntity(Loc.GetString("custom-examine-data-changed-visibly"), target); + } + + /// + /// Returns true if the player can instantly change custom examine (is + /// + /// + /// + /// + protected bool CanInstantlyChangeExamine(ICommonSession actor, EntityUid examinee) => + actor.AttachedEntity == examinee && _actionBlocker.CanConsciouslyPerformAction(examinee) + || _admin.IsAdmin(actor) && HasComp(actor.AttachedEntity); // Must be an aghost, not just an adminned player + + protected bool CanSlowlyChangeExamine(ICommonSession actor, EntityUid examinee) => + actor.AttachedEntity is { } user && CanSlowlyChangeExamine(user, examinee); + + private bool CanSlowlyChangeExamine(EntityUid user, EntityUid examinee) => + _actionBlocker.CanInteract(user, examinee) + && HasComp(user) // This sucks, but ghosts actually CAN interact with anything + && _interactions.InRangeAndAccessible(user, examinee); + + /// + /// Returns true if the player can change examine at all. + /// + protected bool CanChangeExamine(ICommonSession actor, EntityUid examinee) => + CanInstantlyChangeExamine(actor, examinee) || CanSlowlyChangeExamine(actor, examinee); + private void CheckExpirations(Entity ent) { bool Check(ref CustomExamineData data) @@ -129,6 +222,20 @@ protected void TrimData(ref CustomExamineData data) data.Content = null; } + /// + /// Sets custom examine data on the entity and dirties it. + /// + private void SetData(CustomExamineData publicData, CustomExamineData subtleData, EntityUid target) + { + var comp = EnsureComp(target); + + TrimData(ref publicData, ref subtleData); + comp.PublicData = publicData; + comp.SubtleData = subtleData; + + Dirty(target, comp); + } + protected int LengthWithoutMarkup(string text) => FormattedMessage.RemoveMarkupPermissive(text).Length; protected int MarkupLength(string text) => text.Length - LengthWithoutMarkup(text); diff --git a/Content.Shared/_Floof/Examine/SetCustomExamineDoAfterEvent.cs b/Content.Shared/_Floof/Examine/SetCustomExamineDoAfterEvent.cs new file mode 100644 index 00000000000..364a4ac19a5 --- /dev/null +++ b/Content.Shared/_Floof/Examine/SetCustomExamineDoAfterEvent.cs @@ -0,0 +1,24 @@ +using Content.Shared._Floof.Examine; +using Content.Shared.DoAfter; +using Robust.Shared.Serialization; + +namespace Content.Server._Floof.Examine; + +/// +/// Do-after event for changing examine data on a different entity. +/// +[Serializable, NetSerializable] +public sealed partial class SetCustomExamineDoAfterEvent : DoAfterEvent +{ + [DataField] + public CustomExamineData PublicData, SubtleData; + + public SetCustomExamineDoAfterEvent(CustomExamineData publicData, CustomExamineData subtleData) + { + PublicData = publicData; + SubtleData = subtleData; + } + + // Why are we doing this again? All upstream Clone() implementations just return `this`, and it doesn't seem to need cloning in the first place + public override DoAfterEvent Clone() => new SetCustomExamineDoAfterEvent(PublicData, SubtleData); +} diff --git a/Resources/Locale/en-US/_Floof/examine/custom-examine.ftl b/Resources/Locale/en-US/_Floof/examine/custom-examine.ftl index bad766c4ca1..e33f90c6a06 100644 --- a/Resources/Locale/en-US/_Floof/examine/custom-examine.ftl +++ b/Resources/Locale/en-US/_Floof/examine/custom-examine.ftl @@ -22,3 +22,7 @@ custom-examine-too-long = (too long, may lose data) custom-examine-verb = Custom Examine custom-examine-nsfw-hidden = [color=#9999a9]