diff --git a/Content.Client/_Floof/Examine/CustomExamineSystem.cs b/Content.Client/_Floof/Examine/CustomExamineSystem.cs index da1f813d1d3..8ec61704fa5 100644 --- a/Content.Client/_Floof/Examine/CustomExamineSystem.cs +++ b/Content.Client/_Floof/Examine/CustomExamineSystem.cs @@ -31,7 +31,7 @@ public override void Initialize() private void OnGetVerbs(GetVerbsEvent args) { - if (_player.LocalSession is null || !CanChangeExamine(_player.LocalSession, args.Target)) + if (_player.LocalSession is null || !CanChangeExamine(_player.LocalSession, args.Target, out _)) return; var target = args.Target; @@ -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/_Common/Consent/SharedConsentSystem.cs b/Content.Shared/_Common/Consent/SharedConsentSystem.cs index a9b521fd521..dfb33354d1f 100644 --- a/Content.Shared/_Common/Consent/SharedConsentSystem.cs +++ b/Content.Shared/_Common/Consent/SharedConsentSystem.cs @@ -35,7 +35,7 @@ public override void Initialize() public bool HasConsent(Entity ent, ProtoId consentId) { - if (!Resolve(ent, ref ent.Comp)) + if (!Resolve(ent, ref ent.Comp, false)) // Don't log an error { // Entities that have never been controlled by a player consent to all mechanics. return true; 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..1e9dc04ffbd 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; @@ -18,10 +22,16 @@ namespace Content.Shared._Floof.Examine; public abstract class SharedCustomExamineSystem : EntitySystem { public static ProtoId NsfwDescConsent = "NSFWDescriptions"; + public static ProtoId CustomExamineChangedByOthersConsent = "CustomExamineChangedByOthers"; public static int PublicMaxLength = 256, SubtleMaxLength = 256; /// 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); + /// The time multiplier for changing examine of another player. + public static float SlowCustomExaminePlayerPenalty = 2; + private static readonly string[] AllowedTags = // This sucks, shared markup when [ "bolditalic", @@ -35,15 +45,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 +97,99 @@ private void OnExamined(Entity ent, ref ExaminedEvent ar } } - protected bool CanChangeExamine(ICommonSession actor, EntityUid examinee) + private void OnSetCustomExamineMessage(SetCustomExamineMessage msg, EntitySessionEventArgs args) + { + 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, out var reasonLoc)) + { + SetData(msg.PublicData, msg.SubtleData, target); + return; + } + + if (CanSlowlyChangeExamine(args.SenderSession, target, out reasonLoc)) + { + var user = args.SenderSession.AttachedEntity!.Value; // CanSlowlyChangeExamine ensures its not null + if (TryStartExamineChangeDoAfter(msg.PublicData, msg.SubtleData, user, target)) + return; + } + + // Show a popup to the user if it fails. 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 && reasonLoc is not null) + _popups.PopupEntity(Loc.GetString(reasonLoc), target, args.SenderSession, PopupType.Medium); + } + + private void OnSetExamineDoAfter(SetCustomExamineDoAfterEvent args) { - return actor.AttachedEntity == examinee && _actionBlocker.CanConsciouslyPerformAction(examinee) - || _admin.IsAdmin(actor); + if (args.Cancelled || args.Handled || args.Target is not {} target) + return; + + // Sanity check + if (!CanSlowlyChangeExamine(args.User, target, out _)) + return; + + SetData(args.PublicData, args.SubtleData, target); + // Small popup to let other players know what happened + _popups.PopupPredicted(Loc.GetString("custom-examine-data-changed-visibly"), target, null); } + /// + /// Returns true if the player can instantly change custom examine. + /// + protected bool CanInstantlyChangeExamine(ICommonSession actor, EntityUid examinee, out string? reasonLoc) + { + if (actor.AttachedEntity == examinee && _actionBlocker.CanConsciouslyPerformAction(examinee) + || _admin.IsAdmin(actor) && HasComp(actor.AttachedEntity)) // Must be an aghost, not just an adminned player + { + reasonLoc = null; + return true; + } + + reasonLoc = "custom-examine-cant-change-data-generic"; + return false; + } + + protected bool CanSlowlyChangeExamine(ICommonSession actor, EntityUid examinee, out string? reasonLoc) + { + reasonLoc = null; + return actor.AttachedEntity is { } user && CanSlowlyChangeExamine(user, examinee, out reasonLoc); + } + + private bool CanSlowlyChangeExamine(EntityUid user, EntityUid examinee, out string? reasonLoc) + { + if (!_actionBlocker.CanInteract(user, examinee) + || HasComp(user) // This sucks, but ghosts actually CAN interact with anything + || !_interactions.InRangeAndAccessible(user, examinee)) + { + reasonLoc = "custom-examine-cant-interact"; + return false; + } + + // This assumes user != target, prevent the menu from showing up if the target hasn't consented to it + if (HasComp(examinee) && !_consent.HasConsent(examinee, CustomExamineChangedByOthersConsent)) + { + reasonLoc = "custom-examine-cant-change-data-consent"; + return false; + } + + reasonLoc = null; + return true; + } + + /// + /// Returns true if the player can change examine at all. + /// + protected bool CanChangeExamine(ICommonSession actor, EntityUid examinee, out string? reasonLoc) => + CanInstantlyChangeExamine(actor, examinee, out reasonLoc) || CanSlowlyChangeExamine(actor, examinee, out reasonLoc); + private void CheckExpirations(Entity ent) { bool Check(ref CustomExamineData data) @@ -129,6 +233,64 @@ protected void TrimData(ref CustomExamineData data) data.Content = null; } + /// + /// Sets custom examine data on the entity and dirties it. This performs NO checks. + /// + 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); + } + + /// + /// Tries to start a do-after that would change the custom examine of another player. Returns true if the do-after has started or has already been going. + /// This will perform some consent checks. + /// + public bool TryStartExamineChangeDoAfter(CustomExamineData publicData, CustomExamineData subtleData, EntityUid user, EntityUid target, bool quiet = false) + { + // Basic consent check is already done in CanChangeExamine + // Sanitize message data - remove NSFW contents if the target didn't consent for it + if (!_consent.HasConsent(target, NsfwDescConsent)) + { + if (publicData.RequiresConsent) + publicData.Content = ""; + if (subtleData.RequiresConsent) + subtleData.Content = ""; + } + + // If it's a player, change the do-after length respectively and show a popup for them + var delay = SlowCustomExamineChangeDuration; + if (HasComp(target)) + { + delay *= SlowCustomExaminePlayerPenalty; + if (_net.IsServer && !quiet) // The target will never predict it + _popups.PopupEntity(Loc.GetString("custom-examine-do-after-started-target", ("user", user)), target, target, PopupType.SmallCaution); + } + + var doAfterArgs = new DoAfterArgs + { + DuplicateCondition = DuplicateConditions.SameEvent, + CancelDuplicate = true, + BreakOnDamage = true, + BreakOnMove = true, + BreakOnWeightlessMove = true, + NeedHand = false, + RequireCanInteract = true, + Target = target, + User = user, + Delay = delay, + Event = new SetCustomExamineDoAfterEvent(publicData, subtleData), + Broadcast = true, // No component to listen on + }; + + return _doAfters.TryStartDoAfter(doAfterArgs); + } + 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..3f76aff7163 100644 --- a/Resources/Locale/en-US/_Floof/examine/custom-examine.ftl +++ b/Resources/Locale/en-US/_Floof/examine/custom-examine.ftl @@ -1,9 +1,11 @@ custom-exam-guidelines-heading = Guidelines custom-exam-guidelines = + {"["}color=yellow]0.[/color] This menu is scrollable. {"["}color=yellow]1.[/color] Be [color=#8020ff]concise[/color]. Not everyone will have time to read long paragraphs. Try not to put static character descriptions here. - {"["}color=yellow]2.[/color] When including [color=red]ERP/NSFW content[/color], enable the [color=8020ff]NSFW toggle[/color]. Only people consenting to it will see it. - {"["}color=yellow]3.[/color] Stay [color=8020ff]in-character[/color]. Include things other character would [color=8020ff]see[/color] or otherwise [color=8020ff]perceive[/color] about your character. Don't include subjective info, for example "they look good". - {"["}color=yellow]4.[/color] If necessary, you can use same markup tags in your text as on paper (for example, "\[color=red\]red text\[color\]" to create [color=red]red text[/color]). However, you cannot use headings (because they increase font size drastically). Markup tags won't count towards the character limit. + {"["}color=yellow]2.[/color] When including [color=red]ERP/NSFW content[/color], enable the [color=8020ff]NSFW toggle[/color]. Only people consenting to it will see it. + {"["}color=yellow]3.[/color] Stay [color=8020ff]in-character[/color]. Include things other character would [color=8020ff]see[/color] or otherwise [color=8020ff]perceive[/color] about your character. Don't include subjective info, for example "they look good". + {"["}color=yellow]4.[/color] If necessary, you can use same markup tags in your text as on paper (for example, "\[color=red\]red text\[color\]" to create [color=red]red text[/color]). However, you cannot use headings (because they increase font size drastically). Markup tags won't count towards the character limit. + {"["}color=yellow]5.[/color] You can modify the custom examine of any other entity you can interact with. This will involve a slight do-after. If the object whose examine you're trying to interact is a player, then you can only change it if they have consented to that in their consent settings. custom-exam-part-title-nsfw = NSFW (require consent) custom-exam-title-distance = Visible at distance: custom-exam-title-expiration = Clear after (minutes): @@ -22,3 +24,10 @@ custom-examine-too-long = (too long, may lose data) custom-examine-verb = Custom Examine custom-examine-nsfw-hidden = [color=#9999a9]