diff --git a/.github/workflows/discord-changelog.yml b/.github/workflows/discord-changelog.yml
index 681e4ab006..74be415432 100644
--- a/.github/workflows/discord-changelog.yml
+++ b/.github/workflows/discord-changelog.yml
@@ -20,4 +20,5 @@ jobs:
env:
CHANGELOG_DIR: ${{ vars.CHANGELOG_DIR }}
CHANGELOG_WEBHOOK: ${{ secrets.CHANGELOG_WEBHOOK }}
+ GITHUB_TOKEN: ${{ secrets.BOT_TOKEN }}
continue-on-error: true
diff --git a/Content.Benchmarks/SpawnEquipDeleteBenchmark.cs b/Content.Benchmarks/SpawnEquipDeleteBenchmark.cs
index de51b2fb19..8512107b69 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 0000000000..96f136abf0
--- /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 0000000000..b0d0365ef6
--- /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 0000000000..08cae979b9
--- /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 0000000000..8824a776ee
--- /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 0000000000..a55321833c
--- /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 fea18e5cf3..d4614210d9 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 f1dae68077..0000000000
--- 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 892ddfc15b..0000000000
--- 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 0000000000..3f378f60ef
--- /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 0000000000..bd1a6767bd
--- /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 0000000000..6dde6cf563
--- /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 0000000000..6a3072ce08
--- /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 0000000000..b4f25a02e4
--- /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 0000000000..8aa2b10356
--- /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 0000000000..355d3a020f
--- /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 0000000000..da671adaa7
--- /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 0000000000..0e1034e394
--- /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 0000000000..74e3a2ec30
--- /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 0000000000..b0fee70975
--- /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 8087d1833e..867dcbc269 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 503a9ac953..0e56153752 100644
--- a/Content.Client/Input/ContentContexts.cs
+++ b/Content.Client/Input/ContentContexts.cs
@@ -82,6 +82,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/Language/LanguageMenuWindow.xaml.cs b/Content.Client/Language/LanguageMenuWindow.xaml.cs
index 11d1c290d1..ed6ec6b3e2 100644
--- a/Content.Client/Language/LanguageMenuWindow.xaml.cs
+++ b/Content.Client/Language/LanguageMenuWindow.xaml.cs
@@ -1,4 +1,5 @@
using Content.Client.Language.Systems;
+using Content.Shared.Language.Events;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
@@ -7,7 +8,7 @@
namespace Content.Client.Language;
[GenerateTypedNameReferences]
-public sealed partial class LanguageMenuWindow : DefaultWindow
+public sealed partial class LanguageMenuWindow : DefaultWindow, IEntityEventSubscriber
{
private readonly LanguageSystem _clientLanguageSystem;
private readonly List _entries = new();
@@ -17,6 +18,14 @@ public LanguageMenuWindow()
{
RobustXamlLoader.Load(this);
_clientLanguageSystem = IoCManager.Resolve().GetEntitySystem();
+
+ _clientLanguageSystem.OnLanguagesChanged += OnUpdateState;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ _clientLanguageSystem.OnLanguagesChanged -= OnUpdateState;
}
protected override void Opened()
@@ -28,6 +37,11 @@ protected override void Opened()
}
+ private void OnUpdateState(object? sender, LanguagesUpdatedMessage args)
+ {
+ UpdateState(args.CurrentLanguage, args.Spoken);
+ }
+
public void UpdateState(string currentLanguage, List spokenLanguages)
{
var langName = Loc.GetString($"language-{currentLanguage}-name");
diff --git a/Content.Client/Language/Systems/LanguageSystem.cs b/Content.Client/Language/Systems/LanguageSystem.cs
index 5dc2fc1f4e..cb6bb60512 100644
--- a/Content.Client/Language/Systems/LanguageSystem.cs
+++ b/Content.Client/Language/Systems/LanguageSystem.cs
@@ -29,6 +29,8 @@ public sealed class LanguageSystem : SharedLanguageSystem
///
public List UnderstoodLanguages { get; private set; } = new();
+ public event EventHandler? OnLanguagesChanged;
+
public override void Initialize()
{
base.Initialize();
@@ -39,9 +41,13 @@ public override void Initialize()
private void OnLanguagesUpdated(LanguagesUpdatedMessage message)
{
+ // TODO this entire thing is horrible. If someone is willing to refactor this, LanguageSpeakerComponent should become shared with SendOnlyToOwner = true
+ // That way, this system will be able to use the existing networking infrastructure instead of relying on this makeshift... whatever this is.
CurrentLanguage = message.CurrentLanguage;
SpokenLanguages = message.Spoken;
UnderstoodLanguages = message.Understood;
+
+ OnLanguagesChanged?.Invoke(this, message);
}
private void OnRunLevelChanged(object? sender, RunLevelChangedEventArgs args)
diff --git a/Content.Client/Nutrition/EntitySystems/FoodGuideDataSystem.cs b/Content.Client/Nutrition/EntitySystems/FoodGuideDataSystem.cs
new file mode 100644
index 0000000000..37c7a25e21
--- /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 69daaa2cea..ab3b88ca4e 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 bb2c1ce0ed..c3a8e66470 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 3d8ae50748..ab4ebd83fa 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 7728a64784..e8e6cdfa72 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 31042854d4..9d453e5518 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 51ec66ea44..3eb0397a69 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
new file mode 100644
index 0000000000..4d50d91e16
--- /dev/null
+++ b/Content.Client/ShortConstruction/UI/ShortConstructionMenuBUI.cs
@@ -0,0 +1,126 @@
+using System.Numerics;
+using Content.Client.Construction;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Construction.Prototypes;
+using Content.Shared.ShortConstruction;
+using JetBrains.Annotations;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.Input;
+using Robust.Client.Placement;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Shared.Enums;
+using Robust.Shared.Prototypes;
+
+// ReSharper disable InconsistentNaming
+
+namespace Content.Client.ShortConstruction.UI;
+
+[UsedImplicitly]
+public sealed class ShortConstructionMenuBUI : BoundUserInterface
+{
+ [Dependency] private readonly IClyde _displayManager = default!;
+ [Dependency] private readonly IInputManager _inputManager = default!;
+ [Dependency] private readonly EntityManager _entManager = default!;
+ [Dependency] private readonly IPrototypeManager _protoManager = default!;
+ [Dependency] private readonly IPlacementManager _placementManager = default!;
+
+ private readonly ConstructionSystem _construction;
+ private readonly SpriteSystem _spriteSystem;
+
+ private RadialMenu? _menu;
+
+ public ShortConstructionMenuBUI(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ _construction = _entManager.System();
+ _spriteSystem = _entManager.System();
+ }
+
+ protected override void Open()
+ {
+ _menu = FormMenu();
+ _menu.OnClose += Close;
+ _menu.OpenCenteredAt(_inputManager.MouseScreenPosition.Position / _displayManager.ScreenSize);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing)
+ _menu?.Dispose();
+ }
+
+ private RadialMenu FormMenu()
+ {
+ var menu = new RadialMenu
+ {
+ HorizontalExpand = true,
+ VerticalExpand = true,
+ BackButtonStyleClass = "RadialMenuBackButton",
+ CloseButtonStyleClass = "RadialMenuCloseButton"
+ };
+
+ if (!_entManager.TryGetComponent(Owner, out var crafting))
+ return menu;
+
+ var mainContainer = new RadialContainer
+ {
+ Radius = 36f / MathF.Sin(MathF.PI / 2f / crafting.Prototypes.Count)
+ };
+
+ foreach (var protoId in crafting.Prototypes)
+ {
+ if (!_protoManager.TryIndex(protoId, out var proto))
+ continue;
+
+ var button = new RadialMenuTextureButton
+ {
+ ToolTip = Loc.GetString(proto.Name),
+ StyleClasses = { "RadialMenuButton" },
+ SetSize = new Vector2(48f, 48f)
+ };
+
+ var texture = new TextureRect
+ {
+ VerticalAlignment = Control.VAlignment.Center,
+ HorizontalAlignment = Control.HAlignment.Center,
+ Texture = _spriteSystem.Frame0(proto.Icon),
+ TextureScale = new Vector2(1.5f, 1.5f)
+ };
+
+ button.AddChild(texture);
+
+ button.OnButtonUp += _ =>
+ {
+ ConstructItem(proto);
+ };
+
+ mainContainer.AddChild(button);
+ }
+
+ menu.AddChild(mainContainer);
+ return menu;
+ }
+
+ ///
+ /// Makes an item or places a schematic based on the type of construction recipe.
+ ///
+ private void ConstructItem(ConstructionPrototype prototype)
+ {
+ if (prototype.Type == ConstructionType.Item)
+ {
+ _construction.TryStartItemConstruction(prototype.ID);
+ return;
+ }
+
+ _placementManager.BeginPlacing(new PlacementInformation
+ {
+ IsTile = false,
+ PlacementOption = prototype.PlacementMode
+ }, new ConstructionPlacementHijack(_construction, prototype));
+
+ // Should only close the menu if we're placing a construction hijack.
+ _menu!.Close();
+ }
+}
diff --git a/Content.Client/Standing/LayingDownSystem.cs b/Content.Client/Standing/LayingDownSystem.cs
new file mode 100644
index 0000000000..3a1f438df0
--- /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/Store/Ui/StoreMenu.xaml.cs b/Content.Client/Store/Ui/StoreMenu.xaml.cs
index b7a2c285fe..7eb597f2f3 100644
--- a/Content.Client/Store/Ui/StoreMenu.xaml.cs
+++ b/Content.Client/Store/Ui/StoreMenu.xaml.cs
@@ -3,6 +3,7 @@
using Content.Client.Message;
using Content.Shared.FixedPoint;
using Content.Shared.Store;
+using Content.Client.Stylesheets;
using Robust.Client.AutoGenerated;
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
@@ -147,6 +148,10 @@ private void AddListingGui(ListingData listing)
}
var newListing = new StoreListingControl(listing, GetListingPriceString(listing), hasBalance, texture);
+
+ if (listing.DiscountValue > 0)
+ newListing.StoreItemBuyButton.AddStyleClass(StyleNano.ButtonColorDangerDefault.ToString());
+
newListing.StoreItemBuyButton.OnButtonDown += args
=> OnListingButtonPressed?.Invoke(args, listing);
diff --git a/Content.Client/Telescope/TelescopeSystem.cs b/Content.Client/Telescope/TelescopeSystem.cs
new file mode 100644
index 0000000000..ac2270aa97
--- /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.Client/UserInterface/Systems/Language/LanguageMenuUIController.cs b/Content.Client/UserInterface/Systems/Language/LanguageMenuUIController.cs
index f36521ce81..e351a16bfb 100644
--- a/Content.Client/UserInterface/Systems/Language/LanguageMenuUIController.cs
+++ b/Content.Client/UserInterface/Systems/Language/LanguageMenuUIController.cs
@@ -2,7 +2,6 @@
using Content.Client.Gameplay;
using Content.Client.UserInterface.Controls;
using Content.Shared.Input;
-using Content.Shared.Language.Events;
using Robust.Client.UserInterface.Controllers;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Input.Binding;
@@ -18,12 +17,6 @@ public sealed class LanguageMenuUIController : UIController, IOnStateEntered UIManager.GetActiveUIWidgetOrNull()?.LanguageButton;
- public override void Initialize()
- {
- SubscribeNetworkEvent((LanguagesUpdatedMessage message, EntitySessionEventArgs _) =>
- LanguageWindow?.UpdateState(message.CurrentLanguage, message.Spoken));
- }
-
public void OnStateEntered(GameplayState state)
{
DebugTools.Assert(LanguageWindow == null);
@@ -31,6 +24,17 @@ public void OnStateEntered(GameplayState state)
LanguageWindow = UIManager.CreateWindow();
LayoutContainer.SetAnchorPreset(LanguageWindow, LayoutContainer.LayoutPreset.CenterTop);
+ LanguageWindow.OnClose += () =>
+ {
+ if (LanguageButton != null)
+ LanguageButton.Pressed = false;
+ };
+ LanguageWindow.OnOpen += () =>
+ {
+ if (LanguageButton != null)
+ LanguageButton.Pressed = true;
+ };
+
CommandBinds.Builder.Bind(ContentKeyFunctions.OpenLanguageMenu,
InputCmdHandler.FromDelegate(_ => ToggleWindow())).Register();
}
@@ -60,12 +64,6 @@ public void LoadButton()
return;
LanguageButton.OnPressed += LanguageButtonPressed;
-
- if (LanguageWindow == null)
- return;
-
- LanguageWindow.OnClose += () => LanguageButton.Pressed = false;
- LanguageWindow.OnOpen += () => LanguageButton.Pressed = true;
}
private void LanguageButtonPressed(ButtonEventArgs args)
diff --git a/Content.IntegrationTests/Pair/TestPair.Helpers.cs b/Content.IntegrationTests/Pair/TestPair.Helpers.cs
index 0ea6d3e2dc..f46b83165f 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 b544fe2854..3f489de649 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/Construction/Interaction/MachineConstruction.cs b/Content.IntegrationTests/Tests/Construction/Interaction/MachineConstruction.cs
index f52f820a4c..cd95a85f20 100644
--- a/Content.IntegrationTests/Tests/Construction/Interaction/MachineConstruction.cs
+++ b/Content.IntegrationTests/Tests/Construction/Interaction/MachineConstruction.cs
@@ -57,4 +57,3 @@ public async Task ChangeMachine()
AssertPrototype("Autolathe");
}
}
-
diff --git a/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs b/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs
new file mode 100644
index 0000000000..662ea3b974
--- /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 0000000000..62fa93c999
--- /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 1e3f9c9854..ffaff3b8de 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 0f665a63de..5d7ae8efbf 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 317aa10400..4415eddf37 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/Interaction/InteractionTest.Constants.cs b/Content.IntegrationTests/Tests/Interaction/InteractionTest.Constants.cs
index 11381fb8cc..a915e5d47d 100644
--- a/Content.IntegrationTests/Tests/Interaction/InteractionTest.Constants.cs
+++ b/Content.IntegrationTests/Tests/Interaction/InteractionTest.Constants.cs
@@ -1,3 +1,4 @@
+
namespace Content.IntegrationTests.Tests.Interaction;
// This partial class contains various constant prototype IDs common to interaction tests.
@@ -27,8 +28,6 @@ public abstract partial class InteractionTest
// Parts
protected const string Bin1 = "MatterBinStockPart";
- protected const string Cap1 = "CapacitorStockPart";
protected const string Manipulator1 = "MicroManipulatorStockPart";
- protected const string Battery1 = "PowerCellSmall";
- protected const string Battery4 = "PowerCellHyper";
}
+
diff --git a/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs b/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs
index d5c2a9124d..40457f5488 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 61dcc3331d..28da7a9465 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/Abilities/HealOtherPowerSystem.cs b/Content.Server/Abilities/Psionics/Abilities/HealOtherPowerSystem.cs
new file mode 100644
index 0000000000..85bae78dc6
--- /dev/null
+++ b/Content.Server/Abilities/Psionics/Abilities/HealOtherPowerSystem.cs
@@ -0,0 +1,152 @@
+using Robust.Shared.Player;
+using Content.Server.DoAfter;
+using Content.Shared.Abilities.Psionics;
+using Content.Shared.Damage;
+using Content.Shared.DoAfter;
+using Content.Shared.Popups;
+using Content.Shared.Psionics.Events;
+using Content.Shared.Examine;
+using static Content.Shared.Examine.ExamineSystemShared;
+using Robust.Shared.Timing;
+using Content.Shared.Actions.Events;
+using Robust.Server.Audio;
+using Content.Server.Atmos.Rotting;
+using Content.Shared.Mobs.Systems;
+using Content.Shared.Mobs;
+using Content.Shared.Mobs.Components;
+using Content.Shared.Psionics.Glimmer;
+
+namespace Content.Server.Abilities.Psionics;
+
+public sealed class RevivifyPowerSystem : EntitySystem
+{
+ [Dependency] private readonly AudioSystem _audioSystem = default!;
+ [Dependency] private readonly DoAfterSystem _doAfterSystem = default!;
+ [Dependency] private readonly SharedPopupSystem _popupSystem = default!;
+ [Dependency] private readonly SharedPsionicAbilitiesSystem _psionics = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly ExamineSystemShared _examine = default!;
+ [Dependency] private readonly DamageableSystem _damageable = default!;
+ [Dependency] private readonly RottingSystem _rotting = default!;
+ [Dependency] private readonly MobThresholdSystem _mobThreshold = default!;
+ [Dependency] private readonly MobStateSystem _mobState = default!;
+ [Dependency] private readonly GlimmerSystem _glimmer = default!;
+
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnPowerUsed);
+ SubscribeLocalEvent(OnDispelled);
+ SubscribeLocalEvent(OnDoAfter);
+ }
+
+
+ private void OnPowerUsed(EntityUid uid, PsionicComponent component, PsionicHealOtherPowerActionEvent args)
+ {
+ if (component.DoAfter is not null)
+ return;
+
+ if (!args.Immediate)
+ AttemptDoAfter(uid, component, args);
+ else ActivatePower(uid, component, args);
+
+ if (args.PopupText is not null
+ && _glimmer.Glimmer > args.GlimmerObviousPopupThreshold * component.CurrentDampening)
+ _popupSystem.PopupEntity(Loc.GetString(args.PopupText, ("entity", uid)), uid,
+ Filter.Pvs(uid).RemoveWhereAttachedEntity(entity => !_examine.InRangeUnOccluded(uid, entity, ExamineRange, null)),
+ true,
+ args.PopupType);
+
+ if (args.PlaySound
+ && _glimmer.Glimmer > args.GlimmerObviousSoundThreshold * component.CurrentDampening)
+ _audioSystem.PlayPvs(args.SoundUse, uid, args.AudioParams);
+
+ // Sanitize the Glimmer inputs because otherwise the game will crash if someone makes MaxGlimmer lower than MinGlimmer.
+ var minGlimmer = (int) Math.Round(MathF.MinMagnitude(args.MinGlimmer, args.MaxGlimmer)
+ + component.CurrentAmplification - component.CurrentDampening);
+ var maxGlimmer = (int) Math.Round(MathF.MaxMagnitude(args.MinGlimmer, args.MaxGlimmer)
+ + component.CurrentAmplification - component.CurrentDampening);
+
+ _psionics.LogPowerUsed(uid, args.PowerName, minGlimmer, maxGlimmer);
+ args.Handled = true;
+ }
+
+ private void AttemptDoAfter(EntityUid uid, PsionicComponent component, PsionicHealOtherPowerActionEvent args)
+ {
+ var ev = new PsionicHealOtherDoAfterEvent(_gameTiming.CurTime);
+ ev.HealingAmount = args.HealingAmount;
+ ev.RotReduction = args.RotReduction;
+ ev.DoRevive = args.DoRevive;
+ var doAfterArgs = new DoAfterArgs(EntityManager, uid, args.UseDelay, ev, uid, target: args.Target)
+ {
+ BreakOnUserMove = args.BreakOnUserMove,
+ BreakOnTargetMove = args.BreakOnTargetMove,
+ };
+
+ if (!_doAfterSystem.TryStartDoAfter(doAfterArgs, out var doAfterId))
+ return;
+
+ component.DoAfter = doAfterId;
+ }
+
+ private void OnDispelled(EntityUid uid, PsionicComponent component, DispelledEvent args)
+ {
+ if (component.DoAfter is null)
+ return;
+
+ _doAfterSystem.Cancel(component.DoAfter);
+ component.DoAfter = null;
+ args.Handled = true;
+ }
+
+ private void OnDoAfter(EntityUid uid, PsionicComponent component, PsionicHealOtherDoAfterEvent args)
+ {
+ // It's entirely possible for the caster to stop being Psionic(due to mindbreaking) mid cast
+ if (component is null)
+ return;
+ component.DoAfter = null;
+
+ // The target can also cease existing mid-cast
+ if (args.Target is null)
+ return;
+
+ _rotting.ReduceAccumulator(args.Target.Value, TimeSpan.FromSeconds(args.RotReduction * component.CurrentAmplification));
+
+ if (!TryComp(args.Target.Value, out var damageableComponent))
+ return;
+
+ _damageable.TryChangeDamage(args.Target.Value, args.HealingAmount * component.CurrentAmplification, true, false, damageableComponent, uid);
+
+ if (!args.DoRevive
+ || !TryComp(args.Target, out var mob)
+ || !_mobThreshold.TryGetThresholdForState(args.Target.Value, MobState.Dead, out var threshold)
+ || damageableComponent.TotalDamage > threshold)
+ return;
+
+ _mobState.ChangeMobState(args.Target.Value, MobState.Critical, mob, uid);
+ }
+
+ // This would be the same thing as OnDoAfter, except that here the target isn't nullable, so I have to reuse code with different arguments.
+ private void ActivatePower(EntityUid uid, PsionicComponent component, PsionicHealOtherPowerActionEvent args)
+ {
+ if (component is null)
+ return;
+
+ _rotting.ReduceAccumulator(args.Target, TimeSpan.FromSeconds(args.RotReduction * component.CurrentAmplification));
+
+ if (!TryComp(args.Target, out var damageableComponent))
+ return;
+
+ _damageable.TryChangeDamage(args.Target, args.HealingAmount * component.CurrentAmplification, true, false, damageableComponent, uid);
+
+ if (!args.DoRevive
+ || !TryComp(args.Target, out var mob)
+ || !_mobThreshold.TryGetThresholdForState(args.Target, MobState.Dead, out var threshold)
+ || damageableComponent.TotalDamage > threshold)
+ return;
+
+ _mobState.ChangeMobState(args.Target, MobState.Critical, mob, uid);
+ }
+}
diff --git a/Content.Server/Abilities/Psionics/PsionicAbilitiesSystem.cs b/Content.Server/Abilities/Psionics/PsionicAbilitiesSystem.cs
index 32e51d3c10..536235b6d6 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 2f979f4340..e19c5b72fa 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 6f10ef9b47..04fd38598f 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 eff97136d0..4103b8a8aa 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/Anomaly/AnomalySystem.Vessel.cs b/Content.Server/Anomaly/AnomalySystem.Vessel.cs
index 35de5c1a64..9a6c99f820 100644
--- a/Content.Server/Anomaly/AnomalySystem.Vessel.cs
+++ b/Content.Server/Anomaly/AnomalySystem.Vessel.cs
@@ -1,4 +1,5 @@
using Content.Server.Anomaly.Components;
+using Content.Server.Construction;
using Content.Server.Power.EntitySystems;
using Content.Shared.Anomaly;
using Content.Shared.Anomaly.Components;
@@ -20,6 +21,7 @@ private void InitializeVessel()
{
SubscribeLocalEvent(OnVesselShutdown);
SubscribeLocalEvent(OnVesselMapInit);
+ SubscribeLocalEvent(OnUpgradeExamine);
SubscribeLocalEvent(OnVesselInteractUsing);
SubscribeLocalEvent(OnExamined);
SubscribeLocalEvent(OnVesselGetPointsPerSecond);
@@ -65,6 +67,11 @@ private void OnVesselMapInit(EntityUid uid, AnomalyVesselComponent component, Ma
UpdateVesselAppearance(uid, component);
}
+ private void OnUpgradeExamine(EntityUid uid, AnomalyVesselComponent component, UpgradeExamineEvent args)
+ {
+ args.AddPercentageUpgrade("anomaly-vessel-component-upgrade-output", component.PointMultiplier);
+ }
+
private void OnVesselInteractUsing(EntityUid uid, AnomalyVesselComponent component, InteractUsingEvent args)
{
if (component.Anomaly != null ||
diff --git a/Content.Server/Antag/AntagSelectionPlayerPool.cs b/Content.Server/Antag/AntagSelectionPlayerPool.cs
new file mode 100644
index 0000000000..87873e96d1
--- /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 0000000000..59bf05fe03
--- /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 b11c562df5..d74824dd2d 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
///