using Content.Shared.Administration;
using Content.Shared.CCVar;
using Content.Shared.Chat;
+using Content.Shared.Damage.ForceSay;
using Content.Shared.Examine;
using Content.Shared.Input;
using Content.Shared.Radio;
using Robust.Shared.Input.Binding;
using Robust.Shared.Map;
using Robust.Shared.Network;
+using Robust.Shared.Random;
using Robust.Shared.Replays;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
_player.LocalPlayerChanged += OnLocalPlayerChanged;
_state.OnStateChanged += StateChanged;
_net.RegisterNetMessage<MsgChatMessage>(OnChatMessage);
+ SubscribeNetworkEvent<DamageForceSayEvent>(OnDamageForceSay);
_speechBubbleRoot = new LayoutContainer();
_manager.SendMessage(text, prefixChannel == 0 ? channel : prefixChannel);
}
+ private void OnDamageForceSay(DamageForceSayEvent ev, EntitySessionEventArgs _)
+ {
+ if (UIManager.ActiveScreen?.GetWidget<ChatBox>() is not { } chatBox)
+ return;
+
+ // Don't send on OOC/LOOC obviously!
+ if (chatBox.SelectedChannel is not
+ (ChatSelectChannel.Local or
+ ChatSelectChannel.Radio or
+ ChatSelectChannel.Whisper))
+ return;
+
+ if (_player.LocalPlayer?.ControlledEntity is not { } ent
+ || !EntityManager.TryGetComponent<DamageForceSayComponent>(ent, out var forceSay))
+ return;
+
+ var msg = chatBox.ChatInput.Input.Text.TrimEnd();
+
+ if (string.IsNullOrWhiteSpace(msg))
+ return;
+
+ var modifiedText = ev.Suffix != null
+ ? Loc.GetString(forceSay.ForceSayMessageWrap,
+ ("message", msg), ("suffix", ev.Suffix))
+ : Loc.GetString(forceSay.ForceSayMessageWrapNoSuffix,
+ ("message", msg));
+
+ chatBox.ChatInput.Input.SetText(modifiedText);
+ chatBox.ChatInput.Input.ForceSubmitText();
+ }
+
private void OnChatMessage(MsgChatMessage message)
{
var msg = message.Message;
--- /dev/null
+using Content.Shared.Damage;
+using Content.Shared.Damage.ForceSay;
+using Content.Shared.FixedPoint;
+using Content.Shared.Mobs;
+using Content.Shared.Mobs.Systems;
+using Content.Shared.Stunnable;
+using Robust.Server.GameObjects;
+using Robust.Server.Player;
+using Robust.Shared.Players;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Damage.ForceSay;
+
+/// <inheritdoc cref="DamageForceSayComponent"/>
+public sealed class DamageForceSaySystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent<DamageForceSayComponent, StunnedEvent>(OnStunned);
+ SubscribeLocalEvent<DamageForceSayComponent, MobStateChangedEvent>(OnMobStateChanged);
+
+ // need to raise after mobthreshold
+ // so that we don't accidentally raise one for damage before one for mobstate
+ // (this won't double raise, because of the cooldown)
+ SubscribeLocalEvent<DamageForceSayComponent, DamageChangedEvent>(OnDamageChanged, after: new []{ typeof(MobThresholdSystem)} );
+ SubscribeLocalEvent<DamageForceSayComponent, SleepStateChangedEvent>(OnSleep);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = AllEntityQuery<AllowNextCritSpeechComponent>();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (_timing.CurTime < comp.Timeout)
+ continue;
+
+ RemCompDeferred<AllowNextCritSpeechComponent>(uid);
+ }
+ }
+
+ private void TryForceSay(EntityUid uid, DamageForceSayComponent component, bool useSuffix=true, string? suffixOverride = null)
+ {
+ if (!TryComp<ActorComponent>(uid, out var actor))
+ return;
+
+ // disallow if cooldown hasn't ended
+ if (component.NextAllowedTime != null &&
+ _timing.CurTime < component.NextAllowedTime)
+ return;
+
+ var suffix = Loc.GetString(suffixOverride ?? component.ForceSayStringPrefix + _random.Next(1, component.ForceSayStringCount));
+
+ // set cooldown & raise event
+ component.NextAllowedTime = _timing.CurTime + component.Cooldown;
+ RaiseNetworkEvent(new DamageForceSayEvent { Suffix = useSuffix ? suffix : null }, actor.PlayerSession);
+ }
+
+ private void AllowNextSpeech(EntityUid uid)
+ {
+ if (!TryComp<ActorComponent>(uid, out var actor))
+ return;
+
+ var nextCrit = EnsureComp<AllowNextCritSpeechComponent>(uid);
+
+ // timeout is *3 ping to compensate for roundtrip + leeway
+ nextCrit.Timeout = _timing.CurTime + TimeSpan.FromMilliseconds(actor.PlayerSession.Ping * 3);
+ }
+
+ private void OnSleep(EntityUid uid, DamageForceSayComponent component, SleepStateChangedEvent args)
+ {
+ if (!args.FellAsleep)
+ return;
+
+ TryForceSay(uid, component, true, "damage-force-say-sleep");
+ AllowNextSpeech(uid);
+ }
+
+ private void OnStunned(EntityUid uid, DamageForceSayComponent component, ref StunnedEvent args)
+ {
+ TryForceSay(uid, component);
+ }
+
+ private void OnDamageChanged(EntityUid uid, DamageForceSayComponent component, DamageChangedEvent args)
+ {
+ if (args.DamageDelta == null || !args.DamageIncreased || args.DamageDelta.Total < component.DamageThreshold)
+ return;
+
+ if (component.ValidDamageGroups != null)
+ {
+ var totalApplicableDamage = FixedPoint2.Zero;
+ foreach (var (group, value) in args.DamageDelta.GetDamagePerGroup(_prototype))
+ {
+ if (!component.ValidDamageGroups.Contains(group))
+ continue;
+
+ totalApplicableDamage += value;
+ }
+
+ if (totalApplicableDamage < component.DamageThreshold)
+ return;
+ }
+
+ TryForceSay(uid, component);
+ }
+
+ private void OnMobStateChanged(EntityUid uid, DamageForceSayComponent component, MobStateChangedEvent args)
+ {
+ if (args is not { OldMobState: MobState.Alive, NewMobState: MobState.Critical or MobState.Dead })
+ return;
+
+ // no suffix for the drama
+ // LING IN MAI-
+ TryForceSay(uid, component, false);
+ AllowNextSpeech(uid);
+ }
+}
using Content.Shared.Actions;
using Content.Shared.Bed.Sleep;
+using Content.Shared.Damage.ForceSay;
using Content.Shared.Eye.Blinding.Systems;
using Content.Shared.Speech;
using Robust.Shared.Network;
private void OnSpeakAttempt(EntityUid uid, SleepingComponent component, SpeakAttemptEvent args)
{
+ // TODO reduce duplication of this behavior with MobStateSystem somehow
+ if (HasComp<AllowNextCritSpeechComponent>(uid))
+ {
+ RemCompDeferred<AllowNextCritSpeechComponent>(uid);
+ return;
+ }
+
args.Cancel();
}
--- /dev/null
+using Content.Shared.Mobs.Components;
+using Content.Shared.Mobs.Systems;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Damage.ForceSay;
+
+/// <summary>
+/// The reason for this component's existence is slightly unintuitive, so for context: this is put on an entity
+/// to allow its next speech attempt to bypass <see cref="MobStateComponent"/> checks. The reason for this is to allow
+/// 'force saying'--for instance, with deathgasping or with <see cref="DamageForceSayComponent"/>.
+///
+/// This component is either removed in the <see cref="MobStateSystem"/> speech attempt check, or after <see cref="Timeout"/>
+/// has passed. This is to allow a player-submitted forced message in the case of <see cref="DamageForceSayComponent"/>,
+/// while also ensuring that it isn't valid forever. It has to work this way, because the server is not a keylogger and doesn't
+/// have any knowledge of what the client might actually have typed, so it gives them some leeway for ping.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class AllowNextCritSpeechComponent : Component
+{
+ /// <summary>
+ /// Should be set when adding the component to specify the time that this should be valid for,
+ /// if it should stay valid for some amount of time.
+ /// </summary>
+ public TimeSpan? Timeout = null;
+}
--- /dev/null
+using Content.Shared.Damage.Prototypes;
+using Content.Shared.FixedPoint;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set;
+
+namespace Content.Shared.Damage.ForceSay;
+
+/// <summary>
+/// This is used for forcing clients to send messages with a suffix attached (like -GLORF) when taking large amounts
+/// of damage, or things like entering crit or being stunned.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class DamageForceSayComponent : Component
+{
+ /// <summary>
+ /// The localization string that the message & suffix will be passed into
+ /// </summary>
+ [DataField]
+ public LocId ForceSayMessageWrap = "damage-force-say-message-wrap";
+
+ /// <summary>
+ /// Same as <see cref="ForceSayMessageWrap"/> but for cases where no suffix is used,
+ /// such as when going into crit.
+ /// </summary>
+ [DataField]
+ public LocId ForceSayMessageWrapNoSuffix = "damage-force-say-message-wrap-no-suffix";
+
+ /// <summary>
+ /// The fluent string prefix to use when picking a random suffix
+ /// </summary>
+ [DataField]
+ public string ForceSayStringPrefix = "damage-force-say-";
+
+ /// <summary>
+ /// The number of suffixes that exist for use with <see cref="ForceSayStringPrefix"/>.
+ /// i.e. (prefix)-1 through (prefix)-(count)
+ /// </summary>
+ [DataField]
+ public int ForceSayStringCount = 7;
+
+ /// <summary>
+ /// The amount of total damage between <see cref="ValidDamageGroups"/> that needs to be taken before
+ /// a force say occurs.
+ /// </summary>
+ [DataField]
+ public FixedPoint2 DamageThreshold = FixedPoint2.New(10);
+
+ /// <summary>
+ /// A list of damage group types that are considered when checking <see cref="DamageThreshold"/>.
+ /// </summary>
+ [DataField]
+ public HashSet<ProtoId<DamageGroupPrototype>>? ValidDamageGroups = new()
+ {
+ "Brute",
+ "Burn",
+ };
+
+ /// <summary>
+ /// The time enforced between force says to avoid spam.
+ /// </summary>
+ [DataField]
+ public TimeSpan Cooldown = TimeSpan.FromSeconds(5.0);
+
+ public TimeSpan? NextAllowedTime = null;
+}
--- /dev/null
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Damage.ForceSay;
+
+/// <summary>
+/// Sent to clients as a network event when their entity contains <see cref="DamageForceSayComponent"/>
+/// that COMMANDS them to speak the current message in their chatbox
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class DamageForceSayEvent : EntityEventArgs
+{
+ public string? Suffix;
+}
using Content.Shared.Bed.Sleep;
+using Content.Shared.Damage.ForceSay;
using Content.Shared.Emoting;
using Content.Shared.Hands;
using Content.Shared.Interaction;
SubscribeLocalEvent<MobStateComponent, AttackAttemptEvent>(CheckAct);
SubscribeLocalEvent<MobStateComponent, InteractionAttemptEvent>(CheckAct);
SubscribeLocalEvent<MobStateComponent, ThrowAttemptEvent>(CheckAct);
- SubscribeLocalEvent<MobStateComponent, SpeakAttemptEvent>(CheckAct);
+ SubscribeLocalEvent<MobStateComponent, SpeakAttemptEvent>(OnSpeakAttempt);
SubscribeLocalEvent<MobStateComponent, IsEquippingAttemptEvent>(OnEquipAttempt);
SubscribeLocalEvent<MobStateComponent, EmoteAttemptEvent>(CheckAct);
SubscribeLocalEvent<MobStateComponent, IsUnequippingAttemptEvent>(OnUnequipAttempt);
args.Multiplier /= 2;
}
+ private void OnSpeakAttempt(EntityUid uid, MobStateComponent component, SpeakAttemptEvent args)
+ {
+ if (HasComp<AllowNextCritSpeechComponent>(uid))
+ {
+ RemCompDeferred<AllowNextCritSpeechComponent>(uid);
+ return;
+ }
+
+ CheckAct(uid, component, args);
+ }
+
private void CheckAct(EntityUid target, MobStateComponent component, CancellableEntityEventArgs args)
{
switch (component.CurrentState)
if (!_statusEffect.TryAddStatusEffect<StunnedComponent>(uid, "Stun", time, refresh))
return false;
+
+ var ev = new StunnedEvent();
+ RaiseLocalEvent(uid, ref ev);
+
_adminLogger.Add(LogType.Stamina, LogImpact.Medium, $"{ToPrettyString(uid):user} stunned for {time.Seconds} seconds");
return true;
}
if (!Resolve(uid, ref status, false))
return false;
- return _statusEffect.TryAddStatusEffect<KnockedDownComponent>(uid, "KnockedDown", time, refresh);
+ if (!_statusEffect.TryAddStatusEffect<KnockedDownComponent>(uid, "KnockedDown", time, refresh))
+ return false;
+
+ var ev = new KnockedDownEvent();
+ RaiseLocalEvent(uid, ref ev);
+
+ return true;
}
/// <summary>
}
#endregion
-
}
+
+/// <summary>
+/// Raised directed on an entity when it is stunned.
+/// </summary>
+[ByRefEvent]
+public record struct StunnedEvent;
+
+/// <summary>
+/// Raised directed on an entity when it is knocked down.
+/// </summary>
+[ByRefEvent]
+public record struct KnockedDownEvent;
--- /dev/null
+damage-force-say-message-wrap = {$message}-{$suffix}
+damage-force-say-message-wrap-no-suffix = {$message}-
+
+damage-force-say-1 = GACK!
+damage-force-say-2 = GLORF!
+damage-force-say-3 = OOF!
+damage-force-say-4 = AUGH!
+damage-force-say-5 = OW!
+damage-force-say-6 = URGH!
+damage-force-say-7 = HRNK!
+
+damage-force-say-sleep = zzz...
- type: Puller
- type: Speech
speechSounds: Alto
+ - type: DamageForceSay
- type: Vocal
sounds:
Male: MaleHuman