using Content.Server.Administration.Logs; using Content.Server.Body.Components; using Content.Server.Temperature.Components; using Content.Shared.Alert; using Content.Shared.Damage.Components; using Content.Shared.Damage.Systems; using Content.Shared.Database; using Content.Shared.Rounding; using Content.Shared.Temperature; using Content.Shared.Temperature.Components; using Robust.Shared.Prototypes; using Robust.Shared.Timing; namespace Content.Server.Temperature.Systems; /// /// Handles entities taking damage from being too hot or too cold. /// Also handles alerts relevant to the same. /// public sealed partial class TemperatureSystem { [Dependency] private readonly AlertsSystem _alerts = default!; [Dependency] private readonly DamageableSystem _damageable = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; [Dependency] private readonly IGameTiming _gameTiming = default!; private EntityQuery _tempDamageQuery; private EntityQuery _containerTemperatureQuery; private EntityQuery _thermalRegulatorQuery; /// /// All the components that will have their damage updated at the end of the tick. /// This is done because both AtmosExposed and Flammable call ChangeHeat in the same tick, meaning /// that we need some mechanism to ensure it doesn't double-dip on damage for both calls. /// public HashSet> ShouldUpdateDamage = new(); /// /// Alert prototype for Temperature. /// public static readonly ProtoId TemperatureAlertCategory = "Temperature"; /// /// The maximum severity applicable to temperature alerts. /// public static readonly short MaxTemperatureAlertSeverity = 3; /// /// On a scale of 0. to 1. where 0. is the ideal temperature and 1. is a temperature damage threshold this is the point where the component starts raising temperature alerts. /// public static readonly float MinAlertTemperatureScale = 0.33f; private void InitializeDamage() { SubscribeLocalEvent(ServerAlert); SubscribeLocalEvent(EnqueueDamage); SubscribeLocalEvent(OnUnpaused); // Allows overriding thresholds based on the parent's thresholds. SubscribeLocalEvent(OnParentChange); SubscribeLocalEvent(OnParentThresholdStartup); SubscribeLocalEvent(OnParentThresholdShutdown); _tempDamageQuery = GetEntityQuery(); _containerTemperatureQuery = GetEntityQuery(); _thermalRegulatorQuery = GetEntityQuery(); } private void UpdateDamage() { foreach (var entity in ShouldUpdateDamage) { if (Deleted(entity) || Paused(entity)) continue; var deltaTime = _gameTiming.CurTime - entity.Comp.LastUpdate; if (entity.Comp.TakingDamage && deltaTime < entity.Comp.UpdateInterval) continue; ChangeDamage(entity, deltaTime); } ShouldUpdateDamage.Clear(); } private void ChangeDamage(Entity entity, TimeSpan deltaTime) { entity.Comp.LastUpdate = _gameTiming.CurTime; if (!HasComp(entity) || !TemperatureQuery.TryComp(entity, out var temperature)) return; // See this link for where the scaling func comes from: // https://www.desmos.com/calculator/0vknqtdvq9 // Based on a logistic curve, which caps out at MaxDamage var heatK = 0.005; var a = 1; var y = entity.Comp.DamageCap; var c = y * 2; var heatDamageThreshold = entity.Comp.ParentHeatDamageThreshold ?? entity.Comp.HeatDamageThreshold; var coldDamageThreshold = entity.Comp.ParentColdDamageThreshold ?? entity.Comp.ColdDamageThreshold; if (temperature.CurrentTemperature >= heatDamageThreshold) { if (!entity.Comp.TakingDamage) { _adminLogger.Add(LogType.Temperature, $"{ToPrettyString(entity):entity} started taking high temperature damage"); entity.Comp.TakingDamage = true; } var diff = Math.Abs(temperature.CurrentTemperature - heatDamageThreshold); var tempDamage = c / (1 + a * Math.Pow(Math.E, -heatK * diff)) - y; _damageable.TryChangeDamage(entity.Owner, entity.Comp.HeatDamage * tempDamage * deltaTime.TotalSeconds, ignoreResistances: true, interruptsDoAfters: false); } else if (temperature.CurrentTemperature <= coldDamageThreshold) { if (!entity.Comp.TakingDamage) { _adminLogger.Add(LogType.Temperature, $"{ToPrettyString(entity):entity} started taking low temperature damage"); entity.Comp.TakingDamage = true; } var diff = Math.Abs(temperature.CurrentTemperature - coldDamageThreshold); var tempDamage = Math.Sqrt(diff * (Math.Pow(entity.Comp.DamageCap.Double(), 2) / coldDamageThreshold)); _damageable.TryChangeDamage(entity.Owner, entity.Comp.ColdDamage * tempDamage * deltaTime.TotalSeconds, ignoreResistances: true, interruptsDoAfters: false); } else if (entity.Comp.TakingDamage) { _adminLogger.Add(LogType.Temperature, $"{ToPrettyString(entity):entity} stopped taking temperature damage"); entity.Comp.TakingDamage = false; } } private void ServerAlert(Entity entity, ref OnTemperatureChangeEvent args) { ProtoId type; float threshold; float idealTemp; if (!_tempDamageQuery.TryComp(entity, out var thresholds)) { _alerts.ClearAlertCategory(entity.Owner, TemperatureAlertCategory); return; } if (_thermalRegulatorQuery.TryComp(entity, out var regulator) && regulator.NormalBodyTemperature > thresholds.ColdDamageThreshold && regulator.NormalBodyTemperature < thresholds.HeatDamageThreshold) { idealTemp = regulator.NormalBodyTemperature; } else { idealTemp = (thresholds.ColdDamageThreshold + thresholds.HeatDamageThreshold) / 2; } if (args.CurrentTemperature <= idealTemp) { type = thresholds.ColdAlert; threshold = thresholds.ColdDamageThreshold; } else { type = thresholds.HotAlert; threshold = thresholds.HeatDamageThreshold; } // Calculates a scale where 0.0 is the ideal temperature and 1.0 is where temperature damage begins // The cold and hot scales will differ in their range if the ideal temperature is not exactly halfway between the thresholds var tempScale = (args.CurrentTemperature - idealTemp) / (threshold - idealTemp); var alertLevel = (short)ContentHelpers.RoundToLevels(tempScale - MinAlertTemperatureScale, 1.00f - MinAlertTemperatureScale, MaxTemperatureAlertSeverity + 1); if (alertLevel > 0) _alerts.ShowAlert(entity.AsNullable(), type, alertLevel); else _alerts.ClearAlertCategory(entity.AsNullable(), TemperatureAlertCategory); } private void EnqueueDamage(Entity ent, ref OnTemperatureChangeEvent args) { if (ShouldUpdateDamage.Add(ent) && !ent.Comp.TakingDamage) ent.Comp.LastUpdate = _gameTiming.CurTime; } private void OnUnpaused(Entity ent, ref EntityUnpausedEvent args) { ent.Comp.LastUpdate += args.PausedTime; } private void OnParentChange(Entity entity, ref EntParentChangedMessage args) { // We only need to update thresholds if the thresholds changed for the entity's ancestors. var oldThresholds = args.OldParent != null ? RecalculateParentThresholds(args.OldParent.Value) : (null, null); var xform = Transform(entity.Owner); var newThresholds = RecalculateParentThresholds(xform.ParentUid); if (oldThresholds != newThresholds) RecursiveThresholdUpdate((entity, entity.Comp, xform)); } private void OnParentThresholdStartup(Entity entity, ref ComponentStartup args) { RecursiveThresholdUpdate(entity.Owner); } private void OnParentThresholdShutdown(Entity entity, ref ComponentShutdown args) { RecursiveThresholdUpdate(entity.Owner); } /// /// Recalculate and apply parent thresholds for the root entity and all its children. /// /// The root entity we're currently updating private void RecursiveThresholdUpdate(Entity root) { RecalculateAndApplyParentThresholds(root); var xform = root.Comp2 ?? Transform(root); var enumerator = xform.ChildEnumerator; while (enumerator.MoveNext(out var child)) { RecursiveThresholdUpdate(child); } } /// /// Recalculate parent thresholds and apply them on the uid temperature component. /// /// The entity whose temperature damage thresholds we're updating private void RecalculateAndApplyParentThresholds(Entity entity) { if (!_tempDamageQuery.Resolve(entity, ref entity.Comp, logMissing: false)) return; var newThresholds = RecalculateParentThresholds(Transform(entity).ParentUid); entity.Comp.ParentHeatDamageThreshold = newThresholds.Item1; entity.Comp.ParentColdDamageThreshold = newThresholds.Item2; } /// /// Recalculate Parent Heat/Cold DamageThreshold by recursively checking each ancestor and fetching the /// maximum HeatDamageThreshold and the minimum ColdDamageThreshold if any exists (aka the best value for each). /// /// parent we start with private (float?, float?) RecalculateParentThresholds(EntityUid initialParentUid) { // Recursively check parents for the best threshold available var parentUid = initialParentUid; float? newHeatThreshold = null; float? newColdThreshold = null; while (parentUid.IsValid()) { if (_containerTemperatureQuery.TryComp(parentUid, out var newThresholds)) { if (newThresholds.HeatDamageThreshold != null) { newHeatThreshold = Math.Max(newThresholds.HeatDamageThreshold.Value, newHeatThreshold ?? 0); } if (newThresholds.ColdDamageThreshold != null) { newColdThreshold = Math.Min(newThresholds.ColdDamageThreshold.Value, newColdThreshold ?? float.MaxValue); } } parentUid = Transform(parentUid).ParentUid; } return (newHeatThreshold, newColdThreshold); } }