using System.Linq; using Content.Shared.Administration.Logs; using Content.Shared.Alert; 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.IdentityManagement; using Content.Shared.Popups; using Content.Shared.Projectiles; using Content.Shared.Rejuvenate; using Content.Shared.Rounding; 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.Network; using Robust.Shared.Player; using Robust.Shared.Random; using Robust.Shared.Timing; namespace Content.Shared.Damage.Systems; public sealed partial class StaminaSystem : EntitySystem { [Dependency] private 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 SharedColorFlashEffectSystem _color = default!; [Dependency] private readonly SharedStunSystem _stunSystem = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; /// /// How much of a buffer is there between the stun duration and when stuns can be re-applied. /// private static readonly TimeSpan StamCritBufferTime = TimeSpan.FromSeconds(3f); public override void Initialize() { base.Initialize(); InitializeModifier(); SubscribeLocalEvent(OnStartup); SubscribeLocalEvent(OnShutdown); SubscribeLocalEvent(OnStamHandleState); SubscribeLocalEvent(OnDisarmed); SubscribeLocalEvent(OnRejuvenate); SubscribeLocalEvent(OnProjectileEmbed); SubscribeLocalEvent(OnProjectileHit); SubscribeLocalEvent(OnThrowHit); SubscribeLocalEvent(OnMeleeHit); } private void OnStamHandleState(EntityUid uid, StaminaComponent component, ref AfterAutoHandleStateEvent args) { if (component.Critical) EnterStamCrit(uid, component); else { if (component.StaminaDamage > 0f) EnsureComp(uid); ExitStamCrit(uid, component); } } private void OnShutdown(EntityUid uid, StaminaComponent component, ComponentShutdown args) { if (MetaData(uid).EntityLifeStage < EntityLifeStage.Terminating) { RemCompDeferred(uid); } _alerts.ClearAlert(uid, component.StaminaAlert); } private void OnStartup(EntityUid uid, StaminaComponent component, ComponentStartup args) { SetStaminaAlert(uid, component); } [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(EntityUid uid, StaminaComponent component, RejuvenateEvent args) { if (component.StaminaDamage >= component.CritThreshold) { ExitStamCrit(uid, component); } component.StaminaDamage = 0; RemComp(uid); SetStaminaAlert(uid, component); Dirty(uid, component); } private void OnDisarmed(EntityUid uid, StaminaComponent component, 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 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) { // 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: false); 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 shouldLog = true) // stalker-changes { if (!Resolve(uid, ref component, false)) return; var ev = new BeforeStaminaDamageEvent(value); RaiseLocalEvent(uid, ref ev); if (ev.Cancelled) return; // 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; } var slowdownThreshold = component.SlowdownThreshold; // stalker-changes // If we go above n% then apply slowdown if (oldDamage < slowdownThreshold && component.StaminaDamage > slowdownThreshold) { _stunSystem.TrySlowdown(uid, TimeSpan.FromSeconds(3), true, 0.8f, 0.8f); } SetStaminaAlert(uid, component); 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 && shouldLog) // stalker-changes { _adminLogger.Add(LogType.Stamina, $"{ToPrettyString(source.Value):user} caused {value} stamina damage to {ToPrettyString(uid):target}{(with != null ? $" using {ToPrettyString(with.Value):using}" : "")}"); } else if (shouldLog) // stalker-changes { _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); if (!_timing.IsFirstTimePredicted) return; 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; // We were in crit so come out of it and continue. if (comp.Critical) { ExitStamCrit(uid, comp); continue; } comp.NextUpdate += TimeSpan.FromSeconds(1f); TakeStaminaDamage(uid, -comp.Decay, comp); Dirty(uid, comp); } } private void EnterStamCrit(EntityUid uid, StaminaComponent? component = null) { if (!Resolve(uid, ref component) || component.Critical) { return; } // To make the difference between a stun and a stamcrit clear // TODO: Mask? component.Critical = true; component.StaminaDamage = component.CritThreshold; _stunSystem.TryParalyze(uid, component.StunTime, true); // 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.StaminaDamage = 0f; component.NextUpdate = _timing.CurTime; SetStaminaAlert(uid, component); RemComp(uid); Dirty(uid, component); _adminLogger.Add(LogType.Stamina, LogImpact.Low, $"{ToPrettyString(uid):user} recovered from stamina crit"); } } /// /// Raised before stamina damage is dealt to allow other systems to cancel it. /// [ByRefEvent] public record struct BeforeStaminaDamageEvent(float Value, bool Cancelled = false);