diff --git a/Content.Benchmarks/SpawnEquipDeleteBenchmark.cs b/Content.Benchmarks/SpawnEquipDeleteBenchmark.cs
index de51b2fb192..8512107b69d 100644
--- a/Content.Benchmarks/SpawnEquipDeleteBenchmark.cs
+++ b/Content.Benchmarks/SpawnEquipDeleteBenchmark.cs
@@ -58,7 +58,7 @@ await _pair.Server.WaitPost(() =>
for (var i = 0; i < N; i++)
{
_entity = server.EntMan.SpawnAttachedTo(Mob, _coords);
- _spawnSys.EquipStartingGear(_entity, _gear, null);
+ _spawnSys.EquipStartingGear(_entity, _gear);
server.EntMan.DeleteEntity(_entity);
}
});
diff --git a/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml b/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml
new file mode 100644
index 00000000000..96f136abf0e
--- /dev/null
+++ b/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml.cs b/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml.cs
new file mode 100644
index 00000000000..b0d0365ef6b
--- /dev/null
+++ b/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml.cs
@@ -0,0 +1,215 @@
+using Content.Client.Stylesheets;
+using Content.Shared.Atmos;
+using Content.Shared.Atmos.Components;
+using Content.Shared.Atmos.Monitor;
+using Content.Shared.FixedPoint;
+using Content.Shared.Temperature;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.ResourceManagement;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Map;
+using System.Linq;
+
+namespace Content.Client.Atmos.Consoles;
+
+[GenerateTypedNameReferences]
+public sealed partial class AtmosAlarmEntryContainer : BoxContainer
+{
+ public NetEntity NetEntity;
+ public EntityCoordinates? Coordinates;
+
+ private readonly IEntityManager _entManager;
+ private readonly IResourceCache _cache;
+
+ private Dictionary _alarmStrings = new Dictionary()
+ {
+ [AtmosAlarmType.Invalid] = "atmos-alerts-window-invalid-state",
+ [AtmosAlarmType.Normal] = "atmos-alerts-window-normal-state",
+ [AtmosAlarmType.Warning] = "atmos-alerts-window-warning-state",
+ [AtmosAlarmType.Danger] = "atmos-alerts-window-danger-state",
+ };
+
+ private Dictionary _gasShorthands = new Dictionary()
+ {
+ [Gas.Ammonia] = "NH₃",
+ [Gas.CarbonDioxide] = "CO₂",
+ [Gas.Frezon] = "F",
+ [Gas.Nitrogen] = "N₂",
+ [Gas.NitrousOxide] = "N₂O",
+ [Gas.Oxygen] = "O₂",
+ [Gas.Plasma] = "P",
+ [Gas.Tritium] = "T",
+ [Gas.WaterVapor] = "H₂O",
+ };
+
+ public AtmosAlarmEntryContainer(NetEntity uid, EntityCoordinates? coordinates)
+ {
+ RobustXamlLoader.Load(this);
+
+ _entManager = IoCManager.Resolve();
+ _cache = IoCManager.Resolve();
+
+ NetEntity = uid;
+ Coordinates = coordinates;
+
+ // Load fonts
+ var headerFont = new VectorFont(_cache.GetResource("/Fonts/NotoSans/NotoSans-Bold.ttf"), 11);
+ var normalFont = new VectorFont(_cache.GetResource("/Fonts/NotoSansDisplay/NotoSansDisplay-Regular.ttf"), 11);
+ var smallFont = new VectorFont(_cache.GetResource("/Fonts/NotoSans/NotoSans-Regular.ttf"), 10);
+
+ // Set fonts
+ TemperatureHeaderLabel.FontOverride = headerFont;
+ PressureHeaderLabel.FontOverride = headerFont;
+ OxygenationHeaderLabel.FontOverride = headerFont;
+ GasesHeaderLabel.FontOverride = headerFont;
+
+ TemperatureLabel.FontOverride = normalFont;
+ PressureLabel.FontOverride = normalFont;
+ OxygenationLabel.FontOverride = normalFont;
+
+ NoDataLabel.FontOverride = headerFont;
+
+ SilenceCheckBox.Label.FontOverride = smallFont;
+ SilenceCheckBox.Label.FontColorOverride = Color.DarkGray;
+ }
+
+ public void UpdateEntry(AtmosAlertsComputerEntry entry, bool isFocus, AtmosAlertsFocusDeviceData? focusData = null)
+ {
+ NetEntity = entry.NetEntity;
+ Coordinates = _entManager.GetCoordinates(entry.Coordinates);
+
+ // Load fonts
+ var normalFont = new VectorFont(_cache.GetResource("/Fonts/NotoSansDisplay/NotoSansDisplay-Regular.ttf"), 11);
+
+ // Update alarm state
+ if (!_alarmStrings.TryGetValue(entry.AlarmState, out var alarmString))
+ alarmString = "atmos-alerts-window-invalid-state";
+
+ AlarmStateLabel.Text = Loc.GetString(alarmString);
+ AlarmStateLabel.FontColorOverride = GetAlarmStateColor(entry.AlarmState);
+
+ // Update alarm name
+ AlarmNameLabel.Text = Loc.GetString("atmos-alerts-window-alarm-label", ("name", entry.EntityName), ("address", entry.Address));
+
+ // Focus updates
+ FocusContainer.Visible = isFocus;
+
+ if (isFocus)
+ SetAsFocus();
+ else
+ RemoveAsFocus();
+
+ if (isFocus && entry.Group == AtmosAlertsComputerGroup.AirAlarm)
+ {
+ MainDataContainer.Visible = (entry.AlarmState != AtmosAlarmType.Invalid);
+ NoDataLabel.Visible = (entry.AlarmState == AtmosAlarmType.Invalid);
+
+ if (focusData != null)
+ {
+ // Update temperature
+ var tempK = (FixedPoint2)focusData.Value.TemperatureData.Item1;
+ var tempC = (FixedPoint2)TemperatureHelpers.KelvinToCelsius(tempK.Float());
+
+ TemperatureLabel.Text = Loc.GetString("atmos-alerts-window-temperature-value", ("valueInC", tempC), ("valueInK", tempK));
+ TemperatureLabel.FontColorOverride = GetAlarmStateColor(focusData.Value.TemperatureData.Item2);
+
+ // Update pressure
+ PressureLabel.Text = Loc.GetString("atmos-alerts-window-pressure-value", ("value", (FixedPoint2)focusData.Value.PressureData.Item1));
+ PressureLabel.FontColorOverride = GetAlarmStateColor(focusData.Value.PressureData.Item2);
+
+ // Update oxygenation
+ var oxygenPercent = (FixedPoint2)0f;
+ var oxygenAlert = AtmosAlarmType.Invalid;
+
+ if (focusData.Value.GasData.TryGetValue(Gas.Oxygen, out var oxygenData))
+ {
+ oxygenPercent = oxygenData.Item2 * 100f;
+ oxygenAlert = oxygenData.Item3;
+ }
+
+ OxygenationLabel.Text = Loc.GetString("atmos-alerts-window-oxygenation-value", ("value", oxygenPercent));
+ OxygenationLabel.FontColorOverride = GetAlarmStateColor(oxygenAlert);
+
+ // Update other present gases
+ GasGridContainer.RemoveAllChildren();
+
+ var gasData = focusData.Value.GasData.Where(g => g.Key != Gas.Oxygen);
+
+ if (gasData.Count() == 0)
+ {
+ // No other gases
+ var gasLabel = new Label()
+ {
+ Text = Loc.GetString("atmos-alerts-window-other-gases-value-nil"),
+ FontOverride = normalFont,
+ FontColorOverride = StyleNano.DisabledFore,
+ HorizontalAlignment = HAlignment.Center,
+ VerticalAlignment = VAlignment.Center,
+ HorizontalExpand = true,
+ Margin = new Thickness(0, 2, 0, 0),
+ SetHeight = 24f,
+ };
+
+ GasGridContainer.AddChild(gasLabel);
+ }
+
+ else
+ {
+ // Add an entry for each gas
+ foreach ((var gas, (var mol, var percent, var alert)) in gasData)
+ {
+ var gasPercent = (FixedPoint2)0f;
+ gasPercent = percent * 100f;
+
+ if (!_gasShorthands.TryGetValue(gas, out var gasShorthand))
+ gasShorthand = "X";
+
+ var gasLabel = new Label()
+ {
+ Text = Loc.GetString("atmos-alerts-window-other-gases-value", ("shorthand", gasShorthand), ("value", gasPercent)),
+ FontOverride = normalFont,
+ FontColorOverride = GetAlarmStateColor(alert),
+ HorizontalAlignment = HAlignment.Center,
+ VerticalAlignment = VAlignment.Center,
+ HorizontalExpand = true,
+ Margin = new Thickness(0, 2, 0, 0),
+ SetHeight = 24f,
+ };
+
+ GasGridContainer.AddChild(gasLabel);
+ }
+ }
+ }
+ }
+ }
+
+ public void SetAsFocus()
+ {
+ FocusButton.AddStyleClass(StyleNano.StyleClassButtonColorGreen);
+ ArrowTexture.TexturePath = "/Textures/Interface/Nano/inverted_triangle.svg.png";
+ }
+
+ public void RemoveAsFocus()
+ {
+ FocusButton.RemoveStyleClass(StyleNano.StyleClassButtonColorGreen);
+ ArrowTexture.TexturePath = "/Textures/Interface/Nano/triangle_right.png";
+ FocusContainer.Visible = false;
+ }
+
+ private Color GetAlarmStateColor(AtmosAlarmType alarmType)
+ {
+ switch (alarmType)
+ {
+ case AtmosAlarmType.Normal:
+ return StyleNano.GoodGreenFore;
+ case AtmosAlarmType.Warning:
+ return StyleNano.ConcerningOrangeFore;
+ case AtmosAlarmType.Danger:
+ return StyleNano.DangerousRedFore;
+ }
+
+ return StyleNano.DisabledFore;
+ }
+}
\ No newline at end of file
diff --git a/Content.Client/Atmos/Consoles/AtmosAlertsComputerBoundUserInterface.cs b/Content.Client/Atmos/Consoles/AtmosAlertsComputerBoundUserInterface.cs
new file mode 100644
index 00000000000..08cae979b9b
--- /dev/null
+++ b/Content.Client/Atmos/Consoles/AtmosAlertsComputerBoundUserInterface.cs
@@ -0,0 +1,52 @@
+using Content.Shared.Atmos.Components;
+
+namespace Content.Client.Atmos.Consoles;
+
+public sealed class AtmosAlertsComputerBoundUserInterface : BoundUserInterface
+{
+ [ViewVariables]
+ private AtmosAlertsComputerWindow? _menu;
+
+ public AtmosAlertsComputerBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) { }
+
+ protected override void Open()
+ {
+ _menu = new AtmosAlertsComputerWindow(this, Owner);
+ _menu.OpenCentered();
+ _menu.OnClose += Close;
+
+ EntMan.TryGetComponent(Owner, out var xform);
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ var castState = (AtmosAlertsComputerBoundInterfaceState) state;
+
+ if (castState == null)
+ return;
+
+ EntMan.TryGetComponent(Owner, out var xform);
+ _menu?.UpdateUI(xform?.Coordinates, castState.AirAlarms, castState.FireAlarms, castState.FocusData);
+ }
+
+ public void SendFocusChangeMessage(NetEntity? netEntity)
+ {
+ SendMessage(new AtmosAlertsComputerFocusChangeMessage(netEntity));
+ }
+
+ public void SendDeviceSilencedMessage(NetEntity netEntity, bool silenceDevice)
+ {
+ SendMessage(new AtmosAlertsComputerDeviceSilencedMessage(netEntity, silenceDevice));
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (!disposing)
+ return;
+
+ _menu?.Dispose();
+ }
+}
diff --git a/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml b/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml
new file mode 100644
index 00000000000..8824a776ee6
--- /dev/null
+++ b/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml.cs b/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml.cs
new file mode 100644
index 00000000000..a55321833cd
--- /dev/null
+++ b/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml.cs
@@ -0,0 +1,550 @@
+using Content.Client.Message;
+using Content.Client.Pinpointer.UI;
+using Content.Client.Stylesheets;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Atmos.Components;
+using Content.Shared.Atmos.Monitor;
+using Content.Shared.Pinpointer;
+using Robust.Client.AutoGenerated;
+using Robust.Client.GameObjects;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Map;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+using Robust.Shared.ContentPack;
+using Robust.Shared.Prototypes;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+namespace Content.Client.Atmos.Consoles;
+
+[GenerateTypedNameReferences]
+public sealed partial class AtmosAlertsComputerWindow : FancyWindow
+{
+ private readonly IEntityManager _entManager;
+ private readonly SpriteSystem _spriteSystem;
+
+ private EntityUid? _owner;
+ private NetEntity? _trackedEntity;
+
+ private AtmosAlertsComputerEntry[]? _airAlarms = null;
+ private AtmosAlertsComputerEntry[]? _fireAlarms = null;
+ private IEnumerable? _allAlarms = null;
+
+ private IEnumerable? _activeAlarms = null;
+ private Dictionary _deviceSilencingProgress = new();
+
+ public event Action? SendFocusChangeMessageAction;
+ public event Action? SendDeviceSilencedMessageAction;
+
+ private bool _autoScrollActive = false;
+ private bool _autoScrollAwaitsUpdate = false;
+
+ private const float SilencingDuration = 2.5f;
+
+ public AtmosAlertsComputerWindow(AtmosAlertsComputerBoundUserInterface userInterface, EntityUid? owner)
+ {
+ RobustXamlLoader.Load(this);
+ _entManager = IoCManager.Resolve();
+ _spriteSystem = _entManager.System();
+
+ // Pass the owner to nav map
+ _owner = owner;
+ NavMap.Owner = _owner;
+
+ // Set nav map colors
+ NavMap.WallColor = new Color(64, 64, 64);
+ NavMap.TileColor = Color.DimGray * NavMap.WallColor;
+
+ // Set nav map grid uid
+ var stationName = Loc.GetString("atmos-alerts-window-unknown-location");
+
+ if (_entManager.TryGetComponent(owner, out var xform))
+ {
+ NavMap.MapUid = xform.GridUid;
+
+ // Assign station name
+ if (_entManager.TryGetComponent(xform.GridUid, out var stationMetaData))
+ stationName = stationMetaData.EntityName;
+
+ var msg = new FormattedMessage();
+ msg.AddMarkup(Loc.GetString("atmos-alerts-window-station-name", ("stationName", stationName)));
+
+ StationName.SetMessage(msg);
+ }
+
+ else
+ {
+ StationName.SetMessage(stationName);
+ NavMap.Visible = false;
+ }
+
+ // Set trackable entity selected action
+ NavMap.TrackedEntitySelectedAction += SetTrackedEntityFromNavMap;
+
+ // Update nav map
+ NavMap.ForceNavMapUpdate();
+
+ // Set tab container headers
+ MasterTabContainer.SetTabTitle(0, Loc.GetString("atmos-alerts-window-tab-no-alerts"));
+ MasterTabContainer.SetTabTitle(1, Loc.GetString("atmos-alerts-window-tab-air-alarms"));
+ MasterTabContainer.SetTabTitle(2, Loc.GetString("atmos-alerts-window-tab-fire-alarms"));
+
+ // Set UI toggles
+ ShowInactiveAlarms.OnToggled += _ => OnShowAlarmsToggled(ShowInactiveAlarms, AtmosAlarmType.Invalid);
+ ShowNormalAlarms.OnToggled += _ => OnShowAlarmsToggled(ShowNormalAlarms, AtmosAlarmType.Normal);
+ ShowWarningAlarms.OnToggled += _ => OnShowAlarmsToggled(ShowWarningAlarms, AtmosAlarmType.Warning);
+ ShowDangerAlarms.OnToggled += _ => OnShowAlarmsToggled(ShowDangerAlarms, AtmosAlarmType.Danger);
+
+ // Set atmos monitoring message action
+ SendFocusChangeMessageAction += userInterface.SendFocusChangeMessage;
+ SendDeviceSilencedMessageAction += userInterface.SendDeviceSilencedMessage;
+ }
+
+ #region Toggle handling
+
+ private void OnShowAlarmsToggled(CheckBox toggle, AtmosAlarmType toggledAlarmState)
+ {
+ if (_owner == null)
+ return;
+
+ if (!_entManager.TryGetComponent(_owner.Value, out var console))
+ return;
+
+ foreach (var device in console.AtmosDevices)
+ {
+ var alarmState = GetAlarmState(device.NetEntity);
+
+ if (toggledAlarmState != alarmState)
+ continue;
+
+ if (toggle.Pressed)
+ AddTrackedEntityToNavMap(device, alarmState);
+
+ else
+ NavMap.TrackedEntities.Remove(device.NetEntity);
+ }
+ }
+
+ private void OnSilenceAlertsToggled(NetEntity netEntity, bool toggleState)
+ {
+ if (!_entManager.TryGetComponent(_owner, out var console))
+ return;
+
+ if (toggleState)
+ _deviceSilencingProgress[netEntity] = SilencingDuration;
+
+ else
+ _deviceSilencingProgress.Remove(netEntity);
+
+ foreach (AtmosAlarmEntryContainer entryContainer in AlertsTable.Children)
+ {
+ if (entryContainer.NetEntity == netEntity)
+ entryContainer.SilenceAlarmProgressBar.Visible = toggleState;
+ }
+
+ SendDeviceSilencedMessageAction?.Invoke(netEntity, toggleState);
+ }
+
+ #endregion
+
+ public void UpdateUI(EntityCoordinates? consoleCoords, AtmosAlertsComputerEntry[] airAlarms, AtmosAlertsComputerEntry[] fireAlarms, AtmosAlertsFocusDeviceData? focusData)
+ {
+ if (_owner == null)
+ return;
+
+ if (!_entManager.TryGetComponent(_owner.Value, out var console))
+ return;
+
+ if (_trackedEntity != focusData?.NetEntity)
+ {
+ SendFocusChangeMessageAction?.Invoke(_trackedEntity);
+ focusData = null;
+ }
+
+ // Retain alarm data for use inbetween updates
+ _airAlarms = airAlarms;
+ _fireAlarms = fireAlarms;
+ _allAlarms = airAlarms.Concat(fireAlarms);
+
+ var silenced = console.SilencedDevices;
+
+ _activeAlarms = _allAlarms.Where(x => x.AlarmState > AtmosAlarmType.Normal &&
+ (!silenced.Contains(x.NetEntity) || _deviceSilencingProgress.ContainsKey(x.NetEntity)));
+
+ // Reset nav map data
+ NavMap.TrackedCoordinates.Clear();
+ NavMap.TrackedEntities.Clear();
+
+ // Add tracked entities to the nav map
+ foreach (var device in console.AtmosDevices)
+ {
+ if (!NavMap.Visible)
+ continue;
+
+ var alarmState = GetAlarmState(device.NetEntity);
+
+ if (_trackedEntity != device.NetEntity)
+ {
+ // Skip air alarms if the appropriate overlay is off
+ if (!ShowInactiveAlarms.Pressed && alarmState == AtmosAlarmType.Invalid)
+ continue;
+
+ if (!ShowNormalAlarms.Pressed && alarmState == AtmosAlarmType.Normal)
+ continue;
+
+ if (!ShowWarningAlarms.Pressed && alarmState == AtmosAlarmType.Warning)
+ continue;
+
+ if (!ShowDangerAlarms.Pressed && alarmState == AtmosAlarmType.Danger)
+ continue;
+ }
+
+ AddTrackedEntityToNavMap(device, alarmState);
+ }
+
+ // Show the monitor location
+ var consoleUid = _entManager.GetNetEntity(_owner);
+
+ if (consoleCoords != null && consoleUid != null)
+ {
+ var texture = _spriteSystem.Frame0(new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_circle.png")));
+ var blip = new NavMapBlip(consoleCoords.Value, texture, Color.Cyan, true, false);
+ NavMap.TrackedEntities[consoleUid.Value] = blip;
+ }
+
+ // Update the nav map
+ NavMap.ForceNavMapUpdate();
+
+ // Clear excess children from the tables
+ var activeAlarmCount = _activeAlarms.Count();
+
+ while (AlertsTable.ChildCount > activeAlarmCount)
+ AlertsTable.RemoveChild(AlertsTable.GetChild(AlertsTable.ChildCount - 1));
+
+ while (AirAlarmsTable.ChildCount > airAlarms.Length)
+ AirAlarmsTable.RemoveChild(AirAlarmsTable.GetChild(AirAlarmsTable.ChildCount - 1));
+
+ while (FireAlarmsTable.ChildCount > fireAlarms.Length)
+ FireAlarmsTable.RemoveChild(FireAlarmsTable.GetChild(FireAlarmsTable.ChildCount - 1));
+
+ // Update all entries in each table
+ for (int index = 0; index < _activeAlarms.Count(); index++)
+ {
+ var entry = _activeAlarms.ElementAt(index);
+ UpdateUIEntry(entry, index, AlertsTable, console, focusData);
+ }
+
+ for (int index = 0; index < airAlarms.Count(); index++)
+ {
+ var entry = airAlarms.ElementAt(index);
+ UpdateUIEntry(entry, index, AirAlarmsTable, console, focusData);
+ }
+
+ for (int index = 0; index < fireAlarms.Count(); index++)
+ {
+ var entry = fireAlarms.ElementAt(index);
+ UpdateUIEntry(entry, index, FireAlarmsTable, console, focusData);
+ }
+
+ // If no alerts are active, display a message
+ if (MasterTabContainer.CurrentTab == 0 && activeAlarmCount == 0)
+ {
+ var label = new RichTextLabel()
+ {
+ HorizontalExpand = true,
+ VerticalExpand = true,
+ HorizontalAlignment = HAlignment.Center,
+ VerticalAlignment = VAlignment.Center,
+ };
+
+ label.SetMarkup(Loc.GetString("atmos-alerts-window-no-active-alerts", ("color", StyleNano.GoodGreenFore.ToHexNoAlpha())));
+
+ AlertsTable.AddChild(label);
+ }
+
+ // Update the alerts tab with the number of active alerts
+ if (activeAlarmCount == 0)
+ MasterTabContainer.SetTabTitle(0, Loc.GetString("atmos-alerts-window-tab-no-alerts"));
+
+ else
+ MasterTabContainer.SetTabTitle(0, Loc.GetString("atmos-alerts-window-tab-alerts", ("value", activeAlarmCount)));
+
+ // Auto-scroll re-enable
+ if (_autoScrollAwaitsUpdate)
+ {
+ _autoScrollActive = true;
+ _autoScrollAwaitsUpdate = false;
+ }
+ }
+
+ private void AddTrackedEntityToNavMap(AtmosAlertsDeviceNavMapData metaData, AtmosAlarmType alarmState)
+ {
+ var data = GetBlipTexture(alarmState);
+
+ if (data == null)
+ return;
+
+ var texture = data.Value.Item1;
+ var color = data.Value.Item2;
+ var coords = _entManager.GetCoordinates(metaData.NetCoordinates);
+
+ if (_trackedEntity != null && _trackedEntity != metaData.NetEntity)
+ color *= Color.DimGray;
+
+ var selectable = true;
+ var blip = new NavMapBlip(coords, _spriteSystem.Frame0(texture), color, _trackedEntity == metaData.NetEntity, selectable);
+
+ NavMap.TrackedEntities[metaData.NetEntity] = blip;
+ }
+
+ private void UpdateUIEntry(AtmosAlertsComputerEntry entry, int index, Control table, AtmosAlertsComputerComponent console, AtmosAlertsFocusDeviceData? focusData = null)
+ {
+ // Make new UI entry if required
+ if (index >= table.ChildCount)
+ {
+ var newEntryContainer = new AtmosAlarmEntryContainer(entry.NetEntity, _entManager.GetCoordinates(entry.Coordinates));
+
+ // On click
+ newEntryContainer.FocusButton.OnButtonUp += args =>
+ {
+ if (_trackedEntity == newEntryContainer.NetEntity)
+ {
+ _trackedEntity = null;
+ }
+
+ else
+ {
+ _trackedEntity = newEntryContainer.NetEntity;
+
+ if (newEntryContainer.Coordinates != null)
+ NavMap.CenterToCoordinates(newEntryContainer.Coordinates.Value);
+ }
+
+ // Send message to console that the focus has changed
+ SendFocusChangeMessageAction?.Invoke(_trackedEntity);
+
+ // Update affected UI elements across all tables
+ UpdateConsoleTable(console, AlertsTable, _trackedEntity);
+ UpdateConsoleTable(console, AirAlarmsTable, _trackedEntity);
+ UpdateConsoleTable(console, FireAlarmsTable, _trackedEntity);
+ };
+
+ // On toggling the silence check box
+ newEntryContainer.SilenceCheckBox.OnToggled += _ => OnSilenceAlertsToggled(newEntryContainer.NetEntity, newEntryContainer.SilenceCheckBox.Pressed);
+
+ // Add the entry to the current table
+ table.AddChild(newEntryContainer);
+ }
+
+ // Update values and UI elements
+ var tableChild = table.GetChild(index);
+
+ if (tableChild is not AtmosAlarmEntryContainer)
+ {
+ table.RemoveChild(tableChild);
+ UpdateUIEntry(entry, index, table, console, focusData);
+
+ return;
+ }
+
+ var entryContainer = (AtmosAlarmEntryContainer)tableChild;
+
+ entryContainer.UpdateEntry(entry, entry.NetEntity == _trackedEntity, focusData);
+
+ if (_trackedEntity != entry.NetEntity)
+ {
+ var silenced = console.SilencedDevices;
+ entryContainer.SilenceCheckBox.Pressed = (silenced.Contains(entry.NetEntity) || _deviceSilencingProgress.ContainsKey(entry.NetEntity));
+ }
+
+ entryContainer.SilenceAlarmProgressBar.Visible = (table == AlertsTable && _deviceSilencingProgress.ContainsKey(entry.NetEntity));
+ }
+
+ private void UpdateConsoleTable(AtmosAlertsComputerComponent console, Control table, NetEntity? currTrackedEntity)
+ {
+ foreach (var tableChild in table.Children)
+ {
+ if (tableChild is not AtmosAlarmEntryContainer)
+ continue;
+
+ var entryContainer = (AtmosAlarmEntryContainer)tableChild;
+
+ if (entryContainer.NetEntity != currTrackedEntity)
+ entryContainer.RemoveAsFocus();
+
+ else if (entryContainer.NetEntity == currTrackedEntity)
+ entryContainer.SetAsFocus();
+ }
+ }
+
+ private void SetTrackedEntityFromNavMap(NetEntity? netEntity)
+ {
+ if (netEntity == null)
+ return;
+
+ if (!_entManager.TryGetComponent(_owner, out var console))
+ return;
+
+ _trackedEntity = netEntity;
+
+ if (netEntity != null)
+ {
+ // Tab switching
+ if (MasterTabContainer.CurrentTab != 0 || _activeAlarms?.Any(x => x.NetEntity == netEntity) == false)
+ {
+ var device = console.AtmosDevices.FirstOrNull(x => x.NetEntity == netEntity);
+
+ switch (device?.Group)
+ {
+ case AtmosAlertsComputerGroup.AirAlarm:
+ MasterTabContainer.CurrentTab = 1; break;
+ case AtmosAlertsComputerGroup.FireAlarm:
+ MasterTabContainer.CurrentTab = 2; break;
+ }
+ }
+
+ // Get the scroll position of the selected entity on the selected button the UI
+ ActivateAutoScrollToFocus();
+ }
+
+ // Send message to console that the focus has changed
+ SendFocusChangeMessageAction?.Invoke(_trackedEntity);
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ AutoScrollToFocus();
+
+ // Device silencing update
+ foreach ((var device, var remainingTime) in _deviceSilencingProgress)
+ {
+ var t = remainingTime - args.DeltaSeconds;
+
+ if (t <= 0)
+ {
+ _deviceSilencingProgress.Remove(device);
+
+ if (device == _trackedEntity)
+ _trackedEntity = null;
+ }
+
+ else
+ _deviceSilencingProgress[device] = t;
+ }
+ }
+
+ private void ActivateAutoScrollToFocus()
+ {
+ _autoScrollActive = false;
+ _autoScrollAwaitsUpdate = true;
+ }
+
+ private void AutoScrollToFocus()
+ {
+ if (!_autoScrollActive)
+ return;
+
+ var scroll = MasterTabContainer.Children.ElementAt(MasterTabContainer.CurrentTab) as ScrollContainer;
+ if (scroll == null)
+ return;
+
+ if (!TryGetVerticalScrollbar(scroll, out var vScrollbar))
+ return;
+
+ if (!TryGetNextScrollPosition(out float? nextScrollPosition))
+ return;
+
+ vScrollbar.ValueTarget = nextScrollPosition.Value;
+
+ if (MathHelper.CloseToPercent(vScrollbar.Value, vScrollbar.ValueTarget))
+ _autoScrollActive = false;
+ }
+
+ private bool TryGetVerticalScrollbar(ScrollContainer scroll, [NotNullWhen(true)] out VScrollBar? vScrollBar)
+ {
+ vScrollBar = null;
+
+ foreach (var child in scroll.Children)
+ {
+ if (child is not VScrollBar)
+ continue;
+
+ var castChild = child as VScrollBar;
+
+ if (castChild != null)
+ {
+ vScrollBar = castChild;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private bool TryGetNextScrollPosition([NotNullWhen(true)] out float? nextScrollPosition)
+ {
+ nextScrollPosition = null;
+
+ var scroll = MasterTabContainer.Children.ElementAt(MasterTabContainer.CurrentTab) as ScrollContainer;
+ if (scroll == null)
+ return false;
+
+ var container = scroll.Children.ElementAt(0) as BoxContainer;
+ if (container == null || container.Children.Count() == 0)
+ return false;
+
+ // Exit if the heights of the children haven't been initialized yet
+ if (!container.Children.Any(x => x.Height > 0))
+ return false;
+
+ nextScrollPosition = 0;
+
+ foreach (var control in container.Children)
+ {
+ if (control == null || control is not AtmosAlarmEntryContainer)
+ continue;
+
+ if (((AtmosAlarmEntryContainer)control).NetEntity == _trackedEntity)
+ return true;
+
+ nextScrollPosition += control.Height;
+ }
+
+ // Failed to find control
+ nextScrollPosition = null;
+
+ return false;
+ }
+
+ private AtmosAlarmType GetAlarmState(NetEntity netEntity)
+ {
+ var alarmState = _allAlarms?.FirstOrNull(x => x.NetEntity == netEntity)?.AlarmState;
+
+ if (alarmState == null)
+ return AtmosAlarmType.Invalid;
+
+ return alarmState.Value;
+ }
+
+ private (SpriteSpecifier.Texture, Color)? GetBlipTexture(AtmosAlarmType alarmState)
+ {
+ (SpriteSpecifier.Texture, Color)? output = null;
+
+ switch (alarmState)
+ {
+ case AtmosAlarmType.Invalid:
+ output = (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_circle.png")), StyleNano.DisabledFore); break;
+ case AtmosAlarmType.Normal:
+ output = (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_circle.png")), Color.LimeGreen); break;
+ case AtmosAlarmType.Warning:
+ output = (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_triangle.png")), new Color(255, 182, 72)); break;
+ case AtmosAlarmType.Danger:
+ output = (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_square.png")), new Color(255, 67, 67)); break;
+ }
+
+ return output;
+ }
+}
\ No newline at end of file
diff --git a/Content.Client/Buckle/BuckleSystem.cs b/Content.Client/Buckle/BuckleSystem.cs
index fea18e5cf3c..d4614210d9f 100644
--- a/Content.Client/Buckle/BuckleSystem.cs
+++ b/Content.Client/Buckle/BuckleSystem.cs
@@ -50,17 +50,11 @@ private void OnBuckleAfterAutoHandleState(EntityUid uid, BuckleComponent compone
private void OnAppearanceChange(EntityUid uid, BuckleComponent component, ref AppearanceChangeEvent args)
{
- if (!TryComp(uid, out var rotVisuals))
+ if (!TryComp(uid, out var rotVisuals)
+ || !Appearance.TryGetData(uid, BuckleVisuals.Buckled, out var buckled, args.Component)
+ || !buckled || args.Sprite == null)
return;
- if (!Appearance.TryGetData(uid, BuckleVisuals.Buckled, out var buckled, args.Component) ||
- !buckled ||
- args.Sprite == null)
- {
- _rotationVisualizerSystem.SetHorizontalAngle((uid, rotVisuals), rotVisuals.DefaultRotation);
- return;
- }
-
// Animate strapping yourself to something at a given angle
// TODO: Dump this when buckle is better
_rotationVisualizerSystem.AnimateSpriteRotation(uid, args.Sprite, rotVisuals.HorizontalRotation, 0.125f);
diff --git a/Content.Client/DeltaV/Options/UI/Tabs/DeltaTab.xaml b/Content.Client/DeltaV/Options/UI/Tabs/DeltaTab.xaml
deleted file mode 100644
index f1dae68077d..00000000000
--- a/Content.Client/DeltaV/Options/UI/Tabs/DeltaTab.xaml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Content.Client/DeltaV/Options/UI/Tabs/DeltaTab.xaml.cs b/Content.Client/DeltaV/Options/UI/Tabs/DeltaTab.xaml.cs
deleted file mode 100644
index 892ddfc15bf..00000000000
--- a/Content.Client/DeltaV/Options/UI/Tabs/DeltaTab.xaml.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using Content.Shared.DeltaV.CCVars;
-using Robust.Client.AutoGenerated;
-using Robust.Client.UserInterface;
-using Robust.Client.UserInterface.Controls;
-using Robust.Client.UserInterface.XAML;
-using Robust.Shared.Configuration;
-
-namespace Content.Client.DeltaV.Options.UI.Tabs;
-
-[GenerateTypedNameReferences]
-public sealed partial class DeltaTab : Control
-{
- [Dependency] private readonly IConfigurationManager _cfg = default!;
-
- public DeltaTab()
- {
- RobustXamlLoader.Load(this);
- IoCManager.InjectDependencies(this);
-
- DisableFiltersCheckBox.OnToggled += OnCheckBoxToggled;
- DisableFiltersCheckBox.Pressed = _cfg.GetCVar(DCCVars.NoVisionFilters);
-
- ApplyButton.OnPressed += OnApplyButtonPressed;
- UpdateApplyButton();
- }
-
- private void OnCheckBoxToggled(BaseButton.ButtonToggledEventArgs args)
- {
- UpdateApplyButton();
- }
-
- private void OnApplyButtonPressed(BaseButton.ButtonEventArgs args)
- {
- _cfg.SetCVar(DCCVars.NoVisionFilters, DisableFiltersCheckBox.Pressed);
-
- _cfg.SaveToFile();
- UpdateApplyButton();
- }
-
- private void UpdateApplyButton()
- {
- var isNoVisionFiltersSame = DisableFiltersCheckBox.Pressed == _cfg.GetCVar(DCCVars.NoVisionFilters);
-
- ApplyButton.Disabled = isNoVisionFiltersSame;
- }
-}
diff --git a/Content.Client/Flight/Components/FlightVisualsComponent.cs b/Content.Client/Flight/Components/FlightVisualsComponent.cs
new file mode 100644
index 00000000000..3f378f60ef2
--- /dev/null
+++ b/Content.Client/Flight/Components/FlightVisualsComponent.cs
@@ -0,0 +1,40 @@
+using Robust.Client.Graphics;
+using Robust.Shared.GameStates;
+
+namespace Content.Client.Flight.Components;
+
+[RegisterComponent]
+public sealed partial class FlightVisualsComponent : Component
+{
+ ///
+ /// How long does the animation last
+ ///
+ [DataField]
+ public float Speed;
+
+ ///
+ /// How far it goes in any direction.
+ ///
+ [DataField]
+ public float Multiplier;
+
+ ///
+ /// How much the limbs (if there are any) rotate.
+ ///
+ [DataField]
+ public float Offset;
+
+ ///
+ /// Are we animating layers or the entire sprite?
+ ///
+ public bool AnimateLayer = false;
+ public int? TargetLayer;
+
+ [DataField]
+ public string AnimationKey = "default";
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ public ShaderInstance Shader = default!;
+
+
+}
\ No newline at end of file
diff --git a/Content.Client/Flight/FlightSystem.cs b/Content.Client/Flight/FlightSystem.cs
new file mode 100644
index 00000000000..bd1a6767bd9
--- /dev/null
+++ b/Content.Client/Flight/FlightSystem.cs
@@ -0,0 +1,67 @@
+using Robust.Client.GameObjects;
+using Content.Shared.Flight;
+using Content.Shared.Flight.Events;
+using Content.Client.Flight.Components;
+
+namespace Content.Client.Flight;
+public sealed class FlightSystem : SharedFlightSystem
+{
+ [Dependency] private readonly IEntityManager _entityManager = default!;
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeNetworkEvent(OnFlight);
+
+ }
+
+ private void OnFlight(FlightEvent args)
+ {
+ var uid = GetEntity(args.Uid);
+ if (!_entityManager.TryGetComponent(uid, out SpriteComponent? sprite)
+ || !args.IsAnimated
+ || !_entityManager.TryGetComponent(uid, out FlightComponent? flight))
+ return;
+
+
+ int? targetLayer = null;
+ if (flight.IsLayerAnimated && flight.Layer is not null)
+ {
+ targetLayer = GetAnimatedLayer(uid, flight.Layer, sprite);
+ if (targetLayer == null)
+ return;
+ }
+
+ if (args.IsFlying && args.IsAnimated && flight.AnimationKey != "default")
+ {
+ var comp = new FlightVisualsComponent
+ {
+ AnimateLayer = flight.IsLayerAnimated,
+ AnimationKey = flight.AnimationKey,
+ Multiplier = flight.ShaderMultiplier,
+ Offset = flight.ShaderOffset,
+ Speed = flight.ShaderSpeed,
+ TargetLayer = targetLayer,
+ };
+ AddComp(uid, comp);
+ }
+ if (!args.IsFlying)
+ RemComp(uid);
+ }
+
+ public int? GetAnimatedLayer(EntityUid uid, string targetLayer, SpriteComponent? sprite = null)
+ {
+ if (!Resolve(uid, ref sprite))
+ return null;
+
+ int index = 0;
+ foreach (var layer in sprite.AllLayers)
+ {
+ // This feels like absolute shitcode, isn't there a better way to check for it?
+ if (layer.Rsi?.Path.ToString() == targetLayer)
+ return index;
+ index++;
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/Content.Client/Flight/FlyingVisualizerSystem.cs b/Content.Client/Flight/FlyingVisualizerSystem.cs
new file mode 100644
index 00000000000..6dde6cf5638
--- /dev/null
+++ b/Content.Client/Flight/FlyingVisualizerSystem.cs
@@ -0,0 +1,64 @@
+using Content.Client.Flight.Components;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Flight;
+
+///
+/// Handles offsetting an entity while flying
+///
+public sealed class FlyingVisualizerSystem : EntitySystem
+{
+ [Dependency] private readonly IPrototypeManager _protoMan = default!;
+ [Dependency] private readonly SpriteSystem _spriteSystem = default!;
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnShutdown);
+ SubscribeLocalEvent(OnBeforeShaderPost);
+ }
+
+ private void OnStartup(EntityUid uid, FlightVisualsComponent comp, ComponentStartup args)
+ {
+ comp.Shader = _protoMan.Index(comp.AnimationKey).InstanceUnique();
+ AddShader(uid, comp.Shader, comp.AnimateLayer, comp.TargetLayer);
+ SetValues(comp, comp.Speed, comp.Offset, comp.Multiplier);
+ }
+
+ private void OnShutdown(EntityUid uid, FlightVisualsComponent comp, ComponentShutdown args)
+ {
+ AddShader(uid, null, comp.AnimateLayer, comp.TargetLayer);
+ }
+
+ private void AddShader(Entity entity, ShaderInstance? shader, bool animateLayer, int? layer)
+ {
+ if (!Resolve(entity, ref entity.Comp, false))
+ return;
+
+ if (!animateLayer)
+ entity.Comp.PostShader = shader;
+
+ if (animateLayer && layer is not null)
+ entity.Comp.LayerSetShader(layer.Value, shader);
+
+ entity.Comp.GetScreenTexture = shader is not null;
+ entity.Comp.RaiseShaderEvent = shader is not null;
+ }
+
+ ///
+ /// This function can be used to modify the shader's values while its running.
+ ///
+ private void OnBeforeShaderPost(EntityUid uid, FlightVisualsComponent comp, ref BeforePostShaderRenderEvent args)
+ {
+ SetValues(comp, comp.Speed, comp.Offset, comp.Multiplier);
+ }
+
+ private void SetValues(FlightVisualsComponent comp, float speed, float offset, float multiplier)
+ {
+ comp.Shader.SetParameter("Speed", speed);
+ comp.Shader.SetParameter("Offset", offset);
+ comp.Shader.SetParameter("Multiplier", multiplier);
+ }
+}
\ No newline at end of file
diff --git a/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml b/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml
new file mode 100644
index 00000000000..6a3072ce08b
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml.cs b/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml.cs
new file mode 100644
index 00000000000..b4f25a02e4c
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml.cs
@@ -0,0 +1,31 @@
+using Content.Client.UserInterface.ControlExtensions;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.FixedPoint;
+using JetBrains.Annotations;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Guidebook.Controls;
+
+[UsedImplicitly, GenerateTypedNameReferences]
+public sealed partial class GuideFoodComposition : BoxContainer, ISearchableControl
+{
+ public GuideFoodComposition(ReagentPrototype proto, FixedPoint2 quantity)
+ {
+ RobustXamlLoader.Load(this);
+
+ ReagentLabel.Text = proto.LocalizedName;
+ AmountLabel.Text = quantity.ToString();
+ }
+
+ public bool CheckMatchesSearch(string query)
+ {
+ return this.ChildrenContainText(query);
+ }
+
+ public void SetHiddenState(bool state, string query)
+ {
+ Visible = CheckMatchesSearch(query) ? state : !state;
+ }
+}
diff --git a/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml b/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml
new file mode 100644
index 00000000000..8aa2b10356e
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml.cs b/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml.cs
new file mode 100644
index 00000000000..355d3a020f8
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml.cs
@@ -0,0 +1,161 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Content.Client.Chemistry.EntitySystems;
+using Content.Client.Guidebook.Richtext;
+using Content.Client.Message;
+using Content.Client.Nutrition.EntitySystems;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.FixedPoint;
+using JetBrains.Annotations;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+
+namespace Content.Client.Guidebook.Controls;
+
+///
+/// Control for embedding a food recipe into a guidebook.
+///
+[UsedImplicitly, GenerateTypedNameReferences]
+public sealed partial class GuideFoodEmbed : BoxContainer, IDocumentTag, ISearchableControl
+{
+ [Dependency] private readonly IEntitySystemManager _systemManager = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+
+ private readonly FoodGuideDataSystem _foodGuideData;
+ private readonly ISawmill _logger = default!;
+
+ public GuideFoodEmbed()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+ _foodGuideData = _systemManager.GetEntitySystem();
+ _logger = Logger.GetSawmill("food guide");
+ MouseFilter = MouseFilterMode.Stop;
+ }
+
+ public GuideFoodEmbed(FoodGuideEntry entry) : this()
+ {
+ GenerateControl(entry);
+ }
+
+ public bool CheckMatchesSearch(string query)
+ {
+ return FoodName.GetMessage()?.Contains(query, StringComparison.InvariantCultureIgnoreCase) == true
+ || FoodDescription.GetMessage()?.Contains(query, StringComparison.InvariantCultureIgnoreCase) == true;
+ }
+
+ public void SetHiddenState(bool state, string query)
+ {
+ Visible = CheckMatchesSearch(query) ? state : !state;
+ }
+
+ public bool TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control)
+ {
+ control = null;
+ if (!args.TryGetValue("Food", out var id))
+ {
+ _logger.Error("Food embed tag is missing food prototype argument.");
+ return false;
+ }
+
+ if (!_foodGuideData.TryGetData(id, out var data))
+ {
+ _logger.Warning($"Specified food prototype \"{id}\" does not have any known sources.");
+ return false;
+ }
+
+ GenerateControl(data);
+
+ control = this;
+ return true;
+ }
+
+ private void GenerateControl(FoodGuideEntry data)
+ {
+ _prototype.TryIndex(data.Result, out var proto);
+ if (proto == null)
+ {
+ FoodName.SetMarkup(Loc.GetString("guidebook-food-unknown-proto", ("id", data.Result)));
+ return;
+ }
+
+ var composition = data.Composition
+ .Select(it => _prototype.TryIndex(it.Reagent.Prototype, out var reagent) ? (reagent, it.Quantity) : (null, 0))
+ .Where(it => it.reagent is not null)
+ .Cast<(ReagentPrototype, FixedPoint2)>()
+ .ToList();
+
+ #region Colors
+
+ CalculateColors(composition, out var textColor, out var backgroundColor);
+
+ NameBackground.PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = backgroundColor
+ };
+ FoodName.SetMarkup(Loc.GetString("guidebook-food-name", ("color", textColor), ("name", proto.Name)));
+
+ #endregion
+
+ #region Sources
+
+ foreach (var source in data.Sources.OrderBy(it => it.OutputCount))
+ {
+ var control = new GuideFoodSource(proto, source, _prototype);
+ SourcesDescriptionContainer.AddChild(control);
+ }
+
+ #endregion
+
+ #region Composition
+
+ foreach (var (reagent, quantity) in composition)
+ {
+ var control = new GuideFoodComposition(reagent, quantity);
+ CompositionDescriptionContainer.AddChild(control);
+ }
+
+ #endregion
+
+ FormattedMessage description = new();
+ description.AddText(proto?.Description ?? string.Empty);
+ // Cannot describe food flavor or smth beause food is entirely server-side
+
+ FoodDescription.SetMessage(description);
+ }
+
+ private void CalculateColors(List<(ReagentPrototype, FixedPoint2)> composition, out Color text, out Color background)
+ {
+ // Background color is calculated as the weighted average of the colors of the composition.
+ // Text color is determined based on background luminosity.
+ float r = 0, g = 0, b = 0;
+ FixedPoint2 weight = 0;
+
+ foreach (var (proto, quantity) in composition)
+ {
+ var tcolor = proto.SubstanceColor;
+ var prevalence =
+ quantity <= 0 ? 0f
+ : weight == 0f ? 1f
+ : (quantity / (weight + quantity)).Float();
+
+ r = r * (1 - prevalence) + tcolor.R * prevalence;
+ g = g * (1 - prevalence) + tcolor.G * prevalence;
+ b = b * (1 - prevalence) + tcolor.B * prevalence;
+
+ if (quantity > 0)
+ weight += quantity;
+ }
+
+ // Copied from GuideReagentEmbed which was probably copied from stackoverflow. This is the formula for color luminosity.
+ var lum = 0.2126f * r + 0.7152f * g + 0.0722f;
+
+ background = new Color(r, g, b);
+ text = lum > 0.5f ? Color.Black : Color.White;
+ }
+}
diff --git a/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml b/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml
new file mode 100644
index 00000000000..da671adaa72
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml.cs b/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml.cs
new file mode 100644
index 00000000000..0e1034e3943
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml.cs
@@ -0,0 +1,38 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Content.Client.Guidebook.Richtext;
+using Content.Client.Nutrition.EntitySystems;
+using JetBrains.Annotations;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Guidebook.Controls;
+
+[UsedImplicitly, GenerateTypedNameReferences]
+public sealed partial class GuideFoodGroupEmbed : BoxContainer, IDocumentTag
+{
+ [Dependency] private readonly IEntitySystemManager _sysMan = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+
+ public GuideFoodGroupEmbed()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+ MouseFilter = MouseFilterMode.Stop;
+
+ foreach (var data in _sysMan.GetEntitySystem().Registry.OrderBy(it => it.Identifier))
+ {
+ var embed = new GuideFoodEmbed(data);
+ GroupContainer.AddChild(embed);
+ }
+ }
+
+ public bool TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control)
+ {
+ control = this;
+ return true;
+ }
+}
diff --git a/Content.Client/Guidebook/Controls/GuideFoodSource.xaml b/Content.Client/Guidebook/Controls/GuideFoodSource.xaml
new file mode 100644
index 00000000000..74e3a2ec30d
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodSource.xaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Guidebook/Controls/GuideFoodSource.xaml.cs b/Content.Client/Guidebook/Controls/GuideFoodSource.xaml.cs
new file mode 100644
index 00000000000..b0fee709759
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodSource.xaml.cs
@@ -0,0 +1,160 @@
+using System.Linq;
+using Content.Client.Chemistry.EntitySystems;
+using Content.Client.UserInterface.ControlExtensions;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.FixedPoint;
+using Content.Shared.Nutrition.Components;
+using JetBrains.Annotations;
+using Robust.Client.AutoGenerated;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+
+namespace Content.Client.Guidebook.Controls;
+
+[UsedImplicitly, GenerateTypedNameReferences]
+public sealed partial class GuideFoodSource : BoxContainer, ISearchableControl
+{
+ private readonly IPrototypeManager _protoMan;
+ private readonly SpriteSystem _sprites = default!;
+
+ public GuideFoodSource(IPrototypeManager protoMan)
+ {
+ RobustXamlLoader.Load(this);
+ _protoMan = protoMan;
+ _sprites = IoCManager.Resolve().GetEntitySystem();
+ }
+
+ public GuideFoodSource(EntityPrototype result, FoodSourceData entry, IPrototypeManager protoMan) : this(protoMan)
+ {
+ switch (entry)
+ {
+ case FoodButcheringData butchering:
+ GenerateControl(butchering);
+ break;
+ case FoodSlicingData slicing:
+ GenerateControl(slicing);
+ break;
+ case FoodRecipeData recipe:
+ GenerateControl(recipe);
+ break;
+ case FoodReactionData reaction:
+ GenerateControl(reaction);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(entry), entry, null);
+ }
+
+ GenerateOutputs(result, entry);
+ }
+
+ private void GenerateControl(FoodButcheringData entry)
+ {
+ if (!_protoMan.TryIndex(entry.Butchered, out var ent))
+ {
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-unknown-proto", ("id", entry.Butchered)));
+ return;
+ }
+
+ SetSource(ent);
+ ProcessingLabel.Text = Loc.GetString("guidebook-food-processing-butchering");
+
+ ProcessingTexture.Texture = entry.Type switch
+ {
+ ButcheringType.Knife => GetRsiTexture("/Textures/Objects/Weapons/Melee/kitchen_knife.rsi", "icon"),
+ _ => GetRsiTexture("/Textures/Structures/meat_spike.rsi", "spike")
+ };
+ }
+
+ private void GenerateControl(FoodSlicingData entry)
+ {
+ if (!_protoMan.TryIndex(entry.Sliced, out var ent))
+ {
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-unknown-proto", ("id", entry.Sliced)));
+ return;
+ }
+
+ SetSource(ent);
+ ProcessingLabel.Text = Loc.GetString("guidebook-food-processing-slicing");
+ ProcessingTexture.Texture = GetRsiTexture("/Textures/Objects/Misc/utensils.rsi", "plastic_knife");
+ }
+
+ private void GenerateControl(FoodRecipeData entry)
+ {
+ if (!_protoMan.TryIndex(entry.Recipe, out var recipe))
+ {
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-unknown-proto", ("id", entry.Result)));
+ return;
+ }
+
+ var combinedSolids = recipe.IngredientsSolids
+ .Select(it => _protoMan.TryIndex(it.Key, out var proto) ? FormatIngredient(proto, it.Value) : "")
+ .Where(it => it.Length > 0);
+ var combinedLiquids = recipe.IngredientsReagents
+ .Select(it => _protoMan.TryIndex(it.Key, out var proto) ? FormatIngredient(proto, it.Value) : "")
+ .Where(it => it.Length > 0);
+
+ var combinedIngredients = string.Join("\n", combinedLiquids.Union(combinedSolids));
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-processing-recipe", ("ingredients", combinedIngredients)));
+
+ ProcessingTexture.Texture = GetRsiTexture("/Textures/Structures/Machines/microwave.rsi", "mw");
+ ProcessingLabel.Text = Loc.GetString("guidebook-food-processing-cooking", ("time", recipe.CookTime));
+ }
+
+ private void GenerateControl(FoodReactionData entry)
+ {
+ if (!_protoMan.TryIndex(entry.Reaction, out var reaction))
+ {
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-unknown-proto", ("id", entry.Reaction)));
+ return;
+ }
+
+ var combinedReagents = reaction.Reactants
+ .Select(it => _protoMan.TryIndex(it.Key, out var proto) ? FormatIngredient(proto, it.Value.Amount) : "")
+ .Where(it => it.Length > 0);
+
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-processing-recipe", ("ingredients", string.Join("\n", combinedReagents))));
+ ProcessingTexture.TexturePath = "/Textures/Interface/Misc/beakerlarge.png";
+ ProcessingLabel.Text = Loc.GetString("guidebook-food-processing-reaction");
+ }
+
+ private Texture GetRsiTexture(string path, string state)
+ {
+ return _sprites.Frame0(new SpriteSpecifier.Rsi(new ResPath(path), state));
+ }
+
+ private void GenerateOutputs(EntityPrototype result, FoodSourceData entry)
+ {
+ OutputsLabel.Text = Loc.GetString("guidebook-food-output", ("name", result.Name), ("number", entry.OutputCount));
+ OutputsTexture.Texture = _sprites.Frame0(result);
+ }
+
+ private void SetSource(EntityPrototype ent)
+ {
+ SourceLabel.SetMessage(ent.Name);
+ OutputsTexture.Texture = _sprites.Frame0(ent);
+ }
+
+ private string FormatIngredient(EntityPrototype proto, FixedPoint2 amount)
+ {
+ return Loc.GetString("guidebook-food-ingredient-solid", ("name", proto.Name), ("amount", amount));
+ }
+
+ private string FormatIngredient(ReagentPrototype proto, FixedPoint2 amount)
+ {
+ return Loc.GetString("guidebook-food-ingredient-liquid", ("name", proto.LocalizedName), ("amount", amount));
+ }
+
+ public bool CheckMatchesSearch(string query)
+ {
+ return this.ChildrenContainText(query);
+ }
+
+ public void SetHiddenState(bool state, string query)
+ {
+ Visible = CheckMatchesSearch(query) ? state : !state;
+ }
+}
diff --git a/Content.Client/Humanoid/HumanoidAppearanceSystem.cs b/Content.Client/Humanoid/HumanoidAppearanceSystem.cs
index 8087d1833e6..867dcbc2692 100644
--- a/Content.Client/Humanoid/HumanoidAppearanceSystem.cs
+++ b/Content.Client/Humanoid/HumanoidAppearanceSystem.cs
@@ -118,8 +118,11 @@ private void SetLayerData(
/// This should not be used if the entity is owned by the server. The server will otherwise
/// override this with the appearance data it sends over.
///
- public override void LoadProfile(EntityUid uid, HumanoidCharacterProfile profile, HumanoidAppearanceComponent? humanoid = null)
+ public override void LoadProfile(EntityUid uid, HumanoidCharacterProfile? profile, HumanoidAppearanceComponent? humanoid = null)
{
+ if (profile == null)
+ return;
+
if (!Resolve(uid, ref humanoid))
{
return;
diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs
index 73a5fbd7511..c188c1f4bc9 100644
--- a/Content.Client/Input/ContentContexts.cs
+++ b/Content.Client/Input/ContentContexts.cs
@@ -83,6 +83,7 @@ public static void SetupContexts(IInputContextContainer contexts)
human.AddFunction(ContentKeyFunctions.Arcade1);
human.AddFunction(ContentKeyFunctions.Arcade2);
human.AddFunction(ContentKeyFunctions.Arcade3);
+ human.AddFunction(ContentKeyFunctions.LookUp);
// actions should be common (for ghosts, mobs, etc)
common.AddFunction(ContentKeyFunctions.OpenActionsMenu);
diff --git a/Content.Client/Nutrition/EntitySystems/FoodGuideDataSystem.cs b/Content.Client/Nutrition/EntitySystems/FoodGuideDataSystem.cs
new file mode 100644
index 00000000000..37c7a25e219
--- /dev/null
+++ b/Content.Client/Nutrition/EntitySystems/FoodGuideDataSystem.cs
@@ -0,0 +1,30 @@
+using Content.Client.Chemistry.EntitySystems;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Nutrition.EntitySystems;
+
+public sealed class FoodGuideDataSystem : SharedFoodGuideDataSystem
+{
+ public override void Initialize()
+ {
+ SubscribeNetworkEvent(OnReceiveRegistryUpdate);
+ }
+
+ private void OnReceiveRegistryUpdate(FoodGuideRegistryChangedEvent message)
+ {
+ Registry = message.Changeset;
+ }
+
+ public bool TryGetData(EntProtoId result, out FoodGuideEntry entry)
+ {
+ var index = Registry.FindIndex(it => it.Result == result);
+ if (index == -1)
+ {
+ entry = default;
+ return false;
+ }
+
+ entry = Registry[index];
+ return true;
+ }
+}
diff --git a/Content.Client/Options/UI/OptionsMenu.xaml b/Content.Client/Options/UI/OptionsMenu.xaml
index 69daaa2cea7..ab3b88ca4e6 100644
--- a/Content.Client/Options/UI/OptionsMenu.xaml
+++ b/Content.Client/Options/UI/OptionsMenu.xaml
@@ -1,6 +1,5 @@
@@ -9,6 +8,5 @@
-
diff --git a/Content.Client/Options/UI/OptionsMenu.xaml.cs b/Content.Client/Options/UI/OptionsMenu.xaml.cs
index bb2c1ce0ed9..c3a8e664705 100644
--- a/Content.Client/Options/UI/OptionsMenu.xaml.cs
+++ b/Content.Client/Options/UI/OptionsMenu.xaml.cs
@@ -20,7 +20,6 @@ public OptionsMenu()
Tabs.SetTabTitle(2, Loc.GetString("ui-options-tab-controls"));
Tabs.SetTabTitle(3, Loc.GetString("ui-options-tab-audio"));
Tabs.SetTabTitle(4, Loc.GetString("ui-options-tab-network"));
- Tabs.SetTabTitle(5, Loc.GetString("ui-options-tab-deltav")); // DeltaV specific settings
UpdateTabs();
}
diff --git a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
index 51f527540dd..911880e80df 100644
--- a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
+++ b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
@@ -97,12 +97,30 @@ private void HandleToggleWalk(BaseButton.ButtonToggledEventArgs args)
_deferCommands.Add(_inputManager.SaveToUserData);
}
+ private void HandleHoldLookUp(BaseButton.ButtonToggledEventArgs args)
+ {
+ _cfg.SetCVar(CCVars.HoldLookUp, args.Pressed);
+ _cfg.SaveToFile();
+ }
+
+ private void HandleDefaultWalk(BaseButton.ButtonToggledEventArgs args)
+ {
+ _cfg.SetCVar(CCVars.DefaultWalk, args.Pressed);
+ _cfg.SaveToFile();
+ }
+
private void HandleStaticStorageUI(BaseButton.ButtonToggledEventArgs args)
{
_cfg.SetCVar(CCVars.StaticStorageUI, args.Pressed);
_cfg.SaveToFile();
}
+ private void HandleToggleAutoGetUp(BaseButton.ButtonToggledEventArgs args)
+ {
+ _cfg.SetCVar(CCVars.AutoGetUp, args.Pressed);
+ _cfg.SaveToFile();
+ }
+
public KeyRebindTab()
{
IoCManager.InjectDependencies(this);
@@ -161,6 +179,7 @@ void AddCheckBox(string checkBoxName, bool currentState, Action
+
(OnPlayerAttached);
SubscribeLocalEvent(OnPlayerDetached);
- Subs.CVar(_cfg, DCCVars.NoVisionFilters, OnNoVisionFiltersChanged);
+ Subs.CVar(_cfg, CCVars.NoVisionFilters, OnNoVisionFiltersChanged);
_overlay = new();
}
@@ -33,7 +33,7 @@ private void OnDogVisionInit(EntityUid uid, DogVisionComponent component, Compon
if (uid != _playerMan.LocalEntity)
return;
- if (!_cfg.GetCVar(DCCVars.NoVisionFilters))
+ if (!_cfg.GetCVar(CCVars.NoVisionFilters))
_overlayMan.AddOverlay(_overlay);
}
@@ -47,7 +47,7 @@ private void OnDogVisionShutdown(EntityUid uid, DogVisionComponent component, Co
private void OnPlayerAttached(EntityUid uid, DogVisionComponent component, LocalPlayerAttachedEvent args)
{
- if (!_cfg.GetCVar(DCCVars.NoVisionFilters))
+ if (!_cfg.GetCVar(CCVars.NoVisionFilters))
_overlayMan.AddOverlay(_overlay);
}
diff --git a/Content.Client/Overlays/UltraVisionSystem.cs b/Content.Client/Overlays/UltraVisionSystem.cs
index 7728a647848..e8e6cdfa72b 100644
--- a/Content.Client/Overlays/UltraVisionSystem.cs
+++ b/Content.Client/Overlays/UltraVisionSystem.cs
@@ -1,5 +1,5 @@
using Content.Shared.Traits.Assorted.Components;
-using Content.Shared.DeltaV.CCVars;
+using Content.Shared.CCVar;
using Robust.Client.Graphics;
using Robust.Shared.Configuration;
using Robust.Shared.Player;
@@ -23,7 +23,7 @@ public override void Initialize()
SubscribeLocalEvent(OnPlayerAttached);
SubscribeLocalEvent(OnPlayerDetached);
- Subs.CVar(_cfg, DCCVars.NoVisionFilters, OnNoVisionFiltersChanged);
+ Subs.CVar(_cfg, CCVars.NoVisionFilters, OnNoVisionFiltersChanged);
_overlay = new();
}
@@ -33,7 +33,7 @@ private void OnUltraVisionInit(EntityUid uid, UltraVisionComponent component, Co
if (uid != _playerMan.LocalEntity)
return;
- if (!_cfg.GetCVar(DCCVars.NoVisionFilters))
+ if (!_cfg.GetCVar(CCVars.NoVisionFilters))
_overlayMan.AddOverlay(_overlay);
}
@@ -47,7 +47,7 @@ private void OnUltraVisionShutdown(EntityUid uid, UltraVisionComponent component
private void OnPlayerAttached(EntityUid uid, UltraVisionComponent component, LocalPlayerAttachedEvent args)
{
- if (!_cfg.GetCVar(DCCVars.NoVisionFilters))
+ if (!_cfg.GetCVar(CCVars.NoVisionFilters))
_overlayMan.AddOverlay(_overlay);
}
diff --git a/Content.Client/Physics/Controllers/MoverController.cs b/Content.Client/Physics/Controllers/MoverController.cs
index 31042854d4a..9d453e5518f 100644
--- a/Content.Client/Physics/Controllers/MoverController.cs
+++ b/Content.Client/Physics/Controllers/MoverController.cs
@@ -1,9 +1,12 @@
+using Content.Shared.CCVar;
using Content.Shared.Movement.Components;
+using Content.Shared.Movement.Events;
using Content.Shared.Movement.Pulling.Components;
using Content.Shared.Movement.Systems;
using Robust.Client.GameObjects;
using Robust.Client.Physics;
using Robust.Client.Player;
+using Robust.Shared.Configuration;
using Robust.Shared.Physics.Components;
using Robust.Shared.Player;
using Robust.Shared.Timing;
@@ -12,6 +15,7 @@ namespace Content.Client.Physics.Controllers
{
public sealed class MoverController : SharedMoverController
{
+ [Dependency] private readonly IConfigurationManager _config = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
@@ -26,6 +30,8 @@ public override void Initialize()
SubscribeLocalEvent(OnUpdatePredicted);
SubscribeLocalEvent(OnUpdateRelayTargetPredicted);
SubscribeLocalEvent(OnUpdatePullablePredicted);
+
+ Subs.CVar(_config, CCVars.DefaultWalk, _ => RaiseNetworkEvent(new UpdateInputCVarsMessage()));
}
private void OnUpdatePredicted(EntityUid uid, InputMoverComponent component, ref UpdateIsPredictedEvent args)
diff --git a/Content.Client/RCD/RCDMenu.xaml.cs b/Content.Client/RCD/RCDMenu.xaml.cs
index 51ec66ea444..3eb0397a690 100644
--- a/Content.Client/RCD/RCDMenu.xaml.cs
+++ b/Content.Client/RCD/RCDMenu.xaml.cs
@@ -68,7 +68,7 @@ public RCDMenu(EntityUid owner, RCDMenuBoundUserInterface bui)
tooltip = Loc.GetString(entProto.Name);
}
- tooltip = char.ToUpper(tooltip[0]) + tooltip.Remove(0, 1);
+ tooltip = OopsConcat(char.ToUpper(tooltip[0]).ToString(), tooltip.Remove(0, 1));
var button = new RCDMenuButton()
{
@@ -119,6 +119,12 @@ public RCDMenu(EntityUid owner, RCDMenuBoundUserInterface bui)
SendRCDSystemMessageAction += bui.SendRCDSystemMessage;
}
+ private static string OopsConcat(string a, string b)
+ {
+ // This exists to prevent Roslyn being clever and compiling something that fails sandbox checks.
+ return a + b;
+ }
+
private void AddRCDMenuButtonOnClickActions(Control control)
{
var radialContainer = control as RadialContainer;
diff --git a/Content.Client/ShortConstruction/UI/ShortConstructionMenuBUI.cs b/Content.Client/ShortConstruction/UI/ShortConstructionMenuBUI.cs
index 37606d86564..4d50d91e168 100644
--- a/Content.Client/ShortConstruction/UI/ShortConstructionMenuBUI.cs
+++ b/Content.Client/ShortConstruction/UI/ShortConstructionMenuBUI.cs
@@ -66,7 +66,7 @@ private RadialMenu FormMenu()
var mainContainer = new RadialContainer
{
- Radius = 36f / MathF.Sin(MathF.PI / crafting.Prototypes.Count)
+ Radius = 36f / MathF.Sin(MathF.PI / 2f / crafting.Prototypes.Count)
};
foreach (var protoId in crafting.Prototypes)
diff --git a/Content.Client/Standing/LayingDownSystem.cs b/Content.Client/Standing/LayingDownSystem.cs
new file mode 100644
index 00000000000..3a1f438df05
--- /dev/null
+++ b/Content.Client/Standing/LayingDownSystem.cs
@@ -0,0 +1,97 @@
+using Content.Shared.Buckle;
+using Content.Shared.Rotation;
+using Content.Shared.Standing;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Shared.Timing;
+using DrawDepth = Content.Shared.DrawDepth.DrawDepth;
+
+namespace Content.Client.Standing;
+
+public sealed class LayingDownSystem : SharedLayingDownSystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IEyeManager _eyeManager = default!;
+ [Dependency] private readonly StandingStateSystem _standing = default!;
+ [Dependency] private readonly AnimationPlayerSystem _animation = default!;
+ [Dependency] private readonly SharedBuckleSystem _buckle = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnMovementInput);
+ SubscribeNetworkEvent(OnDowned);
+ SubscribeLocalEvent(OnStood);
+
+ SubscribeNetworkEvent(OnCheckAutoGetUp);
+ }
+
+ private void OnMovementInput(EntityUid uid, LayingDownComponent component, MoveEvent args)
+ {
+ if (!_timing.IsFirstTimePredicted
+ || !_standing.IsDown(uid)
+ || _buckle.IsBuckled(uid)
+ || _animation.HasRunningAnimation(uid, "rotate")
+ || !TryComp(uid, out var transform)
+ || !TryComp(uid, out var sprite)
+ || !TryComp(uid, out var rotationVisuals))
+ return;
+
+ var rotation = transform.LocalRotation + (_eyeManager.CurrentEye.Rotation - (transform.LocalRotation - transform.WorldRotation));
+
+ if (rotation.GetDir() is Direction.SouthEast or Direction.East or Direction.NorthEast or Direction.North)
+ {
+ rotationVisuals.HorizontalRotation = Angle.FromDegrees(270);
+ sprite.Rotation = Angle.FromDegrees(270);
+ return;
+ }
+
+ rotationVisuals.HorizontalRotation = Angle.FromDegrees(90);
+ sprite.Rotation = Angle.FromDegrees(90);
+ }
+
+ private void OnDowned(DrawDownedEvent args)
+ {
+ var uid = GetEntity(args.Uid);
+
+ if (!TryComp(uid, out var sprite)
+ || !TryComp(uid, out var component))
+ return;
+
+ if (!component.OriginalDrawDepth.HasValue)
+ component.OriginalDrawDepth = sprite.DrawDepth;
+
+ sprite.DrawDepth = (int) DrawDepth.SmallMobs;
+ }
+
+ private void OnStood(EntityUid uid, LayingDownComponent component, StoodEvent args)
+ {
+ if (!TryComp(uid, out var sprite)
+ || !component.OriginalDrawDepth.HasValue)
+ return;
+
+ sprite.DrawDepth = component.OriginalDrawDepth.Value;
+ }
+
+ private void OnCheckAutoGetUp(CheckAutoGetUpEvent ev, EntitySessionEventArgs args)
+ {
+ if (!_timing.IsFirstTimePredicted)
+ return;
+
+ var uid = GetEntity(ev.User);
+
+ if (!TryComp(uid, out var transform) || !TryComp(uid, out var rotationVisuals))
+ return;
+
+ var rotation = transform.LocalRotation + (_eyeManager.CurrentEye.Rotation - (transform.LocalRotation - transform.WorldRotation));
+
+ if (rotation.GetDir() is Direction.SouthEast or Direction.East or Direction.NorthEast or Direction.North)
+ {
+ rotationVisuals.HorizontalRotation = Angle.FromDegrees(270);
+ return;
+ }
+
+ rotationVisuals.HorizontalRotation = Angle.FromDegrees(90);
+ }
+}
diff --git a/Content.Client/Telescope/TelescopeSystem.cs b/Content.Client/Telescope/TelescopeSystem.cs
new file mode 100644
index 00000000000..ac2270aa971
--- /dev/null
+++ b/Content.Client/Telescope/TelescopeSystem.cs
@@ -0,0 +1,128 @@
+using System.Numerics;
+using Content.Client.Viewport;
+using Content.Shared.CCVar;
+using Content.Shared.Telescope;
+using Content.Shared.Input;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.Input;
+using Robust.Client.Player;
+using Robust.Client.UserInterface;
+using Robust.Shared.Configuration;
+using Robust.Shared.Input;
+using Robust.Shared.Input.Binding;
+using Robust.Shared.Timing;
+
+namespace Content.Client.Telescope;
+
+public sealed class TelescopeSystem : SharedTelescopeSystem
+{
+ [Dependency] private readonly InputSystem _inputSystem = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IPlayerManager _player = default!;
+ [Dependency] private readonly IInputManager _input = default!;
+ [Dependency] private readonly IEyeManager _eyeManager = default!;
+ [Dependency] private readonly IUserInterfaceManager _uiManager = default!;
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+
+ private ScalingViewport? _viewport;
+ private bool _holdLookUp;
+ private bool _toggled;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _cfg.OnValueChanged(CCVars.HoldLookUp,
+ val =>
+ {
+ var input = val ? null : InputCmdHandler.FromDelegate(_ => _toggled = !_toggled);
+ _input.SetInputCommand(ContentKeyFunctions.LookUp, input);
+ _holdLookUp = val;
+ _toggled = false;
+ },
+ true);
+ }
+
+ public override void FrameUpdate(float frameTime)
+ {
+ base.FrameUpdate(frameTime);
+
+ if (_timing.ApplyingState
+ || !_timing.IsFirstTimePredicted
+ || !_input.MouseScreenPosition.IsValid)
+ return;
+
+ var player = _player.LocalEntity;
+
+ var telescope = GetRightTelescope(player);
+
+ if (telescope == null)
+ {
+ _toggled = false;
+ return;
+ }
+
+ if (!TryComp(player, out var eye))
+ return;
+
+ var offset = Vector2.Zero;
+
+ if (_holdLookUp)
+ {
+ if (_inputSystem.CmdStates.GetState(ContentKeyFunctions.LookUp) != BoundKeyState.Down)
+ {
+ RaiseEvent(offset);
+ return;
+ }
+ }
+ else if (!_toggled)
+ {
+ RaiseEvent(offset);
+ return;
+ }
+
+ var mousePos = _input.MouseScreenPosition;
+
+ if (_uiManager.MouseGetControl(mousePos) as ScalingViewport is { } viewport)
+ _viewport = viewport;
+
+ if (_viewport == null)
+ return;
+
+ var centerPos = _eyeManager.WorldToScreen(eye.Eye.Position.Position + eye.Offset);
+
+ var diff = mousePos.Position - centerPos;
+ var len = diff.Length();
+
+ var size = _viewport.PixelSize;
+
+ var maxLength = Math.Min(size.X, size.Y) * 0.4f;
+ var minLength = maxLength * 0.2f;
+
+ if (len > maxLength)
+ {
+ diff *= maxLength / len;
+ len = maxLength;
+ }
+
+ var divisor = maxLength * telescope.Divisor;
+
+ if (len > minLength)
+ {
+ diff -= diff * minLength / len;
+ offset = new Vector2(diff.X / divisor, -diff.Y / divisor);
+ offset = new Angle(-eye.Rotation.Theta).RotateVec(offset);
+ }
+
+ RaiseEvent(offset);
+ }
+
+ private void RaiseEvent(Vector2 offset)
+ {
+ RaisePredictiveEvent(new EyeOffsetChangedEvent
+ {
+ Offset = offset
+ });
+ }
+}
diff --git a/Content.IntegrationTests/Pair/TestPair.Helpers.cs b/Content.IntegrationTests/Pair/TestPair.Helpers.cs
index 0ea6d3e2dcc..f46b83165ff 100644
--- a/Content.IntegrationTests/Pair/TestPair.Helpers.cs
+++ b/Content.IntegrationTests/Pair/TestPair.Helpers.cs
@@ -2,6 +2,9 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
+using Content.Server.Preferences.Managers;
+using Content.Shared.Preferences;
+using Content.Shared.Roles;
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
@@ -128,4 +131,29 @@ public List GetPrototypesWithComponent(
return list;
}
+
+ ///
+ /// Helper method for enabling or disabling a antag role
+ ///
+ public async Task SetAntagPref(ProtoId id, bool value)
+ {
+ var prefMan = Server.ResolveDependency();
+
+ var prefs = prefMan.GetPreferences(Client.User!.Value);
+ // what even is the point of ICharacterProfile if we always cast it to HumanoidCharacterProfile to make it usable?
+ var profile = (HumanoidCharacterProfile) prefs.SelectedCharacter;
+
+ Assert.That(profile.AntagPreferences.Any(preference => preference == id), Is.EqualTo(!value));
+ var newProfile = profile.WithAntagPreference(id, value);
+
+ await Server.WaitPost(() =>
+ {
+ prefMan.SetProfile(Client.User.Value, prefs.SelectedCharacterIndex, newProfile).Wait();
+ });
+
+ // And why the fuck does it always create a new preference and profile object instead of just reusing them?
+ var newPrefs = prefMan.GetPreferences(Client.User.Value);
+ var newProf = (HumanoidCharacterProfile) newPrefs.SelectedCharacter;
+ Assert.That(newProf.AntagPreferences.Any(preference => preference == id), Is.EqualTo(value));
+ }
}
diff --git a/Content.IntegrationTests/PoolManager.cs b/Content.IntegrationTests/PoolManager.cs
index b544fe28547..3f489de6497 100644
--- a/Content.IntegrationTests/PoolManager.cs
+++ b/Content.IntegrationTests/PoolManager.cs
@@ -68,11 +68,11 @@ public static partial class PoolManager
options.BeforeStart += () =>
{
+ // Server-only systems (i.e., systems that subscribe to events with server-only components)
var entSysMan = IoCManager.Resolve();
- entSysMan.LoadExtraSystemType();
- entSysMan.LoadExtraSystemType();
entSysMan.LoadExtraSystemType();
entSysMan.LoadExtraSystemType();
+
IoCManager.Resolve().GetSawmill("loc").Level = LogLevel.Error;
IoCManager.Resolve()
.OnValueChanged(RTCVars.FailureLogLevel, value => logHandler.FailureLevel = value, true);
diff --git a/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs b/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs
new file mode 100644
index 00000000000..662ea3b9747
--- /dev/null
+++ b/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs
@@ -0,0 +1,76 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using Content.Server.Antag;
+using Content.Server.Antag.Components;
+using Content.Server.GameTicking;
+using Content.Shared.GameTicking;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Player;
+using Robust.Shared.Random;
+
+namespace Content.IntegrationTests.Tests.GameRules;
+
+// Once upon a time, players in the lobby weren't ever considered eligible for antag roles.
+// Lets not let that happen again.
+[TestFixture]
+public sealed class AntagPreferenceTest
+{
+ [Test]
+ public async Task TestLobbyPlayersValid()
+ {
+ await using var pair = await PoolManager.GetServerClient(new PoolSettings
+ {
+ DummyTicker = false,
+ Connected = true,
+ InLobby = true
+ });
+
+ var server = pair.Server;
+ var client = pair.Client;
+ var ticker = server.System();
+ var sys = server.System();
+
+ // Initially in the lobby
+ Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.PreRoundLobby));
+ Assert.That(client.AttachedEntity, Is.Null);
+ Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.NotReadyToPlay));
+
+ EntityUid uid = default;
+ await server.WaitPost(() => uid = server.EntMan.Spawn("Traitor"));
+ var rule = new Entity(uid, server.EntMan.GetComponent(uid));
+ var def = rule.Comp.Definitions.Single();
+
+ // IsSessionValid & IsEntityValid are preference agnostic and should always be true for players in the lobby.
+ // Though maybe that will change in the future, but then GetPlayerPool() needs to be updated to reflect that.
+ Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True);
+ Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True);
+
+ // By default, traitor/antag preferences are disabled, so the pool should be empty.
+ var sessions = new List{pair.Player!};
+ var pool = sys.GetPlayerPool(rule, sessions, def);
+ Assert.That(pool.Count, Is.EqualTo(0));
+
+ // Opt into the traitor role.
+ await pair.SetAntagPref("Traitor", true);
+
+ Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True);
+ Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True);
+ pool = sys.GetPlayerPool(rule, sessions, def);
+ Assert.That(pool.Count, Is.EqualTo(1));
+ pool.TryPickAndTake(pair.Server.ResolveDependency(), out var picked);
+ Assert.That(picked, Is.EqualTo(pair.Player));
+ Assert.That(sessions.Count, Is.EqualTo(1));
+
+ // opt back out
+ await pair.SetAntagPref("Traitor", false);
+
+ Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True);
+ Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True);
+ pool = sys.GetPlayerPool(rule, sessions, def);
+ Assert.That(pool.Count, Is.EqualTo(0));
+
+ await server.WaitPost(() => server.EntMan.DeleteEntity(uid));
+ await pair.CleanReturnAsync();
+ }
+}
diff --git a/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs
new file mode 100644
index 00000000000..62fa93c9997
--- /dev/null
+++ b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs
@@ -0,0 +1,212 @@
+/* WD edit
+
+#nullable enable
+using System.Linq;
+using Content.Server.Body.Components;
+using Content.Server.GameTicking;
+using Content.Server.GameTicking.Presets;
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Mind;
+using Content.Server.NPC.Systems;
+using Content.Server.Pinpointer;
+using Content.Server.Roles;
+using Content.Server.Shuttles.Components;
+using Content.Server.Station.Components;
+using Content.Shared.CCVar;
+using Content.Shared.Damage;
+using Content.Shared.FixedPoint;
+using Content.Shared.GameTicking;
+using Content.Shared.Hands.Components;
+using Content.Shared.Inventory;
+using Content.Shared.NukeOps;
+using Robust.Server.GameObjects;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map.Components;
+
+namespace Content.IntegrationTests.Tests.GameRules;
+
+[TestFixture]
+public sealed class NukeOpsTest
+{
+ ///
+ /// Check that a nuke ops game mode can start without issue. I.e., that the nuke station and such all get loaded.
+ ///
+ [Test]
+ public async Task TryStopNukeOpsFromConstantlyFailing()
+ {
+ await using var pair = await PoolManager.GetServerClient(new PoolSettings
+ {
+ Dirty = true,
+ DummyTicker = false,
+ Connected = true,
+ InLobby = true
+ });
+
+ var server = pair.Server;
+ var client = pair.Client;
+ var entMan = server.EntMan;
+ var mapSys = server.System();
+ var ticker = server.System();
+ var mindSys = server.System();
+ var roleSys = server.System();
+ var invSys = server.System();
+ var factionSys = server.System();
+
+ Assert.That(server.CfgMan.GetCVar(CCVars.GridFill), Is.False);
+ server.CfgMan.SetCVar(CCVars.GridFill, true);
+
+ // Initially in the lobby
+ Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.PreRoundLobby));
+ Assert.That(client.AttachedEntity, Is.Null);
+ Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.NotReadyToPlay));
+
+ // Opt into the nukies role.
+ await pair.SetAntagPref("NukeopsCommander", true);
+
+ // There are no grids or maps
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+
+ // And no nukie related components
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+ Assert.That(entMan.Count(), Is.Zero);
+
+ // Ready up and start nukeops
+ await pair.WaitClientCommand("toggleready True");
+ Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.ReadyToPlay));
+ await pair.WaitCommand("forcepreset Nukeops");
+ await pair.RunTicksSync(10);
+
+ // Game should have started
+ Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.InRound));
+ Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.JoinedGame));
+ Assert.That(client.EntMan.EntityExists(client.AttachedEntity));
+ var player = pair.Player!.AttachedEntity!.Value;
+ Assert.That(entMan.EntityExists(player));
+
+ // Maps now exist
+ Assert.That(entMan.Count(), Is.GreaterThan(0));
+ Assert.That(entMan.Count(), Is.GreaterThan(0));
+ Assert.That(entMan.Count(), Is.EqualTo(2)); // The main station & nukie station
+ Assert.That(entMan.Count(), Is.GreaterThan(3)); // Each station has at least 1 grid, plus some shuttles
+ Assert.That(entMan.Count(), Is.EqualTo(1));
+
+ // And we now have nukie related components
+ Assert.That(entMan.Count(), Is.EqualTo(1));
+ Assert.That(entMan.Count(), Is.EqualTo(1));
+ Assert.That(entMan.Count(), Is.EqualTo(1));
+ Assert.That(entMan.Count(), Is.EqualTo(1));
+
+ // The player entity should be the nukie commander
+ var mind = mindSys.GetMind(player)!.Value;
+ Assert.That(entMan.HasComponent(player));
+ Assert.That(roleSys.MindIsAntagonist(mind));
+ Assert.That(roleSys.MindHasRole(mind));
+
+ Assert.That(factionSys.IsMember(player, "Syndicate"), Is.True);
+ Assert.That(factionSys.IsMember(player, "NanoTrasen"), Is.False);
+
+ var roles = roleSys.MindGetAllRoles(mind);
+ var cmdRoles = roles.Where(x => x.Prototype == "NukeopsCommander" && x.Component is NukeopsRoleComponent);
+ Assert.That(cmdRoles.Count(), Is.EqualTo(1));
+
+ // The game rule exists, and all the stations/shuttles/maps are properly initialized
+ var rule = entMan.AllComponents().Single().Component;
+ var mapRule = entMan.AllComponents().Single().Component;
+ foreach (var grid in mapRule.MapGrids)
+ {
+ Assert.That(entMan.EntityExists(grid));
+ Assert.That(entMan.HasComponent(grid));
+ Assert.That(entMan.HasComponent(grid));
+ }
+ Assert.That(entMan.EntityExists(rule.TargetStation));
+
+ Assert.That(entMan.HasComponent(rule.TargetStation));
+
+ var nukieShuttlEnt = entMan.AllComponents().FirstOrDefault().Uid;
+ Assert.That(entMan.EntityExists(nukieShuttlEnt));
+
+ EntityUid? nukieStationEnt = null;
+ foreach (var grid in mapRule.MapGrids)
+ {
+ if (entMan.HasComponent(grid))
+ {
+ nukieStationEnt = grid;
+ break;
+ }
+ }
+
+ Assert.That(entMan.EntityExists(nukieStationEnt));
+ var nukieStation = entMan.GetComponent(nukieStationEnt!.Value);
+
+ Assert.That(entMan.EntityExists(nukieStation.Station));
+ Assert.That(nukieStation.Station, Is.Not.EqualTo(rule.TargetStation));
+
+ Assert.That(server.MapMan.MapExists(mapRule.Map));
+ var nukieMap = mapSys.GetMap(mapRule.Map!.Value);
+
+ var targetStation = entMan.GetComponent(rule.TargetStation!.Value);
+ var targetGrid = targetStation.Grids.First();
+ var targetMap = entMan.GetComponent(targetGrid).MapUid!.Value;
+ Assert.That(targetMap, Is.Not.EqualTo(nukieMap));
+
+ Assert.That(entMan.GetComponent(player).MapUid, Is.EqualTo(nukieMap));
+ Assert.That(entMan.GetComponent(nukieStationEnt.Value).MapUid, Is.EqualTo(nukieMap));
+ Assert.That(entMan.GetComponent(nukieShuttlEnt).MapUid, Is.EqualTo(nukieMap));
+
+ // The maps are all map-initialized, including the player
+ // Yes, this is necessary as this has repeatedly been broken somehow.
+ Assert.That(mapSys.IsInitialized(nukieMap));
+ Assert.That(mapSys.IsInitialized(targetMap));
+ Assert.That(mapSys.IsPaused(nukieMap), Is.False);
+ Assert.That(mapSys.IsPaused(targetMap), Is.False);
+
+ EntityLifeStage LifeStage(EntityUid? uid) => entMan.GetComponent(uid!.Value).EntityLifeStage;
+ Assert.That(LifeStage(player), Is.GreaterThan(EntityLifeStage.Initialized));
+ Assert.That(LifeStage(nukieMap), Is.GreaterThan(EntityLifeStage.Initialized));
+ Assert.That(LifeStage(targetMap), Is.GreaterThan(EntityLifeStage.Initialized));
+ Assert.That(LifeStage(nukieStationEnt.Value), Is.GreaterThan(EntityLifeStage.Initialized));
+ Assert.That(LifeStage(nukieShuttlEnt), Is.GreaterThan(EntityLifeStage.Initialized));
+ Assert.That(LifeStage(rule.TargetStation), Is.GreaterThan(EntityLifeStage.Initialized));
+
+ // Make sure the player has hands. We've had fucking disarmed nukies before.
+ Assert.That(entMan.HasComponent(player));
+ Assert.That(entMan.GetComponent(player).Hands.Count, Is.GreaterThan(0));
+
+ // While we're at it, lets make sure they aren't naked. I don't know how many inventory slots all mobs will be
+ // likely to have in the future. But nukies should probably have at least 3 slots with something in them.
+ var enumerator = invSys.GetSlotEnumerator(player);
+ int total = 0;
+ while (enumerator.NextItem(out _))
+ {
+ total++;
+ }
+ Assert.That(total, Is.GreaterThan(3));
+
+ // Finally lets check the nukie commander passed basic training and figured out how to breathe.
+ var totalSeconds = 30;
+ var totalTicks = (int) Math.Ceiling(totalSeconds / server.Timing.TickPeriod.TotalSeconds);
+ int increment = 5;
+ var resp = entMan.GetComponent(player);
+ var damage = entMan.GetComponent(player);
+ for (var tick = 0; tick < totalTicks; tick += increment)
+ {
+ await pair.RunTicksSync(increment);
+ Assert.That(resp.SuffocationCycles, Is.LessThanOrEqualTo(resp.SuffocationCycleThreshold));
+ Assert.That(damage.TotalDamage, Is.EqualTo(FixedPoint2.Zero));
+ }
+
+ //ticker.SetGamePreset((GamePresetPrototype?)null); WD edit
+ server.CfgMan.SetCVar(CCVars.GridFill, false);
+ await pair.SetAntagPref("NukeopsCommander", false);
+ await pair.CleanReturnAsync();
+ }
+}
+
+*/
diff --git a/Content.IntegrationTests/Tests/GameRules/RuleMaxTimeRestartTest.cs b/Content.IntegrationTests/Tests/GameRules/RuleMaxTimeRestartTest.cs
index 1e3f9c9854f..ffaff3b8ded 100644
--- a/Content.IntegrationTests/Tests/GameRules/RuleMaxTimeRestartTest.cs
+++ b/Content.IntegrationTests/Tests/GameRules/RuleMaxTimeRestartTest.cs
@@ -1,5 +1,6 @@
using Content.Server.GameTicking;
using Content.Server.GameTicking.Commands;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Rules;
using Content.Server.GameTicking.Rules.Components;
using Content.Shared.CCVar;
diff --git a/Content.IntegrationTests/Tests/GameRules/SecretStartsTest.cs b/Content.IntegrationTests/Tests/GameRules/SecretStartsTest.cs
index 0f665a63de0..5d7ae8efbf4 100644
--- a/Content.IntegrationTests/Tests/GameRules/SecretStartsTest.cs
+++ b/Content.IntegrationTests/Tests/GameRules/SecretStartsTest.cs
@@ -17,6 +17,7 @@ public async Task TestSecretStarts()
var server = pair.Server;
await server.WaitIdleAsync();
+ var entMan = server.ResolveDependency();
var gameTicker = server.ResolveDependency().GetEntitySystem();
await server.WaitAssertion(() =>
@@ -32,10 +33,7 @@ await server.WaitAssertion(() =>
await server.WaitAssertion(() =>
{
- foreach (var rule in gameTicker.GetAddedGameRules())
- {
- Assert.That(gameTicker.GetActiveGameRules(), Does.Contain(rule));
- }
+ Assert.That(gameTicker.GetAddedGameRules().Count(), Is.GreaterThan(1), $"No additional rules started by secret rule.");
// End all rules
gameTicker.ClearGameRules();
diff --git a/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs b/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs
index 317aa10400b..4415eddf376 100644
--- a/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs
+++ b/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs
@@ -407,7 +407,6 @@ await server.WaitAssertion(() =>
await pair.CleanReturnAsync();
}
- [Reflect(false)]
public sealed class TestInteractionSystem : EntitySystem
{
public EntityEventHandler? InteractUsingEvent;
diff --git a/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs b/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs
index d5c2a9124dd..40457f54883 100644
--- a/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs
+++ b/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs
@@ -9,7 +9,6 @@ namespace Content.IntegrationTests.Tests
[TestOf(typeof(RoundRestartCleanupEvent))]
public sealed class ResettingEntitySystemTests
{
- [Reflect(false)]
public sealed class TestRoundRestartCleanupEvent : EntitySystem
{
public bool HasBeenReset { get; set; }
@@ -49,8 +48,6 @@ await server.WaitAssertion(() =>
system.HasBeenReset = false;
- Assert.That(system.HasBeenReset, Is.False);
-
gameTicker.RestartRound();
Assert.That(system.HasBeenReset);
diff --git a/Content.IntegrationTests/Tests/Slipping/SlippingTest.cs b/Content.IntegrationTests/Tests/Slipping/SlippingTest.cs
index 61dcc3331da..28da7a94658 100644
--- a/Content.IntegrationTests/Tests/Slipping/SlippingTest.cs
+++ b/Content.IntegrationTests/Tests/Slipping/SlippingTest.cs
@@ -34,7 +34,7 @@ private void OnSlip(EntityUid uid, SlipperyComponent component, ref SlipEvent ar
public async Task BananaSlipTest()
{
var sys = SEntMan.System();
- var sprintWalks = sys.Config.GetCVar(CCVars.GamePressToSprint);
+ var sprintWalks = sys.Config.GetCVar(CCVars.DefaultWalk);
await SpawnTarget("TrashBananaPeel");
// var modifier = Comp(Player).SprintSpeedModifier;
diff --git a/Content.Server/Abilities/Psionics/PsionicAbilitiesSystem.cs b/Content.Server/Abilities/Psionics/PsionicAbilitiesSystem.cs
index 32e51d3c101..536235b6d63 100644
--- a/Content.Server/Abilities/Psionics/PsionicAbilitiesSystem.cs
+++ b/Content.Server/Abilities/Psionics/PsionicAbilitiesSystem.cs
@@ -12,6 +12,7 @@
using System.Linq;
using Robust.Server.Player;
using Content.Server.Chat.Managers;
+using Content.Server.Psionics.Glimmer;
namespace Content.Server.Abilities.Psionics
{
@@ -122,6 +123,8 @@ public void InitializePsionicPower(EntityUid uid, PsionicPowerPrototype proto, P
AddPsionicStatSources(proto, psionic);
RefreshPsionicModifiers(uid, psionic);
SendFeedbackMessage(uid, proto, playFeedback);
+ UpdatePowerSlots(psionic);
+ //UpdatePsionicDanger(uid, psionic); // TODO: After Glimmer Refactor
//SendFeedbackAudio(uid, proto, playPopup); // TODO: This one is coming next!
}
@@ -297,6 +300,27 @@ private void SendFeedbackMessage(EntityUid uid, PsionicPowerPrototype proto, boo
session.Channel);
}
+ private void UpdatePowerSlots(PsionicComponent psionic)
+ {
+ var slotsUsed = 0;
+ foreach (var power in psionic.ActivePowers)
+ slotsUsed += power.PowerSlotCost;
+
+ psionic.PowerSlotsTaken = slotsUsed;
+ }
+
+ ///
+ /// Psions over a certain power threshold become a glimmer source. This cannot be fully implemented until after I rework Glimmer
+ ///
+ //private void UpdatePsionicDanger(EntityUid uid, PsionicComponent psionic)
+ //{
+ // if (psionic.PowerSlotsTaken <= psionic.PowerSlots)
+ // return;
+ //
+ // EnsureComp(uid, out var glimmerSource);
+ // glimmerSource.SecondsPerGlimmer = 10 / (psionic.PowerSlotsTaken - psionic.PowerSlots);
+ //}
+
///
/// Remove all Psychic Actions listed in an entity's Psionic Component. Unfortunately, removing actions associated with a specific Power Prototype is not supported.
///
diff --git a/Content.Server/Administration/Commands/SetOutfitCommand.cs b/Content.Server/Administration/Commands/SetOutfitCommand.cs
index 2f979f4340b..e19c5b72fa4 100644
--- a/Content.Server/Administration/Commands/SetOutfitCommand.cs
+++ b/Content.Server/Administration/Commands/SetOutfitCommand.cs
@@ -13,6 +13,8 @@
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Content.Server.Silicon.IPC;
+using Content.Shared.Radio.Components;
+using Content.Shared.Cluwne;
namespace Content.Server.Administration.Commands
{
@@ -127,7 +129,14 @@ public static bool SetOutfit(EntityUid target, string gear, IEntityManager entit
handsSystem.TryPickup(target, inhandEntity, checkActionBlocker: false, handsComp: handsComponent);
}
}
- InternalEncryptionKeySpawner.TryInsertEncryptionKey(target, startingGear, entityManager, profile);
+
+ if (entityManager.HasComponent(target))
+ return true; //Fuck it, nuclear option for not Cluwning an IPC because that causes a crash that SOMEHOW ignores null checks.
+ if (entityManager.HasComponent(target))
+ {
+ var encryption = new InternalEncryptionKeySpawner();
+ encryption.TryInsertEncryptionKey(target, startingGear, entityManager);
+ }
return true;
}
}
diff --git a/Content.Server/Administration/ServerApi.cs b/Content.Server/Administration/ServerApi.cs
index 6f10ef9b479..04fd38598fb 100644
--- a/Content.Server/Administration/ServerApi.cs
+++ b/Content.Server/Administration/ServerApi.cs
@@ -8,6 +8,7 @@
using System.Threading.Tasks;
using Content.Server.Administration.Systems;
using Content.Server.GameTicking;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Presets;
using Content.Server.GameTicking.Rules.Components;
using Content.Server.Maps;
diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
index eff97136d06..4103b8a8aa7 100644
--- a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
+++ b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
@@ -1,23 +1,37 @@
-using Content.Server.GameTicking.Rules;
+using Content.Server.Administration.Commands;
+using Content.Server.Antag;
+using Content.Server.GameTicking.Rules.Components;
using Content.Server.Zombies;
using Content.Shared.Administration;
using Content.Shared.Database;
-using Content.Shared.Humanoid;
using Content.Shared.Mind.Components;
+using Content.Shared.Roles;
using Content.Shared.Verbs;
using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Server.Administration.Systems;
public sealed partial class AdminVerbSystem
{
+ [Dependency] private readonly AntagSelectionSystem _antag = default!;
[Dependency] private readonly ZombieSystem _zombie = default!;
- [Dependency] private readonly ThiefRuleSystem _thief = default!;
- [Dependency] private readonly TraitorRuleSystem _traitorRule = default!;
- [Dependency] private readonly NukeopsRuleSystem _nukeopsRule = default!;
- [Dependency] private readonly PiratesRuleSystem _piratesRule = default!;
- [Dependency] private readonly RevolutionaryRuleSystem _revolutionaryRule = default!;
+
+ [ValidatePrototypeId]
+ private const string DefaultTraitorRule = "Traitor";
+
+ [ValidatePrototypeId]
+ private const string DefaultNukeOpRule = "LoneOpsSpawn";
+
+ [ValidatePrototypeId]
+ private const string DefaultRevsRule = "Revolutionary";
+
+ [ValidatePrototypeId]
+ private const string DefaultThiefRule = "Thief";
+
+ [ValidatePrototypeId]
+ private const string PirateGearId = "PirateGear";
// All antag verbs have names so invokeverb works.
private void AddAntagVerbs(GetVerbsEvent args)
@@ -30,9 +44,11 @@ private void AddAntagVerbs(GetVerbsEvent args)
if (!_adminManager.HasAdminFlag(player, AdminFlags.Fun))
return;
- if (!HasComp(args.Target))
+ if (!HasComp(args.Target) || !TryComp(args.Target, out var targetActor))
return;
+ var targetPlayer = targetActor.PlayerSession;
+
Verb traitor = new()
{
Text = Loc.GetString("admin-verb-text-make-traitor"),
@@ -40,9 +56,7 @@ private void AddAntagVerbs(GetVerbsEvent args)
Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Structures/Wallmounts/posters.rsi"), "poster5_contraband"),
Act = () =>
{
- // if its a monkey or mouse or something dont give uplink or objectives
- var isHuman = HasComp(args.Target);
- _traitorRule.MakeTraitorAdmin(args.Target, giveUplink: isHuman, giveObjectives: isHuman);
+ _antag.ForceMakeAntag(targetPlayer, DefaultTraitorRule);
},
Impact = LogImpact.High,
Message = Loc.GetString("admin-verb-make-traitor"),
@@ -71,7 +85,7 @@ private void AddAntagVerbs(GetVerbsEvent args)
Icon = new SpriteSpecifier.Rsi(new("/Textures/Structures/Wallmounts/signs.rsi"), "radiation"),
Act = () =>
{
- _nukeopsRule.MakeLoneNukie(args.Target);
+ _antag.ForceMakeAntag(targetPlayer, DefaultNukeOpRule);
},
Impact = LogImpact.High,
Message = Loc.GetString("admin-verb-make-nuclear-operative"),
@@ -85,14 +99,14 @@ private void AddAntagVerbs(GetVerbsEvent args)
Icon = new SpriteSpecifier.Rsi(new("/Textures/Clothing/Head/Hats/pirate.rsi"), "icon"),
Act = () =>
{
- _piratesRule.MakePirate(args.Target);
+ // pirates just get an outfit because they don't really have logic associated with them
+ SetOutfitCommand.SetOutfit(args.Target, PirateGearId, EntityManager);
},
Impact = LogImpact.High,
Message = Loc.GetString("admin-verb-make-pirate"),
};
args.Verbs.Add(pirate);
- //todo come here at some point dear lort.
Verb headRev = new()
{
Text = Loc.GetString("admin-verb-text-make-head-rev"),
@@ -100,7 +114,7 @@ private void AddAntagVerbs(GetVerbsEvent args)
Icon = new SpriteSpecifier.Rsi(new("/Textures/Interface/Misc/job_icons.rsi"), "HeadRevolutionary"),
Act = () =>
{
- _revolutionaryRule.OnHeadRevAdmin(args.Target);
+ _antag.ForceMakeAntag(targetPlayer, DefaultRevsRule);
},
Impact = LogImpact.High,
Message = Loc.GetString("admin-verb-make-head-rev"),
@@ -114,7 +128,7 @@ private void AddAntagVerbs(GetVerbsEvent args)
Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Clothing/Hands/Gloves/Color/black.rsi"), "icon"),
Act = () =>
{
- _thief.AdminMakeThief(args.Target, false); //Midround add pacified is bad
+ _antag.ForceMakeAntag(targetPlayer, DefaultThiefRule);
},
Impact = LogImpact.High,
Message = Loc.GetString("admin-verb-make-thief"),
diff --git a/Content.Server/Antag/AntagSelectionPlayerPool.cs b/Content.Server/Antag/AntagSelectionPlayerPool.cs
new file mode 100644
index 00000000000..87873e96d1a
--- /dev/null
+++ b/Content.Server/Antag/AntagSelectionPlayerPool.cs
@@ -0,0 +1,27 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Robust.Shared.Player;
+using Robust.Shared.Random;
+
+namespace Content.Server.Antag;
+
+public sealed class AntagSelectionPlayerPool (List> orderedPools)
+{
+ public bool TryPickAndTake(IRobustRandom random, [NotNullWhen(true)] out ICommonSession? session)
+ {
+ session = null;
+
+ foreach (var pool in orderedPools)
+ {
+ if (pool.Count == 0)
+ continue;
+
+ session = random.PickAndTake(pool);
+ break;
+ }
+
+ return session != null;
+ }
+
+ public int Count => orderedPools.Sum(p => p.Count);
+}
diff --git a/Content.Server/Antag/AntagSelectionSystem.API.cs b/Content.Server/Antag/AntagSelectionSystem.API.cs
new file mode 100644
index 00000000000..59bf05fe03f
--- /dev/null
+++ b/Content.Server/Antag/AntagSelectionSystem.API.cs
@@ -0,0 +1,328 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Content.Server.Antag.Components;
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Objectives;
+using Content.Shared.Chat;
+using Content.Shared.Mind;
+using JetBrains.Annotations;
+using Robust.Shared.Audio;
+using Robust.Shared.Enums;
+using Robust.Shared.Player;
+
+namespace Content.Server.Antag;
+
+public sealed partial class AntagSelectionSystem
+{
+ ///
+ /// Tries to get the next non-filled definition based on the current amount of selected minds and other factors.
+ ///
+ public bool TryGetNextAvailableDefinition(Entity ent,
+ [NotNullWhen(true)] out AntagSelectionDefinition? definition)
+ {
+ definition = null;
+
+ var totalTargetCount = GetTargetAntagCount(ent);
+ var mindCount = ent.Comp.SelectedMinds.Count;
+ if (mindCount >= totalTargetCount)
+ return false;
+
+ // TODO ANTAG fix this
+ // If here are two definitions with 1/10 and 10/10 slots filled, this will always return the second definition
+ // even though it has already met its target
+ // AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA I fucking hate game ticker code.
+ // It needs to track selected minds for each definition independently.
+ foreach (var def in ent.Comp.Definitions)
+ {
+ var target = GetTargetAntagCount(ent, null, def);
+
+ if (mindCount < target)
+ {
+ definition = def;
+ return true;
+ }
+
+ mindCount -= target;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Gets the number of antagonists that should be present for a given rule based on the provided pool.
+ /// A null pool will simply use the player count.
+ ///
+ public int GetTargetAntagCount(Entity ent, int? playerCount = null)
+ {
+ var count = 0;
+ foreach (var def in ent.Comp.Definitions)
+ {
+ count += GetTargetAntagCount(ent, playerCount, def);
+ }
+
+ return count;
+ }
+
+ public int GetTotalPlayerCount(IList pool)
+ {
+ var count = 0;
+ foreach (var session in pool)
+ {
+ if (session.Status is SessionStatus.Disconnected or SessionStatus.Zombie)
+ continue;
+
+ count++;
+ }
+
+ return count;
+ }
+
+ ///
+ /// Gets the number of antagonists that should be present for a given antag definition based on the provided pool.
+ /// A null pool will simply use the player count.
+ ///
+ public int GetTargetAntagCount(Entity ent, int? playerCount, AntagSelectionDefinition def)
+ {
+ // TODO ANTAG
+ // make pool non-nullable
+ // Review uses and ensure that people are INTENTIONALLY including players in the lobby if this is a mid-round
+ // antag selection.
+ var poolSize = playerCount ?? GetTotalPlayerCount(_playerManager.Sessions);
+
+ // factor in other definitions' affect on the count.
+ var countOffset = 0;
+ foreach (var otherDef in ent.Comp.Definitions)
+ {
+ countOffset += Math.Clamp(poolSize / otherDef.PlayerRatio, otherDef.Min, otherDef.Max) * otherDef.PlayerRatio;
+ }
+ // make sure we don't double-count the current selection
+ countOffset -= Math.Clamp((poolSize + countOffset) / def.PlayerRatio, def.Min, def.Max) * def.PlayerRatio;
+
+ return Math.Clamp((poolSize - countOffset) / def.PlayerRatio, def.Min, def.Max);
+ }
+
+ ///
+ /// Returns identifiable information for all antagonists to be used in a round end summary.
+ ///
+ ///
+ /// A list containing, in order, the antag's mind, the session data, and the original name stored as a string.
+ ///
+ public List<(EntityUid, SessionData, string)> GetAntagIdentifiers(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return new List<(EntityUid, SessionData, string)>();
+
+ var output = new List<(EntityUid, SessionData, string)>();
+ foreach (var (mind, name) in ent.Comp.SelectedMinds)
+ {
+ if (!TryComp(mind, out var mindComp) || mindComp.OriginalOwnerUserId == null)
+ continue;
+
+ if (!_playerManager.TryGetPlayerData(mindComp.OriginalOwnerUserId.Value, out var data))
+ continue;
+
+ output.Add((mind, data, name));
+ }
+ return output;
+ }
+
+ ///
+ /// Returns all the minds of antagonists.
+ ///
+ public List> GetAntagMinds(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return new();
+
+ var output = new List>();
+ foreach (var (mind, _) in ent.Comp.SelectedMinds)
+ {
+ if (!TryComp(mind, out var mindComp) || mindComp.OriginalOwnerUserId == null)
+ continue;
+
+ output.Add((mind, mindComp));
+ }
+ return output;
+ }
+
+ ///
+ /// Helper specifically for
+ ///
+ public List GetAntagMindEntityUids(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return new();
+
+ return ent.Comp.SelectedMinds.Select(p => p.Item1).ToList();
+ }
+
+ ///
+ /// Returns all the antagonists for this rule who are currently alive
+ ///
+ public IEnumerable GetAliveAntags(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ yield break;
+
+ var minds = GetAntagMinds(ent);
+ foreach (var mind in minds)
+ {
+ if (_mind.IsCharacterDeadIc(mind))
+ continue;
+
+ if (mind.Comp.OriginalOwnedEntity != null)
+ yield return GetEntity(mind.Comp.OriginalOwnedEntity.Value);
+ }
+ }
+
+ ///
+ /// Returns the number of alive antagonists for this rule.
+ ///
+ public int GetAliveAntagCount(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return 0;
+
+ var numbah = 0;
+ var minds = GetAntagMinds(ent);
+ foreach (var mind in minds)
+ {
+ if (_mind.IsCharacterDeadIc(mind))
+ continue;
+
+ numbah++;
+ }
+
+ return numbah;
+ }
+
+ ///
+ /// Returns if there are any remaining antagonists alive for this rule.
+ ///
+ public bool AnyAliveAntags(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return false;
+
+ return GetAliveAntags(ent).Any();
+ }
+
+ ///
+ /// Checks if all the antagonists for this rule are alive.
+ ///
+ public bool AllAntagsAlive(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return false;
+
+ return GetAliveAntagCount(ent) == ent.Comp.SelectedMinds.Count;
+ }
+
+ ///
+ /// Helper method to send the briefing text and sound to a player entity
+ ///
+ /// The entity chosen to be antag
+ /// The briefing text to send
+ /// The color the briefing should be, null for default
+ /// The sound to briefing/greeting sound to play
+ public void SendBriefing(EntityUid entity, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
+ {
+ if (!_mind.TryGetMind(entity, out _, out var mindComponent))
+ return;
+
+ if (mindComponent.Session == null)
+ return;
+
+ SendBriefing(mindComponent.Session, briefing, briefingColor, briefingSound);
+ }
+
+ ///
+ /// Helper method to send the briefing text and sound to a list of sessions
+ ///
+ /// The sessions that will be sent the briefing
+ /// The briefing text to send
+ /// The color the briefing should be, null for default
+ /// The sound to briefing/greeting sound to play
+ [PublicAPI]
+ public void SendBriefing(List sessions, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
+ {
+ foreach (var session in sessions)
+ {
+ SendBriefing(session, briefing, briefingColor, briefingSound);
+ }
+ }
+
+ ///
+ /// Helper method to send the briefing text and sound to a session
+ ///
+ /// The player chosen to be an antag
+ /// The briefing data
+ public void SendBriefing(
+ ICommonSession? session,
+ BriefingData? data)
+ {
+ if (session == null || data == null)
+ return;
+
+ var text = data.Value.Text == null ? string.Empty : Loc.GetString(data.Value.Text);
+ SendBriefing(session, text, data.Value.Color, data.Value.Sound);
+ }
+
+ ///
+ /// Helper method to send the briefing text and sound to a session
+ ///
+ /// The player chosen to be an antag
+ /// The briefing text to send
+ /// The color the briefing should be, null for default
+ /// The sound to briefing/greeting sound to play
+ public void SendBriefing(
+ ICommonSession? session,
+ string briefing,
+ Color? briefingColor,
+ SoundSpecifier? briefingSound)
+ {
+ if (session == null)
+ return;
+
+ _audio.PlayGlobal(briefingSound, session);
+ if (!string.IsNullOrEmpty(briefing))
+ {
+ var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", briefing));
+ _chat.ChatMessageToOne(ChatChannel.Server, briefing, wrappedMessage, default, false, session.Channel,
+ briefingColor);
+ }
+ }
+
+ ///
+ /// This technically is a gamerule-ent-less way to make an entity an antag.
+ /// You should almost never be using this.
+ ///
+ public void ForceMakeAntag(ICommonSession? player, string defaultRule) where T : Component
+ {
+ var rule = ForceGetGameRuleEnt(defaultRule);
+
+ if (!TryGetNextAvailableDefinition(rule, out var def))
+ def = rule.Comp.Definitions.Last();
+
+ MakeAntag(rule, player, def.Value);
+ }
+
+ ///
+ /// Tries to grab one of the weird specific antag gamerule ents or starts a new one.
+ /// This is gross code but also most of this is pretty gross to begin with.
+ ///
+ public Entity ForceGetGameRuleEnt(string id) where T : Component
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out _, out var comp))
+ {
+ return (uid, comp);
+ }
+ var ruleEnt = GameTicker.AddGameRule(id);
+ RemComp(ruleEnt);
+ var antag = Comp(ruleEnt);
+ antag.SelectionsComplete = true; // don't do normal selection.
+ GameTicker.StartGameRule(ruleEnt);
+ return (ruleEnt, antag);
+ }
+}
diff --git a/Content.Server/Antag/AntagSelectionSystem.cs b/Content.Server/Antag/AntagSelectionSystem.cs
index b11c562df5a..d74824dd2d5 100644
--- a/Content.Server/Antag/AntagSelectionSystem.cs
+++ b/Content.Server/Antag/AntagSelectionSystem.cs
@@ -1,347 +1,453 @@
+using System.Linq;
+using Content.Server.Antag.Components;
+using Content.Server.Chat.Managers;
+using Content.Server.GameTicking;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Rules;
-using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Ghost.Roles;
+using Content.Server.Ghost.Roles.Components;
using Content.Server.Mind;
using Content.Server.Preferences.Managers;
+using Content.Server.Roles;
using Content.Server.Roles.Jobs;
using Content.Server.Shuttles.Components;
+using Content.Server.Station.Systems;
using Content.Shared.Antag;
+using Content.Shared.GameTicking;
+using Content.Shared.Ghost;
using Content.Shared.Humanoid;
using Content.Shared.Players;
using Content.Shared.Preferences;
-using Content.Shared.Roles;
using Robust.Server.Audio;
-using Robust.Shared.Audio;
+using Robust.Server.GameObjects;
+using Robust.Server.Player;
+using Robust.Shared.Enums;
+using Robust.Shared.Map;
using Robust.Shared.Player;
-using Robust.Shared.Prototypes;
using Robust.Shared.Random;
-using System.Linq;
-using Content.Shared.Chat;
-using Robust.Shared.Enums;
+using Robust.Shared.Utility;
namespace Content.Server.Antag;
-public sealed class AntagSelectionSystem : GameRuleSystem
+public sealed partial class AntagSelectionSystem : GameRuleSystem
{
- [Dependency] private readonly IServerPreferencesManager _prefs = default!;
- [Dependency] private readonly AudioSystem _audioSystem = default!;
+ [Dependency] private readonly IChatManager _chat = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly IServerPreferencesManager _pref = default!;
+ [Dependency] private readonly AudioSystem _audio = default!;
+ [Dependency] private readonly GhostRoleSystem _ghostRole = default!;
[Dependency] private readonly JobSystem _jobs = default!;
- [Dependency] private readonly MindSystem _mindSystem = default!;
- [Dependency] private readonly SharedRoleSystem _roleSystem = default!;
+ [Dependency] private readonly MindSystem _mind = default!;
+ [Dependency] private readonly RoleSystem _role = default!;
+ [Dependency] private readonly StationSpawningSystem _stationSpawning = default!;
+ [Dependency] private readonly TransformSystem _transform = default!;
- #region Eligible Player Selection
- ///
- /// Get all players that are eligible for an antag role
- ///
- /// All sessions from which to select eligible players
- /// The prototype to get eligible players for
- /// Should jobs that prohibit antag roles (ie Heads, Sec, Interns) be included
- /// Should players already selected as antags be eligible
- /// Should we ignore if the player has enabled this specific role
- /// A custom condition that each player is tested against, if it returns true the player is excluded from eligibility
- /// List of all player entities that match the requirements
- public List GetEligiblePlayers(IEnumerable playerSessions,
- ProtoId antagPrototype,
- bool includeAllJobs = false,
- AntagAcceptability acceptableAntags = AntagAcceptability.NotExclusive,
- bool ignorePreferences = false,
- bool allowNonHumanoids = false,
- Func? customExcludeCondition = null)
+ // arbitrary random number to give late joining some mild interest.
+ public const float LateJoinRandomChance = 0.5f;
+
+ ///
+ public override void Initialize()
{
- var eligiblePlayers = new List();
+ base.Initialize();
- foreach (var player in playerSessions)
- {
- if (IsPlayerEligible(player, antagPrototype, includeAllJobs, acceptableAntags, ignorePreferences, allowNonHumanoids, customExcludeCondition))
- eligiblePlayers.Add(player.AttachedEntity!.Value);
- }
+ SubscribeLocalEvent(OnTakeGhostRole);
- return eligiblePlayers;
+ SubscribeLocalEvent(OnPlayerSpawning);
+ SubscribeLocalEvent(OnJobsAssigned);
+ SubscribeLocalEvent(OnSpawnComplete);
}
- ///
- /// Get all sessions that are eligible for an antag role, can be run prior to sessions being attached to an entity
- /// This does not exclude sessions that have already been chosen as antags - that must be handled manually
- ///
- /// All sessions from which to select eligible players
- /// The prototype to get eligible players for
- /// Should we ignore if the player has enabled this specific role
- /// List of all player sessions that match the requirements
- public List GetEligibleSessions(IEnumerable playerSessions, ProtoId antagPrototype, bool ignorePreferences = false)
+ private void OnTakeGhostRole(Entity ent, ref TakeGhostRoleEvent args)
{
- var eligibleSessions = new List();
+ if (args.TookRole)
+ return;
+
+ if (ent.Comp.Rule is not { } rule || ent.Comp.Definition is not { } def)
+ return;
- foreach (var session in playerSessions)
+ if (!Exists(rule) || !TryComp(rule, out var select))
+ return;
+
+ MakeAntag((rule, select), args.Player, def, ignoreSpawner: true);
+ args.TookRole = true;
+ _ghostRole.UnregisterGhostRole((ent, Comp(ent)));
+ }
+
+ private void OnPlayerSpawning(RulePlayerSpawningEvent args)
+ {
+ var pool = args.PlayerPool;
+
+ var query = QueryActiveRules();
+ while (query.MoveNext(out var uid, out _, out var comp, out _))
{
- if (IsSessionEligible(session, antagPrototype, ignorePreferences))
- eligibleSessions.Add(session);
+ if (comp.SelectionTime != AntagSelectionTime.PrePlayerSpawn)
+ continue;
+
+ if (comp.SelectionsComplete)
+ continue;
+
+ ChooseAntags((uid, comp), pool);
+
+ foreach (var session in comp.SelectedSessions)
+ {
+ args.PlayerPool.Remove(session);
+ GameTicker.PlayerJoinGame(session);
+ }
}
+ }
- return eligibleSessions;
+ private void OnJobsAssigned(RulePlayerJobsAssignedEvent args)
+ {
+ var query = QueryActiveRules();
+ while (query.MoveNext(out var uid, out _, out var comp, out _))
+ {
+ if (comp.SelectionTime != AntagSelectionTime.PostPlayerSpawn)
+ continue;
+
+ ChooseAntags((uid, comp), args.Players);
+ }
}
- ///
- /// Test eligibility of the player for a specific antag role
- ///
- /// The player session to test
- /// The prototype to get eligible players for
- /// Should jobs that prohibit antag roles (ie Heads, Sec, Interns) be included
- /// Should players already selected as antags be eligible
- /// Should we ignore if the player has enabled this specific role
- /// A function, accepting an EntityUid and returning bool. Each player is tested against this, returning truw will exclude the player from eligibility
- /// True if the player session matches the requirements, false otherwise
- public bool IsPlayerEligible(ICommonSession session,
- ProtoId antagPrototype,
- bool includeAllJobs = false,
- AntagAcceptability acceptableAntags = AntagAcceptability.NotExclusive,
- bool ignorePreferences = false,
- bool allowNonHumanoids = false,
- Func? customExcludeCondition = null)
+ private void OnSpawnComplete(PlayerSpawnCompleteEvent args)
{
- if (!IsSessionEligible(session, antagPrototype, ignorePreferences))
- return false;
+ if (!args.LateJoin)
+ return;
- //Ensure the player has a mind
- if (session.GetMind() is not { } playerMind)
- return false;
+ // TODO: this really doesn't handle multiple latejoin definitions well
+ // eventually this should probably store the players per definition with some kind of unique identifier.
+ // something to figure out later.
- //Ensure the player has an attached entity
- if (session.AttachedEntity is not { } playerEntity)
- return false;
+ var query = QueryActiveRules();
+ while (query.MoveNext(out var uid, out _, out var antag, out _))
+ {
+ // TODO ANTAG
+ // what why aasdiuhasdopiuasdfhksad
+ // stop this insanity please
+ // probability of antag assignment shouldn't depend on the order in which rules are returned by the query.
+ if (!RobustRandom.Prob(LateJoinRandomChance))
+ continue;
- //Ignore latejoined players, ie those on the arrivals station
- if (HasComp(playerEntity))
- return false;
+ if (!antag.Definitions.Any(p => p.LateJoinAdditional))
+ continue;
- //Exclude jobs that cannot be antag, unless explicitly allowed
- if (!includeAllJobs && !_jobs.CanBeAntag(session))
- return false;
+ DebugTools.AssertEqual(antag.SelectionTime, AntagSelectionTime.PostPlayerSpawn);
- //Check if the entity is already an antag
- switch (acceptableAntags)
- {
- //If we dont want to select any antag roles
- case AntagAcceptability.None:
- {
- if (_roleSystem.MindIsAntagonist(playerMind))
- return false;
- break;
- }
- //If we dont want to select exclusive antag roles
- case AntagAcceptability.NotExclusive:
- {
- if (_roleSystem.MindIsExclusiveAntagonist(playerMind))
- return false;
- break;
- }
+ if (!TryGetNextAvailableDefinition((uid, antag), out var def))
+ continue;
+
+ if (TryMakeAntag((uid, antag), args.Player, def.Value))
+ break;
}
+ }
- //Unless explictly allowed, ignore non humanoids (eg pets)
- if (!allowNonHumanoids && !HasComp(playerEntity))
- return false;
+ protected override void Added(EntityUid uid, AntagSelectionComponent component, GameRuleComponent gameRule, GameRuleAddedEvent args)
+ {
+ base.Added(uid, component, gameRule, args);
- //If a custom condition was provided, test it and exclude the player if it returns true
- if (customExcludeCondition != null && customExcludeCondition(playerEntity))
- return false;
+ for (var i = 0; i < component.Definitions.Count; i++)
+ {
+ var def = component.Definitions[i];
+ if (def.MinRange != null)
+ {
+ def.Min = def.MinRange.Value.Next(RobustRandom);
+ }
- return true;
+ if (def.MaxRange != null)
+ {
+ def.Max = def.MaxRange.Value.Next(RobustRandom);
+ }
+ }
}
- ///
- /// Check if the session is eligible for a role, can be run prior to the session being attached to an entity
- ///
- /// Player session to check
- /// Which antag prototype to check for
- /// Ignore if the player has enabled this antag
- /// True if the session matches the requirements, false otherwise
- public bool IsSessionEligible(ICommonSession session, ProtoId antagPrototype, bool ignorePreferences = false)
+ protected override void Started(EntityUid uid, AntagSelectionComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args)
{
- //Exclude disconnected or zombie sessions
- //No point giving antag roles to them
- if (session.Status == SessionStatus.Disconnected ||
- session.Status == SessionStatus.Zombie)
- return false;
+ base.Started(uid, component, gameRule, args);
- //Check the player has this antag preference selected
- //Unless we are ignoring preferences, in which case add them anyway
- var pref = (HumanoidCharacterProfile) _prefs.GetPreferences(session.UserId).SelectedCharacter;
- if (!pref.AntagPreferences.Contains(antagPrototype.Id) && !ignorePreferences)
- return false;
+ // If the round has not yet started, we defer antag selection until roundstart
+ if (GameTicker.RunLevel != GameRunLevel.InRound)
+ return;
- return true;
+ if (component.SelectionsComplete)
+ return;
+
+ var players = _playerManager.Sessions
+ .Where(x => GameTicker.PlayerGameStatuses[x.UserId] == PlayerGameStatus.JoinedGame)
+ .ToList();
+
+ ChooseAntags((uid, component), players);
}
- #endregion
///
- /// Helper method to calculate the number of antags to select based upon the number of players
+ /// Chooses antagonists from the given selection of players
///
- /// How many players there are on the server
- /// How many players should there be for an additional antag
- /// Maximum number of antags allowed
- /// The number of antags that should be chosen
- public int CalculateAntagCount(int playerCount, int playersPerAntag, int maxAntags)
+ public void ChooseAntags(Entity ent, IList pool)
{
- return Math.Clamp(playerCount / playersPerAntag, 1, maxAntags);
+ if (ent.Comp.SelectionsComplete)
+ return;
+
+ foreach (var def in ent.Comp.Definitions)
+ {
+ ChooseAntags(ent, pool, def);
+ }
+
+ ent.Comp.SelectionsComplete = true;
}
- #region Antag Selection
///
- /// Selects a set number of entities from several lists, prioritising the first list till its empty, then second list etc
+ /// Chooses antagonists from the given selection of players for the given antag definition.
///
- /// Array of lists, which are chosen from in order until the correct number of items are selected
- /// How many items to select
- /// Up to the specified count of elements from all provided lists
- public List ChooseAntags(int count, params List[] eligiblePlayerLists)
+ public void ChooseAntags(Entity ent, IList pool, AntagSelectionDefinition def)
{
- var chosenPlayers = new List();
- foreach (var playerList in eligiblePlayerLists)
+ var playerPool = GetPlayerPool(ent, pool, def);
+ var count = GetTargetAntagCount(ent, GetTotalPlayerCount(pool), def);
+
+ for (var i = 0; i < count; i++)
{
- //Remove all chosen players from this list, to prevent duplicates
- foreach (var chosenPlayer in chosenPlayers)
+ var session = (ICommonSession?) null;
+ if (def.PickPlayer)
{
- playerList.Remove(chosenPlayer);
- }
+ if (!playerPool.TryPickAndTake(RobustRandom, out session))
+ break;
- //If we have reached the desired number of players, skip
- if (chosenPlayers.Count >= count)
- continue;
+ if (ent.Comp.SelectedSessions.Contains(session))
+ continue;
+ }
- //Pick and choose a random number of players from this list
- chosenPlayers.AddRange(ChooseAntags(count - chosenPlayers.Count, playerList));
+ MakeAntag(ent, session, def);
}
- return chosenPlayers;
}
+
///
- /// Helper method to choose antags from a list
+ /// Tries to makes a given player into the specified antagonist.
///
- /// List of eligible players
- /// How many to choose
- /// Up to the specified count of elements from the provided list
- public List ChooseAntags(int count, List eligiblePlayers)
+ public bool TryMakeAntag(Entity ent, ICommonSession? session, AntagSelectionDefinition def, bool ignoreSpawner = false)
{
- var chosenPlayers = new List();
-
- for (var i = 0; i < count; i++)
+ if (!IsSessionValid(ent, session, def) ||
+ !IsEntityValid(session?.AttachedEntity, def))
{
- if (eligiblePlayers.Count == 0)
- break;
-
- chosenPlayers.Add(RobustRandom.PickAndTake(eligiblePlayers));
+ return false;
}
- return chosenPlayers;
+ MakeAntag(ent, session, def, ignoreSpawner);
+ return true;
}
///
- /// Selects a set number of sessions from several lists, prioritising the first list till its empty, then second list etc
+ /// Makes a given player into the specified antagonist.
///
- /// Array of lists, which are chosen from in order until the correct number of items are selected
- /// How many items to select
- /// Up to the specified count of elements from all provided lists
- public List ChooseAntags(int count, params List[] eligiblePlayerLists)
+ public void MakeAntag(Entity ent, ICommonSession? session, AntagSelectionDefinition def, bool ignoreSpawner = false)
{
- var chosenPlayers = new List();
- foreach (var playerList in eligiblePlayerLists)
+ var antagEnt = (EntityUid?) null;
+ var isSpawner = false;
+
+ if (session != null)
{
- //Remove all chosen players from this list, to prevent duplicates
- foreach (var chosenPlayer in chosenPlayers)
+ ent.Comp.SelectedSessions.Add(session);
+
+ // we shouldn't be blocking the entity if they're just a ghost or smth.
+ if (!HasComp(session.AttachedEntity))
+ antagEnt = session.AttachedEntity;
+ }
+ else if (!ignoreSpawner && def.SpawnerPrototype != null) // don't add spawners if we have a player, dummy.
+ {
+ antagEnt = Spawn(def.SpawnerPrototype);
+ isSpawner = true;
+ }
+
+ if (!antagEnt.HasValue)
+ {
+ var getEntEv = new AntagSelectEntityEvent(session, ent);
+ RaiseLocalEvent(ent, ref getEntEv, true);
+
+ if (!getEntEv.Handled)
{
- playerList.Remove(chosenPlayer);
+ throw new InvalidOperationException($"Attempted to make {session} antagonist in gamerule {ToPrettyString(ent)} but there was no valid entity for player.");
}
- //If we have reached the desired number of players, skip
- if (chosenPlayers.Count >= count)
- continue;
+ antagEnt = getEntEv.Entity;
+ }
+
+ if (antagEnt is not { } player)
+ return;
- //Pick and choose a random number of players from this list
- chosenPlayers.AddRange(ChooseAntags(count - chosenPlayers.Count, playerList));
+ var getPosEv = new AntagSelectLocationEvent(session, ent);
+ RaiseLocalEvent(ent, ref getPosEv, true);
+ if (getPosEv.Handled)
+ {
+ var playerXform = Transform(player);
+ var pos = RobustRandom.Pick(getPosEv.Coordinates);
+ _transform.SetMapCoordinates((player, playerXform), pos);
}
- return chosenPlayers;
- }
- ///
- /// Helper method to choose sessions from a list
- ///
- /// List of eligible sessions
- /// How many to choose
- /// Up to the specified count of elements from the provided list
- public List ChooseAntags(int count, List eligiblePlayers)
- {
- var chosenPlayers = new List();
- for (int i = 0; i < count; i++)
+ if (isSpawner)
{
- if (eligiblePlayers.Count == 0)
- break;
+ if (!TryComp(player, out var spawnerComp))
+ {
+ Log.Error("Antag spawner with GhostRoleAntagSpawnerComponent.");
+ return;
+ }
- chosenPlayers.Add(RobustRandom.PickAndTake(eligiblePlayers));
+ spawnerComp.Rule = ent;
+ spawnerComp.Definition = def;
+ return;
}
- return chosenPlayers;
+ EntityManager.AddComponents(player, def.Components);
+ _stationSpawning.EquipStartingGear(player, def.StartingGear);
+
+ if (session != null)
+ {
+ var curMind = session.GetMind();
+ if (curMind == null)
+ {
+ curMind = _mind.CreateMind(session.UserId, Name(antagEnt.Value));
+ _mind.SetUserId(curMind.Value, session.UserId);
+ }
+
+ _mind.TransferTo(curMind.Value, antagEnt, ghostCheckOverride: true);
+ _role.MindAddRoles(curMind.Value, def.MindComponents);
+ ent.Comp.SelectedMinds.Add((curMind.Value, Name(player)));
+ SendBriefing(session, def.Briefing);
+ }
+
+ var afterEv = new AfterAntagEntitySelectedEvent(session, player, ent, def);
+ RaiseLocalEvent(ent, ref afterEv, true);
}
- #endregion
- #region Briefings
///
- /// Helper method to send the briefing text and sound to a list of entities
+ /// Gets an ordered player pool based on player preferences and the antagonist definition.
///
- /// The players chosen to be antags
- /// The briefing text to send
- /// The color the briefing should be, null for default
- /// The sound to briefing/greeting sound to play
- public void SendBriefing(List entities, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
+ public AntagSelectionPlayerPool GetPlayerPool(Entity ent, IList sessions, AntagSelectionDefinition def)
{
- foreach (var entity in entities)
+ var preferredList = new List();
+ var fallbackList = new List();
+ foreach (var session in sessions)
{
- SendBriefing(entity, briefing, briefingColor, briefingSound);
+ if (!IsSessionValid(ent, session, def) ||
+ !IsEntityValid(session.AttachedEntity, def))
+ continue;
+
+ var pref = (HumanoidCharacterProfile) _pref.GetPreferences(session.UserId).SelectedCharacter;
+ if (def.PrefRoles.Count != 0 && pref.AntagPreferences.Any(p => def.PrefRoles.Contains(p)))
+ {
+ preferredList.Add(session);
+ }
+ else if (def.FallbackRoles.Count != 0 && pref.AntagPreferences.Any(p => def.FallbackRoles.Contains(p)))
+ {
+ fallbackList.Add(session);
+ }
}
+
+ return new AntagSelectionPlayerPool(new() { preferredList, fallbackList });
}
///
- /// Helper method to send the briefing text and sound to a player entity
+ /// Checks if a given session is valid for an antagonist.
///
- /// The entity chosen to be antag
- /// The briefing text to send
- /// The color the briefing should be, null for default
- /// The sound to briefing/greeting sound to play
- public void SendBriefing(EntityUid entity, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
+ public bool IsSessionValid(Entity ent, ICommonSession? session, AntagSelectionDefinition def, EntityUid? mind = null)
{
- if (!_mindSystem.TryGetMind(entity, out _, out var mindComponent))
- return;
+ if (session == null)
+ return true;
- if (mindComponent.Session == null)
- return;
+ if (session.Status is SessionStatus.Disconnected or SessionStatus.Zombie)
+ return false;
- SendBriefing(mindComponent.Session, briefing, briefingColor, briefingSound);
- }
+ if (ent.Comp.SelectedSessions.Contains(session))
+ return false;
- ///
- /// Helper method to send the briefing text and sound to a list of sessions
- ///
- ///
- ///
- ///
- ///
+ mind ??= session.GetMind();
- public void SendBriefing(List sessions, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
- {
- foreach (var session in sessions)
+ // If the player has not spawned in as any entity (e.g., in the lobby), they can be given an antag role/entity.
+ if (mind == null)
+ return true;
+
+ //todo: we need some way to check that we're not getting the same role twice. (double picking thieves or zombies through midrounds)
+
+ switch (def.MultiAntagSetting)
{
- SendBriefing(session, briefing, briefingColor, briefingSound);
+ case AntagAcceptability.None:
+ {
+ if (_role.MindIsAntagonist(mind))
+ return false;
+ break;
+ }
+ case AntagAcceptability.NotExclusive:
+ {
+ if (_role.MindIsExclusiveAntagonist(mind))
+ return false;
+ break;
+ }
}
+
+ // todo: expand this to allow for more fine antag-selection logic for game rules.
+ if (!_jobs.CanBeAntag(session))
+ return false;
+
+ return true;
}
+
///
- /// Helper method to send the briefing text and sound to a session
+ /// Checks if a given entity (mind/session not included) is valid for a given antagonist.
///
- /// The player chosen to be an antag
- /// The briefing text to send
- /// The color the briefing should be, null for default
- /// The sound to briefing/greeting sound to play
-
- public void SendBriefing(ICommonSession session, string briefing, Color? briefingColor, SoundSpecifier? briefingSound)
+ public bool IsEntityValid(EntityUid? entity, AntagSelectionDefinition def)
{
- _audioSystem.PlayGlobal(briefingSound, session);
- var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", briefing));
- ChatManager.ChatMessageToOne(ChatChannel.Server, briefing, wrappedMessage, default, false, session.Channel, briefingColor);
+ // If the player has not spawned in as any entity (e.g., in the lobby), they can be given an antag role/entity.
+ if (entity == null)
+ return true;
+
+ if (HasComp(entity))
+ return false;
+
+ if (!def.AllowNonHumans && !HasComp(entity))
+ return false;
+
+ if (def.Whitelist != null)
+ {
+ if (!def.Whitelist.IsValid(entity.Value, EntityManager))
+ return false;
+ }
+
+ if (def.Blacklist != null)
+ {
+ if (def.Blacklist.IsValid(entity.Value, EntityManager))
+ return false;
+ }
+
+ return true;
}
- #endregion
}
+
+///
+/// Event raised on a game rule entity in order to determine what the antagonist entity will be.
+/// Only raised if the selected player's current entity is invalid.
+///
+[ByRefEvent]
+public record struct AntagSelectEntityEvent(ICommonSession? Session, Entity GameRule)
+{
+ public readonly ICommonSession? Session = Session;
+
+ public bool Handled => Entity != null;
+
+ public EntityUid? Entity;
+}
+
+///
+/// Event raised on a game rule entity to determine the location for the antagonist.
+///
+[ByRefEvent]
+public record struct AntagSelectLocationEvent(ICommonSession? Session, Entity GameRule)
+{
+ public readonly ICommonSession? Session = Session;
+
+ public bool Handled => Coordinates.Any();
+
+ public List Coordinates = new();
+}
+
+///
+/// Event raised on a game rule entity after the setup logic for an antag is complete.
+/// Used for applying additional more complex setup logic.
+///
+[ByRefEvent]
+public readonly record struct AfterAntagEntitySelectedEvent(ICommonSession? Session, EntityUid EntityUid, Entity GameRule, AntagSelectionDefinition Def);
diff --git a/Content.Server/Antag/Components/AntagSelectionComponent.cs b/Content.Server/Antag/Components/AntagSelectionComponent.cs
new file mode 100644
index 00000000000..096be14049a
--- /dev/null
+++ b/Content.Server/Antag/Components/AntagSelectionComponent.cs
@@ -0,0 +1,189 @@
+using Content.Server.Administration.Systems;
+using Content.Server.Destructible.Thresholds;
+using Content.Shared.Antag;
+using Content.Shared.Roles;
+using Content.Shared.Storage;
+using Content.Shared.Whitelist;
+using Robust.Shared.Audio;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Antag.Components;
+
+[RegisterComponent, Access(typeof(AntagSelectionSystem), typeof(AdminVerbSystem))]
+public sealed partial class AntagSelectionComponent : Component
+{
+ ///
+ /// Has the primary selection of antagonists finished yet?
+ ///
+ [DataField]
+ public bool SelectionsComplete;
+
+ ///
+ /// The definitions for the antagonists
+ ///
+ [DataField]
+ public List Definitions = new();
+
+ ///
+ /// The minds and original names of the players selected to be antagonists.
+ ///
+ [DataField]
+ public List<(EntityUid, string)> SelectedMinds = new();
+
+ ///
+ /// When the antag selection will occur.
+ ///
+ [DataField]
+ public AntagSelectionTime SelectionTime = AntagSelectionTime.PostPlayerSpawn;
+
+ ///
+ /// Cached sessions of players who are chosen. Used so we don't have to rebuild the pool multiple times in a tick.
+ /// Is not serialized.
+ ///
+ public HashSet SelectedSessions = new();
+}
+
+[DataDefinition]
+public partial struct AntagSelectionDefinition()
+{
+ ///
+ /// A list of antagonist roles that are used for selecting which players will be antagonists.
+ ///
+ [DataField]
+ public List> PrefRoles = new();
+
+ ///
+ /// Fallback for . Useful if you need multiple role preferences for a team antagonist.
+ ///
+ [DataField]
+ public List> FallbackRoles = new();
+
+ ///
+ /// Should we allow people who already have an antagonist role?
+ ///
+ [DataField]
+ public AntagAcceptability MultiAntagSetting = AntagAcceptability.None;
+
+ ///
+ /// The minimum number of this antag.
+ ///
+ [DataField]
+ public int Min = 1;
+
+ ///
+ /// The maximum number of this antag.
+ ///
+ [DataField]
+ public int Max = 1;
+
+ ///
+ /// A range used to randomly select
+ ///
+ [DataField]
+ public MinMax? MinRange;
+
+ ///
+ /// A range used to randomly select
+ ///
+ [DataField]
+ public MinMax? MaxRange;
+
+ ///
+ /// a player to antag ratio: used to determine the amount of antags that will be present.
+ ///
+ [DataField]
+ public int PlayerRatio = 10;
+
+ ///
+ /// Whether or not players should be picked to inhabit this antag or not.
+ ///
+ [DataField]
+ public bool PickPlayer = true;
+
+ ///
+ /// If true, players that latejoin into a round have a chance of being converted into antagonists.
+ ///
+ [DataField]
+ public bool LateJoinAdditional = false;
+
+ //todo: find out how to do this with minimal boilerplate: filler department, maybe?
+ //public HashSet> JobBlacklist = new()
+
+ ///
+ /// Mostly just here for legacy compatibility and reducing boilerplate
+ ///
+ [DataField]
+ public bool AllowNonHumans = false;
+
+ ///
+ /// A whitelist for selecting which players can become this antag.
+ ///
+ [DataField]
+ public EntityWhitelist? Whitelist;
+
+ ///
+ /// A blacklist for selecting which players can become this antag.
+ ///
+ [DataField]
+ public EntityWhitelist? Blacklist;
+
+ ///
+ /// Components added to the player.
+ ///
+ [DataField]
+ public ComponentRegistry Components = new();
+
+ ///
+ /// Components added to the player's mind.
+ ///
+ [DataField]
+ public ComponentRegistry MindComponents = new();
+
+ ///
+ /// A set of starting gear that's equipped to the player.
+ ///
+ [DataField]
+ public ProtoId? StartingGear;
+
+ ///
+ /// A briefing shown to the player.
+ ///
+ [DataField]
+ public BriefingData? Briefing;
+
+ ///
+ /// A spawner used to defer the selection of this particular definition.
+ ///
+ ///
+ /// Not the cleanest way of doing this code but it's just an odd specific behavior.
+ /// Sue me.
+ ///
+ [DataField]
+ public EntProtoId? SpawnerPrototype;
+}
+
+///
+/// Contains data used to generate a briefing.
+///
+[DataDefinition]
+public partial struct BriefingData
+{
+ ///
+ /// The text shown
+ ///
+ [DataField]
+ public LocId? Text;
+
+ ///
+ /// The color of the text.
+ ///
+ [DataField]
+ public Color? Color;
+
+ ///
+ /// The sound played.
+ ///
+ [DataField]
+ public SoundSpecifier? Sound;
+}
diff --git a/Content.Server/Antag/Components/GhostRoleAntagSpawnerComponent.cs b/Content.Server/Antag/Components/GhostRoleAntagSpawnerComponent.cs
new file mode 100644
index 00000000000..fcaa4d42672
--- /dev/null
+++ b/Content.Server/Antag/Components/GhostRoleAntagSpawnerComponent.cs
@@ -0,0 +1,14 @@
+namespace Content.Server.Antag.Components;
+
+///
+/// Ghost role spawner that creates an antag for the associated gamerule.
+///
+[RegisterComponent, Access(typeof(AntagSelectionSystem))]
+public sealed partial class GhostRoleAntagSpawnerComponent : Component
+{
+ [DataField]
+ public EntityUid? Rule;
+
+ [DataField]
+ public AntagSelectionDefinition? Definition;
+}
diff --git a/Content.Server/Antag/MobReplacementRuleSystem.cs b/Content.Server/Antag/MobReplacementRuleSystem.cs
index ba09c84bce4..18837b5a7c8 100644
--- a/Content.Server/Antag/MobReplacementRuleSystem.cs
+++ b/Content.Server/Antag/MobReplacementRuleSystem.cs
@@ -1,45 +1,16 @@
-using System.Numerics;
-using Content.Server.Advertise.Components;
-using Content.Server.Advertise.EntitySystems;
using Content.Server.Antag.Mimic;
-using Content.Server.Chat.Systems;
+using Content.Server.GameTicking.Components;
using Content.Server.GameTicking.Rules;
using Content.Server.GameTicking.Rules.Components;
-using Content.Server.NPC.Systems;
-using Content.Server.Station.Systems;
-using Content.Server.GameTicking;
using Content.Shared.VendingMachines;
using Robust.Shared.Map;
-using Robust.Shared.Prototypes;
using Robust.Shared.Random;
-using Robust.Server.GameObjects;
-using Robust.Shared.Physics.Systems;
-using System.Linq;
-using Robust.Shared.Physics;
-using Content.Shared.Movement.Components;
-using Content.Shared.Damage;
-using Content.Server.NPC.HTN;
-using Content.Server.NPC;
-using Content.Shared.Weapons.Melee;
-using Content.Server.Power.Components;
-using Content.Shared.CombatMode;
namespace Content.Server.Antag;
public sealed class MobReplacementRuleSystem : GameRuleSystem
{
[Dependency] private readonly IRobustRandom _random = default!;
- [Dependency] private readonly StationSystem _station = default!;
- [Dependency] private readonly GameTicker _gameTicker = default!;
- [Dependency] private readonly ChatSystem _chat = default!;
- [Dependency] private readonly IPrototypeManager _prototype = default!;
- [Dependency] private readonly IComponentFactory _componentFactory = default!;
- [Dependency] private readonly SharedPhysicsSystem _physics = default!;
- [Dependency] private readonly NpcFactionSystem _npcFaction = default!;
- [Dependency] private readonly NPCSystem _npc = default!;
- [Dependency] private readonly SharedTransformSystem _transform = default!;
- [Dependency] private readonly AdvertiseSystem _advertise = default!;
-
protected override void Started(EntityUid uid, MobReplacementRuleComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args)
{
@@ -47,133 +18,21 @@ protected override void Started(EntityUid uid, MobReplacementRuleComponent compo
var query = AllEntityQuery();
var spawns = new List<(EntityUid Entity, EntityCoordinates Coordinates)>();
- var stations = _gameTicker.GetSpawnableStations();
while (query.MoveNext(out var vendingUid, out _, out var xform))
{
- var ownerStation = _station.GetOwningStation(vendingUid);
-
- if (ownerStation == null
- || ownerStation != stations[0])
- continue;
-
- // Make sure that we aren't running this on something that is already a mimic
- if (HasComp(vendingUid))
+ if (!_random.Prob(component.Chance))
continue;
spawns.Add((vendingUid, xform.Coordinates));
}
- if (spawns == null)
+ foreach (var entity in spawns)
{
- //WTF THE STATION DOESN'T EXIST! WE MUST BE IN A TEST! QUICK, PUT A MIMIC AT 0,0!!!
- Spawn(component.Proto, new EntityCoordinates(uid, new Vector2(0, 0)));
- }
- else
- {
- // This is intentionally not clamped. If a server host wants to replace every vending machine in the entire station with a mimic, who am I to stop them?
- var k = MathF.MaxMagnitude(component.NumberToReplace, 1);
- while (k > 0 && spawns != null && spawns.Count > 0)
- {
- if (k > 1)
- {
- var spawnLocation = _random.PickAndTake(spawns);
- BuildAMimicWorkshop(spawnLocation.Entity, component);
- }
- else
- {
- BuildAMimicWorkshop(spawns[0].Entity, component);
- }
-
- if (k == MathF.MaxMagnitude(component.NumberToReplace, 1)
- && component.DoAnnouncement)
- _chat.DispatchStationAnnouncement(stations[0], Loc.GetString("station-event-rampant-intelligence-announcement"), playDefaultSound: true,
- colorOverride: Color.Red, sender: "Central Command");
-
- k--;
- }
- }
- }
-
- ///
- /// It's like Build a Bear, but MURDER
- ///
- ///
- public void BuildAMimicWorkshop(EntityUid uid, MobReplacementRuleComponent component)
- {
- var metaData = MetaData(uid);
- var vendorPrototype = metaData.EntityPrototype;
- var mimicProto = _prototype.Index(component.Proto);
-
- var vendorComponents = vendorPrototype?.Components.Keys
- .Where(n => n != "Transform" && n != "MetaData")
- .Select(name => (name, _componentFactory.GetRegistration(name).Type))
- .ToList() ?? new List<(string name, Type type)>();
-
- var mimicComponents = mimicProto?.Components.Keys
- .Where(n => n != "Transform" && n != "MetaData")
- .Select(name => (name, _componentFactory.GetRegistration(name).Type))
- .ToList() ?? new List<(string name, Type type)>();
+ var coordinates = entity.Coordinates;
+ Del(entity.Entity);
- foreach (var name in mimicComponents.Except(vendorComponents))
- {
- var newComponent = _componentFactory.GetComponent(name.name);
- EntityManager.AddComponent(uid, newComponent);
+ Spawn(component.Proto, coordinates);
}
-
- var xform = Transform(uid);
- if (xform.Anchored)
- _transform.Unanchor(uid, xform);
-
- SetupMimicNPC(uid, component);
-
- if (TryComp(uid, out var vendor)
- && component.VendorModify)
- SetupMimicVendor(uid, component, vendor);
- }
- ///
- /// This handles getting the entity ready to be a hostile NPC
- ///
- ///
- ///
- private void SetupMimicNPC(EntityUid uid, MobReplacementRuleComponent component)
- {
- _physics.SetBodyType(uid, BodyType.KinematicController);
- _npcFaction.AddFaction(uid, "SimpleHostile");
-
- var melee = EnsureComp(uid);
- melee.Angle = 0;
- DamageSpecifier dspec = new()
- {
- DamageDict = new()
- {
- { "Blunt", component.MimicMeleeDamage }
- }
- };
- melee.Damage = dspec;
-
- var movementSpeed = EnsureComp(uid);
- (movementSpeed.BaseSprintSpeed, movementSpeed.BaseWalkSpeed) = (component.MimicMoveSpeed, component.MimicMoveSpeed);
-
- var htn = EnsureComp