diff --git a/Content.Client/GameObjects/Components/StationEvents/RadiationPulseComponent.cs b/Content.Client/GameObjects/Components/StationEvents/RadiationPulseComponent.cs
new file mode 100644
index 0000000000..23d57a957c
--- /dev/null
+++ b/Content.Client/GameObjects/Components/StationEvents/RadiationPulseComponent.cs
@@ -0,0 +1,24 @@
+#nullable enable
+using System;
+using Content.Shared.GameObjects.Components;
+using Robust.Shared.GameObjects;
+
+namespace Content.Client.GameObjects.Components.StationEvents
+{
+ [RegisterComponent]
+ public sealed class RadiationPulseComponent : SharedRadiationPulseComponent
+ {
+ public TimeSpan EndTime { get; private set; }
+
+ public override void HandleComponentState(ComponentState? curState, ComponentState? nextState)
+ {
+ base.HandleComponentState(curState, nextState);
+ if (!(curState is RadiationPulseMessage state))
+ {
+ return;
+ }
+
+ EndTime = state.EndTime;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Client/StationEvents/RadiationPulseOverlay.cs b/Content.Client/StationEvents/RadiationPulseOverlay.cs
new file mode 100644
index 0000000000..2604419ed5
--- /dev/null
+++ b/Content.Client/StationEvents/RadiationPulseOverlay.cs
@@ -0,0 +1,152 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Content.Client.GameObjects.Components.StationEvents;
+using Content.Shared.GameObjects.Components.Mobs;
+using JetBrains.Annotations;
+using Robust.Client.Graphics.Drawing;
+using Robust.Client.Graphics.Overlays;
+using Robust.Client.Interfaces.Graphics.ClientEye;
+using Robust.Client.Player;
+using Robust.Shared.Interfaces.GameObjects;
+using Robust.Shared.Interfaces.Map;
+using Robust.Shared.Interfaces.Timing;
+using Robust.Shared.IoC;
+using Color = Robust.Shared.Maths.Color;
+
+namespace Content.Client.StationEvents
+{
+ [UsedImplicitly]
+ public sealed class RadiationPulseOverlay : Overlay
+ {
+ [Dependency] private readonly IComponentManager _componentManager = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly IEyeManager _eyeManager = default!;
+
+ ///
+ /// Current color of a pulse
+ ///
+ private readonly Dictionary _colors = new Dictionary();
+
+ ///
+ /// Whether our alpha is increasing or decreasing and at what time does it flip (or stop)
+ ///
+ private readonly Dictionary _transitions =
+ new Dictionary();
+
+ ///
+ /// How much the alpha changes per second for each pulse
+ ///
+ private readonly Dictionary _alphaRateOfChange = new Dictionary();
+
+ private TimeSpan _lastTick;
+
+ // TODO: When worldHandle can do DrawCircle change this.
+ public override OverlaySpace Space => OverlaySpace.ScreenSpace;
+
+ public RadiationPulseOverlay() : base(nameof(SharedOverlayID.RadiationPulseOverlay))
+ {
+ IoCManager.InjectDependencies(this);
+ _lastTick = _gameTiming.CurTime;
+ }
+
+ ///
+ /// Get the current color for the entity,
+ /// accounting for what its alpha should be and whether it should be transitioning in or out
+ ///
+ ///
+ /// frametime
+ ///
+ ///
+ private Color GetColor(IEntity entity, float elapsedTime, TimeSpan endTime)
+ {
+ var currentTime = _gameTiming.CurTime;
+
+ // New pulse
+ if (!_colors.ContainsKey(entity))
+ {
+ UpdateTransition(entity, currentTime, endTime);
+ }
+
+ var currentColor = _colors[entity];
+ var alphaChange = _alphaRateOfChange[entity] * elapsedTime;
+
+ if (!_transitions[entity].EasingIn)
+ {
+ alphaChange *= -1;
+ }
+
+ if (currentTime > _transitions[entity].TransitionTime)
+ {
+ UpdateTransition(entity, currentTime, endTime);
+ }
+
+ _colors[entity] = _colors[entity].WithAlpha(currentColor.A + alphaChange);
+ return _colors[entity];
+ }
+
+ private void UpdateTransition(IEntity entity, TimeSpan currentTime, TimeSpan endTime)
+ {
+ bool easingIn;
+ TimeSpan transitionTime;
+
+ if (!_transitions.TryGetValue(entity, out var transition))
+ {
+ // Start as false because it will immediately be flipped
+ easingIn = false;
+ transitionTime = (endTime - currentTime) / 2 + currentTime;
+ }
+ else
+ {
+ easingIn = transition.EasingIn;
+ transitionTime = endTime;
+ }
+
+ _transitions[entity] = (!easingIn, transitionTime);
+ _colors[entity] = Color.Green.WithAlpha(0.0f);
+ _alphaRateOfChange[entity] = 1.0f / (float) (transitionTime - currentTime).TotalSeconds;
+ }
+
+ protected override void Draw(DrawingHandleBase handle, OverlaySpace currentSpace)
+ {
+ // PVS should control the overlay pretty well so the overlay doesn't get instantiated unless we're near one...
+ var playerEntity = _playerManager.LocalPlayer?.ControlledEntity;
+
+ if (playerEntity == null)
+ {
+ return;
+ }
+
+ var elapsedTime = (float) (_gameTiming.CurTime - _lastTick).TotalSeconds;
+ _lastTick = _gameTiming.CurTime;
+
+ var radiationPulses = _componentManager
+ .EntityQuery()
+ .ToList();
+
+ var screenHandle = (DrawingHandleScreen) handle;
+ var viewport = _eyeManager.GetWorldViewport();
+
+ foreach (var grid in _mapManager.FindGridsIntersecting(playerEntity.Transform.MapID, viewport))
+ {
+ foreach (var pulse in radiationPulses)
+ {
+ if (grid.Index != pulse.Owner.Transform.GridID) continue;
+
+ // TODO: Check if viewport intersects circle
+ var circlePosition = _eyeManager.WorldToScreen(pulse.Owner.Transform.WorldPosition);
+ var comp = (RadiationPulseComponent) pulse;
+
+ // change to worldhandle when implemented
+ screenHandle.DrawCircle(
+ circlePosition,
+ comp.Range * 64,
+ GetColor(pulse.Owner, elapsedTime, comp.EndTime));
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.IntegrationTests/Tests/StationEvents/StationEventsSystemTest.cs b/Content.IntegrationTests/Tests/StationEvents/StationEventsSystemTest.cs
new file mode 100644
index 0000000000..2e115930ed
--- /dev/null
+++ b/Content.IntegrationTests/Tests/StationEvents/StationEventsSystemTest.cs
@@ -0,0 +1,44 @@
+using System.Threading.Tasks;
+using Content.Server.GameObjects.EntitySystems;
+using Content.Server.GameObjects.EntitySystems.StationEvents;
+using NUnit.Framework;
+using Robust.Shared.GameObjects.Systems;
+using Robust.Shared.Interfaces.Timing;
+using Robust.Shared.IoC;
+
+namespace Content.IntegrationTests.Tests.StationEvents
+{
+ [TestFixture]
+ public class StationEventsSystemTest : ContentIntegrationTest
+ {
+ [Test]
+ public async Task Test()
+ {
+ var server = StartServerDummyTicker();
+
+ server.Assert(() =>
+ {
+ // Idle each event once
+ var stationEventsSystem = EntitySystem.Get();
+ var dummyFrameTime = (float) IoCManager.Resolve().TickPeriod.TotalSeconds;
+
+ foreach (var stationEvent in stationEventsSystem.StationEvents)
+ {
+ stationEvent.Startup();
+ stationEvent.Update(dummyFrameTime);
+ stationEvent.Shutdown();
+ Assert.That(stationEvent.Occurrences == 1);
+ }
+
+ stationEventsSystem.ResettingCleanup();
+
+ foreach (var stationEvent in stationEventsSystem.StationEvents)
+ {
+ Assert.That(stationEvent.Occurrences == 0);
+ }
+ });
+
+ await server.WaitIdleAsync();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/Chat/ChatManager.cs b/Content.Server/Chat/ChatManager.cs
index bb33c2c6ca..1a035fbb5c 100644
--- a/Content.Server/Chat/ChatManager.cs
+++ b/Content.Server/Chat/ChatManager.cs
@@ -44,6 +44,15 @@ namespace Content.Server.Chat
_netManager.ServerSendToAll(msg);
}
+ public void DispatchStationAnnouncement(string message)
+ {
+ var msg = _netManager.CreateNetMessage();
+ msg.Channel = ChatChannel.Radio;
+ msg.Message = message;
+ msg.MessageWrap = "Station: {0}";
+ _netManager.ServerSendToAll(msg);
+ }
+
public void DispatchServerMessage(IPlayerSession player, string message)
{
var msg = _netManager.CreateNetMessage();
diff --git a/Content.Server/GameObjects/Components/StationEvents/RadiationPulseComponent.cs b/Content.Server/GameObjects/Components/StationEvents/RadiationPulseComponent.cs
new file mode 100644
index 0000000000..c0fd604f92
--- /dev/null
+++ b/Content.Server/GameObjects/Components/StationEvents/RadiationPulseComponent.cs
@@ -0,0 +1,62 @@
+using System;
+using Content.Shared.GameObjects.Components;
+using Robust.Server.GameObjects.EntitySystems;
+using Robust.Shared.GameObjects;
+using Robust.Shared.GameObjects.Systems;
+using Robust.Shared.Interfaces.Random;
+using Robust.Shared.Interfaces.Timing;
+using Robust.Shared.IoC;
+using Robust.Shared.Random;
+using Robust.Shared.Serialization;
+using Robust.Shared.Timers;
+
+namespace Content.Server.GameObjects.Components.StationEvents
+{
+ [RegisterComponent]
+ public sealed class RadiationPulseComponent : SharedRadiationPulseComponent
+ {
+ private const float MinPulseLifespan = 0.8f;
+ private const float MaxPulseLifespan = 2.5f;
+
+ public float DPS => _dps;
+ private float _dps;
+
+ private TimeSpan _endTime;
+
+ public override void ExposeData(ObjectSerializer serializer)
+ {
+ base.ExposeData(serializer);
+ serializer.DataField(ref _dps, "dps", 40.0f);
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ var currentTime = IoCManager.Resolve().CurTime;
+ var duration =
+ TimeSpan.FromSeconds(
+ IoCManager.Resolve().NextFloat() * (MaxPulseLifespan - MinPulseLifespan) +
+ MinPulseLifespan);
+
+ _endTime = currentTime + duration;
+
+ Timer.Spawn(duration,
+ () =>
+ {
+ if (!Owner.Deleted)
+ {
+ Owner.Delete();
+ }
+ });
+
+ EntitySystem.Get().PlayAtCoords("/Audio/Weapons/Guns/Gunshots/laser3.ogg", Owner.Transform.GridPosition);
+ Dirty();
+ }
+
+ public override ComponentState GetComponentState()
+ {
+ return new RadiationPulseMessage(_endTime);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/GameObjects/EntitySystems/StationEvents/RadiationPulseSystem.cs b/Content.Server/GameObjects/EntitySystems/StationEvents/RadiationPulseSystem.cs
new file mode 100644
index 0000000000..2e48bf7120
--- /dev/null
+++ b/Content.Server/GameObjects/EntitySystems/StationEvents/RadiationPulseSystem.cs
@@ -0,0 +1,89 @@
+using System.Collections.Generic;
+using Content.Server.GameObjects.Components.Damage;
+using Content.Server.GameObjects.Components.Mobs;
+using Content.Server.GameObjects.Components.StationEvents;
+using Content.Shared.GameObjects;
+using Content.Shared.GameObjects.Components.Damage;
+using JetBrains.Annotations;
+using Robust.Shared.GameObjects;
+using Robust.Shared.GameObjects.Systems;
+using Robust.Shared.Interfaces.GameObjects;
+using Robust.Shared.IoC;
+
+namespace Content.Server.GameObjects.EntitySystems.StationEvents
+{
+ [UsedImplicitly]
+ public sealed class RadiationPulseSystem : EntitySystem
+ {
+ // Rather than stuffing around with collidables and checking entities on initialize etc. we'll just tick over
+ // for each entity in range. Seemed easier than checking entities on spawn, then checking collidables, etc.
+ // Especially considering each pulse is a big chonker, + no circle hitboxes yet.
+
+ private TypeEntityQuery _speciesQuery;
+
+ ///
+ /// Damage works with ints so we'll just accumulate damage and once we hit this threshold we'll apply it.
+ ///
+ /// This also server to stop spamming the damagethreshold with 1 damage continuously.
+ private const int DamageThreshold = 10;
+
+ private Dictionary _accumulatedDamage = new Dictionary();
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ _speciesQuery = new TypeEntityQuery(typeof(SpeciesComponent));
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+ var anyPulses = false;
+
+ foreach (var comp in ComponentManager.EntityQuery())
+ {
+ anyPulses = true;
+
+ foreach (var species in EntityManager.GetEntities(_speciesQuery))
+ {
+ // Work out if we're in range and accumulate more damage
+ // If we've hit the DamageThreshold we'll also apply that damage to the mob
+ // If we're really lagging server can apply multiples of the DamageThreshold at once
+ if (species.Transform.MapID != comp.Owner.Transform.MapID) continue;
+
+ if ((species.Transform.WorldPosition - comp.Owner.Transform.WorldPosition).Length > comp.Range)
+ {
+ continue;
+ }
+
+ var totalDamage = frameTime * comp.DPS;
+
+ if (!_accumulatedDamage.TryGetValue(species, out var accumulatedSpecies))
+ {
+ _accumulatedDamage[species] = 0.0f;
+ }
+
+ totalDamage += accumulatedSpecies;
+ _accumulatedDamage[species] = totalDamage;
+
+ if (totalDamage < DamageThreshold) continue;
+ if (!species.TryGetComponent(out DamageableComponent damageableComponent)) continue;
+
+ var damageMultiple = (int) (totalDamage / DamageThreshold);
+ _accumulatedDamage[species] = totalDamage % DamageThreshold;
+
+ damageableComponent.TakeDamage(DamageType.Heat, damageMultiple * DamageThreshold, comp.Owner, comp.Owner);
+ }
+ }
+
+ if (anyPulses)
+ {
+ return;
+ }
+
+ // probably don't need to worry about clearing this at roundreset unless you have a radiation pulse at roundstart
+ // (which is currently not possible)
+ _accumulatedDamage.Clear();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/GameObjects/EntitySystems/StationEvents/StationEventSystem.cs b/Content.Server/GameObjects/EntitySystems/StationEvents/StationEventSystem.cs
new file mode 100644
index 0000000000..1885b32231
--- /dev/null
+++ b/Content.Server/GameObjects/EntitySystems/StationEvents/StationEventSystem.cs
@@ -0,0 +1,321 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Content.Server.StationEvents;
+using JetBrains.Annotations;
+using Robust.Server.Interfaces.Player;
+using Robust.Shared.GameObjects.Systems;
+using Robust.Shared.Interfaces.Random;
+using Robust.Shared.Interfaces.Reflection;
+using Robust.Shared.Interfaces.Timing;
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+
+namespace Content.Server.GameObjects.EntitySystems.StationEvents
+{
+ [UsedImplicitly]
+ public sealed class StationEventSystem : EntitySystem
+ {
+ // Somewhat based off of TG's implementation of events
+
+ public StationEvent CurrentEvent { get; private set; }
+
+ public IReadOnlyCollection StationEvents => _stationEvents;
+ private List _stationEvents = new List();
+
+ private const float MinimumTimeUntilFirstEvent = 600;
+
+ ///
+ /// How long until the next check for an event runs
+ ///
+ /// Default value is how long until first event is allowed
+ private float _timeUntilNextEvent = MinimumTimeUntilFirstEvent;
+
+ ///
+ /// Whether random events can run
+ ///
+ /// If disabled while an event is running (even if admin run) it will disable it
+ public bool Enabled
+ {
+ get => _enabled;
+ set
+ {
+ if (_enabled == value)
+ {
+ return;
+ }
+
+ _enabled = value;
+ CurrentEvent?.Shutdown();
+ CurrentEvent = null;
+ }
+ }
+
+ private bool _enabled = true;
+
+ ///
+ /// Admins can get a list of all events available to run, regardless of whether their requirements have been met
+ ///
+ ///
+ public string GetEventNames()
+ {
+ StringBuilder result = new StringBuilder();
+
+ foreach (var stationEvent in _stationEvents)
+ {
+ result.Append(stationEvent.Name + "\n");
+ }
+
+ return result.ToString();
+ }
+
+ ///
+ /// Admins can forcibly run events by passing in the Name
+ ///
+ /// The exact string for Name, without localization
+ ///
+ public string RunEvent(string name)
+ {
+ // Could use a dictionary but it's such a minor thing, eh.
+ // Wasn't sure on whether to localize this given it's a command
+ var upperName = name.ToUpperInvariant();
+
+ foreach (var stationEvent in _stationEvents)
+ {
+ if (stationEvent.Name.ToUpperInvariant() != upperName)
+ {
+ continue;
+ }
+
+ CurrentEvent?.Shutdown();
+ CurrentEvent = stationEvent;
+ stationEvent.Startup();
+ return Loc.GetString("Running event ") + stationEvent.Name;
+ }
+
+ // I had string interpolation but lord it made it hard to read
+ return Loc.GetString("No event named ") + name;
+ }
+
+ ///
+ /// Randomly run a valid event immediately, ignoring earlieststart
+ ///
+ ///
+ public string RunRandomEvent()
+ {
+ var availableEvents = AvailableEvents(true);
+ var randomEvent = FindEvent(availableEvents);
+
+ if (randomEvent == null)
+ {
+ return Loc.GetString("No valid events available");
+ }
+
+ CurrentEvent?.Shutdown();
+ CurrentEvent = randomEvent;
+ CurrentEvent.Startup();
+
+ return Loc.GetString("Running ") + randomEvent.Name;
+ }
+
+ ///
+ /// Admins can stop the currently running event (if applicable) and reset the timer
+ ///
+ ///
+ public string StopEvent()
+ {
+ string resultText;
+
+ if (CurrentEvent == null)
+ {
+ resultText = Loc.GetString("No event running currently");
+ }
+ else
+ {
+ resultText = Loc.GetString("Stopped event ") + CurrentEvent.Name;
+ CurrentEvent.Shutdown();
+ CurrentEvent = null;
+ }
+
+ ResetTimer();
+ return resultText;
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ var reflectionManager = IoCManager.Resolve();
+ var typeFactory = IoCManager.Resolve();
+
+ foreach (var type in reflectionManager.GetAllChildren(typeof(StationEvent)))
+ {
+ if (type.IsAbstract) continue;
+
+ var stationEvent = (StationEvent) typeFactory.CreateInstance(type);
+ _stationEvents.Add(stationEvent);
+ }
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ if (!Enabled)
+ {
+ return;
+ }
+
+ // Keep running the current event
+ if (CurrentEvent != null)
+ {
+ CurrentEvent.Update(frameTime);
+
+ // Shutdown the event and set the timer for the next event
+ if (!CurrentEvent.Running)
+ {
+ CurrentEvent.Shutdown();
+ CurrentEvent = null;
+ ResetTimer();
+ }
+
+ return;
+ }
+
+ if (_timeUntilNextEvent > 0)
+ {
+ _timeUntilNextEvent -= frameTime;
+ return;
+ }
+
+ // No point hammering this trying to find events if none are available
+ var stationEvent = FindEvent(AvailableEvents());
+ if (stationEvent == null)
+ {
+ ResetTimer();
+ }
+ else
+ {
+ CurrentEvent = stationEvent;
+ }
+ }
+
+ ///
+ /// Reset the event timer once the event is done.
+ ///
+ private void ResetTimer()
+ {
+ var robustRandom = IoCManager.Resolve();
+ // 5 - 15 minutes. TG does 3-10 but that's pretty frequent
+ _timeUntilNextEvent = robustRandom.Next(300, 900);
+ }
+
+ ///
+ /// Pick a random event from the available events at this time, also considering their weightings.
+ ///
+ ///
+ private StationEvent FindEvent(List availableEvents)
+ {
+ if (availableEvents.Count == 0)
+ {
+ return null;
+ }
+
+ var sumOfWeights = 0;
+
+ foreach (var stationEvent in availableEvents)
+ {
+ sumOfWeights += (int) stationEvent.Weight;
+ }
+
+ var robustRandom = IoCManager.Resolve();
+ sumOfWeights = robustRandom.Next(sumOfWeights);
+
+ foreach (var stationEvent in availableEvents)
+ {
+ sumOfWeights -= (int) stationEvent.Weight;
+
+ if (sumOfWeights <= 0)
+ {
+ return stationEvent;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Gets the events that have met their player count, time-until start, etc.
+ ///
+ ///
+ ///
+ private List AvailableEvents(bool ignoreEarliestStart = false)
+ {
+ TimeSpan currentTime;
+ var playerCount = IoCManager.Resolve().PlayerCount;
+
+ // playerCount does a lock so we'll just keep the variable here
+ if (!ignoreEarliestStart)
+ {
+ currentTime = IoCManager.Resolve().CurTime;
+ }
+ else
+ {
+ currentTime = TimeSpan.Zero;
+ }
+
+ var result = new List();
+
+ foreach (var stationEvent in _stationEvents)
+ {
+ if (CanRun(stationEvent, playerCount, currentTime))
+ {
+ result.Add(stationEvent);
+ }
+ }
+
+ return result;
+ }
+
+ private bool CanRun(StationEvent stationEvent, int playerCount, TimeSpan currentTime)
+ {
+ if (stationEvent.MaxOccurrences.HasValue && stationEvent.Occurrences >= stationEvent.MaxOccurrences.Value)
+ {
+ return false;
+ }
+
+ if (playerCount < stationEvent.MinimumPlayers)
+ {
+ return false;
+ }
+
+ if (currentTime != TimeSpan.Zero && currentTime.TotalMinutes < stationEvent.EarliestStart)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ public void ResettingCleanup()
+ {
+ if (CurrentEvent != null && CurrentEvent.Running)
+ {
+ CurrentEvent.Shutdown();
+ CurrentEvent = null;
+ }
+
+ foreach (var stationEvent in _stationEvents)
+ {
+ stationEvent.Occurrences = 0;
+ }
+
+ _timeUntilNextEvent = MinimumTimeUntilFirstEvent;
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ CurrentEvent?.Shutdown();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/GameTicking/GameTicker.cs b/Content.Server/GameTicking/GameTicker.cs
index b02df83377..8c3ed03f1b 100644
--- a/Content.Server/GameTicking/GameTicker.cs
+++ b/Content.Server/GameTicking/GameTicker.cs
@@ -13,6 +13,7 @@ using Content.Server.GameObjects.Components.PDA;
using Content.Server.GameObjects.EntitySystems;
using Content.Server.GameObjects.EntitySystems.AI.Pathfinding;
using Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Accessible;
+using Content.Server.GameObjects.EntitySystems.StationEvents;
using Content.Server.GameTicking.GamePresets;
using Content.Server.Interfaces;
using Content.Server.Interfaces.Chat;
@@ -619,15 +620,14 @@ namespace Content.Server.GameTicking
_playerJoinLobby(player);
}
-
- // Reset pathing system
+
EntitySystem.Get().ResettingCleanup();
EntitySystem.Get().ResettingCleanup();
+ EntitySystem.Get().ResetLayouts();
+ EntitySystem.Get().ResettingCleanup();
_spawnedPositions.Clear();
_manifest.Clear();
-
- EntitySystem.Get().ResetLayouts();
}
private void _preRoundSetup()
diff --git a/Content.Server/Interfaces/Chat/IChatManager.cs b/Content.Server/Interfaces/Chat/IChatManager.cs
index c3c9b6ceb3..e32c9bea8d 100644
--- a/Content.Server/Interfaces/Chat/IChatManager.cs
+++ b/Content.Server/Interfaces/Chat/IChatManager.cs
@@ -12,6 +12,12 @@ namespace Content.Server.Interfaces.Chat
///
void DispatchServerAnnouncement(string message);
+ ///
+ /// Station announcement to every player
+ ///
+ ///
+ void DispatchStationAnnouncement(string message);
+
void DispatchServerMessage(IPlayerSession player, string message);
void EntitySay(IEntity source, string message);
diff --git a/Content.Server/StationEvents/PowerGridCheck.cs b/Content.Server/StationEvents/PowerGridCheck.cs
new file mode 100644
index 0000000000..882fa61f7a
--- /dev/null
+++ b/Content.Server/StationEvents/PowerGridCheck.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+using Content.Server.GameObjects.Components.Power;
+using Content.Server.GameObjects.Components.Power.ApcNetComponents;
+using Content.Server.GameObjects.Components.Power.PowerNetComponents;
+using JetBrains.Annotations;
+using Robust.Server.GameObjects.EntitySystems;
+using Robust.Shared.GameObjects;
+using Robust.Shared.GameObjects.Systems;
+using Robust.Shared.Interfaces.GameObjects;
+using Robust.Shared.Interfaces.Random;
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+
+namespace Content.Server.StationEvents
+{
+ [UsedImplicitly]
+ public sealed class PowerGridCheck : StationEvent
+ {
+ public override string Name => "PowerGridCheck";
+
+ public override StationEventWeight Weight => StationEventWeight.Normal;
+
+ public override int? MaxOccurrences => 3;
+
+ protected override string StartAnnouncement => Loc.GetString(
+ "Abnormal activity detected in the station's powernet. As a precautionary measure, the station's power will be shut off for an indeterminate duration.");
+
+ protected override string EndAnnouncement => Loc.GetString(
+ "Power has been restored to the station. We apologize for the inconvenience.");
+
+ private float _elapsedTime;
+ private int _failDuration;
+
+ private Dictionary _powered = new Dictionary();
+
+ private readonly List _toPowerDown = new List();
+
+ public override void Startup()
+ {
+ base.Startup();
+ EntitySystem.Get().PlayGlobal("/Audio/Announcements/power_off.ogg");
+
+ _elapsedTime = 0.0f;
+ _failDuration = IoCManager.Resolve().Next(30, 120);
+ var componentManager = IoCManager.Resolve();
+
+ foreach (var component in componentManager.EntityQuery())
+ {
+ component.PowerDisabled = true;
+ }
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ EntitySystem.Get().PlayGlobal("/Audio/Announcements/power_on.ogg");
+
+ foreach (var (entity, powered) in _powered)
+ {
+ if (entity.Deleted) continue;
+
+ if (entity.TryGetComponent(out PowerReceiverComponent powerReceiverComponent))
+ {
+ powerReceiverComponent.PowerDisabled = powered;
+ }
+ }
+
+ _powered.Clear();
+ }
+
+ public override void Update(float frameTime)
+ {
+ if (!Running)
+ {
+ return;
+ }
+
+ _elapsedTime += frameTime;
+
+ if (_elapsedTime < _failDuration)
+ {
+ return;
+ }
+
+ Running = false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/StationEvents/RadiationStorm.cs b/Content.Server/StationEvents/RadiationStorm.cs
new file mode 100644
index 0000000000..9163b097e3
--- /dev/null
+++ b/Content.Server/StationEvents/RadiationStorm.cs
@@ -0,0 +1,131 @@
+using Content.Server.GameObjects.Components.Mobs;
+using Content.Shared.GameObjects.Components.Mobs;
+using JetBrains.Annotations;
+using Robust.Server.GameObjects.EntitySystems;
+using Robust.Shared.GameObjects.Systems;
+using Robust.Shared.Interfaces.GameObjects;
+using Robust.Shared.Interfaces.Map;
+using Robust.Shared.Interfaces.Random;
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+using Robust.Shared.Map;
+using Robust.Shared.Random;
+
+namespace Content.Server.StationEvents
+{
+ [UsedImplicitly]
+ public sealed class RadiationStorm : StationEvent
+ {
+ // Based on Goonstation style radiation storm with some TG elements (announcer, etc.)
+
+ [Dependency] private IEntityManager _entityManager = default!;
+ [Dependency] private IMapManager _mapManager = default!;
+ [Dependency] private IRobustRandom _robustRandom = default!;
+
+ public override string Name => "RadiationStorm";
+
+ protected override string StartAnnouncement => Loc.GetString(
+ "High levels of radiation detected near the station. Evacuate any areas containing abnormal green energy fields.");
+
+ protected override string EndAnnouncement => Loc.GetString(
+ "The radiation threat has passed. Please return to your workplaces.");
+
+ ///
+ /// How long until the radiation storm starts
+ ///
+ private const float StartupTime = 10;
+
+ ///
+ /// How long the radiation storm has been running for
+ ///
+ private float _timeElapsed;
+
+ private int _pulsesRemaining;
+ private float _timeUntilPulse;
+ private const float MinPulseDelay = 0.2f;
+ private const float MaxPulseDelay = 0.8f;
+
+ public override void Startup()
+ {
+ base.Startup();
+ EntitySystem.Get().PlayGlobal("/Audio/Announcements/radiation.ogg");
+ IoCManager.InjectDependencies(this);
+
+ _timeElapsed = 0.0f;
+ _pulsesRemaining = _robustRandom.Next(30, 100);
+
+ var componentManager = IoCManager.Resolve();
+
+ foreach (var overlay in componentManager.EntityQuery())
+ {
+ overlay.AddOverlay(SharedOverlayID.RadiationPulseOverlay);
+ }
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+
+ // IOC uninject?
+ _entityManager = null;
+ _mapManager = null;
+ _robustRandom = null;
+
+ var componentManager = IoCManager.Resolve();
+
+ foreach (var overlay in componentManager.EntityQuery())
+ {
+ overlay.RemoveOverlay(SharedOverlayID.RadiationPulseOverlay);
+ }
+ }
+
+ public override void Update(float frameTime)
+ {
+ _timeElapsed += frameTime;
+
+ if (_pulsesRemaining == 0)
+ {
+ Running = false;
+ }
+
+ if (!Running)
+ {
+ return;
+ }
+
+ if (_timeElapsed < StartupTime)
+ {
+ return;
+ }
+
+ _timeUntilPulse -= frameTime;
+
+ if (_timeUntilPulse <= 0.0f)
+ {
+ // TODO: Probably rate-limit this for small grids (e.g. no more than 25% covered)
+ foreach (var grid in _mapManager.GetAllGrids())
+ {
+ if (grid.IsDefaultGrid) continue;
+ SpawnPulse(grid);
+ }
+ }
+ }
+
+ private void SpawnPulse(IMapGrid mapGrid)
+ {
+ _entityManager.SpawnEntity("RadiationPulse", FindRandomGrid(mapGrid));
+ _timeUntilPulse = _robustRandom.NextFloat() * (MaxPulseDelay - MinPulseDelay) + MinPulseDelay;
+ _pulsesRemaining -= 1;
+ }
+
+ private GridCoordinates FindRandomGrid(IMapGrid mapGrid)
+ {
+ // TODO: Need to get valid tiles? (maybe just move right if the tile we chose is invalid?)
+
+ var randomX = _robustRandom.Next((int) mapGrid.WorldBounds.Left, (int) mapGrid.WorldBounds.Right);
+ var randomY = _robustRandom.Next((int) mapGrid.WorldBounds.Bottom, (int) mapGrid.WorldBounds.Top);
+
+ return mapGrid.GridTileToLocal(new MapIndices(randomX, randomY));
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/StationEvents/StationEvent.cs b/Content.Server/StationEvents/StationEvent.cs
new file mode 100644
index 0000000000..8d351aff8e
--- /dev/null
+++ b/Content.Server/StationEvents/StationEvent.cs
@@ -0,0 +1,94 @@
+using Content.Server.Interfaces.Chat;
+using Robust.Server.GameObjects;
+using Robust.Shared.IoC;
+
+namespace Content.Server.StationEvents
+{
+ public abstract class StationEvent
+ {
+ ///
+ /// If the event has started and is currently running
+ ///
+ public bool Running { get; protected set; }
+
+ ///
+ /// Human-readable name for the event
+ ///
+ public abstract string Name { get; }
+
+ public virtual StationEventWeight Weight { get; } = StationEventWeight.Normal;
+
+ ///
+ /// What should be said in chat when the event starts (if anything).
+ ///
+ protected virtual string StartAnnouncement { get; } = null;
+
+ ///
+ /// What should be said in chat when the event end (if anything).
+ ///
+ protected virtual string EndAnnouncement { get; } = null;
+
+ ///
+ /// In minutes, when is the first time this event can start
+ ///
+ ///
+ public virtual int EarliestStart { get; } = 20;
+
+ ///
+ /// How many players need to be present on station for the event to run
+ ///
+ /// To avoid running deadly events with low-pop
+ public virtual int MinimumPlayers { get; } = 0;
+
+ ///
+ /// How many times this event has run this round
+ ///
+ public int Occurrences { get; set; } = 0;
+
+ ///
+ /// How many times this even can occur in a single round
+ ///
+ public virtual int? MaxOccurrences { get; } = null;
+
+ ///
+ /// Called once when the station event starts
+ ///
+ public virtual void Startup()
+ {
+ Running = true;
+ Occurrences += 1;
+ if (StartAnnouncement != null)
+ {
+ var chatManager = IoCManager.Resolve();
+ chatManager.DispatchStationAnnouncement(StartAnnouncement);
+ }
+ }
+
+ ///
+ /// Called every tick when this event is active
+ ///
+ ///
+ public abstract void Update(float frameTime);
+
+ ///
+ /// Called once when the station event ends
+ ///
+ public virtual void Shutdown()
+ {
+ if (EndAnnouncement != null)
+ {
+ var chatManager = IoCManager.Resolve();
+ chatManager.DispatchStationAnnouncement(EndAnnouncement);
+ }
+ }
+ }
+
+ public enum StationEventWeight
+ {
+ VeryLow = 0,
+ Low = 5,
+ Normal = 10,
+ High = 15,
+ VeryHigh = 20,
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/StationEvents/StationEventCommand.cs b/Content.Server/StationEvents/StationEventCommand.cs
new file mode 100644
index 0000000000..39331c6d5b
--- /dev/null
+++ b/Content.Server/StationEvents/StationEventCommand.cs
@@ -0,0 +1,102 @@
+#nullable enable
+using Content.Server.GameObjects.EntitySystems;
+using Content.Server.GameObjects.EntitySystems.StationEvents;
+using JetBrains.Annotations;
+using Robust.Server.Interfaces.Console;
+using Robust.Server.Interfaces.Player;
+using Robust.Shared.GameObjects.Systems;
+using Robust.Shared.Localization;
+
+namespace Content.Client.Commands
+{
+ [UsedImplicitly]
+ public sealed class StationEventCommand : IClientCommand
+ {
+ public string Command => "events";
+ public string Description => "Provides admin control to station events";
+ public string Help => "events >\n" +
+ "list: return all event names that can be run\n " +
+ "pause: stop all random events from running\n" +
+ "resume: allow random events to run again\n" +
+ "random: choose a random event that is valid and run it\n" +
+ "run: start a particular event now; is case-insensitive and not localized";
+ public void Execute(IConsoleShell shell, IPlayerSession? player, string[] args)
+ {
+ if (args.Length == 0)
+ {
+ shell.SendText(player, "Need more args");
+ return;
+ }
+
+ if (args[0] == "list")
+ {
+ var resultText = "Random\n" + EntitySystem.Get().GetEventNames();
+ shell.SendText(player, resultText);
+ return;
+ }
+
+ // Didn't use a "toggle" so it's explicit
+ if (args[0] == "pause")
+ {
+ var stationEventSystem = EntitySystem.Get();
+
+ if (!stationEventSystem.Enabled)
+ {
+ shell.SendText(player, Loc.GetString("Station events are already paused"));
+ return;
+ }
+ else
+ {
+ stationEventSystem.Enabled = false;
+ shell.SendText(player, Loc.GetString("Station events paused"));
+ return;
+ }
+ }
+
+ if (args[0] == "resume")
+ {
+ var stationEventSystem = EntitySystem.Get();
+
+ if (stationEventSystem.Enabled)
+ {
+ shell.SendText(player, Loc.GetString("Station events are already running"));
+ return;
+ }
+ else
+ {
+ stationEventSystem.Enabled = true;
+ shell.SendText(player, Loc.GetString("Station events resumed"));
+ return;
+ }
+ }
+
+ if (args[0] == "stop")
+ {
+ var resultText = EntitySystem.Get().StopEvent();
+ shell.SendText(player, resultText);
+ return;
+ }
+
+ if (args[0] == "run" && args.Length == 2)
+ {
+ var eventName = args[1];
+ string resultText;
+
+ if (eventName == "random")
+ {
+ resultText = EntitySystem.Get().RunRandomEvent();
+ }
+ else
+ {
+ resultText = EntitySystem.Get().RunEvent(eventName);
+ }
+
+ shell.SendText(player, resultText);
+ return;
+ }
+
+ shell.SendText(player, Loc.GetString("Invalid events command"));
+ return;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Shared/GameObjects/Components/Mobs/SharedOverlayEffectsComponent.cs b/Content.Shared/GameObjects/Components/Mobs/SharedOverlayEffectsComponent.cs
index 865b3092e8..3ee301ab96 100644
--- a/Content.Shared/GameObjects/Components/Mobs/SharedOverlayEffectsComponent.cs
+++ b/Content.Shared/GameObjects/Components/Mobs/SharedOverlayEffectsComponent.cs
@@ -114,6 +114,7 @@ namespace Content.Shared.GameObjects.Components.Mobs
{
GradientCircleMaskOverlay,
CircleMaskOverlay,
- FlashOverlay
+ FlashOverlay,
+ RadiationPulseOverlay,
}
}
diff --git a/Content.Shared/GameObjects/Components/SharedRadiationStorm.cs b/Content.Shared/GameObjects/Components/SharedRadiationStorm.cs
new file mode 100644
index 0000000000..efa28f0919
--- /dev/null
+++ b/Content.Shared/GameObjects/Components/SharedRadiationStorm.cs
@@ -0,0 +1,38 @@
+using System;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.GameObjects.Components
+{
+ public abstract class SharedRadiationPulseComponent : Component
+ {
+ public override string Name => "RadiationPulse";
+ public override uint? NetID => ContentNetIDs.RADIATION_PULSE;
+
+ ///
+ /// Radius of the pulse from its position
+ ///
+ public float Range => _range;
+ private float _range;
+
+ public override void ExposeData(ObjectSerializer serializer)
+ {
+ base.ExposeData(serializer);
+ serializer.DataField(ref _range, "range", 5.0f);
+ }
+ }
+
+ ///
+ /// For syncing the pulse's lifespan between client and server for the overlay
+ ///
+ [Serializable, NetSerializable]
+ public sealed class RadiationPulseMessage : ComponentState
+ {
+ public TimeSpan EndTime { get; }
+
+ public RadiationPulseMessage(TimeSpan endTime) : base(ContentNetIDs.RADIATION_PULSE)
+ {
+ EndTime = endTime;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Shared/GameObjects/ContentNetIDs.cs b/Content.Shared/GameObjects/ContentNetIDs.cs
index 6a38d3e669..9ecd4a918b 100644
--- a/Content.Shared/GameObjects/ContentNetIDs.cs
+++ b/Content.Shared/GameObjects/ContentNetIDs.cs
@@ -63,6 +63,7 @@
public const uint DISPOSABLE = 1056;
public const uint GAS_ANALYZER = 1057;
public const uint DO_AFTER = 1058;
+ public const uint RADIATION_PULSE = 1059;
// Net IDs for integration tests.
public const uint PREDICTION_TEST = 10001;
diff --git a/Resources/Audio/Announcements/power_off.ogg b/Resources/Audio/Announcements/power_off.ogg
new file mode 100644
index 0000000000..7dcd968c73
Binary files /dev/null and b/Resources/Audio/Announcements/power_off.ogg differ
diff --git a/Resources/Audio/Announcements/power_on.ogg b/Resources/Audio/Announcements/power_on.ogg
new file mode 100644
index 0000000000..ca641eafe0
Binary files /dev/null and b/Resources/Audio/Announcements/power_on.ogg differ
diff --git a/Resources/Audio/Announcements/radiation.ogg b/Resources/Audio/Announcements/radiation.ogg
new file mode 100644
index 0000000000..610f6cd8c0
Binary files /dev/null and b/Resources/Audio/Announcements/radiation.ogg differ
diff --git a/Resources/Groups/groups.yml b/Resources/Groups/groups.yml
index ce1885473a..1cf921b5f8 100644
--- a/Resources/Groups/groups.yml
+++ b/Resources/Groups/groups.yml
@@ -36,6 +36,7 @@
- listplayers
- loc
- hostlogin
+ - events
- Index: 100
Name: Administrator
@@ -93,6 +94,7 @@
- unanchor
- tubeconnections
- tilewalls
+ - events
CanViewVar: true
CanAdminPlace: true
@@ -181,6 +183,7 @@
- settemp
- setatmostemp
- tilewalls
+ - events
CanViewVar: true
CanAdminPlace: true
CanScript: true
diff --git a/Resources/Prototypes/Entities/radiation.yml b/Resources/Prototypes/Entities/radiation.yml
new file mode 100644
index 0000000000..81d20ca718
--- /dev/null
+++ b/Resources/Prototypes/Entities/radiation.yml
@@ -0,0 +1,7 @@
+- type: entity
+ name: shimmering anomaly
+ id: RadiationPulse
+ abstract: true
+ description: Looking at this anomaly makes you feel strange, like something is pushing at your eyes.
+ components:
+ - type: RadiationPulse
\ No newline at end of file