using System.Linq; using Content.Shared.Administration.Logs; using Content.Shared.Alert; using Content.Shared.CCVar; using Content.Shared.CombatMode; using Content.Shared.Damage.Components; using Content.Shared.Damage.Events; using Content.Shared.Database; using Content.Shared.Effects; using Content.Shared.FixedPoint; using Content.Shared.Movement.Components; using Content.Shared.Movement.Systems; using Content.Shared.Projectiles; using Content.Shared.Rejuvenate; using Content.Shared.Rounding; using Content.Shared.StatusEffectNew; using Content.Shared.Stunnable; using Content.Shared.Throwing; using Content.Shared.Weapons.Melee.Events; using JetBrains.Annotations; using Robust.Shared.Audio; using Robust.Shared.Audio.Systems; using Robust.Shared.Configuration; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Serialization; using Robust.Shared.Timing; namespace Content.Shared.Damage.Systems; public abstract partial class SharedStaminaSystem : EntitySystem { public static readonly EntProtoId StaminaLow = "StatusEffectStaminaLow"; [Dependency] private readonly IConfigurationManager _config = default!; [Dependency] protected readonly IGameTiming Timing = default!; [Dependency] private readonly INetManager _net = default!; [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; [Dependency] private readonly AlertsSystem _alerts = default!; [Dependency] private readonly MetaDataSystem _metadata = default!; [Dependency] private readonly MovementModStatusSystem _movementMod = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly SharedColorFlashEffectSystem _color = default!; [Dependency] private readonly StatusEffectsSystem _status = default!; [Dependency] protected readonly SharedStunSystem StunSystem = default!; /// /// How much of a buffer is there between the stun duration and when stuns can be re-applied. /// protected static readonly TimeSpan StamCritBufferTime = TimeSpan.FromSeconds(3f); public float UniversalStaminaDamageModifier { get; private set; } = 1f; public override void Initialize() { base.Initialize(); InitializeModifier(); InitializeResistance(); SubscribeLocalEvent(OnStartup); SubscribeLocalEvent(OnShutdown); SubscribeLocalEvent(OnStamHandleState); SubscribeLocalEvent(OnDisarmed); SubscribeLocalEvent(OnRejuvenate); SubscribeLocalEvent(OnProjectileEmbed); SubscribeLocalEvent(OnProjectileHit); SubscribeLocalEvent(OnThrowHit); SubscribeLocalEvent(OnMeleeHit); Subs.CVar(_config, CCVars.PlaytestStaminaDamageModifier, value => UniversalStaminaDamageModifier = value, true); } protected virtual void OnStamHandleState(Entity entity, ref AfterAutoHandleStateEvent args) { if (entity.Comp.Critical) EnterStamCrit(entity); else { if (entity.Comp.StaminaDamage > 0f) EnsureComp(entity); ExitStamCrit(entity); } } protected virtual void OnShutdown(Entity entity, ref ComponentShutdown args) { if (MetaData(entity).EntityLifeStage < EntityLifeStage.Terminating) { RemCompDeferred(entity); } _alerts.ClearAlert(entity.Owner, entity.Comp.StaminaAlert); } private void OnStartup(Entity entity, ref ComponentStartup args) { UpdateStaminaVisuals(entity); } [PublicAPI] public float GetStaminaDamage(EntityUid uid, StaminaComponent? component = null) { if (!Resolve(uid, ref component)) return 0f; var curTime = Timing.CurTime; var pauseTime = _metadata.GetPauseTime(uid); return MathF.Max(0f, component.StaminaDamage - MathF.Max(0f, (float) (curTime - (component.NextUpdate + pauseTime)).TotalSeconds * component.Decay)); } private void OnRejuvenate(Entity entity, ref RejuvenateEvent args) { if (entity.Comp.StaminaDamage >= entity.Comp.CritThreshold) { ExitStamCrit(entity, entity.Comp); } entity.Comp.StaminaDamage = 0; AdjustStatus(entity.Owner); RemComp(entity); _status.TryRemoveStatusEffect(entity, StaminaLow); UpdateStaminaVisuals(entity); Dirty(entity); } private void OnDisarmed(EntityUid uid, StaminaComponent component, ref DisarmedEvent args) { if (args.Handled) return; if (component.Critical) return; var damage = args.PushProbability * component.CritThreshold; TakeStaminaDamage(uid, damage, component, source: args.Source); args.PopupPrefix = "disarm-action-shove-"; args.IsStunned = component.Critical; args.Handled = true; } private void OnMeleeHit(EntityUid uid, StaminaDamageOnHitComponent component, MeleeHitEvent args) { if (!args.IsHit || !args.HitEntities.Any() || component.Damage <= 0f) { return; } var ev = new StaminaDamageOnHitAttemptEvent(); RaiseLocalEvent(uid, ref ev); if (ev.Cancelled) return; var stamQuery = GetEntityQuery(); var toHit = new List<(EntityUid Entity, StaminaComponent Component)>(); // Split stamina damage between all eligible targets. foreach (var ent in args.HitEntities) { if (!stamQuery.TryGetComponent(ent, out var stam)) continue; toHit.Add((ent, stam)); } var hitEvent = new StaminaMeleeHitEvent(toHit); RaiseLocalEvent(uid, hitEvent); if (hitEvent.Handled) return; var damage = component.Damage; damage *= hitEvent.Multiplier; damage += hitEvent.FlatModifier; foreach (var (ent, comp) in toHit) { TakeStaminaDamage(ent, damage / toHit.Count, comp, source: args.User, with: args.Weapon, sound: component.Sound); } } private void OnProjectileHit(EntityUid uid, StaminaDamageOnCollideComponent component, ref ProjectileHitEvent args) { OnCollide(uid, component, args.Target); } private void OnProjectileEmbed(EntityUid uid, StaminaDamageOnEmbedComponent component, ref EmbedEvent args) { if (!TryComp(args.Embedded, out var stamina)) return; TakeStaminaDamage(args.Embedded, component.Damage, stamina, source: uid); } private void OnThrowHit(EntityUid uid, StaminaDamageOnCollideComponent component, ThrowDoHitEvent args) { OnCollide(uid, component, args.Target); } private void OnCollide(EntityUid uid, StaminaDamageOnCollideComponent component, EntityUid target) { // you can't inflict stamina damage on things with no stamina component // this prevents stun batons from using up charges when throwing it at lockers or lights if (!HasComp(target)) return; var ev = new StaminaDamageOnHitAttemptEvent(); RaiseLocalEvent(uid, ref ev); if (ev.Cancelled) return; TakeStaminaDamage(target, component.Damage, source: uid, sound: component.Sound); } private void UpdateStaminaVisuals(Entity entity) { SetStaminaAlert(entity, entity.Comp); SetStaminaAnimation(entity); } // Here so server can properly tell all clients in PVS range to start the animation protected virtual void SetStaminaAnimation(Entity entity){} private void SetStaminaAlert(EntityUid uid, StaminaComponent? component = null) { if (!Resolve(uid, ref component, false) || component.Deleted) return; var severity = ContentHelpers.RoundToLevels(MathF.Max(0f, component.CritThreshold - component.StaminaDamage), component.CritThreshold, 7); _alerts.ShowAlert(uid, component.StaminaAlert, (short) severity); } /// /// Tries to take stamina damage without raising the entity over the crit threshold. /// public bool TryTakeStamina(EntityUid uid, float value, StaminaComponent? component = null, EntityUid? source = null, EntityUid? with = null, bool visual = false) { // Something that has no Stamina component automatically passes stamina checks if (!Resolve(uid, ref component, false)) return true; var oldStam = component.StaminaDamage; if (oldStam + value >= component.CritThreshold || component.Critical) return false; TakeStaminaDamage(uid, value, component, source, with, visual: visual); return true; } public void TakeStaminaDamage(EntityUid uid, float value, StaminaComponent? component = null, EntityUid? source = null, EntityUid? with = null, bool visual = true, SoundSpecifier? sound = null, bool ignoreResist = false) { if (!Resolve(uid, ref component, false)) return; var ev = new BeforeStaminaDamageEvent(value); RaiseLocalEvent(uid, ref ev); if (ev.Cancelled) return; // Allow stamina resistance to be applied. if (!ignoreResist) { value = ev.Value; } value = UniversalStaminaDamageModifier * value; // Have we already reached the point of max stamina damage? if (component.Critical) return; var oldDamage = component.StaminaDamage; component.StaminaDamage = MathF.Max(0f, component.StaminaDamage + value); // Reset the decay cooldown upon taking damage. if (oldDamage < component.StaminaDamage) { var nextUpdate = Timing.CurTime + TimeSpan.FromSeconds(component.Cooldown); if (component.NextUpdate < nextUpdate) component.NextUpdate = nextUpdate; } AdjustStatus(uid); UpdateStaminaVisuals((uid, component)); // Checking if the stamina damage has decreased to zero after exiting the stamcrit if (component.AfterCritical && oldDamage > component.StaminaDamage && component.StaminaDamage <= 0f) { component.AfterCritical = false; // Since the recovery from the crit has been completed, we are no longer 'after crit' _status.TryRemoveStatusEffect(uid, StaminaLow); } if (!component.Critical) { if (component.StaminaDamage >= component.CritThreshold) { EnterStamCrit(uid, component); } } else { if (component.StaminaDamage < component.CritThreshold) { ExitStamCrit(uid, component); } } EnsureComp(uid); Dirty(uid, component); if (value <= 0) return; if (source != null) { _adminLogger.Add(LogType.Stamina, $"{ToPrettyString(source.Value):user} caused {value} stamina damage to {ToPrettyString(uid):target}{(with != null ? $" using {ToPrettyString(with.Value):using}" : "")}"); } else { _adminLogger.Add(LogType.Stamina, $"{ToPrettyString(uid):target} took {value} stamina damage"); } if (visual) { _color.RaiseEffect(Color.Aqua, new List() { uid }, Filter.Pvs(uid, entityManager: EntityManager)); } if (_net.IsServer) { _audio.PlayPvs(sound, uid); } } public override void Update(float frameTime) { base.Update(frameTime); var stamQuery = GetEntityQuery(); var query = EntityQueryEnumerator(); var curTime = Timing.CurTime; while (query.MoveNext(out var uid, out _)) { // Just in case we have active but not stamina we'll check and account for it. if (!stamQuery.TryGetComponent(uid, out var comp) || comp.StaminaDamage <= 0f && !comp.Critical) { RemComp(uid); continue; } // Shouldn't need to consider paused time as we're only iterating non-paused stamina components. var nextUpdate = comp.NextUpdate; if (nextUpdate > curTime) continue; // Handle exiting critical condition and restoring stamina damage if (comp.Critical) ExitStamCrit(uid, comp); comp.NextUpdate += TimeSpan.FromSeconds(1f); TakeStaminaDamage( uid, comp.AfterCritical ? -comp.Decay * comp.AfterCritDecayMultiplier : -comp.Decay, // Recover faster after crit comp); Dirty(uid, comp); } } private void EnterStamCrit(EntityUid uid, StaminaComponent? component = null) { if (!Resolve(uid, ref component) || component.Critical) { return; } component.Critical = true; component.StaminaDamage = component.CritThreshold; if (StunSystem.TryUpdateParalyzeDuration(uid, component.StunTime)) StunSystem.TrySeeingStars(uid); // Give them buffer before being able to be re-stunned component.NextUpdate = Timing.CurTime + component.StunTime + StamCritBufferTime; EnsureComp(uid); Dirty(uid, component); _adminLogger.Add(LogType.Stamina, LogImpact.Medium, $"{ToPrettyString(uid):user} entered stamina crit"); } private void ExitStamCrit(EntityUid uid, StaminaComponent? component = null) { if (!Resolve(uid, ref component) || !component.Critical) { return; } component.Critical = false; component.AfterCritical = true; // Set to true to indicate that stamina will be restored after exiting stamcrit component.NextUpdate = Timing.CurTime; UpdateStaminaVisuals((uid, component)); Dirty(uid, component); _adminLogger.Add(LogType.Stamina, LogImpact.Low, $"{ToPrettyString(uid):user} recovered from stamina crit"); } /// /// Adjusts the modifiers of the status effect entity and applies relevant statuses. /// System iterates through the to find correct movement modifer. /// This modifier is saved to the Stamina Low Status Effect entity's . /// /// Entity to update private void AdjustStatus(Entity ent) { if (!Resolve(ent, ref ent.Comp)) return; if (!_status.TrySetStatusEffectDuration(ent, StaminaLow, out var status)) return; var closest = FixedPoint2.Zero; // Iterate through the dictionary in the similar way as in Damage.SlowOnDamageSystem.OnRefreshMovespeed foreach (var thres in ent.Comp.StunModifierThresholds) { var key = thres.Key.Float(); if (ent.Comp.StaminaDamage >= key && key > closest && closest < ent.Comp.CritThreshold) closest = thres.Key; } _movementMod.TryUpdateMovementStatus(ent.Owner, status.Value, ent.Comp.StunModifierThresholds[closest]); } [Serializable, NetSerializable] public sealed class StaminaAnimationEvent(NetEntity entity) : EntityEventArgs { public NetEntity Entity = entity; } }