diff --git a/Content.Benchmarks/DynamicTreeBenchmark.cs b/Content.Benchmarks/DynamicTreeBenchmark.cs
index 4f3dad4073..9086b4d3ac 100644
--- a/Content.Benchmarks/DynamicTreeBenchmark.cs
+++ b/Content.Benchmarks/DynamicTreeBenchmark.cs
@@ -44,7 +44,7 @@ namespace Content.Benchmarks
for (var i = 0; i < Aabbs1.Length; i++)
{
var aabb = Aabbs1[i];
- _b2Tree.CreateProxy(aabb, i);
+ _b2Tree.CreateProxy(aabb, uint.MaxValue, i);
_tree.Add(i);
}
}
diff --git a/Content.Client/Administration/AdminNameOverlay.cs b/Content.Client/Administration/AdminNameOverlay.cs
index 86c9b595f6..3c968ec17c 100644
--- a/Content.Client/Administration/AdminNameOverlay.cs
+++ b/Content.Client/Administration/AdminNameOverlay.cs
@@ -50,6 +50,8 @@ internal sealed class AdminNameOverlay : Overlay
//TODO make this adjustable via GUI
var classic = _config.GetCVar(CCVars.AdminOverlayClassic);
+ var playTime = _config.GetCVar(CCVars.AdminOverlayPlaytime);
+ var startingJob = _config.GetCVar(CCVars.AdminOverlayStartingJob);
foreach (var playerInfo in _system.PlayerList)
{
@@ -76,25 +78,44 @@ internal sealed class AdminNameOverlay : Overlay
}
var uiScale = _userInterfaceManager.RootControl.UIScale;
- var lineoffset = new Vector2(0f, 11f) * uiScale;
+ var lineoffset = new Vector2(0f, 14f) * uiScale;
var screenCoordinates = _eyeManager.WorldToScreen(aabb.Center +
new Angle(-_eyeManager.CurrentEye.Rotation).RotateVec(
aabb.TopRight - aabb.Center)) + new Vector2(1f, 7f);
+ var currentOffset = Vector2.Zero;
+
+ args.ScreenHandle.DrawString(_font, screenCoordinates + currentOffset, playerInfo.CharacterName, uiScale, playerInfo.Connected ? Color.Aquamarine : Color.White);
+ currentOffset += lineoffset;
+
+ args.ScreenHandle.DrawString(_font, screenCoordinates + currentOffset, playerInfo.Username, uiScale, playerInfo.Connected ? Color.Yellow : Color.White);
+ currentOffset += lineoffset;
+
+ if (!string.IsNullOrEmpty(playerInfo.PlaytimeString) && playTime)
+ {
+ args.ScreenHandle.DrawString(_font, screenCoordinates + currentOffset, playerInfo.PlaytimeString, uiScale, playerInfo.Connected ? Color.Orange : Color.White);
+ currentOffset += lineoffset;
+ }
+
+ if (!string.IsNullOrEmpty(playerInfo.StartingJob) && startingJob)
+ {
+ args.ScreenHandle.DrawString(_font, screenCoordinates + currentOffset, Loc.GetString(playerInfo.StartingJob), uiScale, playerInfo.Connected ? Color.GreenYellow : Color.White);
+ currentOffset += lineoffset;
+ }
+
if (classic && playerInfo.Antag)
{
- args.ScreenHandle.DrawString(_font, screenCoordinates + (lineoffset * 2), _antagLabelClassic, uiScale, _antagColorClassic);
+ args.ScreenHandle.DrawString(_font, screenCoordinates + currentOffset, _antagLabelClassic, uiScale, Color.OrangeRed);
+ currentOffset += lineoffset;
}
else if (!classic && _filter.Contains(playerInfo.RoleProto))
{
- var label = Loc.GetString(playerInfo.RoleProto.Name).ToUpper();
- var color = playerInfo.RoleProto.Color;
+ var label = Loc.GetString(playerInfo.RoleProto.Name).ToUpper();
+ var color = playerInfo.RoleProto.Color;
- args.ScreenHandle.DrawString(_font, screenCoordinates + (lineoffset * 2), label, uiScale, color);
+ args.ScreenHandle.DrawString(_font, screenCoordinates + currentOffset, label, uiScale, color);
+ currentOffset += lineoffset;
}
-
- args.ScreenHandle.DrawString(_font, screenCoordinates + lineoffset, playerInfo.Username, uiScale, playerInfo.Connected ? Color.Yellow : Color.White);
- args.ScreenHandle.DrawString(_font, screenCoordinates, playerInfo.CharacterName, uiScale, playerInfo.Connected ? Color.Aquamarine : Color.White);
}
}
}
diff --git a/Content.Client/Administration/UI/Bwoink/BwoinkControl.xaml b/Content.Client/Administration/UI/Bwoink/BwoinkControl.xaml
index 42d6f4b354..c3600d08f8 100644
--- a/Content.Client/Administration/UI/Bwoink/BwoinkControl.xaml
+++ b/Content.Client/Administration/UI/Bwoink/BwoinkControl.xaml
@@ -2,24 +2,26 @@
xmlns="https://spacestation14.io"
xmlns:cc="clr-namespace:Content.Client.Administration.UI.CustomControls">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Administration/UI/Bwoink/BwoinkControl.xaml.cs b/Content.Client/Administration/UI/Bwoink/BwoinkControl.xaml.cs
index 3e05018c10..973f1a090b 100644
--- a/Content.Client/Administration/UI/Bwoink/BwoinkControl.xaml.cs
+++ b/Content.Client/Administration/UI/Bwoink/BwoinkControl.xaml.cs
@@ -36,6 +36,9 @@ namespace Content.Client.Administration.UI.Bwoink
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
+ var newPlayerThreshold = 0;
+ _cfg.OnValueChanged(CCVars.NewPlayerThreshold, (val) => { newPlayerThreshold = val; }, true);
+
var uiController = _ui.GetUIController();
if (uiController.UIHelper is not AdminAHelpUIHandler helper)
return;
@@ -59,9 +62,9 @@ namespace Content.Client.Administration.UI.Bwoink
var sb = new StringBuilder();
if (info.Connected)
- sb.Append('●');
+ sb.Append(info.ActiveThisRound ? '⚫' : '◐');
else
- sb.Append(info.ActiveThisRound ? '○' : '·');
+ sb.Append(info.ActiveThisRound ? '⭘' : '·');
sb.Append(' ');
if (AHelpHelper.TryGetChannel(info.SessionId, out var panel) && panel.Unread > 0)
@@ -73,10 +76,12 @@ namespace Content.Client.Administration.UI.Bwoink
sb.Append(' ');
}
+ // Mark antagonists with symbol
if (info.Antag && info.ActiveThisRound)
sb.Append(new Rune(0x1F5E1)); // 🗡
- if (info.OverallPlaytime <= TimeSpan.FromMinutes(_cfg.GetCVar(CCVars.NewPlayerThreshold)))
+ // Mark new players with symbol
+ if (IsNewPlayer(info))
sb.Append(new Rune(0x23F2)); // ⏲
sb.AppendFormat("\"{0}\"", text);
@@ -84,6 +89,19 @@ namespace Content.Client.Administration.UI.Bwoink
return sb.ToString();
};
+ //
+ // Returns true if the player's overall playtime is under the set threshold
+ //
+ bool IsNewPlayer(PlayerInfo info)
+ {
+ // Don't show every disconnected player as new, don't show 0-minute players as new if threshold is
+ if (newPlayerThreshold <= 0 || info.OverallPlaytime is null && !info.Connected)
+ return false;
+
+ return (info.OverallPlaytime is null
+ || info.OverallPlaytime < TimeSpan.FromMinutes(newPlayerThreshold));
+ }
+
ChannelSelector.Comparison = (a, b) =>
{
var ach = AHelpHelper.EnsurePanel(a.SessionId);
@@ -93,31 +111,37 @@ namespace Content.Client.Administration.UI.Bwoink
if (a.IsPinned != b.IsPinned)
return a.IsPinned ? -1 : 1;
- // First, sort by unread. Any chat with unread messages appears first.
+ // Then, any chat with unread messages.
var aUnread = ach.Unread > 0;
var bUnread = bch.Unread > 0;
if (aUnread != bUnread)
return aUnread ? -1 : 1;
- // Sort by recent messages during the current round.
+ // Then, any chat with recent messages from the current round
var aRecent = a.ActiveThisRound && ach.LastMessage != DateTime.MinValue;
var bRecent = b.ActiveThisRound && bch.LastMessage != DateTime.MinValue;
if (aRecent != bRecent)
return aRecent ? -1 : 1;
- // Next, sort by connection status. Any disconnected players are grouped towards the end.
+ // Sort by connection status. Disconnected players will be last.
if (a.Connected != b.Connected)
return a.Connected ? -1 : 1;
- // Sort connected players by New Player status, then by Antag status
+ // Sort connected players by whether they have joined the round, then by New Player status, then by Antag status
if (a.Connected && b.Connected)
{
- var aNewPlayer = a.OverallPlaytime <= TimeSpan.FromMinutes(_cfg.GetCVar(CCVars.NewPlayerThreshold));
- var bNewPlayer = b.OverallPlaytime <= TimeSpan.FromMinutes(_cfg.GetCVar(CCVars.NewPlayerThreshold));
+ var aNewPlayer = IsNewPlayer(a);
+ var bNewPlayer = IsNewPlayer(b);
+ // Players who have joined the round will be listed before players in the lobby
+ if (a.ActiveThisRound != b.ActiveThisRound)
+ return a.ActiveThisRound ? -1 : 1;
+
+ // Within both the joined group and lobby group, new players will be grouped and listed first
if (aNewPlayer != bNewPlayer)
return aNewPlayer ? -1 : 1;
+ // Within all four previous groups, antagonists will be listed first.
if (a.Antag != b.Antag)
return a.Antag ? -1 : 1;
}
diff --git a/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs b/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs
index e8653843c7..e6cd4942a6 100644
--- a/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs
+++ b/Content.Client/Administration/UI/Bwoink/BwoinkWindow.xaml.cs
@@ -22,12 +22,9 @@ namespace Content.Client.Administration.UI.Bwoink
return;
}
- Title = $"{sel.CharacterName} / {sel.Username}";
+ Title = $"{sel.CharacterName} / {sel.Username} | {Loc.GetString("generic-playtime-title")}: ";
- if (sel.OverallPlaytime != null)
- {
- Title += $" | {Loc.GetString("generic-playtime-title")}: {sel.PlaytimeString}";
- }
+ Title += sel.OverallPlaytime != null ? sel.PlaytimeString : Loc.GetString("generic-unknown-title");
};
OnOpen += () =>
diff --git a/Content.Client/Advertise/Systems/SpeakOnUIClosedSystem.cs b/Content.Client/Advertise/Systems/SpeakOnUIClosedSystem.cs
new file mode 100644
index 0000000000..4e82ec4d00
--- /dev/null
+++ b/Content.Client/Advertise/Systems/SpeakOnUIClosedSystem.cs
@@ -0,0 +1,5 @@
+using Content.Shared.Advertise.Systems;
+
+namespace Content.Client.Advertise.Systems;
+
+public sealed class SpeakOnUIClosedSystem : SharedSpeakOnUIClosedSystem;
diff --git a/Content.Client/Chemistry/Visualizers/SolutionContainerVisualsSystem.cs b/Content.Client/Chemistry/Visualizers/SolutionContainerVisualsSystem.cs
index 010bfb3184..0ba537e455 100644
--- a/Content.Client/Chemistry/Visualizers/SolutionContainerVisualsSystem.cs
+++ b/Content.Client/Chemistry/Visualizers/SolutionContainerVisualsSystem.cs
@@ -28,7 +28,6 @@ public sealed class SolutionContainerVisualsSystem : VisualizerSystem CommandName;
-
- public override string Help => LocalizationManager.GetString($"cmd-{Command}-help", ("command", Command));
-
- public override void Execute(IConsoleShell shell, string argStr, string[] args)
- {
- _entitySystemManager.GetEntitySystem().ShowAll = true;
-
- var entMan = IoCManager.Resolve();
- var components = entMan.EntityQuery(true);
-
- foreach (var (_, sprite) in components)
- {
- sprite.DrawDepth = (int) DrawDepth.Overlays;
- }
- }
-}
-
internal sealed class NotifyCommand : LocalizedCommands
{
[Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
diff --git a/Content.Client/Commands/MappingClientSideSetupCommand.cs b/Content.Client/Commands/MappingClientSideSetupCommand.cs
index d17f1fccaf..99a8ba00fe 100644
--- a/Content.Client/Commands/MappingClientSideSetupCommand.cs
+++ b/Content.Client/Commands/MappingClientSideSetupCommand.cs
@@ -24,7 +24,7 @@ internal sealed class MappingClientSideSetupCommand : LocalizedCommands
{
_entitySystemManager.GetEntitySystem().MarkersVisible = true;
_lightManager.Enabled = false;
- shell.ExecuteCommand("showsubfloorforever");
+ shell.ExecuteCommand("showsubfloor");
_entitySystemManager.GetEntitySystem().LoadActionAssignments("/mapping_actions.yml", false);
}
}
diff --git a/Content.Client/Delivery/DeliverySystem.cs b/Content.Client/Delivery/DeliverySystem.cs
new file mode 100644
index 0000000000..c83e09ea74
--- /dev/null
+++ b/Content.Client/Delivery/DeliverySystem.cs
@@ -0,0 +1,5 @@
+using Content.Shared.Delivery;
+
+namespace Content.Client.Delivery;
+
+public sealed class DeliverySystem : SharedDeliverySystem;
diff --git a/Content.Client/Delivery/DeliveryVisualizerSystem.cs b/Content.Client/Delivery/DeliveryVisualizerSystem.cs
new file mode 100644
index 0000000000..8ed15344a4
--- /dev/null
+++ b/Content.Client/Delivery/DeliveryVisualizerSystem.cs
@@ -0,0 +1,45 @@
+using Content.Shared.Delivery;
+using Content.Shared.StatusIcon;
+using Robust.Client.GameObjects;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Delivery;
+
+public sealed class DeliveryVisualizerSystem : VisualizerSystem
+{
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+ [Dependency] private readonly SpriteSystem _sprite = default!;
+
+ private static readonly ProtoId UnknownIcon = "JobIconUnknown";
+
+ protected override void OnAppearanceChange(EntityUid uid, DeliveryComponent component, ref AppearanceChangeEvent args)
+ {
+ if (args.Sprite == null)
+ return;
+
+ _appearance.TryGetData(uid, DeliveryVisuals.JobIcon, out string job, args.Component);
+
+ if (string.IsNullOrEmpty(job))
+ job = UnknownIcon;
+
+ if (!_prototype.TryIndex(job, out var icon))
+ {
+ args.Sprite.LayerSetTexture(DeliveryVisualLayers.JobStamp, _sprite.Frame0(_prototype.Index("JobIconUnknown")));
+ return;
+ }
+
+ args.Sprite.LayerSetTexture(DeliveryVisualLayers.JobStamp, _sprite.Frame0(icon.Icon));
+ }
+}
+
+public enum DeliveryVisualLayers : byte
+{
+ Icon,
+ Lock,
+ FragileStamp,
+ JobStamp,
+ PriorityTape,
+ Breakage,
+ Trash,
+}
diff --git a/Content.Client/Guidebook/Controls/GuideReagentReaction.xaml b/Content.Client/Guidebook/Controls/GuideReagentReaction.xaml
index 63e80de2ae..15430a5bf9 100644
--- a/Content.Client/Guidebook/Controls/GuideReagentReaction.xaml
+++ b/Content.Client/Guidebook/Controls/GuideReagentReaction.xaml
@@ -4,8 +4,7 @@
HorizontalAlignment="Stretch"
HorizontalExpand="True"
Margin="0 0 0 5">
-
+
(OnHandleState);
+ Subs.CVar(_configurationManager, CCVars.AccessibilityClientCensorNudity, OnCvarChanged, true);
+ Subs.CVar(_configurationManager, CCVars.AccessibilityServerCensorNudity, OnCvarChanged, true);
}
private void OnHandleState(EntityUid uid, HumanoidAppearanceComponent component, ref AfterAutoHandleStateEvent args)
@@ -25,6 +30,15 @@ public sealed class HumanoidAppearanceSystem : SharedHumanoidAppearanceSystem
UpdateSprite(component, Comp(uid));
}
+ private void OnCvarChanged(bool value)
+ {
+ var humanoidQuery = EntityManager.AllEntityQueryEnumerator();
+ while (humanoidQuery.MoveNext(out var _, out var humanoidComp, out var spriteComp))
+ {
+ UpdateSprite(humanoidComp, spriteComp);
+ }
+ }
+
private void UpdateSprite(HumanoidAppearanceComponent component, SpriteComponent sprite)
{
UpdateLayers(component, sprite);
@@ -218,16 +232,30 @@ public sealed class HumanoidAppearanceSystem : SharedHumanoidAppearanceSystem
// Really, markings should probably be a separate component altogether.
ClearAllMarkings(humanoid, sprite);
+ var censorNudity = _configurationManager.GetCVar(CCVars.AccessibilityClientCensorNudity) ||
+ _configurationManager.GetCVar(CCVars.AccessibilityServerCensorNudity);
+ // The reason we're splitting this up is in case the character already has undergarment equipped in that slot.
+ var applyUndergarmentTop = censorNudity;
+ var applyUndergarmentBottom = censorNudity;
+
foreach (var markingList in humanoid.MarkingSet.Markings.Values)
{
foreach (var marking in markingList)
{
if (_markingManager.TryGetMarking(marking, out var markingPrototype))
+ {
ApplyMarking(markingPrototype, marking.MarkingColors, marking.Visible, humanoid, sprite);
+ if (markingPrototype.BodyPart == HumanoidVisualLayers.UndergarmentTop)
+ applyUndergarmentTop = false;
+ else if (markingPrototype.BodyPart == HumanoidVisualLayers.UndergarmentBottom)
+ applyUndergarmentBottom = false;
+ }
}
}
humanoid.ClientOldMarkings = new MarkingSet(humanoid.MarkingSet);
+
+ AddUndergarments(humanoid, sprite, applyUndergarmentTop, applyUndergarmentBottom);
}
private void ClearAllMarkings(HumanoidAppearanceComponent humanoid, SpriteComponent sprite)
@@ -275,6 +303,31 @@ public sealed class HumanoidAppearanceSystem : SharedHumanoidAppearanceSystem
spriteComp.RemoveLayer(index);
}
}
+
+ private void AddUndergarments(HumanoidAppearanceComponent humanoid, SpriteComponent sprite, bool undergarmentTop, bool undergarmentBottom)
+ {
+ if (undergarmentTop && humanoid.UndergarmentTop != null)
+ {
+ var marking = new Marking(humanoid.UndergarmentTop, new List { new Color() });
+ if (_markingManager.TryGetMarking(marking, out var prototype))
+ {
+ // Markings are added to ClientOldMarkings because otherwise it causes issues when toggling the feature on/off.
+ humanoid.ClientOldMarkings.Markings.Add(MarkingCategories.UndergarmentTop, new List{ marking });
+ ApplyMarking(prototype, null, true, humanoid, sprite);
+ }
+ }
+
+ if (undergarmentBottom && humanoid.UndergarmentBottom != null)
+ {
+ var marking = new Marking(humanoid.UndergarmentBottom, new List { new Color() });
+ if (_markingManager.TryGetMarking(marking, out var prototype))
+ {
+ humanoid.ClientOldMarkings.Markings.Add(MarkingCategories.UndergarmentBottom, new List{ marking });
+ ApplyMarking(prototype, null, true, humanoid, sprite);
+ }
+ }
+ }
+
private void ApplyMarking(MarkingPrototype markingPrototype,
IReadOnlyList? colors,
bool visible,
diff --git a/Content.Client/IconSmoothing/IconSmoothComponent.cs b/Content.Client/IconSmoothing/IconSmoothComponent.cs
index 3a9fbc46ef..7898e9dc1e 100644
--- a/Content.Client/IconSmoothing/IconSmoothComponent.cs
+++ b/Content.Client/IconSmoothing/IconSmoothComponent.cs
@@ -30,7 +30,7 @@ namespace Content.Client.IconSmoothing
/// Additional keys to smooth with.
///
[DataField]
- public List AdditionalKeys { get; private set; } = new();
+ public List AdditionalKeys = new();
///
/// Prepended to the RSI state.
diff --git a/Content.Client/Lathe/UI/LatheMenu.xaml b/Content.Client/Lathe/UI/LatheMenu.xaml
index 5b21f0bae6..d5e3573148 100644
--- a/Content.Client/Lathe/UI/LatheMenu.xaml
+++ b/Content.Client/Lathe/UI/LatheMenu.xaml
@@ -59,6 +59,7 @@
PlaceHolder="0"
Text="1"
HorizontalExpand="True" />
+
diff --git a/Content.Client/Lathe/UI/LatheMenu.xaml.cs b/Content.Client/Lathe/UI/LatheMenu.xaml.cs
index 2b18ce28de..e796259ead 100644
--- a/Content.Client/Lathe/UI/LatheMenu.xaml.cs
+++ b/Content.Client/Lathe/UI/LatheMenu.xaml.cs
@@ -120,6 +120,8 @@ public sealed partial class LatheMenu : DefaultWindow
if (!int.TryParse(AmountLineEdit.Text, out var quantity) || quantity <= 0)
quantity = 1;
+ RecipeCount.Text = Loc.GetString("lathe-menu-recipe-count", ("count", recipesToShow.Count));
+
var sortedRecipesToShow = recipesToShow.OrderBy(_lathe.GetRecipeName);
RecipeList.Children.Clear();
_entityManager.TryGetComponent(Entity, out LatheComponent? lathe);
diff --git a/Content.Client/Light/AfterLightTargetOverlay.cs b/Content.Client/Light/AfterLightTargetOverlay.cs
index 06c508a54e..7856fd4ded 100644
--- a/Content.Client/Light/AfterLightTargetOverlay.cs
+++ b/Content.Client/Light/AfterLightTargetOverlay.cs
@@ -54,6 +54,6 @@ public sealed class AfterLightTargetOverlay : Overlay
worldHandle.SetTransform(localMatrix);
worldHandle.DrawTextureRectRegion(lightOverlay.EnlargedLightTarget.Texture, bounds, subRegion: subRegion);
- }, null);
+ }, Color.Transparent);
}
}
diff --git a/Content.Client/Light/EntitySystems/PlanetLightSystem.cs b/Content.Client/Light/EntitySystems/PlanetLightSystem.cs
index 2da67137ed..cbe2f47f78 100644
--- a/Content.Client/Light/EntitySystems/PlanetLightSystem.cs
+++ b/Content.Client/Light/EntitySystems/PlanetLightSystem.cs
@@ -16,6 +16,7 @@ public sealed class PlanetLightSystem : EntitySystem
_overlayMan.AddOverlay(new RoofOverlay(EntityManager));
_overlayMan.AddOverlay(new TileEmissionOverlay(EntityManager));
_overlayMan.AddOverlay(new LightBlurOverlay());
+ _overlayMan.AddOverlay(new SunShadowOverlay());
_overlayMan.AddOverlay(new AfterLightTargetOverlay());
}
@@ -31,6 +32,7 @@ public sealed class PlanetLightSystem : EntitySystem
_overlayMan.RemoveOverlay();
_overlayMan.RemoveOverlay();
_overlayMan.RemoveOverlay();
+ _overlayMan.RemoveOverlay();
_overlayMan.RemoveOverlay();
}
}
diff --git a/Content.Client/Light/EntitySystems/SunShadowSystem.cs b/Content.Client/Light/EntitySystems/SunShadowSystem.cs
new file mode 100644
index 0000000000..6f7a965a61
--- /dev/null
+++ b/Content.Client/Light/EntitySystems/SunShadowSystem.cs
@@ -0,0 +1,92 @@
+using System.Diagnostics.Contracts;
+using System.Numerics;
+using Content.Client.GameTicking.Managers;
+using Content.Shared.Light.Components;
+using Content.Shared.Light.EntitySystems;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Client.Light.EntitySystems;
+
+public sealed class SunShadowSystem : SharedSunShadowSystem
+{
+ [Dependency] private readonly ClientGameTicker _ticker = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly MetaDataSystem _metadata = default!;
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ if (!_timing.IsFirstTimePredicted)
+ return;
+
+ var mapQuery = AllEntityQuery();
+ while (mapQuery.MoveNext(out var uid, out var cycle, out var shadow))
+ {
+ if (!cycle.Running || cycle.Directions.Count == 0)
+ continue;
+
+ var pausedTime = _metadata.GetPauseTime(uid);
+
+ var time = (float)(_timing.CurTime
+ .Add(cycle.Offset)
+ .Subtract(_ticker.RoundStartTimeSpan)
+ .Subtract(pausedTime)
+ .TotalSeconds % cycle.Duration.TotalSeconds);
+
+ var (direction, alpha) = GetShadow((uid, cycle), time);
+ shadow.Direction = direction;
+ shadow.Alpha = alpha;
+ }
+ }
+
+ [Pure]
+ public (Vector2 Direction, float Alpha) GetShadow(Entity entity, float time)
+ {
+ // So essentially the values are stored as the percentages of the total duration just so it adjusts the speed
+ // dynamically and we don't have to manually handle it.
+ // It will lerp from each value to the next one with angle and length handled separately
+ var ratio = (float) (time / entity.Comp.Duration.TotalSeconds);
+
+ for (var i = entity.Comp.Directions.Count - 1; i >= 0; i--)
+ {
+ var dir = entity.Comp.Directions[i];
+
+ if (ratio > dir.Ratio)
+ {
+ var next = entity.Comp.Directions[(i + 1) % entity.Comp.Directions.Count];
+ float nextRatio;
+
+ // Last entry
+ if (i == entity.Comp.Directions.Count - 1)
+ {
+ nextRatio = next.Ratio + 1f;
+ }
+ else
+ {
+ nextRatio = next.Ratio;
+ }
+
+ var range = nextRatio - dir.Ratio;
+ var diff = (ratio - dir.Ratio) / range;
+ DebugTools.Assert(diff is >= 0f and <= 1f);
+
+ // We lerp angle + length separately as we don't want a straight-line lerp and want the rotation to be consistent.
+ var currentAngle = dir.Direction.ToAngle();
+ var nextAngle = next.Direction.ToAngle();
+
+ var angle = Angle.Lerp(currentAngle, nextAngle, diff);
+ // This is to avoid getting weird issues where the angle gets pretty close but length still noticeably catches up.
+ var lengthDiff = MathF.Pow(diff, 1f / 2f);
+ var length = float.Lerp(dir.Direction.Length(), next.Direction.Length(), lengthDiff);
+
+ var vector = angle.ToVec() * length;
+ var alpha = float.Lerp(dir.Alpha, next.Alpha, diff);
+ return (vector, alpha);
+ }
+ }
+
+ throw new InvalidOperationException();
+ }
+}
diff --git a/Content.Client/Light/LightCycleSystem.cs b/Content.Client/Light/LightCycleSystem.cs
index 9e19423cc3..8de0165fd2 100644
--- a/Content.Client/Light/LightCycleSystem.cs
+++ b/Content.Client/Light/LightCycleSystem.cs
@@ -1,6 +1,7 @@
using Content.Client.GameTicking.Managers;
using Content.Shared;
using Content.Shared.Light.Components;
+using Content.Shared.Light.EntitySystems;
using Robust.Shared.Map.Components;
using Robust.Shared.Timing;
@@ -11,19 +12,29 @@ public sealed class LightCycleSystem : SharedLightCycleSystem
{
[Dependency] private readonly ClientGameTicker _ticker = default!;
[Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly MetaDataSystem _metadata = default!;
public override void Update(float frameTime)
{
base.Update(frameTime);
+
+ if (!_timing.IsFirstTimePredicted)
+ return;
+
var mapQuery = AllEntityQuery();
while (mapQuery.MoveNext(out var uid, out var cycle, out var map))
{
if (!cycle.Running)
continue;
+ // We still iterate paused entities as we still want to override the lighting color and not have
+ // it apply the server state
+ var pausedTime = _metadata.GetPauseTime(uid);
+
var time = (float) _timing.CurTime
.Add(cycle.Offset)
.Subtract(_ticker.RoundStartTimeSpan)
+ .Subtract(pausedTime)
.TotalSeconds;
var color = GetColor((uid, cycle), cycle.OriginalColor, time);
diff --git a/Content.Client/Light/RoofOverlay.cs b/Content.Client/Light/RoofOverlay.cs
index 0648f8624f..8944630169 100644
--- a/Content.Client/Light/RoofOverlay.cs
+++ b/Content.Client/Light/RoofOverlay.cs
@@ -94,13 +94,15 @@ public sealed class RoofOverlay : Overlay
// Due to stencilling we essentially draw on unrooved tiles
while (tileEnumerator.MoveNext(out var tileRef))
{
- if (!_roof.IsRooved(roofEnt, tileRef.GridIndices))
+ var color = _roof.GetColor(roofEnt, tileRef.GridIndices);
+
+ if (color == null)
{
continue;
}
var local = _lookup.GetLocalBounds(tileRef, grid.Comp.TileSize);
- worldHandle.DrawRect(local, roof.Color);
+ worldHandle.DrawRect(local, color.Value);
}
}
}, null);
diff --git a/Content.Client/Light/SunShadowOverlay.cs b/Content.Client/Light/SunShadowOverlay.cs
new file mode 100644
index 0000000000..a296f95479
--- /dev/null
+++ b/Content.Client/Light/SunShadowOverlay.cs
@@ -0,0 +1,160 @@
+using System.Numerics;
+using Content.Shared.Light.Components;
+using Robust.Client.Graphics;
+using Robust.Shared.Enums;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Physics;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Light;
+
+public sealed class SunShadowOverlay : Overlay
+{
+ public override OverlaySpace Space => OverlaySpace.BeforeLighting;
+
+ [Dependency] private readonly IClyde _clyde = default!;
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly IPrototypeManager _protoManager = default!;
+ private readonly EntityLookupSystem _lookup;
+ private readonly SharedTransformSystem _xformSys;
+
+ private readonly HashSet> _shadows = new();
+
+ private IRenderTexture? _blurTarget;
+ private IRenderTexture? _target;
+
+ public SunShadowOverlay()
+ {
+ IoCManager.InjectDependencies(this);
+ _xformSys = _entManager.System();
+ _lookup = _entManager.System();
+ ZIndex = AfterLightTargetOverlay.ContentZIndex + 1;
+ }
+
+ private List> _grids = new();
+
+ protected override void Draw(in OverlayDrawArgs args)
+ {
+ var viewport = args.Viewport;
+ var eye = viewport.Eye;
+
+ if (eye == null)
+ return;
+
+ _grids.Clear();
+ _mapManager.FindGridsIntersecting(args.MapId,
+ args.WorldBounds.Enlarged(SunShadowComponent.MaxLength),
+ ref _grids);
+
+ var worldHandle = args.WorldHandle;
+ var mapId = args.MapId;
+ var worldBounds = args.WorldBounds;
+ var targetSize = viewport.LightRenderTarget.Size;
+
+ if (_target?.Size != targetSize)
+ {
+ _target = _clyde
+ .CreateRenderTarget(targetSize,
+ new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb),
+ name: "sun-shadow-target");
+
+ if (_blurTarget?.Size != targetSize)
+ {
+ _blurTarget = _clyde
+ .CreateRenderTarget(targetSize, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "sun-shadow-blur");
+ }
+ }
+
+ var lightScale = viewport.LightRenderTarget.Size / (Vector2)viewport.Size;
+ var scale = viewport.RenderScale / (Vector2.One / lightScale);
+
+ foreach (var grid in _grids)
+ {
+ if (!_entManager.TryGetComponent(grid.Owner, out SunShadowComponent? sun))
+ {
+ continue;
+ }
+
+ var direction = sun.Direction;
+ var alpha = Math.Clamp(sun.Alpha, 0f, 1f);
+
+ // Nowhere to cast to so ignore it.
+ if (direction.Equals(Vector2.Zero) || alpha == 0f)
+ continue;
+
+ // Feature todo: dynamic shadows for mobs and trees. Also ideally remove the fake tree shadows.
+ // TODO: Jittering still not quite perfect
+
+ var expandedBounds = worldBounds.Enlarged(direction.Length() + 0.01f);
+ _shadows.Clear();
+
+ // Draw shadow polys to stencil
+ args.WorldHandle.RenderInRenderTarget(_target,
+ () =>
+ {
+ var invMatrix =
+ _target.GetWorldToLocalMatrix(eye, scale);
+ var indices = new Vector2[PhysicsConstants.MaxPolygonVertices * 2];
+
+ // Go through shadows in range.
+
+ // For each one we:
+ // - Get the original vertices.
+ // - Extrapolate these along the sun direction.
+ // - Combine the above into 1 single polygon to draw.
+
+ // Note that this is range-limited for accuracy; if you set it too high it will clip through walls or other undesirable entities.
+ // This is probably not noticeable most of the time but if you want something "accurate" you'll want to code a solution.
+ // Ideally the CPU would have its own shadow-map copy that we could just ray-cast each vert into though
+ // You might need to batch verts or the likes as this could get expensive.
+ _lookup.GetEntitiesIntersecting(mapId, expandedBounds, _shadows);
+
+ foreach (var ent in _shadows)
+ {
+ var xform = _entManager.GetComponent(ent.Owner);
+ var (worldPos, worldRot) = _xformSys.GetWorldPositionRotation(xform);
+ // Need no rotation on matrix as sun shadow direction doesn't care.
+ var worldMatrix = Matrix3x2.CreateTranslation(worldPos);
+ var renderMatrix = Matrix3x2.Multiply(worldMatrix, invMatrix);
+ var pointCount = ent.Comp.Points.Length;
+
+ Array.Copy(ent.Comp.Points, indices, pointCount);
+
+ for (var i = 0; i < pointCount; i++)
+ {
+ // Update point based on entity rotation.
+ indices[i] = worldRot.RotateVec(indices[i]);
+
+ // Add the offset point by the sun shadow direction.
+ indices[pointCount + i] = indices[i] + direction;
+ }
+
+ var points = PhysicsHull.ComputePoints(indices, pointCount * 2);
+ worldHandle.SetTransform(renderMatrix);
+
+ worldHandle.DrawPrimitives(DrawPrimitiveTopology.TriangleFan, points, Color.White);
+ }
+ },
+ Color.Transparent);
+
+ // Slightly blur it just to avoid aliasing issues on the later viewport-wide blur.
+ _clyde.BlurRenderTarget(viewport, _target, _blurTarget!, eye, 1f);
+
+ // Draw stencil (see roofoverlay).
+ args.WorldHandle.RenderInRenderTarget(viewport.LightRenderTarget,
+ () =>
+ {
+ var invMatrix =
+ viewport.LightRenderTarget.GetWorldToLocalMatrix(eye, scale);
+ worldHandle.SetTransform(invMatrix);
+
+ var maskShader = _protoManager.Index("Mix").Instance();
+ worldHandle.UseShader(maskShader);
+
+ worldHandle.DrawTextureRect(_target.Texture, worldBounds, Color.Black.WithAlpha(alpha));
+ }, null);
+ }
+ }
+}
diff --git a/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml b/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml
index 763fd995ca..5041b498a0 100644
--- a/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml
+++ b/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml
@@ -4,6 +4,8 @@
+
@@ -12,6 +14,9 @@
+
+
diff --git a/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml.cs b/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml.cs
index e1dead0b0e..f87cda746c 100644
--- a/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml.cs
+++ b/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml.cs
@@ -21,6 +21,8 @@ public sealed partial class AccessibilityTab : Control
Control.AddOptionPercentSlider(CCVars.SpeechBubbleSpeakerOpacity, SpeechBubbleSpeakerOpacitySlider);
Control.AddOptionPercentSlider(CCVars.SpeechBubbleBackgroundOpacity, SpeechBubbleBackgroundOpacitySlider);
+ Control.AddOptionCheckBox(CCVars.AccessibilityClientCensorNudity, CensorNudityCheckBox);
+
Control.Initialize();
}
}
diff --git a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
index 30034723b9..c9b6ec841a 100644
--- a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
+++ b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
@@ -265,6 +265,51 @@ namespace Content.Client.Options.UI.Tabs
AddButton(EngineKeyFunctions.HideUI);
AddButton(ContentKeyFunctions.InspectEntity);
+ AddHeader("ui-options-header-text-cursor");
+ AddButton(EngineKeyFunctions.TextCursorLeft);
+ AddButton(EngineKeyFunctions.TextCursorRight);
+ AddButton(EngineKeyFunctions.TextCursorUp);
+ AddButton(EngineKeyFunctions.TextCursorDown);
+ AddButton(EngineKeyFunctions.TextCursorWordLeft);
+ AddButton(EngineKeyFunctions.TextCursorWordRight);
+ AddButton(EngineKeyFunctions.TextCursorBegin);
+ AddButton(EngineKeyFunctions.TextCursorEnd);
+
+ AddHeader("ui-options-header-text-cursor-select");
+ AddButton(EngineKeyFunctions.TextCursorSelect);
+ AddButton(EngineKeyFunctions.TextCursorSelectLeft);
+ AddButton(EngineKeyFunctions.TextCursorSelectRight);
+ AddButton(EngineKeyFunctions.TextCursorSelectUp);
+ AddButton(EngineKeyFunctions.TextCursorSelectDown);
+ AddButton(EngineKeyFunctions.TextCursorSelectWordLeft);
+ AddButton(EngineKeyFunctions.TextCursorSelectWordRight);
+ AddButton(EngineKeyFunctions.TextCursorSelectBegin);
+ AddButton(EngineKeyFunctions.TextCursorSelectEnd);
+
+ AddHeader("ui-options-header-text-edit");
+ AddButton(EngineKeyFunctions.TextBackspace);
+ AddButton(EngineKeyFunctions.TextDelete);
+ AddButton(EngineKeyFunctions.TextWordBackspace);
+ AddButton(EngineKeyFunctions.TextWordDelete);
+ AddButton(EngineKeyFunctions.TextNewline);
+ AddButton(EngineKeyFunctions.TextSubmit);
+ AddButton(EngineKeyFunctions.MultilineTextSubmit);
+ AddButton(EngineKeyFunctions.TextSelectAll);
+ AddButton(EngineKeyFunctions.TextCopy);
+ AddButton(EngineKeyFunctions.TextCut);
+ AddButton(EngineKeyFunctions.TextPaste);
+
+ AddHeader("ui-options-header-text-chat");
+ AddButton(EngineKeyFunctions.TextHistoryPrev);
+ AddButton(EngineKeyFunctions.TextHistoryNext);
+ AddButton(EngineKeyFunctions.TextReleaseFocus);
+ AddButton(EngineKeyFunctions.TextScrollToBottom);
+
+ AddHeader("ui-options-header-text-other");
+ AddButton(EngineKeyFunctions.TextTabComplete);
+ AddButton(EngineKeyFunctions.TextCompleteNext);
+ AddButton(EngineKeyFunctions.TextCompletePrev);
+
//CP14
AddHeader("ui-options-header-cp14");
AddButton(ContentKeyFunctions.CP14OpenKnowledgeMenu);
diff --git a/Content.Client/Power/EntitySystems/PowerReceiverSystem.cs b/Content.Client/Power/EntitySystems/PowerReceiverSystem.cs
index ebf6c18c95..319ab5c56d 100644
--- a/Content.Client/Power/EntitySystems/PowerReceiverSystem.cs
+++ b/Content.Client/Power/EntitySystems/PowerReceiverSystem.cs
@@ -27,6 +27,8 @@ public sealed class PowerReceiverSystem : SharedPowerReceiverSystem
return;
component.Powered = state.Powered;
+ component.NeedsPower = state.NeedsPower;
+ component.PowerDisabled = state.PowerDisabled;
}
public override bool ResolveApc(EntityUid entity, [NotNullWhen(true)] ref SharedApcPowerReceiverComponent? component)
diff --git a/Content.Client/SubFloor/SubFloorHideSystem.cs b/Content.Client/SubFloor/SubFloorHideSystem.cs
index 5e5f2993b4..df65717c40 100644
--- a/Content.Client/SubFloor/SubFloorHideSystem.cs
+++ b/Content.Client/SubFloor/SubFloorHideSystem.cs
@@ -64,9 +64,15 @@ public sealed class SubFloorHideSystem : SharedSubFloorHideSystem
args.Sprite.Visible = hasVisibleLayer || revealed;
- // allows a t-ray to show wires/pipes above carpets/puddles
- if (scannerRevealed)
+ if (ShowAll)
{
+ // Allows sandbox mode to make wires visible over other stuff.
+ component.OriginalDrawDepth ??= args.Sprite.DrawDepth;
+ args.Sprite.DrawDepth = (int)Shared.DrawDepth.DrawDepth.Overdoors;
+ }
+ else if (scannerRevealed)
+ {
+ // Allows a t-ray to show wires/pipes above carpets/puddles.
if (component.OriginalDrawDepth is not null)
return;
component.OriginalDrawDepth = args.Sprite.DrawDepth;
diff --git a/Content.Client/UserInterface/Controls/ListContainer.cs b/Content.Client/UserInterface/Controls/ListContainer.cs
index e1b3b948f0..0ee0a67af0 100644
--- a/Content.Client/UserInterface/Controls/ListContainer.cs
+++ b/Content.Client/UserInterface/Controls/ListContainer.cs
@@ -96,9 +96,12 @@ public class ListContainer : Control
{
ListContainerButton control = new(data[0], 0);
GenerateItem?.Invoke(data[0], control);
+ // Yes this AddChild is necessary for reasons (get proper style or whatever?)
+ // without it the DesiredSize may be different to the final DesiredSize.
+ AddChild(control);
control.Measure(Vector2Helpers.Infinity);
_itemHeight = control.DesiredSize.Y;
- control.Dispose();
+ control.Orphan();
}
// Ensure buttons are re-generated.
@@ -384,6 +387,7 @@ public sealed class ListContainerButton : ContainerButton, IEntityControl
public ListContainerButton(ListData data, int index)
{
+ AddStyleClass(StyleClassButton);
Data = data;
Index = index;
// AddChild(Background = new PanelContainer
diff --git a/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs b/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs
index 8f66340a30..8d96a5d670 100644
--- a/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs
+++ b/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs
@@ -467,8 +467,9 @@ public sealed class ChatUIController : UIController
if (existing.Count > SpeechBubbleCap)
{
- // Get the oldest to start fading fast.
- var last = existing[0];
+ // Get the next speech bubble to fade
+ // Any speech bubbles before it are already fading
+ var last = existing[^(SpeechBubbleCap + 1)];
last.FadeNow();
}
}
diff --git a/Content.Client/UserInterface/Systems/DamageOverlays/DamageOverlayUiController.cs b/Content.Client/UserInterface/Systems/DamageOverlays/DamageOverlayUiController.cs
index 9d9dd30911..55d00fec18 100644
--- a/Content.Client/UserInterface/Systems/DamageOverlays/DamageOverlayUiController.cs
+++ b/Content.Client/UserInterface/Systems/DamageOverlays/DamageOverlayUiController.cs
@@ -67,7 +67,7 @@ public sealed class DamageOverlayUiController : UIController
{
_overlay.DeadLevel = 0f;
_overlay.CritLevel = 0f;
- _overlay.BruteLevel = 0f;
+ _overlay.PainLevel = 0f;
_overlay.OxygenLevel = 0f;
}
@@ -95,13 +95,22 @@ public sealed class DamageOverlayUiController : UIController
{
case MobState.Alive:
{
- if (EntityManager.HasComponent(entity))
+ FixedPoint2 painLevel = 0;
+ _overlay.PainLevel = 0;
+
+ if (!EntityManager.HasComponent(entity))
{
- _overlay.BruteLevel = 0;
- }
- else if (damageable.DamagePerGroup.TryGetValue("Brute", out var bruteDamage))
- {
- _overlay.BruteLevel = FixedPoint2.Min(1f, bruteDamage / critThreshold).Float();
+ foreach (var painDamageType in damageable.PainDamageGroups)
+ {
+ damageable.DamagePerGroup.TryGetValue(painDamageType, out var painDamage);
+ painLevel += painDamage;
+ }
+ _overlay.PainLevel = FixedPoint2.Min(1f, painLevel / critThreshold).Float();
+
+ if (_overlay.PainLevel < 0.05f) // Don't show damage overlay if they're near enough to max.
+ {
+ _overlay.PainLevel = 0;
+ }
}
if (damageable.DamagePerGroup.TryGetValue("Airloss", out var oxyDamage))
@@ -109,11 +118,6 @@ public sealed class DamageOverlayUiController : UIController
_overlay.OxygenLevel = FixedPoint2.Min(1f, oxyDamage / critThreshold).Float();
}
- if (_overlay.BruteLevel < 0.05f) // Don't show damage overlay if they're near enough to max.
- {
- _overlay.BruteLevel = 0;
- }
-
_overlay.CritLevel = 0;
_overlay.DeadLevel = 0;
break;
@@ -125,13 +129,13 @@ public sealed class DamageOverlayUiController : UIController
return;
_overlay.CritLevel = critLevel.Value.Float();
- _overlay.BruteLevel = 0;
+ _overlay.PainLevel = 0;
_overlay.DeadLevel = 0;
break;
}
case MobState.Dead:
{
- _overlay.BruteLevel = 0;
+ _overlay.PainLevel = 0;
_overlay.CritLevel = 0;
break;
}
diff --git a/Content.Client/UserInterface/Systems/DamageOverlays/Overlays/DamageOverlay.cs b/Content.Client/UserInterface/Systems/DamageOverlays/Overlays/DamageOverlay.cs
index fd74815316..de70fe16f8 100644
--- a/Content.Client/UserInterface/Systems/DamageOverlays/Overlays/DamageOverlay.cs
+++ b/Content.Client/UserInterface/Systems/DamageOverlays/Overlays/DamageOverlay.cs
@@ -25,9 +25,9 @@ public sealed class DamageOverlay : Overlay
///
/// Handles the red pulsing overlay
///
- public float BruteLevel = 0f;
+ public float PainLevel = 0f;
- private float _oldBruteLevel = 0f;
+ private float _oldPainLevel = 0f;
///
/// Handles the darkening overlay.
@@ -92,14 +92,14 @@ public sealed class DamageOverlay : Overlay
DeadLevel = 0f;
}
- if (!MathHelper.CloseTo(_oldBruteLevel, BruteLevel, 0.001f))
+ if (!MathHelper.CloseTo(_oldPainLevel, PainLevel, 0.001f))
{
- var diff = BruteLevel - _oldBruteLevel;
- _oldBruteLevel += GetDiff(diff, lastFrameTime);
+ var diff = PainLevel - _oldPainLevel;
+ _oldPainLevel += GetDiff(diff, lastFrameTime);
}
else
{
- _oldBruteLevel = BruteLevel;
+ _oldPainLevel = PainLevel;
}
if (!MathHelper.CloseTo(_oldOxygenLevel, OxygenLevel, 0.001f))
@@ -135,7 +135,7 @@ public sealed class DamageOverlay : Overlay
// Makes debugging easier don't @ me
float level = 0f;
- level = _oldBruteLevel;
+ level = _oldPainLevel;
// TODO: Lerping
if (level > 0f && _oldCritLevel <= 0f)
@@ -165,7 +165,7 @@ public sealed class DamageOverlay : Overlay
}
else
{
- _oldBruteLevel = BruteLevel;
+ _oldPainLevel = PainLevel;
}
level = State != MobState.Critical ? _oldOxygenLevel : 1f;
diff --git a/Content.Client/VendingMachines/UI/VendingMachineItem.xaml.cs b/Content.Client/VendingMachines/UI/VendingMachineItem.xaml.cs
index a7212934fd..0f0564c596 100644
--- a/Content.Client/VendingMachines/UI/VendingMachineItem.xaml.cs
+++ b/Content.Client/VendingMachines/UI/VendingMachineItem.xaml.cs
@@ -16,4 +16,9 @@ public sealed partial class VendingMachineItem : BoxContainer
NameLabel.Text = text;
}
+
+ public void SetText(string text)
+ {
+ NameLabel.Text = text;
+ }
}
diff --git a/Content.Client/VendingMachines/UI/VendingMachineMenu.xaml.cs b/Content.Client/VendingMachines/UI/VendingMachineMenu.xaml.cs
index ee7a0e41fa..899a0208cb 100644
--- a/Content.Client/VendingMachines/UI/VendingMachineMenu.xaml.cs
+++ b/Content.Client/VendingMachines/UI/VendingMachineMenu.xaml.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using System.Numerics;
using Content.Shared.VendingMachines;
using Robust.Client.AutoGenerated;
@@ -19,11 +20,16 @@ namespace Content.Client.VendingMachines.UI
[Dependency] private readonly IEntityManager _entityManager = default!;
private readonly Dictionary _dummies = [];
+ private readonly Dictionary _listItems = new();
+ private readonly Dictionary _amounts = new();
+
+ ///
+ /// Whether the vending machine is able to be interacted with or not.
+ ///
+ private bool _enabled;
public event Action? OnItemSelected;
- private readonly StyleBoxFlat _styleBox = new() { BackgroundColor = new Color(70, 73, 102) };
-
public VendingMachineMenu()
{
MinSize = SetSize = new Vector2(250, 150);
@@ -68,18 +74,23 @@ namespace Content.Client.VendingMachines.UI
if (data is not VendorItemsListData { ItemProtoID: var protoID, ItemText: var text })
return;
- button.AddChild(new VendingMachineItem(protoID, text));
-
- button.ToolTip = text;
- button.StyleBoxOverride = _styleBox;
+ var item = new VendingMachineItem(protoID, text);
+ _listItems[protoID] = (button, item);
+ button.AddChild(item);
+ button.AddStyleClass("ButtonSquare");
+ button.Disabled = !_enabled || _amounts[protoID] == 0;
}
///
/// Populates the list of available items on the vending machine interface
/// and sets icons based on their prototypes
///
- public void Populate(List inventory)
+ public void Populate(List inventory, bool enabled)
{
+ _enabled = enabled;
+ _listItems.Clear();
+ _amounts.Clear();
+
if (inventory.Count == 0 && VendingContents.Visible)
{
SearchBar.Visible = false;
@@ -109,7 +120,10 @@ namespace Content.Client.VendingMachines.UI
var entry = inventory[i];
if (!_prototypeManager.TryIndex(entry.ID, out var prototype))
+ {
+ _amounts[entry.ID] = 0;
continue;
+ }
if (!_dummies.TryGetValue(entry.ID, out var dummy))
{
@@ -119,11 +133,15 @@ namespace Content.Client.VendingMachines.UI
var itemName = Identity.Name(dummy, _entityManager);
var itemText = $"{itemName} [{entry.Amount}]";
+ _amounts[entry.ID] = entry.Amount;
if (itemText.Length > longestEntry.Length)
longestEntry = itemText;
- listData.Add(new VendorItemsListData(prototype.ID, itemText, i));
+ listData.Add(new VendorItemsListData(prototype.ID, i)
+ {
+ ItemText = itemText,
+ });
}
VendingContents.PopulateList(listData);
@@ -131,12 +149,43 @@ namespace Content.Client.VendingMachines.UI
SetSizeAfterUpdate(longestEntry.Length, inventory.Count);
}
+ ///
+ /// Updates text entries for vending data in place without modifying the list controls.
+ ///
+ public void UpdateAmounts(List cachedInventory, bool enabled)
+ {
+ _enabled = enabled;
+
+ foreach (var proto in _dummies.Keys)
+ {
+ if (!_listItems.TryGetValue(proto, out var button))
+ continue;
+
+ var dummy = _dummies[proto];
+ var amount = cachedInventory.First(o => o.ID == proto).Amount;
+ // Could be better? Problem is all inventory entries get squashed.
+ var text = GetItemText(dummy, amount);
+
+ button.Item.SetText(text);
+ button.Button.Disabled = !enabled || amount == 0;
+ }
+ }
+
+ private string GetItemText(EntityUid dummy, uint amount)
+ {
+ var itemName = Identity.Name(dummy, _entityManager);
+ return $"{itemName} [{amount}]";
+ }
+
private void SetSizeAfterUpdate(int longestEntryLength, int contentCount)
{
SetSize = new Vector2(Math.Clamp((longestEntryLength + 2) * 12, 250, 400),
Math.Clamp(contentCount * 50, 150, 350));
}
}
-}
-public record VendorItemsListData(EntProtoId ItemProtoID, string ItemText, int ItemIndex) : ListData;
+ public record VendorItemsListData(EntProtoId ItemProtoID, int ItemIndex) : ListData
+ {
+ public string ItemText = string.Empty;
+ }
+}
diff --git a/Content.Client/VendingMachines/VendingMachineBoundUserInterface.cs b/Content.Client/VendingMachines/VendingMachineBoundUserInterface.cs
index 052bdacb89..874808158d 100644
--- a/Content.Client/VendingMachines/VendingMachineBoundUserInterface.cs
+++ b/Content.Client/VendingMachines/VendingMachineBoundUserInterface.cs
@@ -31,10 +31,21 @@ namespace Content.Client.VendingMachines
public void Refresh()
{
+ var enabled = EntMan.TryGetComponent(Owner, out VendingMachineComponent? bendy) && !bendy.Ejecting;
+
var system = EntMan.System();
_cachedInventory = system.GetAllInventory(Owner);
- _menu?.Populate(_cachedInventory);
+ _menu?.Populate(_cachedInventory, enabled);
+ }
+
+ public void UpdateAmounts()
+ {
+ var enabled = EntMan.TryGetComponent(Owner, out VendingMachineComponent? bendy) && !bendy.Ejecting;
+
+ var system = EntMan.System();
+ _cachedInventory = system.GetAllInventory(Owner);
+ _menu?.UpdateAmounts(_cachedInventory, enabled);
}
private void OnItemSelected(GUIBoundKeyEventArgs args, ListData data)
@@ -53,7 +64,7 @@ namespace Content.Client.VendingMachines
if (selectedItem == null)
return;
- SendMessage(new VendingMachineEjectMessage(selectedItem.Type, selectedItem.ID));
+ SendPredictedMessage(new VendingMachineEjectMessage(selectedItem.Type, selectedItem.ID));
}
protected override void Dispose(bool disposing)
diff --git a/Content.Client/VendingMachines/VendingMachineSystem.cs b/Content.Client/VendingMachines/VendingMachineSystem.cs
index 1b1dde2b67..130296c8a1 100644
--- a/Content.Client/VendingMachines/VendingMachineSystem.cs
+++ b/Content.Client/VendingMachines/VendingMachineSystem.cs
@@ -1,6 +1,8 @@
+using System.Linq;
using Content.Shared.VendingMachines;
using Robust.Client.Animations;
using Robust.Client.GameObjects;
+using Robust.Shared.GameStates;
namespace Content.Client.VendingMachines;
@@ -8,7 +10,6 @@ public sealed class VendingMachineSystem : SharedVendingMachineSystem
{
[Dependency] private readonly AnimationPlayerSystem _animationPlayer = default!;
[Dependency] private readonly SharedAppearanceSystem _appearanceSystem = default!;
- [Dependency] private readonly SharedUserInterfaceSystem _uiSystem = default!;
public override void Initialize()
{
@@ -16,14 +17,69 @@ public sealed class VendingMachineSystem : SharedVendingMachineSystem
SubscribeLocalEvent(OnAppearanceChange);
SubscribeLocalEvent(OnAnimationCompleted);
- SubscribeLocalEvent(OnVendingAfterState);
+ SubscribeLocalEvent(OnVendingHandleState);
}
- private void OnVendingAfterState(EntityUid uid, VendingMachineComponent component, ref AfterAutoHandleStateEvent args)
+ private void OnVendingHandleState(Entity entity, ref ComponentHandleState args)
{
- if (_uiSystem.TryGetOpenUi(uid, VendingMachineUiKey.Key, out var bui))
+ if (args.Current is not VendingMachineComponentState state)
+ return;
+
+ var uid = entity.Owner;
+ var component = entity.Comp;
+
+ component.Contraband = state.Contraband;
+ component.EjectEnd = state.EjectEnd;
+ component.DenyEnd = state.DenyEnd;
+ component.DispenseOnHitEnd = state.DispenseOnHitEnd;
+
+ // If all we did was update amounts then we can leave BUI buttons in place.
+ var fullUiUpdate = !component.Inventory.Keys.SequenceEqual(state.Inventory.Keys) ||
+ !component.EmaggedInventory.Keys.SequenceEqual(state.EmaggedInventory.Keys) ||
+ !component.ContrabandInventory.Keys.SequenceEqual(state.ContrabandInventory.Keys);
+
+ component.Inventory.Clear();
+ component.EmaggedInventory.Clear();
+ component.ContrabandInventory.Clear();
+
+ foreach (var entry in state.Inventory)
{
- bui.Refresh();
+ component.Inventory.Add(entry.Key, new(entry.Value));
+ }
+
+ foreach (var entry in state.EmaggedInventory)
+ {
+ component.EmaggedInventory.Add(entry.Key, new(entry.Value));
+ }
+
+ foreach (var entry in state.ContrabandInventory)
+ {
+ component.ContrabandInventory.Add(entry.Key, new(entry.Value));
+ }
+
+ if (UISystem.TryGetOpenUi(uid, VendingMachineUiKey.Key, out var bui))
+ {
+ if (fullUiUpdate)
+ {
+ bui.Refresh();
+ }
+ else
+ {
+ bui.UpdateAmounts();
+ }
+ }
+ }
+
+ protected override void UpdateUI(Entity entity)
+ {
+ if (!Resolve(entity, ref entity.Comp))
+ return;
+
+ if (UISystem.TryGetOpenUi(entity.Owner,
+ VendingMachineUiKey.Key,
+ out var bui))
+ {
+ bui.UpdateAmounts();
}
}
@@ -70,13 +126,13 @@ public sealed class VendingMachineSystem : SharedVendingMachineSystem
if (component.LoopDenyAnimation)
SetLayerState(VendingMachineVisualLayers.BaseUnshaded, component.DenyState, sprite);
else
- PlayAnimation(uid, VendingMachineVisualLayers.BaseUnshaded, component.DenyState, component.DenyDelay, sprite);
+ PlayAnimation(uid, VendingMachineVisualLayers.BaseUnshaded, component.DenyState, (float)component.DenyDelay.TotalSeconds, sprite);
SetLayerState(VendingMachineVisualLayers.Screen, component.ScreenState, sprite);
break;
case VendingMachineVisualState.Eject:
- PlayAnimation(uid, VendingMachineVisualLayers.BaseUnshaded, component.EjectState, component.EjectDelay, sprite);
+ PlayAnimation(uid, VendingMachineVisualLayers.BaseUnshaded, component.EjectState, (float)component.EjectDelay.TotalSeconds, sprite);
SetLayerState(VendingMachineVisualLayers.Screen, component.ScreenState, sprite);
break;
diff --git a/Content.IntegrationTests/Tests/Construction/Interaction/CraftingTests.cs b/Content.IntegrationTests/Tests/Construction/Interaction/CraftingTests.cs
index 74d0e92421..05e8197c8d 100644
--- a/Content.IntegrationTests/Tests/Construction/Interaction/CraftingTests.cs
+++ b/Content.IntegrationTests/Tests/Construction/Interaction/CraftingTests.cs
@@ -54,6 +54,9 @@ public sealed class CraftingTests : InteractionTest
await CraftItem(Spear);
await FindEntity(Spear);
+ // Reset target because entitylookup will dump this.
+ Target = null;
+
// Player's hands should be full of the remaining rods, except those dropped during the failed crafting attempt.
// Spear and left over stacks should be on the floor.
await AssertEntityLookup((Rod, 2), (Cable, 7), (ShardGlass, 2), (Spear, 1));
diff --git a/Content.IntegrationTests/Tests/ContrabandTest.cs b/Content.IntegrationTests/Tests/ContrabandTest.cs
index ebd6afa7ef..a33e7c2067 100644
--- a/Content.IntegrationTests/Tests/ContrabandTest.cs
+++ b/Content.IntegrationTests/Tests/ContrabandTest.cs
@@ -17,23 +17,26 @@ public sealed class ContrabandTest
await client.WaitAssertion(() =>
{
- foreach (var proto in protoMan.EnumeratePrototypes())
+ Assert.Multiple(() =>
{
- if (proto.Abstract || pair.IsTestPrototype(proto))
- continue;
+ foreach (var proto in protoMan.EnumeratePrototypes())
+ {
+ if (proto.Abstract || pair.IsTestPrototype(proto))
+ continue;
- if (!proto.TryGetComponent(out var contraband, componentFactory))
- continue;
+ if (!proto.TryGetComponent(out var contraband, componentFactory))
+ continue;
- Assert.That(protoMan.TryIndex(contraband.Severity, out var severity, false),
- @$"{proto.ID} has a ContrabandComponent with a unknown severity.");
+ Assert.That(protoMan.TryIndex(contraband.Severity, out var severity, false),
+ @$"{proto.ID} has a ContrabandComponent with a unknown severity.");
- if (!severity.ShowDepartmentsAndJobs)
- continue;
+ if (!severity.ShowDepartmentsAndJobs)
+ continue;
- Assert.That(contraband.AllowedDepartments.Count + contraband.AllowedJobs.Count, Is.Not.EqualTo(0),
- @$"{proto.ID} has a ContrabandComponent with ShowDepartmentsAndJobs but no allowed departments or jobs.");
- }
+ Assert.That(contraband.AllowedDepartments.Count + contraband.AllowedJobs.Count, Is.Not.EqualTo(0),
+ @$"{proto.ID} has a ContrabandComponent with ShowDepartmentsAndJobs but no allowed departments or jobs.");
+ }
+ });
});
await pair.CleanReturnAsync();
diff --git a/Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs b/Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs
index d431c440a2..a61f7c3ec8 100644
--- a/Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs
+++ b/Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs
@@ -62,7 +62,10 @@ public abstract partial class InteractionTest
// Please someone purge async construction code
Task task = default!;
- await Server.WaitPost(() => task = SConstruction.TryStartItemConstruction(prototype, SEntMan.GetEntity(Player)));
+ await Server.WaitPost(() =>
+ {
+ task = SConstruction.TryStartItemConstruction(prototype, SEntMan.GetEntity(Player));
+ });
Task? tickTask = null;
while (!task.IsCompleted)
diff --git a/Content.IntegrationTests/Tests/MagazineVisualsSpriteTest.cs b/Content.IntegrationTests/Tests/MagazineVisualsSpriteTest.cs
index f0f3b72d8d..fb0c67afd7 100644
--- a/Content.IntegrationTests/Tests/MagazineVisualsSpriteTest.cs
+++ b/Content.IntegrationTests/Tests/MagazineVisualsSpriteTest.cs
@@ -23,46 +23,49 @@ public sealed class MagazineVisualsSpriteTest
await client.WaitAssertion(() =>
{
- foreach (var proto in protoMan.EnumeratePrototypes())
+ Assert.Multiple(() =>
{
- if (proto.Abstract || pair.IsTestPrototype(proto))
- continue;
-
- if (!proto.TryGetComponent(out var visuals, componentFactory))
- continue;
-
- Assert.That(proto.TryGetComponent(out var sprite, componentFactory),
- @$"{proto.ID} has MagazineVisualsComponent but no SpriteComponent.");
- Assert.That(proto.HasComponent(componentFactory),
- @$"{proto.ID} has MagazineVisualsComponent but no AppearanceComponent.");
-
- var toTest = new List<(int, string)>();
- if (sprite.LayerMapTryGet(GunVisualLayers.Mag, out var magLayerId))
- toTest.Add((magLayerId, ""));
- if (sprite.LayerMapTryGet(GunVisualLayers.MagUnshaded, out var magUnshadedLayerId))
- toTest.Add((magUnshadedLayerId, "-unshaded"));
-
- Assert.That(toTest, Is.Not.Empty,
- @$"{proto.ID} has MagazineVisualsComponent but no Mag or MagUnshaded layer map.");
-
- var start = visuals.ZeroVisible ? 0 : 1;
- foreach (var (id, midfix) in toTest)
+ foreach (var proto in protoMan.EnumeratePrototypes())
{
- Assert.That(sprite.TryGetLayer(id, out var layer));
- var rsi = layer.ActualRsi;
- for (var i = start; i < visuals.MagSteps; i++)
- {
- var state = $"{visuals.MagState}{midfix}-{i}";
- Assert.That(rsi.TryGetState(state, out _),
- @$"{proto.ID} has MagazineVisualsComponent with MagSteps = {visuals.MagSteps}, but {rsi.Path} doesn't have state {state}!");
- }
+ if (proto.Abstract || pair.IsTestPrototype(proto))
+ continue;
- // MagSteps includes the 0th step, so sometimes people are off by one.
- var extraState = $"{visuals.MagState}{midfix}-{visuals.MagSteps}";
- Assert.That(rsi.TryGetState(extraState, out _), Is.False,
- @$"{proto.ID} has MagazineVisualsComponent with MagSteps = {visuals.MagSteps}, but more states exist!");
+ if (!proto.TryGetComponent(out var visuals, componentFactory))
+ continue;
+
+ Assert.That(proto.TryGetComponent(out var sprite, componentFactory),
+ @$"{proto.ID} has MagazineVisualsComponent but no SpriteComponent.");
+ Assert.That(proto.HasComponent(componentFactory),
+ @$"{proto.ID} has MagazineVisualsComponent but no AppearanceComponent.");
+
+ var toTest = new List<(int, string)>();
+ if (sprite.LayerMapTryGet(GunVisualLayers.Mag, out var magLayerId))
+ toTest.Add((magLayerId, ""));
+ if (sprite.LayerMapTryGet(GunVisualLayers.MagUnshaded, out var magUnshadedLayerId))
+ toTest.Add((magUnshadedLayerId, "-unshaded"));
+
+ Assert.That(toTest, Is.Not.Empty,
+ @$"{proto.ID} has MagazineVisualsComponent but no Mag or MagUnshaded layer map.");
+
+ var start = visuals.ZeroVisible ? 0 : 1;
+ foreach (var (id, midfix) in toTest)
+ {
+ Assert.That(sprite.TryGetLayer(id, out var layer));
+ var rsi = layer.ActualRsi;
+ for (var i = start; i < visuals.MagSteps; i++)
+ {
+ var state = $"{visuals.MagState}{midfix}-{i}";
+ Assert.That(rsi.TryGetState(state, out _),
+ @$"{proto.ID} has MagazineVisualsComponent with MagSteps = {visuals.MagSteps}, but {rsi.Path} doesn't have state {state}!");
+ }
+
+ // MagSteps includes the 0th step, so sometimes people are off by one.
+ var extraState = $"{visuals.MagState}{midfix}-{visuals.MagSteps}";
+ Assert.That(rsi.TryGetState(extraState, out _), Is.False,
+ @$"{proto.ID} has MagazineVisualsComponent with MagSteps = {visuals.MagSteps}, but more states exist!");
+ }
}
- }
+ });
});
await pair.CleanReturnAsync();
diff --git a/Content.Server/Access/Systems/IdCardSystem.cs b/Content.Server/Access/Systems/IdCardSystem.cs
index aeb4cc163f..a9b08aac8b 100644
--- a/Content.Server/Access/Systems/IdCardSystem.cs
+++ b/Content.Server/Access/Systems/IdCardSystem.cs
@@ -31,7 +31,7 @@ public sealed class IdCardSystem : SharedIdCardSystem
private void OnMicrowaved(EntityUid uid, IdCardComponent component, BeingMicrowavedEvent args)
{
if (!component.CanMicrowave || !TryComp(args.Microwave, out var micro) || micro.Broken)
- return;
+ return;
if (TryComp(uid, out var access))
{
@@ -78,7 +78,12 @@ public sealed class IdCardSystem : SharedIdCardSystem
}
// Give them a wonderful new access to compensate for everything
- var random = _random.Pick(_prototypeManager.EnumeratePrototypes().ToArray());
+ var ids = _prototypeManager.EnumeratePrototypes().Where(x => x.CanAddToIdCard).ToArray();
+
+ if (ids.Length == 0)
+ return;
+
+ var random = _random.Pick(ids);
access.Tags.Add(random.ID);
Dirty(uid, access);
diff --git a/Content.Server/Administration/Commands/ForceGhostCommand.cs b/Content.Server/Administration/Commands/ForceGhostCommand.cs
new file mode 100644
index 0000000000..68a77b7454
--- /dev/null
+++ b/Content.Server/Administration/Commands/ForceGhostCommand.cs
@@ -0,0 +1,61 @@
+using Content.Server.GameTicking;
+using Content.Server.Ghost;
+using Content.Shared.Administration;
+using Content.Shared.GameTicking;
+using Content.Shared.Mind;
+using Robust.Server.Player;
+using Robust.Shared.Console;
+
+namespace Content.Server.Administration.Commands;
+
+[AdminCommand(AdminFlags.Admin)]
+public sealed class ForceGhostCommand : LocalizedEntityCommands
+{
+ [Dependency] private readonly IEntityManager _entityManager = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly GameTicker _gameTicker = default!;
+ [Dependency] private readonly SharedMindSystem _mind = default!;
+ [Dependency] private readonly GhostSystem _ghost = default!;
+
+ public override string Command => "forceghost";
+
+ public override void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (args.Length == 0 || args.Length > 1)
+ {
+ shell.WriteError(LocalizationManager.GetString("shell-wrong-arguments-number"));
+ return;
+ }
+
+ if (!_playerManager.TryGetSessionByUsername(args[0], out var player))
+ {
+ shell.WriteError(LocalizationManager.GetString("shell-target-player-does-not-exist"));
+ return;
+ }
+
+ if (!_gameTicker.PlayerGameStatuses.TryGetValue(player.UserId, out var playerStatus) ||
+ playerStatus is not PlayerGameStatus.JoinedGame)
+ {
+ shell.WriteLine(Loc.GetString("cmd-forceghost-error-lobby"));
+ return;
+ }
+
+ if (!_mind.TryGetMind(player, out var mindId, out var mind))
+ (mindId, mind) = _mind.CreateMind(player.UserId);
+
+ if (!_ghost.OnGhostAttempt(mindId, false, true, true, mind))
+ shell.WriteLine(Loc.GetString("cmd-forceghost-denied"));
+ }
+
+ public override CompletionResult GetCompletion(IConsoleShell shell, string[] args)
+ {
+ if (args.Length == 1)
+ {
+ return CompletionResult.FromHintOptions(
+ CompletionHelper.SessionNames(players: _playerManager),
+ Loc.GetString("cmd-forceghost-hint"));
+ }
+
+ return CompletionResult.Empty;
+ }
+}
diff --git a/Content.Server/Administration/Commands/RoleBanCommand.cs b/Content.Server/Administration/Commands/RoleBanCommand.cs
index 8b535977ee..68fbac0334 100644
--- a/Content.Server/Administration/Commands/RoleBanCommand.cs
+++ b/Content.Server/Administration/Commands/RoleBanCommand.cs
@@ -7,6 +7,8 @@ using Content.Shared.Database;
using Content.Shared.Roles;
using Robust.Shared.Configuration;
using Robust.Shared.Console;
+using Robust.Shared.Prototypes;
+
namespace Content.Server.Administration.Commands;
[AdminCommand(AdminFlags.Ban)]
@@ -15,6 +17,7 @@ public sealed class RoleBanCommand : IConsoleCommand
[Dependency] private readonly IPlayerLocator _locator = default!;
[Dependency] private readonly IBanManager _bans = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
public string Command => "roleban";
public string Description => Loc.GetString("cmd-roleban-desc");
@@ -76,6 +79,12 @@ public sealed class RoleBanCommand : IConsoleCommand
return;
}
+ if (!_proto.HasIndex(job))
+ {
+ shell.WriteError(Loc.GetString("cmd-roleban-job-parse",("job", job)));
+ return;
+ }
+
var located = await _locator.LookupIdByNameOrIdAsync(target);
if (located == null)
{
diff --git a/Content.Server/Administration/Systems/AdminSystem.cs b/Content.Server/Administration/Systems/AdminSystem.cs
index c1b08437de..7d50730456 100644
--- a/Content.Server/Administration/Systems/AdminSystem.cs
+++ b/Content.Server/Administration/Systems/AdminSystem.cs
@@ -222,6 +222,7 @@ public sealed class AdminSystem : EntitySystem
var entityName = string.Empty;
var identityName = string.Empty;
+ // Visible (identity) name can be different from real name
if (session?.AttachedEntity != null)
{
entityName = EntityManager.GetComponent(session.AttachedEntity.Value).EntityName;
@@ -230,6 +231,7 @@ public sealed class AdminSystem : EntitySystem
var antag = false;
+ // Starting role, antagonist status and role type
RoleTypePrototype roleType = new();
var startingRole = string.Empty;
if (_minds.TryGetMind(session, out var mindId, out var mindComp))
@@ -243,8 +245,13 @@ public sealed class AdminSystem : EntitySystem
startingRole = _jobs.MindTryGetJobName(mindId);
}
+ // Connection status and playtime
var connected = session != null && session.Status is SessionStatus.Connected or SessionStatus.InGame;
- TimeSpan? overallPlaytime = null;
+
+ // Start with the last available playtime data
+ var cachedInfo = GetCachedPlayerInfo(data.UserId);
+ var overallPlaytime = cachedInfo?.OverallPlaytime;
+ // Overwrite with current playtime data, unless it's null (such as if the player just disconnected)
if (session != null &&
_playTime.TryGetTrackerTimes(session, out var playTimes) &&
playTimes.TryGetValue(PlayTimeTrackingShared.TrackerOverall, out var playTime))
diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
index 48781a703e..d6db8e7964 100644
--- a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
+++ b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
@@ -74,23 +74,25 @@ public sealed partial class AdminVerbSystem
args.Verbs.Add(vampire);
/* CP14 disable default antags
+ var traitorName = Loc.GetString("admin-verb-text-make-traitor");
Verb traitor = new()
{
- Text = Loc.GetString("admin-verb-text-make-traitor"),
+ Text = traitorName,
Category = VerbCategory.Antag,
- Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Structures/Wallmounts/posters.rsi"), "poster5_contraband"),
+ Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Interface/Misc/job_icons.rsi"), "Syndicate"),
Act = () =>
{
_antag.ForceMakeAntag(targetPlayer, DefaultTraitorRule);
},
Impact = LogImpact.High,
- Message = Loc.GetString("admin-verb-make-traitor"),
+ Message = string.Join(": ", traitorName, Loc.GetString("admin-verb-make-traitor")),
};
args.Verbs.Add(traitor);
+ var initialInfectedName = Loc.GetString("admin-verb-text-make-initial-infected");
Verb initialInfected = new()
{
- Text = Loc.GetString("admin-verb-text-make-initial-infected"),
+ Text = initialInfectedName,
Category = VerbCategory.Antag,
Icon = new SpriteSpecifier.Rsi(new("/Textures/Interface/Misc/job_icons.rsi"), "InitialInfected"),
Act = () =>
@@ -98,42 +100,44 @@ public sealed partial class AdminVerbSystem
_antag.ForceMakeAntag(targetPlayer, DefaultInitialInfectedRule);
},
Impact = LogImpact.High,
- Message = Loc.GetString("admin-verb-make-initial-infected"),
+ Message = string.Join(": ", initialInfectedName, Loc.GetString("admin-verb-make-initial-infected")),
};
args.Verbs.Add(initialInfected);
+ var zombieName = Loc.GetString("admin-verb-text-make-zombie");
Verb zombie = new()
{
- Text = Loc.GetString("admin-verb-text-make-zombie"),
+ Text = zombieName,
Category = VerbCategory.Antag,
- Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/Actions/zombie-turn.png")),
+ Icon = new SpriteSpecifier.Rsi(new("/Textures/Interface/Misc/job_icons.rsi"), "Zombie"),
Act = () =>
{
_zombie.ZombifyEntity(args.Target);
},
Impact = LogImpact.High,
- Message = Loc.GetString("admin-verb-make-zombie"),
+ Message = string.Join(": ", zombieName, Loc.GetString("admin-verb-make-zombie")),
};
args.Verbs.Add(zombie);
-
+ var nukeOpName = Loc.GetString("admin-verb-text-make-nuclear-operative");
Verb nukeOp = new()
{
- Text = Loc.GetString("admin-verb-text-make-nuclear-operative"),
+ Text = nukeOpName,
Category = VerbCategory.Antag,
- Icon = new SpriteSpecifier.Rsi(new("/Textures/Structures/Wallmounts/signs.rsi"), "radiation"),
+ Icon = new SpriteSpecifier.Rsi(new("/Textures/Clothing/Head/Hardsuits/syndicate.rsi"), "icon"),
Act = () =>
{
_antag.ForceMakeAntag(targetPlayer, DefaultNukeOpRule);
},
Impact = LogImpact.High,
- Message = Loc.GetString("admin-verb-make-nuclear-operative"),
+ Message = string.Join(": ", nukeOpName, Loc.GetString("admin-verb-make-nuclear-operative")),
};
args.Verbs.Add(nukeOp);
+ var pirateName = Loc.GetString("admin-verb-text-make-pirate");
Verb pirate = new()
{
- Text = Loc.GetString("admin-verb-text-make-pirate"),
+ Text = pirateName,
Category = VerbCategory.Antag,
Icon = new SpriteSpecifier.Rsi(new("/Textures/Clothing/Head/Hats/pirate.rsi"), "icon"),
Act = () =>
@@ -142,13 +146,14 @@ public sealed partial class AdminVerbSystem
SetOutfitCommand.SetOutfit(args.Target, PirateGearId, EntityManager);
},
Impact = LogImpact.High,
- Message = Loc.GetString("admin-verb-make-pirate"),
+ Message = string.Join(": ", pirateName, Loc.GetString("admin-verb-make-pirate")),
};
args.Verbs.Add(pirate);
+ var headRevName = Loc.GetString("admin-verb-text-make-head-rev");
Verb headRev = new()
{
- Text = Loc.GetString("admin-verb-text-make-head-rev"),
+ Text = headRevName,
Category = VerbCategory.Antag,
Icon = new SpriteSpecifier.Rsi(new("/Textures/Interface/Misc/job_icons.rsi"), "HeadRevolutionary"),
Act = () =>
@@ -156,13 +161,14 @@ public sealed partial class AdminVerbSystem
_antag.ForceMakeAntag(targetPlayer, DefaultRevsRule);
},
Impact = LogImpact.High,
- Message = Loc.GetString("admin-verb-make-head-rev"),
+ Message = string.Join(": ", headRevName, Loc.GetString("admin-verb-make-head-rev")),
};
args.Verbs.Add(headRev);
+ var thiefName = Loc.GetString("admin-verb-text-make-thief");
Verb thief = new()
{
- Text = Loc.GetString("admin-verb-text-make-thief"),
+ Text = thiefName,
Category = VerbCategory.Antag,
Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Clothing/Hands/Gloves/Color/black.rsi"), "icon"),
Act = () =>
@@ -170,7 +176,7 @@ public sealed partial class AdminVerbSystem
_antag.ForceMakeAntag(targetPlayer, DefaultThiefRule);
},
Impact = LogImpact.High,
- Message = Loc.GetString("admin-verb-make-thief"),
+ Message = string.Join(": ", thiefName, Loc.GetString("admin-verb-make-thief")),
};
args.Verbs.Add(thief);
*/
diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.Smites.cs b/Content.Server/Administration/Systems/AdminVerbSystem.Smites.cs
index 0459b4bdd6..8d56ebf0ff 100644
--- a/Content.Server/Administration/Systems/AdminVerbSystem.Smites.cs
+++ b/Content.Server/Administration/Systems/AdminVerbSystem.Smites.cs
@@ -149,7 +149,7 @@ public sealed partial class AdminVerbSystem
var flamesName = Loc.GetString("admin-smite-set-alight-name").ToLowerInvariant();
Verb flames = new()
{
- Text = "admin-smite-set-alight-name",
+ Text = flamesName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/Alerts/Fire/fire.png")),
Act = () =>
@@ -481,7 +481,7 @@ public sealed partial class AdminVerbSystem
var breadName = Loc.GetString("admin-smite-become-bread-name").ToLowerInvariant(); // Will I get cancelled for breadName-ing you?
Verb bread = new()
{
- Text = "admin-smite-kill-sign-name",
+ Text = breadName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Objects/Consumable/Food/Baked/bread.rsi"), "plain"),
Act = () =>
@@ -496,7 +496,7 @@ public sealed partial class AdminVerbSystem
var mouseName = Loc.GetString("admin-smite-become-mouse-name").ToLowerInvariant();
Verb mouse = new()
{
- Text = "admin-smite-cluwne-name",
+ Text = mouseName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Mobs/Animals/mouse.rsi"), "icon-0"),
Act = () =>
@@ -650,7 +650,7 @@ public sealed partial class AdminVerbSystem
var instrumentationName = Loc.GetString("admin-smite-become-instrument-name").ToLowerInvariant();
Verb instrumentation = new()
{
- Text = "admin-smite-become-mouse-name",
+ Text = instrumentationName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Objects/Fun/Instruments/h_synthesizer.rsi"), "icon"),
Act = () =>
@@ -721,7 +721,7 @@ public sealed partial class AdminVerbSystem
var headstandName = Loc.GetString("admin-smite-headstand-name").ToLowerInvariant();
Verb headstand = new()
{
- Text = "admin-smite-run-walk-swap-name",
+ Text = headstandName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/VerbIcons/refresh.svg.192dpi.png")),
Act = () =>
@@ -819,7 +819,7 @@ public sealed partial class AdminVerbSystem
var superSpeedName = Loc.GetString("admin-smite-super-speed-name").ToLowerInvariant();
Verb superSpeed = new()
{
- Text = "admin-smite-garbage-can-name",
+ Text = superSpeedName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/AdminActions/super_speed.png")),
Act = () =>
@@ -852,7 +852,7 @@ public sealed partial class AdminVerbSystem
args.Verbs.Add(superBonkLite);
var superBonkName = Loc.GetString("admin-smite-super-bonk-name").ToLowerInvariant();
- Verb superBonk= new()
+ Verb superBonk = new()
{
Text = superBonkName,
Category = VerbCategory.Smite,
diff --git a/Content.Server/Advertise/EntitySystems/SpeakOnUIClosedSystem.cs b/Content.Server/Advertise/EntitySystems/SpeakOnUIClosedSystem.cs
index a0a709e5fa..3fca640d4a 100644
--- a/Content.Server/Advertise/EntitySystems/SpeakOnUIClosedSystem.cs
+++ b/Content.Server/Advertise/EntitySystems/SpeakOnUIClosedSystem.cs
@@ -1,13 +1,13 @@
-using Content.Server.Advertise.Components;
using Content.Server.Chat.Systems;
-using Content.Shared.Dataset;
+using Content.Shared.Advertise.Components;
+using Content.Shared.Advertise.Systems;
+using Content.Shared.UserInterface;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
-using ActivatableUIComponent = Content.Shared.UserInterface.ActivatableUIComponent;
-namespace Content.Server.Advertise;
+namespace Content.Server.Advertise.EntitySystems;
-public sealed partial class SpeakOnUIClosedSystem : EntitySystem
+public sealed partial class SpeakOnUIClosedSystem : SharedSpeakOnUIClosedSystem
{
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
@@ -46,13 +46,4 @@ public sealed partial class SpeakOnUIClosedSystem : EntitySystem
entity.Comp.Flag = false;
return true;
}
-
- public bool TrySetFlag(Entity entity, bool value = true)
- {
- if (!Resolve(entity, ref entity.Comp))
- return false;
-
- entity.Comp.Flag = value;
- return true;
- }
}
diff --git a/Content.Server/Antag/AntagSelectionSystem.API.cs b/Content.Server/Antag/AntagSelectionSystem.API.cs
index a4cb6fd0e5..93b5fa6136 100644
--- a/Content.Server/Antag/AntagSelectionSystem.API.cs
+++ b/Content.Server/Antag/AntagSelectionSystem.API.cs
@@ -3,7 +3,9 @@ using System.Linq;
using Content.Server.Antag.Components;
using Content.Server.GameTicking.Rules.Components;
using Content.Server.Objectives;
+using Content.Shared.Antag;
using Content.Shared.Chat;
+using Content.Shared.GameTicking.Components;
using Content.Shared.Mind;
using Content.Shared.Preferences;
using JetBrains.Annotations;
@@ -25,7 +27,7 @@ public sealed partial class AntagSelectionSystem
definition = null;
var totalTargetCount = GetTargetAntagCount(ent, players);
- var mindCount = ent.Comp.SelectedMinds.Count;
+ var mindCount = ent.Comp.AssignedMinds.Count;
if (mindCount >= totalTargetCount)
return false;
@@ -95,7 +97,7 @@ public sealed partial class AntagSelectionSystem
var countOffset = 0;
foreach (var otherDef in ent.Comp.Definitions)
{
- countOffset += Math.Clamp((poolSize - countOffset) / otherDef.PlayerRatio, otherDef.Min, otherDef.Max) * otherDef.PlayerRatio;
+ countOffset += Math.Clamp((poolSize - countOffset) / otherDef.PlayerRatio, otherDef.Min, otherDef.Max) * otherDef.PlayerRatio; // Note: Is the PlayerRatio necessary here? Seems like it can cause issues for defs with varied PlayerRatio.
}
// make sure we don't double-count the current selection
countOffset -= Math.Clamp(poolSize / def.PlayerRatio, def.Min, def.Max) * def.PlayerRatio;
@@ -115,7 +117,7 @@ public sealed partial class AntagSelectionSystem
return new List<(EntityUid, SessionData, string)>();
var output = new List<(EntityUid, SessionData, string)>();
- foreach (var (mind, name) in ent.Comp.SelectedMinds)
+ foreach (var (mind, name) in ent.Comp.AssignedMinds)
{
if (!TryComp(mind, out var mindComp) || mindComp.OriginalOwnerUserId == null)
continue;
@@ -137,7 +139,7 @@ public sealed partial class AntagSelectionSystem
return new();
var output = new List>();
- foreach (var (mind, _) in ent.Comp.SelectedMinds)
+ foreach (var (mind, _) in ent.Comp.AssignedMinds)
{
if (!TryComp(mind, out var mindComp) || mindComp.OriginalOwnerUserId == null)
continue;
@@ -155,7 +157,7 @@ public sealed partial class AntagSelectionSystem
if (!Resolve(ent, ref ent.Comp, false))
return new();
- return ent.Comp.SelectedMinds.Select(p => p.Item1).ToList();
+ return ent.Comp.AssignedMinds.Select(p => p.Item1).ToList();
}
///
@@ -247,7 +249,7 @@ public sealed partial class AntagSelectionSystem
if (!Resolve(ent, ref ent.Comp, false))
return false;
- return GetAliveAntagCount(ent) == ent.Comp.SelectedMinds.Count;
+ return GetAliveAntagCount(ent) == ent.Comp.AssignedMinds.Count;
}
///
@@ -352,8 +354,66 @@ public sealed partial class AntagSelectionSystem
var ruleEnt = GameTicker.AddGameRule(id);
RemComp(ruleEnt);
var antag = Comp(ruleEnt);
- antag.SelectionsComplete = true; // don't do normal selection.
+ antag.AssignmentComplete = true; // don't do normal selection.
GameTicker.StartGameRule(ruleEnt);
return (ruleEnt, antag);
}
+
+ ///
+ /// Get all sessions that have been preselected for antag.
+ ///
+ /// A specific definition to be excluded from the check.
+ public HashSet GetPreSelectedAntagSessions(AntagSelectionDefinition? except = null)
+ {
+ var result = new HashSet();
+ var query = QueryAllRules();
+ while (query.MoveNext(out var uid, out var comp, out _))
+ {
+ if (HasComp(uid))
+ continue;
+
+ foreach (var def in comp.Definitions)
+ {
+ if (def.Equals(except))
+ continue;
+
+ if (comp.PreSelectedSessions.TryGetValue(def, out var set))
+ result.UnionWith(set);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Get all sessions that have been preselected for antag and are exclusive, i.e. should not be paired with other antags.
+ ///
+ /// A specific definition to be excluded from the check.
+ // Note: This is a bit iffy since technically this exclusive definition is defined via the MultiAntagSetting, while there's a separately tracked antagExclusive variable in the mindrole.
+ // We can't query that however since there's no guarantee the mindrole has been given out yet when checking pre-selected antags.
+ // I don't think there's any instance where they differ, but it's something to be aware of for a potential future refactor.
+ public HashSet GetPreSelectedExclusiveAntagSessions(AntagSelectionDefinition? except = null)
+ {
+ var result = new HashSet();
+ var query = QueryAllRules();
+ while (query.MoveNext(out var uid, out var comp, out _))
+ {
+ if (HasComp(uid))
+ continue;
+
+ foreach (var def in comp.Definitions)
+ {
+ if (def.Equals(except))
+ continue;
+
+ if (def.MultiAntagSetting == AntagAcceptability.None && comp.PreSelectedSessions.TryGetValue(def, out var set))
+ {
+ result.UnionWith(set);
+ break;
+ }
+ }
+ }
+
+ return result;
+ }
}
diff --git a/Content.Server/Antag/AntagSelectionSystem.cs b/Content.Server/Antag/AntagSelectionSystem.cs
index ce7f3fec4c..c8b2a7d4bb 100644
--- a/Content.Server/Antag/AntagSelectionSystem.cs
+++ b/Content.Server/Antag/AntagSelectionSystem.cs
@@ -11,8 +11,11 @@ using Content.Server.Preferences.Managers;
using Content.Server.Roles;
using Content.Server.Roles.Jobs;
using Content.Server.Shuttles.Components;
+using Content.Server.Station.Events;
+using Content.Shared.Administration.Logs;
using Content.Shared.Antag;
using Content.Shared.Clothing;
+using Content.Shared.Database;
using Content.Shared.GameTicking;
using Content.Shared.GameTicking.Components;
using Content.Shared.Ghost;
@@ -46,6 +49,7 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem();
- while (query.MoveNext(out var uid, out _, out var antag, out _))
+ while (query.MoveNext(out var uid, out var antag, out _))
{
- rules.Add((uid, antag));
+ if (HasComp(uid) ||
+ (HasComp(uid) && antag.SelectionTime == AntagSelectionTime.IntraPlayerSpawn)) //IntraPlayerSpawn selects antags before spawning, but doesn't activate until after.
+ rules.Add((uid, antag));
}
RobustRandom.Shuffle(rules);
@@ -142,7 +163,7 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem p.LateJoinAdditional))
continue;
- DebugTools.AssertEqual(antag.SelectionTime, AntagSelectionTime.PostPlayerSpawn);
+ DebugTools.AssertNotEqual(antag.SelectionTime, AntagSelectionTime.PrePlayerSpawn);
// do not count players in the lobby for the antag ratio
var players = _playerManager.NetworkedSessions.Count(x => x.AttachedEntity != null);
@@ -150,7 +171,9 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem GameTicker.PlayerGameStatuses.TryGetValue(x.UserId, out var status) && status == PlayerGameStatus.JoinedGame)
- .ToList();
+ if (!component.PreSelectionsComplete)
+ {
+ var players = _playerManager.Sessions
+ .Where(x => GameTicker.PlayerGameStatuses.TryGetValue(x.UserId, out var status) &&
+ status == PlayerGameStatus.JoinedGame)
+ .ToList();
- ChooseAntags((uid, component), players, midround: true);
+ ChooseAntags((uid, component), players, midround: true);
+ }
+
+ AssignPreSelectedSessions((uid, component));
}
///
@@ -201,7 +230,7 @@ public sealed partial class AntagSelectionSystem : GameRuleSystemDisable picking players for pre-spawn antags in the middle of a round
public void ChooseAntags(Entity ent, IList pool, bool midround = false)
{
- if (ent.Comp.SelectionsComplete)
+ if (ent.Comp.PreSelectionsComplete)
return;
foreach (var def in ent.Comp.Definitions)
@@ -209,7 +238,7 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem
@@ -250,21 +279,53 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem x.Contains(session)))
{
Log.Warning($"Somehow picked {session} for an antag when this rule already selected them previously");
continue;
}
}
- MakeAntag(ent, session, def);
+ if (session == null)
+ MakeAntag(ent, null, def); // This is for spawner antags
+ else
+ {
+ if (!ent.Comp.PreSelectedSessions.TryGetValue(def, out var set))
+ ent.Comp.PreSelectedSessions.Add(def, set = new HashSet());
+ set.Add(session); // Selection done!
+ Log.Debug($"Pre-selected {session.Name} as antagonist: {ToPrettyString(ent)}");
+ _adminLogger.Add(LogType.AntagSelection, $"Pre-selected {session.Name} as antagonist: {ToPrettyString(ent)}");
+ }
}
}
+ ///
+ /// Assigns antag roles to sessions selected for it.
+ ///
+ public void AssignPreSelectedSessions(Entity ent)
+ {
+ // Only assign if there's been a pre-selection, and the selection hasn't already been made
+ if (!ent.Comp.PreSelectionsComplete || ent.Comp.AssignmentComplete)
+ return;
+
+ foreach (var def in ent.Comp.Definitions)
+ {
+ if (!ent.Comp.PreSelectedSessions.TryGetValue(def, out var set))
+ continue;
+
+ foreach (var session in set)
+ {
+ TryMakeAntag(ent, session, def);
+ }
+ }
+
+ ent.Comp.AssignmentComplete = true;
+ }
+
///
/// Tries to makes a given player into the specified antagonist.
///
- public bool TryMakeAntag(Entity ent, ICommonSession? session, AntagSelectionDefinition def, bool ignoreSpawner = false, bool checkPref = true)
+ public bool TryMakeAntag(Entity ent, ICommonSession? session, AntagSelectionDefinition def, bool ignoreSpawner = false, bool checkPref = true, bool onlyPreSelect = false)
{
if (checkPref && !HasPrimaryAntagPreference(session, def))
return false;
@@ -272,7 +333,19 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem());
+ set.Add(session);
+ Log.Debug($"Pre-selected {session!.Name} as antagonist: {ToPrettyString(ent)}");
+ _adminLogger.Add(LogType.AntagSelection, $"Pre-selected {session.Name} as antagonist: {ToPrettyString(ent)}");
+ }
+ else
+ {
+ MakeAntag(ent, session, def, ignoreSpawner);
+ }
+
return true;
}
@@ -286,7 +359,10 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem());
+ set.Add(session);
+ ent.Comp.AssignedSessions.Add(session);
// we shouldn't be blocking the entity if they're just a ghost or smth.
if (!HasComp(session.AttachedEntity))
@@ -309,7 +385,11 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem
- /// Has the primary selection of antagonists finished yet?
+ /// Has the primary assignment of antagonists finished yet?
///
[DataField]
- public bool SelectionsComplete;
+ public bool AssignmentComplete;
+
+ ///
+ /// Has the antagonists been preselected but yet to be fully assigned?
+ ///
+ [DataField]
+ public bool PreSelectionsComplete;
///
/// The definitions for the antagonists
@@ -26,10 +32,10 @@ public sealed partial class AntagSelectionComponent : Component
public List Definitions = new();
///
- /// The minds and original names of the players selected to be antagonists.
+ /// The minds and original names of the players assigned to be antagonists.
///
[DataField]
- public List<(EntityUid, string)> SelectedMinds = new();
+ public List<(EntityUid, string)> AssignedMinds = new();
///
/// When the antag selection will occur.
@@ -37,11 +43,17 @@ public sealed partial class AntagSelectionComponent : Component
[DataField]
public AntagSelectionTime SelectionTime = AntagSelectionTime.PostPlayerSpawn;
+ ///
+ /// Cached sessions of antag definitions and selected players. Players in this dict are not guaranteed to have been assigned the role yet.
+ ///
+ [DataField]
+ public Dictionary>PreSelectedSessions = new();
+
///
/// Cached sessions of players who are chosen. Used so we don't have to rebuild the pool multiple times in a tick.
/// Is not serialized.
///
- public HashSet SelectedSessions = new();
+ public HashSet AssignedSessions = new();
///
/// Locale id for the name of the antag.
diff --git a/Content.Server/Arcade/BlockGame/BlockGameArcadeSystem.cs b/Content.Server/Arcade/BlockGame/BlockGameArcadeSystem.cs
index b0bf389509..a0e52e9b48 100644
--- a/Content.Server/Arcade/BlockGame/BlockGameArcadeSystem.cs
+++ b/Content.Server/Arcade/BlockGame/BlockGameArcadeSystem.cs
@@ -1,11 +1,9 @@
-using Content.Server.Power.Components;
using Content.Shared.UserInterface;
-using Content.Server.Advertise;
-using Content.Server.Advertise.Components;
+using Content.Server.Advertise.EntitySystems;
+using Content.Shared.Advertise.Components;
using Content.Shared.Arcade;
using Content.Shared.Power;
using Robust.Server.GameObjects;
-using Robust.Shared.Player;
namespace Content.Server.Arcade.BlockGame;
diff --git a/Content.Server/Arcade/SpaceVillainGame/SpaceVillainArcadeSystem.cs b/Content.Server/Arcade/SpaceVillainGame/SpaceVillainArcadeSystem.cs
index b359a13bd1..2070ab8bfe 100644
--- a/Content.Server/Arcade/SpaceVillainGame/SpaceVillainArcadeSystem.cs
+++ b/Content.Server/Arcade/SpaceVillainGame/SpaceVillainArcadeSystem.cs
@@ -1,9 +1,9 @@
using Content.Server.Power.Components;
using Content.Shared.UserInterface;
-using Content.Server.Advertise;
-using Content.Server.Advertise.Components;
+using Content.Server.Advertise.EntitySystems;
+using Content.Shared.Advertise.Components;
+using Content.Shared.Arcade;
using Content.Shared.Power;
-using static Content.Shared.Arcade.SharedSpaceVillainArcadeComponent;
using Robust.Server.GameObjects;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
@@ -24,7 +24,7 @@ public sealed partial class SpaceVillainArcadeSystem : EntitySystem
SubscribeLocalEvent(OnComponentInit);
SubscribeLocalEvent(OnAfterUIOpenSV);
- SubscribeLocalEvent(OnSVPlayerAction);
+ SubscribeLocalEvent(OnSVPlayerAction);
SubscribeLocalEvent(OnSVillainPower);
}
@@ -70,7 +70,7 @@ public sealed partial class SpaceVillainArcadeSystem : EntitySystem
component.RewardAmount = new Random().Next(component.RewardMinAmount, component.RewardMaxAmount + 1);
}
- private void OnSVPlayerAction(EntityUid uid, SpaceVillainArcadeComponent component, SpaceVillainArcadePlayerActionMessage msg)
+ private void OnSVPlayerAction(EntityUid uid, SpaceVillainArcadeComponent component, SharedSpaceVillainArcadeComponent.SpaceVillainArcadePlayerActionMessage msg)
{
if (component.Game == null)
return;
@@ -79,22 +79,22 @@ public sealed partial class SpaceVillainArcadeSystem : EntitySystem
switch (msg.PlayerAction)
{
- case PlayerAction.Attack:
- case PlayerAction.Heal:
- case PlayerAction.Recharge:
+ case SharedSpaceVillainArcadeComponent.PlayerAction.Attack:
+ case SharedSpaceVillainArcadeComponent.PlayerAction.Heal:
+ case SharedSpaceVillainArcadeComponent.PlayerAction.Recharge:
component.Game.ExecutePlayerAction(uid, msg.PlayerAction, component);
// Any sort of gameplay action counts
if (TryComp(uid, out var speakComponent))
_speakOnUIClosed.TrySetFlag((uid, speakComponent));
break;
- case PlayerAction.NewGame:
+ case SharedSpaceVillainArcadeComponent.PlayerAction.NewGame:
_audioSystem.PlayPvs(component.NewGameSound, uid, AudioParams.Default.WithVolume(-4f));
component.Game = new SpaceVillainGame(uid, component, this);
- _uiSystem.ServerSendUiMessage(uid, SpaceVillainArcadeUiKey.Key, component.Game.GenerateMetaDataMessage());
+ _uiSystem.ServerSendUiMessage(uid, SharedSpaceVillainArcadeComponent.SpaceVillainArcadeUiKey.Key, component.Game.GenerateMetaDataMessage());
break;
- case PlayerAction.RequestData:
- _uiSystem.ServerSendUiMessage(uid, SpaceVillainArcadeUiKey.Key, component.Game.GenerateMetaDataMessage());
+ case SharedSpaceVillainArcadeComponent.PlayerAction.RequestData:
+ _uiSystem.ServerSendUiMessage(uid, SharedSpaceVillainArcadeComponent.SpaceVillainArcadeUiKey.Key, component.Game.GenerateMetaDataMessage());
break;
}
}
@@ -109,6 +109,6 @@ public sealed partial class SpaceVillainArcadeSystem : EntitySystem
if (TryComp(uid, out var power) && power.Powered)
return;
- _uiSystem.CloseUi(uid, SpaceVillainArcadeUiKey.Key);
+ _uiSystem.CloseUi(uid, SharedSpaceVillainArcadeComponent.SpaceVillainArcadeUiKey.Key);
}
}
diff --git a/Content.Server/Body/Systems/BloodstreamSystem.cs b/Content.Server/Body/Systems/BloodstreamSystem.cs
index d04a993226..6dc03fed74 100644
--- a/Content.Server/Body/Systems/BloodstreamSystem.cs
+++ b/Content.Server/Body/Systems/BloodstreamSystem.cs
@@ -1,7 +1,6 @@
using Content.Server.Body.Components;
using Content.Server.EntityEffects.Effects;
using Content.Server.Fluids.EntitySystems;
-using Content.Server.Forensics;
using Content.Server.Popups;
using Content.Shared.Alert;
using Content.Shared.Chemistry.Components;
@@ -40,7 +39,6 @@ public sealed class BloodstreamSystem : EntitySystem
[Dependency] private readonly SharedSolutionContainerSystem _solutionContainerSystem = default!;
[Dependency] private readonly SharedStutteringSystem _stutteringSystem = default!;
[Dependency] private readonly AlertsSystem _alertsSystem = default!;
- [Dependency] private readonly ForensicsSystem _forensicsSystem = default!;
public override void Initialize()
{
@@ -193,17 +191,8 @@ public sealed class BloodstreamSystem : EntitySystem
bloodSolution.MaxVolume = entity.Comp.BloodMaxVolume;
tempSolution.MaxVolume = entity.Comp.BleedPuddleThreshold * 4; // give some leeway, for chemstream as well
- // Ensure blood that should have DNA has it; must be run here, in case DnaComponent has not yet been initialized
-
- if (TryComp(entity.Owner, out var donorComp) && donorComp.DNA == String.Empty)
- {
- donorComp.DNA = _forensicsSystem.GenerateDNA();
-
- var ev = new GenerateDnaEvent { Owner = entity.Owner, DNA = donorComp.DNA };
- RaiseLocalEvent(entity.Owner, ref ev);
- }
-
// Fill blood solution with BLOOD
+ // The DNA string might not be initialized yet, but the reagent data gets updated in the GenerateDnaEvent subscription
bloodSolution.AddReagent(new ReagentId(entity.Comp.BloodReagent, GetEntityBloodData(entity.Owner)), entity.Comp.BloodMaxVolume - bloodSolution.Volume);
}
@@ -492,6 +481,8 @@ public sealed class BloodstreamSystem : EntitySystem
reagentData.AddRange(GetEntityBloodData(entity.Owner));
}
}
+ else
+ Log.Error("Unable to set bloodstream DNA, solution entity could not be resolved");
}
///
@@ -502,13 +493,10 @@ public sealed class BloodstreamSystem : EntitySystem
var bloodData = new List();
var dnaData = new DnaData();
- if (TryComp(uid, out var donorComp))
- {
+ if (TryComp(uid, out var donorComp) && donorComp.DNA != null)
dnaData.DNA = donorComp.DNA;
- } else
- {
+ else
dnaData.DNA = Loc.GetString("forensics-dna-unknown");
- }
bloodData.Add(dnaData);
diff --git a/Content.Server/Cargo/Systems/CargoSystem.Orders.cs b/Content.Server/Cargo/Systems/CargoSystem.Orders.cs
index 1552f13678..9b6407c689 100644
--- a/Content.Server/Cargo/Systems/CargoSystem.Orders.cs
+++ b/Content.Server/Cargo/Systems/CargoSystem.Orders.cs
@@ -62,7 +62,7 @@ namespace Content.Server.Cargo.Systems
return;
_audio.PlayPvs(component.ConfirmSound, uid);
- UpdateBankAccount(stationUid.Value, bank, (int) price);
+ UpdateBankAccount((stationUid.Value, bank), (int) price);
QueueDel(args.Used);
args.Handled = true;
}
@@ -103,7 +103,7 @@ namespace Content.Server.Cargo.Systems
while (stationQuery.MoveNext(out var uid, out var bank))
{
var balanceToAdd = bank.IncreasePerSecond * Delay;
- UpdateBankAccount(uid, bank, balanceToAdd);
+ UpdateBankAccount((uid, bank), balanceToAdd);
}
var query = EntityQueryEnumerator();
@@ -229,7 +229,7 @@ namespace Content.Server.Cargo.Systems
$"{ToPrettyString(player):user} approved order [orderId:{order.OrderId}, quantity:{order.OrderQuantity}, product:{order.ProductId}, requester:{order.Requester}, reason:{order.Reason}] with balance at {bank.Balance}");
orderDatabase.Orders.Remove(order);
- UpdateBankAccount(station.Value, bank, -cost);
+ UpdateBankAccount((station.Value, bank), -cost);
UpdateOrders(station.Value);
}
diff --git a/Content.Server/Cargo/Systems/CargoSystem.cs b/Content.Server/Cargo/Systems/CargoSystem.cs
index b93a0f3315..1b776b8bd0 100644
--- a/Content.Server/Cargo/Systems/CargoSystem.cs
+++ b/Content.Server/Cargo/Systems/CargoSystem.cs
@@ -76,19 +76,23 @@ public sealed partial class CargoSystem : SharedCargoSystem
}
[PublicAPI]
- public void UpdateBankAccount(EntityUid uid, StationBankAccountComponent component, int balanceAdded)
+ public void UpdateBankAccount(Entity ent, int balanceAdded)
{
- component.Balance += balanceAdded;
- var query = EntityQueryEnumerator();
+ if (!Resolve(ent, ref ent.Comp))
+ return;
- var ev = new BankBalanceUpdatedEvent(uid, component.Balance);
+ ent.Comp.Balance += balanceAdded;
+
+ var ev = new BankBalanceUpdatedEvent(ent, ent.Comp.Balance);
+
+ var query = EntityQueryEnumerator();
while (query.MoveNext(out var client, out var comp, out var xform))
{
var station = _station.GetOwningStation(client, xform);
- if (station != uid)
+ if (station != ent)
continue;
- comp.Balance = component.Balance;
+ comp.Balance = ent.Comp.Balance;
Dirty(client, comp);
RaiseLocalEvent(client, ref ev);
}
diff --git a/Content.Server/Cloning/AcceptCloningEui.cs b/Content.Server/Cloning/AcceptCloningEui.cs
index 3d4356f8ca..2d1ea93fdb 100644
--- a/Content.Server/Cloning/AcceptCloningEui.cs
+++ b/Content.Server/Cloning/AcceptCloningEui.cs
@@ -9,13 +9,13 @@ namespace Content.Server.Cloning
{
private readonly EntityUid _mindId;
private readonly MindComponent _mind;
- private readonly CloningSystem _cloningSystem;
+ private readonly CloningPodSystem _cloningPodSystem;
- public AcceptCloningEui(EntityUid mindId, MindComponent mind, CloningSystem cloningSys)
+ public AcceptCloningEui(EntityUid mindId, MindComponent mind, CloningPodSystem cloningPodSys)
{
_mindId = mindId;
_mind = mind;
- _cloningSystem = cloningSys;
+ _cloningPodSystem = cloningPodSys;
}
public override void HandleMessage(EuiMessageBase msg)
@@ -29,7 +29,7 @@ namespace Content.Server.Cloning
return;
}
- _cloningSystem.TransferMindToClone(_mindId, _mind);
+ _cloningPodSystem.TransferMindToClone(_mindId, _mind);
Close();
}
}
diff --git a/Content.Server/Cloning/CloningConsoleSystem.cs b/Content.Server/Cloning/CloningConsoleSystem.cs
index 050e2b7f06..39eac842f0 100644
--- a/Content.Server/Cloning/CloningConsoleSystem.cs
+++ b/Content.Server/Cloning/CloningConsoleSystem.cs
@@ -3,7 +3,6 @@ using Content.Server.Administration.Logs;
using Content.Server.Cloning.Components;
using Content.Server.DeviceLinking.Systems;
using Content.Server.Medical.Components;
-using Content.Server.Power.Components;
using Content.Server.Power.EntitySystems;
using Content.Shared.UserInterface;
using Content.Shared.Cloning;
@@ -16,19 +15,17 @@ using Content.Shared.Mind;
using Content.Shared.Mobs.Components;
using Content.Shared.Mobs.Systems;
using Content.Shared.Power;
-using JetBrains.Annotations;
using Robust.Server.GameObjects;
using Robust.Server.Player;
namespace Content.Server.Cloning
{
- [UsedImplicitly]
public sealed class CloningConsoleSystem : EntitySystem
{
[Dependency] private readonly DeviceLinkSystem _signalSystem = default!;
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
- [Dependency] private readonly CloningSystem _cloningSystem = default!;
+ [Dependency] private readonly CloningPodSystem _cloningPodSystem = default!;
[Dependency] private readonly UserInterfaceSystem _uiSystem = default!;
[Dependency] private readonly MobStateSystem _mobStateSystem = default!;
[Dependency] private readonly PowerReceiverSystem _powerReceiverSystem = default!;
@@ -171,7 +168,7 @@ namespace Content.Server.Cloning
if (mind.UserId.HasValue == false || mind.Session == null)
return;
- if (_cloningSystem.TryCloning(cloningPodUid, body.Value, (mindId, mind), cloningPod, scannerComp.CloningFailChanceMultiplier))
+ if (_cloningPodSystem.TryCloning(cloningPodUid, body.Value, (mindId, mind), cloningPod, scannerComp.CloningFailChanceMultiplier))
_adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(uid)} successfully cloned {ToPrettyString(body.Value)}.");
}
diff --git a/Content.Server/Cloning/CloningPodSystem.cs b/Content.Server/Cloning/CloningPodSystem.cs
new file mode 100644
index 0000000000..594c5ebbb6
--- /dev/null
+++ b/Content.Server/Cloning/CloningPodSystem.cs
@@ -0,0 +1,323 @@
+using Content.Server.Atmos.EntitySystems;
+using Content.Server.Chat.Systems;
+using Content.Server.Cloning.Components;
+using Content.Server.DeviceLinking.Systems;
+using Content.Server.EUI;
+using Content.Server.Fluids.EntitySystems;
+using Content.Server.Materials;
+using Content.Server.Popups;
+using Content.Server.Power.EntitySystems;
+using Content.Shared.Atmos;
+using Content.Shared.CCVar;
+using Content.Shared.Chemistry.Components;
+using Content.Shared.Cloning;
+using Content.Shared.Damage;
+using Content.Shared.DeviceLinking.Events;
+using Content.Shared.Emag.Components;
+using Content.Shared.Emag.Systems;
+using Content.Shared.Examine;
+using Content.Shared.GameTicking;
+using Content.Shared.Mind;
+using Content.Shared.Mind.Components;
+using Content.Shared.Mobs.Systems;
+using Robust.Server.Containers;
+using Robust.Server.Player;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Configuration;
+using Robust.Shared.Containers;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server.Cloning;
+
+public sealed class CloningPodSystem : EntitySystem
+{
+ [Dependency] private readonly DeviceLinkSystem _signalSystem = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = null!;
+ [Dependency] private readonly EuiManager _euiManager = null!;
+ [Dependency] private readonly CloningConsoleSystem _cloningConsoleSystem = default!;
+ [Dependency] private readonly ContainerSystem _containerSystem = default!;
+ [Dependency] private readonly MobStateSystem _mobStateSystem = default!;
+ [Dependency] private readonly PowerReceiverSystem _powerReceiverSystem = default!;
+ [Dependency] private readonly IRobustRandom _robustRandom = default!;
+ [Dependency] private readonly AtmosphereSystem _atmosphereSystem = default!;
+ [Dependency] private readonly SharedTransformSystem _transformSystem = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly PuddleSystem _puddleSystem = default!;
+ [Dependency] private readonly ChatSystem _chatSystem = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly IConfigurationManager _configManager = default!;
+ [Dependency] private readonly MaterialStorageSystem _material = default!;
+ [Dependency] private readonly PopupSystem _popupSystem = default!;
+ [Dependency] private readonly SharedMindSystem _mindSystem = default!;
+ [Dependency] private readonly CloningSystem _cloning = default!;
+ [Dependency] private readonly EmagSystem _emag = default!;
+
+ public readonly Dictionary ClonesWaitingForMind = new();
+ public readonly ProtoId SettingsId = "CloningPod";
+ public const float EasyModeCloningCost = 0.7f;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(Reset);
+ SubscribeLocalEvent(HandleMindAdded);
+ SubscribeLocalEvent(OnComponentInit);
+ SubscribeLocalEvent(OnPortDisconnected);
+ SubscribeLocalEvent(OnAnchor);
+ SubscribeLocalEvent(OnExamined);
+ SubscribeLocalEvent(OnEmagged);
+ }
+
+ private void OnComponentInit(Entity ent, ref ComponentInit args)
+ {
+ ent.Comp.BodyContainer = _containerSystem.EnsureContainer(ent.Owner, "clonepod-bodyContainer");
+ _signalSystem.EnsureSinkPorts(ent.Owner, ent.Comp.PodPort);
+ }
+
+ internal void TransferMindToClone(EntityUid mindId, MindComponent mind)
+ {
+ if (!ClonesWaitingForMind.TryGetValue(mind, out var entity) ||
+ !EntityManager.EntityExists(entity) ||
+ !TryComp(entity, out var mindComp) ||
+ mindComp.Mind != null)
+ return;
+
+ _mindSystem.TransferTo(mindId, entity, ghostCheckOverride: true, mind: mind);
+ _mindSystem.UnVisit(mindId, mind);
+ ClonesWaitingForMind.Remove(mind);
+ }
+
+ private void HandleMindAdded(EntityUid uid, BeingClonedComponent clonedComponent, MindAddedMessage message)
+ {
+ if (clonedComponent.Parent == EntityUid.Invalid ||
+ !EntityManager.EntityExists(clonedComponent.Parent) ||
+ !TryComp(clonedComponent.Parent, out var cloningPodComponent) ||
+ uid != cloningPodComponent.BodyContainer.ContainedEntity)
+ {
+ EntityManager.RemoveComponent(uid);
+ return;
+ }
+ UpdateStatus(clonedComponent.Parent, CloningPodStatus.Cloning, cloningPodComponent);
+ }
+ private void OnPortDisconnected(Entity ent, ref PortDisconnectedEvent args)
+ {
+ ent.Comp.ConnectedConsole = null;
+ }
+
+ private void OnAnchor(Entity ent, ref AnchorStateChangedEvent args)
+ {
+ if (ent.Comp.ConnectedConsole == null || !TryComp(ent.Comp.ConnectedConsole, out var console))
+ return;
+
+ if (args.Anchored)
+ {
+ _cloningConsoleSystem.RecheckConnections(ent.Comp.ConnectedConsole.Value, ent.Owner, console.GeneticScanner, console);
+ return;
+ }
+ _cloningConsoleSystem.UpdateUserInterface(ent.Comp.ConnectedConsole.Value, console);
+ }
+
+ private void OnExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange || !_powerReceiverSystem.IsPowered(ent.Owner))
+ return;
+
+ args.PushMarkup(Loc.GetString("cloning-pod-biomass", ("number", _material.GetMaterialAmount(ent.Owner, ent.Comp.RequiredMaterial))));
+ }
+
+ public bool TryCloning(EntityUid uid, EntityUid bodyToClone, Entity mindEnt, CloningPodComponent? clonePod, float failChanceModifier = 1)
+ {
+ if (!Resolve(uid, ref clonePod))
+ return false;
+
+ if (HasComp(uid))
+ return false;
+
+ var mind = mindEnt.Comp;
+ if (ClonesWaitingForMind.TryGetValue(mind, out var clone))
+ {
+ if (EntityManager.EntityExists(clone) &&
+ !_mobStateSystem.IsDead(clone) &&
+ TryComp(clone, out var cloneMindComp) &&
+ (cloneMindComp.Mind == null || cloneMindComp.Mind == mindEnt))
+ return false; // Mind already has clone
+
+ ClonesWaitingForMind.Remove(mind);
+ }
+
+ if (mind.OwnedEntity != null && !_mobStateSystem.IsDead(mind.OwnedEntity.Value))
+ return false; // Body controlled by mind is not dead
+
+ // Yes, we still need to track down the client because we need to open the Eui
+ if (mind.UserId == null || !_playerManager.TryGetSessionById(mind.UserId.Value, out var client))
+ return false; // If we can't track down the client, we can't offer transfer. That'd be quite bad.
+
+ if (!TryComp(bodyToClone, out var physics))
+ return false;
+
+ var cloningCost = (int)Math.Round(physics.FixturesMass);
+
+ if (_configManager.GetCVar(CCVars.BiomassEasyMode))
+ cloningCost = (int)Math.Round(cloningCost * EasyModeCloningCost);
+
+ // biomass checks
+ var biomassAmount = _material.GetMaterialAmount(uid, clonePod.RequiredMaterial);
+
+ if (biomassAmount < cloningCost)
+ {
+ if (clonePod.ConnectedConsole != null)
+ _chatSystem.TrySendInGameICMessage(clonePod.ConnectedConsole.Value, Loc.GetString("cloning-console-chat-error", ("units", cloningCost)), InGameICChatType.Speak, false);
+ return false;
+ }
+
+ // end of biomass checks
+
+ // genetic damage checks
+ if (TryComp(bodyToClone, out var damageable) &&
+ damageable.Damage.DamageDict.TryGetValue("Cellular", out var cellularDmg))
+ {
+ var chance = Math.Clamp((float)(cellularDmg / 100), 0, 1);
+ chance *= failChanceModifier;
+
+ if (cellularDmg > 0 && clonePod.ConnectedConsole != null)
+ _chatSystem.TrySendInGameICMessage(clonePod.ConnectedConsole.Value, Loc.GetString("cloning-console-cellular-warning", ("percent", Math.Round(100 - chance * 100))), InGameICChatType.Speak, false);
+
+ if (_robustRandom.Prob(chance))
+ {
+ clonePod.FailedClone = true;
+ UpdateStatus(uid, CloningPodStatus.Gore, clonePod);
+ AddComp(uid);
+ _material.TryChangeMaterialAmount(uid, clonePod.RequiredMaterial, -cloningCost);
+ clonePod.UsedBiomass = cloningCost;
+ return true;
+ }
+ }
+ // end of genetic damage checks
+
+ if (!_cloning.TryCloning(bodyToClone, _transformSystem.GetMapCoordinates(bodyToClone), SettingsId, out var mob)) // spawn a new body
+ {
+ if (clonePod.ConnectedConsole != null)
+ _chatSystem.TrySendInGameICMessage(clonePod.ConnectedConsole.Value, Loc.GetString("cloning-console-uncloneable-trait-error"), InGameICChatType.Speak, false);
+ return false;
+ }
+
+ var cloneMindReturn = EntityManager.AddComponent(mob.Value);
+ cloneMindReturn.Mind = mind;
+ cloneMindReturn.Parent = uid;
+ _containerSystem.Insert(mob.Value, clonePod.BodyContainer);
+ ClonesWaitingForMind.Add(mind, mob.Value);
+ _euiManager.OpenEui(new AcceptCloningEui(mindEnt, mind, this), client);
+
+ UpdateStatus(uid, CloningPodStatus.NoMind, clonePod);
+ AddComp(uid);
+ _material.TryChangeMaterialAmount(uid, clonePod.RequiredMaterial, -cloningCost);
+ clonePod.UsedBiomass = cloningCost;
+ return true;
+ }
+
+ public void UpdateStatus(EntityUid podUid, CloningPodStatus status, CloningPodComponent cloningPod)
+ {
+ cloningPod.Status = status;
+ _appearance.SetData(podUid, CloningPodVisuals.Status, cloningPod.Status);
+ }
+
+ public override void Update(float frameTime)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var _, out var cloning))
+ {
+ if (!_powerReceiverSystem.IsPowered(uid))
+ continue;
+
+ if (cloning.BodyContainer.ContainedEntity == null && !cloning.FailedClone)
+ continue;
+
+ cloning.CloningProgress += frameTime;
+ if (cloning.CloningProgress < cloning.CloningTime)
+ continue;
+
+ if (cloning.FailedClone)
+ EndFailedCloning(uid, cloning);
+ else
+ Eject(uid, cloning);
+ }
+ }
+
+ ///
+ /// On emag, spawns a failed clone when cloning process fails which attacks nearby crew.
+ ///
+ private void OnEmagged(Entity ent, ref GotEmaggedEvent args)
+ {
+ if (!_emag.CompareFlag(args.Type, EmagType.Interaction))
+ return;
+
+ if (_emag.CheckFlag(ent.Owner, EmagType.Interaction))
+ return;
+
+ if (!this.IsPowered(ent.Owner, EntityManager))
+ return;
+
+ _popupSystem.PopupEntity(Loc.GetString("cloning-pod-component-upgrade-emag-requirement"), ent.Owner);
+ args.Handled = true;
+ }
+
+ public void Eject(EntityUid uid, CloningPodComponent? clonePod)
+ {
+ if (!Resolve(uid, ref clonePod))
+ return;
+
+ if (clonePod.BodyContainer.ContainedEntity is not { Valid: true } entity || clonePod.CloningProgress < clonePod.CloningTime)
+ return;
+
+ EntityManager.RemoveComponent(entity);
+ _containerSystem.Remove(entity, clonePod.BodyContainer);
+ clonePod.CloningProgress = 0f;
+ clonePod.UsedBiomass = 0;
+ UpdateStatus(uid, CloningPodStatus.Idle, clonePod);
+ RemCompDeferred(uid);
+ }
+
+ private void EndFailedCloning(EntityUid uid, CloningPodComponent clonePod)
+ {
+ clonePod.FailedClone = false;
+ clonePod.CloningProgress = 0f;
+ UpdateStatus(uid, CloningPodStatus.Idle, clonePod);
+ var transform = Transform(uid);
+ var indices = _transformSystem.GetGridTilePositionOrDefault((uid, transform));
+ var tileMix = _atmosphereSystem.GetTileMixture(transform.GridUid, null, indices, true);
+
+ if (HasComp(uid))
+ {
+ _audio.PlayPvs(clonePod.ScreamSound, uid);
+ Spawn(clonePod.MobSpawnId, transform.Coordinates);
+ }
+
+ Solution bloodSolution = new();
+
+ var i = 0;
+ while (i < 1)
+ {
+ tileMix?.AdjustMoles(Gas.Ammonia, 6f);
+ bloodSolution.AddReagent("Blood", 50);
+ if (_robustRandom.Prob(0.2f))
+ i++;
+ }
+ _puddleSystem.TrySpillAt(uid, bloodSolution, out _);
+
+ if (!HasComp(uid))
+ {
+ _material.SpawnMultipleFromMaterial(_robustRandom.Next(1, (int)(clonePod.UsedBiomass / 2.5)), clonePod.RequiredMaterial, Transform(uid).Coordinates);
+ }
+
+ clonePod.UsedBiomass = 0;
+ RemCompDeferred(uid);
+ }
+
+ public void Reset(RoundRestartCleanupEvent ev)
+ {
+ ClonesWaitingForMind.Clear();
+ }
+}
diff --git a/Content.Server/Cloning/CloningSystem.cs b/Content.Server/Cloning/CloningSystem.cs
index d8aac56515..937b311a59 100644
--- a/Content.Server/Cloning/CloningSystem.cs
+++ b/Content.Server/Cloning/CloningSystem.cs
@@ -1,350 +1,123 @@
-using Content.Server.Atmos.EntitySystems;
-using Content.Server.Chat.Systems;
-using Content.Server.Cloning.Components;
-using Content.Server.DeviceLinking.Systems;
-using Content.Server.EUI;
-using Content.Server.Fluids.EntitySystems;
using Content.Server.Humanoid;
-using Content.Server.Jobs;
-using Content.Server.Materials;
-using Content.Server.Popups;
-using Content.Server.Power.EntitySystems;
-using Content.Shared.Atmos;
-using Content.Shared.CCVar;
-using Content.Shared.Chemistry.Components;
+using Content.Shared.Administration.Logs;
using Content.Shared.Cloning;
-using Content.Shared.Damage;
-using Content.Shared.DeviceLinking.Events;
-using Content.Shared.Emag.Components;
-using Content.Shared.Emag.Systems;
-using Content.Shared.Examine;
-using Content.Shared.GameTicking;
+using Content.Shared.Cloning.Events;
+using Content.Shared.Database;
using Content.Shared.Humanoid;
-using Content.Shared.Mind;
-using Content.Shared.Mind.Components;
-using Content.Shared.Mobs.Systems;
-using Content.Shared.Roles.Jobs;
-using Robust.Server.Containers;
-using Robust.Server.GameObjects;
-using Robust.Server.Player;
-using Robust.Shared.Audio.Systems;
-using Robust.Shared.Configuration;
-using Robust.Shared.Containers;
-using Robust.Shared.Physics.Components;
+using Content.Shared.Inventory;
+using Content.Shared.NameModifier.Components;
+using Content.Shared.StatusEffect;
+using Content.Shared.Whitelist;
+using Robust.Shared.Map;
using Robust.Shared.Prototypes;
-using Robust.Shared.Random;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
-namespace Content.Server.Cloning
+namespace Content.Server.Cloning;
+
+///
+/// System responsible for making a copy of a humanoid's body.
+/// For the cloning machines themselves look at CloningPodSystem, CloningConsoleSystem and MedicalScannerSystem instead.
+///
+public sealed class CloningSystem : EntitySystem
{
- public sealed class CloningSystem : EntitySystem
+ [Dependency] private readonly IComponentFactory _componentFactory = default!;
+ [Dependency] private readonly HumanoidAppearanceSystem _humanoidSystem = default!;
+ [Dependency] private readonly InventorySystem _inventory = default!;
+ [Dependency] private readonly MetaDataSystem _metaData = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+ [Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
+ [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
+
+ ///
+ /// Spawns a clone of the given humanoid mob at the specified location or in nullspace.
+ ///
+ public bool TryCloning(EntityUid original, MapCoordinates? coords, ProtoId settingsId, [NotNullWhen(true)] out EntityUid? clone)
{
- [Dependency] private readonly DeviceLinkSystem _signalSystem = default!;
- [Dependency] private readonly IPlayerManager _playerManager = null!;
- [Dependency] private readonly IPrototypeManager _prototype = default!;
- [Dependency] private readonly EuiManager _euiManager = null!;
- [Dependency] private readonly CloningConsoleSystem _cloningConsoleSystem = default!;
- [Dependency] private readonly HumanoidAppearanceSystem _humanoidSystem = default!;
- [Dependency] private readonly ContainerSystem _containerSystem = default!;
- [Dependency] private readonly MobStateSystem _mobStateSystem = default!;
- [Dependency] private readonly PowerReceiverSystem _powerReceiverSystem = default!;
- [Dependency] private readonly IRobustRandom _robustRandom = default!;
- [Dependency] private readonly AtmosphereSystem _atmosphereSystem = default!;
- [Dependency] private readonly TransformSystem _transformSystem = default!;
- [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
- [Dependency] private readonly PuddleSystem _puddleSystem = default!;
- [Dependency] private readonly ChatSystem _chatSystem = default!;
- [Dependency] private readonly SharedAudioSystem _audio = default!;
- [Dependency] private readonly IConfigurationManager _configManager = default!;
- [Dependency] private readonly MaterialStorageSystem _material = default!;
- [Dependency] private readonly PopupSystem _popupSystem = default!;
- [Dependency] private readonly SharedMindSystem _mindSystem = default!;
- [Dependency] private readonly MetaDataSystem _metaSystem = default!;
- [Dependency] private readonly SharedJobSystem _jobs = default!;
- [Dependency] private readonly EmagSystem _emag = default!;
+ clone = null;
+ if (!_prototype.TryIndex(settingsId, out var settings))
+ return false; // invalid settings
- public readonly Dictionary ClonesWaitingForMind = new();
- public const float EasyModeCloningCost = 0.7f;
+ if (!TryComp(original, out var humanoid))
+ return false; // whatever body was to be cloned, was not a humanoid
- public override void Initialize()
+ if (!_prototype.TryIndex(humanoid.Species, out var speciesPrototype))
+ return false; // invalid species
+
+ var attemptEv = new CloningAttemptEvent(settings);
+ RaiseLocalEvent(original, ref attemptEv);
+ if (attemptEv.Cancelled && !settings.ForceCloning)
+ return false; // cannot clone, for example due to the unrevivable trait
+
+ clone = coords == null ? Spawn(speciesPrototype.Prototype) : Spawn(speciesPrototype.Prototype, coords.Value);
+ _humanoidSystem.CloneAppearance(original, clone.Value);
+
+ var componentsToCopy = settings.Components;
+
+ // don't make status effects permanent
+ if (TryComp(original, out var statusComp))
+ componentsToCopy.ExceptWith(statusComp.ActiveEffects.Values.Select(s => s.RelevantComponent).Where(s => s != null)!);
+
+ foreach (var componentName in componentsToCopy)
{
- base.Initialize();
-
- SubscribeLocalEvent(OnComponentInit);
- SubscribeLocalEvent(Reset);
- SubscribeLocalEvent(HandleMindAdded);
- SubscribeLocalEvent(OnPortDisconnected);
- SubscribeLocalEvent(OnAnchor);
- SubscribeLocalEvent(OnExamined);
- SubscribeLocalEvent(OnEmagged);
- }
-
- private void OnComponentInit(EntityUid uid, CloningPodComponent clonePod, ComponentInit args)
- {
- clonePod.BodyContainer = _containerSystem.EnsureContainer(uid, "clonepod-bodyContainer");
- _signalSystem.EnsureSinkPorts(uid, CloningPodComponent.PodPort);
- }
-
- internal void TransferMindToClone(EntityUid mindId, MindComponent mind)
- {
- if (!ClonesWaitingForMind.TryGetValue(mind, out var entity) ||
- !EntityManager.EntityExists(entity) ||
- !TryComp(entity, out var mindComp) ||
- mindComp.Mind != null)
- return;
-
- _mindSystem.TransferTo(mindId, entity, ghostCheckOverride: true, mind: mind);
- _mindSystem.UnVisit(mindId, mind);
- ClonesWaitingForMind.Remove(mind);
- }
-
- private void HandleMindAdded(EntityUid uid, BeingClonedComponent clonedComponent, MindAddedMessage message)
- {
- if (clonedComponent.Parent == EntityUid.Invalid ||
- !EntityManager.EntityExists(clonedComponent.Parent) ||
- !TryComp(clonedComponent.Parent, out var cloningPodComponent) ||
- uid != cloningPodComponent.BodyContainer.ContainedEntity)
+ if (!_componentFactory.TryGetRegistration(componentName, out var componentRegistration))
{
- EntityManager.RemoveComponent(uid);
- return;
- }
- UpdateStatus(clonedComponent.Parent, CloningPodStatus.Cloning, cloningPodComponent);
- }
-
- private void OnPortDisconnected(EntityUid uid, CloningPodComponent pod, PortDisconnectedEvent args)
- {
- pod.ConnectedConsole = null;
- }
-
- private void OnAnchor(EntityUid uid, CloningPodComponent component, ref AnchorStateChangedEvent args)
- {
- if (component.ConnectedConsole == null || !TryComp(component.ConnectedConsole, out var console))
- return;
-
- if (args.Anchored)
- {
- _cloningConsoleSystem.RecheckConnections(component.ConnectedConsole.Value, uid, console.GeneticScanner, console);
- return;
- }
- _cloningConsoleSystem.UpdateUserInterface(component.ConnectedConsole.Value, console);
- }
-
- private void OnExamined(EntityUid uid, CloningPodComponent component, ExaminedEvent args)
- {
- if (!args.IsInDetailsRange || !_powerReceiverSystem.IsPowered(uid))
- return;
-
- args.PushMarkup(Loc.GetString("cloning-pod-biomass", ("number", _material.GetMaterialAmount(uid, component.RequiredMaterial))));
- }
-
- public bool TryCloning(EntityUid uid, EntityUid bodyToClone, Entity mindEnt, CloningPodComponent? clonePod, float failChanceModifier = 1)
- {
- if (!Resolve(uid, ref clonePod))
- return false;
-
- if (HasComp(uid))
- return false;
-
- var mind = mindEnt.Comp;
- if (ClonesWaitingForMind.TryGetValue(mind, out var clone))
- {
- if (EntityManager.EntityExists(clone) &&
- !_mobStateSystem.IsDead(clone) &&
- TryComp(clone, out var cloneMindComp) &&
- (cloneMindComp.Mind == null || cloneMindComp.Mind == mindEnt))
- return false; // Mind already has clone
-
- ClonesWaitingForMind.Remove(mind);
+ Log.Error($"Tried to use invalid component registration for cloning: {componentName}");
+ continue;
}
- if (mind.OwnedEntity != null && !_mobStateSystem.IsDead(mind.OwnedEntity.Value))
- return false; // Body controlled by mind is not dead
-
- // Yes, we still need to track down the client because we need to open the Eui
- if (mind.UserId == null || !_playerManager.TryGetSessionById(mind.UserId.Value, out var client))
- return false; // If we can't track down the client, we can't offer transfer. That'd be quite bad.
-
- if (!TryComp(bodyToClone, out var humanoid))
- return false; // whatever body was to be cloned, was not a humanoid
-
- if (!_prototype.TryIndex(humanoid.Species, out var speciesPrototype))
- return false;
-
- if (!TryComp(bodyToClone, out var physics))
- return false;
-
- var cloningCost = (int) Math.Round(physics.FixturesMass);
-
- if (_configManager.GetCVar(CCVars.BiomassEasyMode))
- cloningCost = (int) Math.Round(cloningCost * EasyModeCloningCost);
-
- // biomass checks
- var biomassAmount = _material.GetMaterialAmount(uid, clonePod.RequiredMaterial);
-
- if (biomassAmount < cloningCost)
+ if (EntityManager.TryGetComponent(original, componentRegistration.Type, out var sourceComp)) // Does the original have this component?
{
- if (clonePod.ConnectedConsole != null)
- _chatSystem.TrySendInGameICMessage(clonePod.ConnectedConsole.Value, Loc.GetString("cloning-console-chat-error", ("units", cloningCost)), InGameICChatType.Speak, false);
- return false;
- }
-
- _material.TryChangeMaterialAmount(uid, clonePod.RequiredMaterial, -cloningCost);
- clonePod.UsedBiomass = cloningCost;
- // end of biomass checks
-
- // genetic damage checks
- if (TryComp(bodyToClone, out var damageable) &&
- damageable.Damage.DamageDict.TryGetValue("Cellular", out var cellularDmg))
- {
- var chance = Math.Clamp((float) (cellularDmg / 100), 0, 1);
- chance *= failChanceModifier;
-
- if (cellularDmg > 0 && clonePod.ConnectedConsole != null)
- _chatSystem.TrySendInGameICMessage(clonePod.ConnectedConsole.Value, Loc.GetString("cloning-console-cellular-warning", ("percent", Math.Round(100 - chance * 100))), InGameICChatType.Speak, false);
-
- if (_robustRandom.Prob(chance))
- {
- UpdateStatus(uid, CloningPodStatus.Gore, clonePod);
- clonePod.FailedClone = true;
- AddComp(uid);
- return true;
- }
- }
- // end of genetic damage checks
-
- var mob = Spawn(speciesPrototype.Prototype, _transformSystem.GetMapCoordinates(uid));
- _humanoidSystem.CloneAppearance(bodyToClone, mob);
-
- var ev = new CloningEvent(bodyToClone, mob);
- RaiseLocalEvent(bodyToClone, ref ev);
-
- if (!ev.NameHandled)
- _metaSystem.SetEntityName(mob, MetaData(bodyToClone).EntityName);
-
- var cloneMindReturn = EntityManager.AddComponent(mob);
- cloneMindReturn.Mind = mind;
- cloneMindReturn.Parent = uid;
- _containerSystem.Insert(mob, clonePod.BodyContainer);
- ClonesWaitingForMind.Add(mind, mob);
- UpdateStatus(uid, CloningPodStatus.NoMind, clonePod);
- _euiManager.OpenEui(new AcceptCloningEui(mindEnt, mind, this), client);
-
- AddComp(uid);
-
- // TODO: Ideally, components like this should be components on the mind entity so this isn't necessary.
- // Add on special job components to the mob.
- if (_jobs.MindTryGetJob(mindEnt, out var prototype))
- {
- foreach (var special in prototype.Special)
- {
- if (special is AddComponentSpecial)
- special.AfterEquip(mob);
- }
- }
-
- return true;
- }
-
- public void UpdateStatus(EntityUid podUid, CloningPodStatus status, CloningPodComponent cloningPod)
- {
- cloningPod.Status = status;
- _appearance.SetData(podUid, CloningPodVisuals.Status, cloningPod.Status);
- }
-
- public override void Update(float frameTime)
- {
- var query = EntityQueryEnumerator();
- while (query.MoveNext(out var uid, out var _, out var cloning))
- {
- if (!_powerReceiverSystem.IsPowered(uid))
- continue;
-
- if (cloning.BodyContainer.ContainedEntity == null && !cloning.FailedClone)
- continue;
-
- cloning.CloningProgress += frameTime;
- if (cloning.CloningProgress < cloning.CloningTime)
- continue;
-
- if (cloning.FailedClone)
- EndFailedCloning(uid, cloning);
- else
- Eject(uid, cloning);
+ if (HasComp(clone.Value, componentRegistration.Type)) // CopyComp cannot overwrite existing components
+ RemComp(clone.Value, componentRegistration.Type);
+ CopyComp(original, clone.Value, sourceComp);
}
}
- ///
- /// On emag, spawns a failed clone when cloning process fails which attacks nearby crew.
- ///
- private void OnEmagged(EntityUid uid, CloningPodComponent clonePod, ref GotEmaggedEvent args)
+ var cloningEv = new CloningEvent(settings, clone.Value);
+ RaiseLocalEvent(original, ref cloningEv); // used for datafields that cannot be directly copied
+
+ // Add equipment first so that SetEntityName also renames the ID card.
+ if (settings.CopyEquipment != null)
+ CopyEquipment(original, clone.Value, settings.CopyEquipment.Value, settings.Whitelist, settings.Blacklist);
+
+ var originalName = Name(original);
+ if (TryComp(original, out var nameModComp)) // if the originals name was modified, use the unmodified name
+ originalName = nameModComp.BaseName;
+
+ // This will properly set the BaseName and EntityName for the clone.
+ // Adding the component first before renaming will make sure RefreshNameModifers is called.
+ // Without this the name would get reverted to Urist.
+ // If the clone has no name modifiers, NameModifierComponent will be removed again.
+ EnsureComp(clone.Value);
+ _metaData.SetEntityName(clone.Value, originalName);
+
+ _adminLogger.Add(LogType.Chat, LogImpact.Medium, $"The body of {original:player} was cloned as {clone.Value:player}");
+ return true;
+ }
+
+ ///
+ /// Copies the equipment the original has to the clone.
+ /// This uses the original prototype of the items, so any changes to components that are done after spawning are lost!
+ ///
+ public void CopyEquipment(EntityUid original, EntityUid clone, SlotFlags slotFlags, EntityWhitelist? whitelist = null, EntityWhitelist? blacklist = null)
+ {
+ if (!TryComp(original, out var originalInventory) || !TryComp(clone, out var cloneInventory))
+ return;
+ // Iterate over all inventory slots
+ var slotEnumerator = _inventory.GetSlotEnumerator((original, originalInventory), slotFlags);
+ while (slotEnumerator.NextItem(out var item, out var slot))
{
- if (!_emag.CompareFlag(args.Type, EmagType.Interaction))
- return;
+ // Spawn a copy of the item using the original prototype.
+ // This means any changes done to the item after spawning will be reset, but that should not be a problem for simple items like clothing etc.
+ // we use a whitelist and blacklist to be sure to exclude any problematic entities
- if (_emag.CheckFlag(uid, EmagType.Interaction))
- return;
+ if (_whitelist.IsWhitelistFail(whitelist, item) || _whitelist.IsBlacklistPass(blacklist, item))
+ continue;
- if (!this.IsPowered(uid, EntityManager))
- return;
-
- _popupSystem.PopupEntity(Loc.GetString("cloning-pod-component-upgrade-emag-requirement"), uid);
- args.Handled = true;
- }
-
- public void Eject(EntityUid uid, CloningPodComponent? clonePod)
- {
- if (!Resolve(uid, ref clonePod))
- return;
-
- if (clonePod.BodyContainer.ContainedEntity is not { Valid: true } entity || clonePod.CloningProgress < clonePod.CloningTime)
- return;
-
- EntityManager.RemoveComponent(entity);
- _containerSystem.Remove(entity, clonePod.BodyContainer);
- clonePod.CloningProgress = 0f;
- clonePod.UsedBiomass = 0;
- UpdateStatus(uid, CloningPodStatus.Idle, clonePod);
- RemCompDeferred(uid);
- }
-
- private void EndFailedCloning(EntityUid uid, CloningPodComponent clonePod)
- {
- clonePod.FailedClone = false;
- clonePod.CloningProgress = 0f;
- UpdateStatus(uid, CloningPodStatus.Idle, clonePod);
- var transform = Transform(uid);
- var indices = _transformSystem.GetGridTilePositionOrDefault((uid, transform));
- var tileMix = _atmosphereSystem.GetTileMixture(transform.GridUid, null, indices, true);
-
- if (_emag.CheckFlag(uid, EmagType.Interaction))
- {
- _audio.PlayPvs(clonePod.ScreamSound, uid);
- Spawn(clonePod.MobSpawnId, transform.Coordinates);
- }
-
- Solution bloodSolution = new();
-
- var i = 0;
- while (i < 1)
- {
- tileMix?.AdjustMoles(Gas.Ammonia, 6f);
- bloodSolution.AddReagent("Blood", 50);
- if (_robustRandom.Prob(0.2f))
- i++;
- }
- _puddleSystem.TrySpillAt(uid, bloodSolution, out _);
-
- if (!_emag.CheckFlag(uid, EmagType.Interaction))
- {
- _material.SpawnMultipleFromMaterial(_robustRandom.Next(1, (int) (clonePod.UsedBiomass / 2.5)), clonePod.RequiredMaterial, Transform(uid).Coordinates);
- }
-
- clonePod.UsedBiomass = 0;
- RemCompDeferred(uid);
- }
-
- public void Reset(RoundRestartCleanupEvent ev)
- {
- ClonesWaitingForMind.Clear();
+ var prototype = MetaData(item).EntityPrototype;
+ if (prototype != null)
+ _inventory.SpawnItemInSlot(clone, slot.Name, prototype.ID, silent: true, inventory: cloneInventory);
}
}
}
diff --git a/Content.Server/Cloning/Components/RandomCloneSpawnerComponent.cs b/Content.Server/Cloning/Components/RandomCloneSpawnerComponent.cs
new file mode 100644
index 0000000000..ee06a532ef
--- /dev/null
+++ b/Content.Server/Cloning/Components/RandomCloneSpawnerComponent.cs
@@ -0,0 +1,17 @@
+using Content.Shared.Cloning;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Cloning.Components;
+
+///
+/// This is added to a marker entity in order to spawn a clone of a random player.
+///
+[RegisterComponent, EntityCategory("Spawner")]
+public sealed partial class RandomCloneSpawnerComponent : Component
+{
+ ///
+ /// Cloning settings to be used.
+ ///
+ [DataField]
+ public ProtoId Settings = "BaseClone";
+}
diff --git a/Content.Server/Cloning/RandomCloneSpawnerSystem.cs b/Content.Server/Cloning/RandomCloneSpawnerSystem.cs
new file mode 100644
index 0000000000..a645a10890
--- /dev/null
+++ b/Content.Server/Cloning/RandomCloneSpawnerSystem.cs
@@ -0,0 +1,47 @@
+using Content.Server.Cloning.Components;
+using Content.Shared.Mind;
+using Content.Shared.Mobs.Systems;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server.Cloning;
+
+///
+/// This deals with spawning and setting up a clone of a random crew member.
+///
+public sealed class RandomCloneSpawnerSystem : EntitySystem
+{
+ [Dependency] private readonly CloningSystem _cloning = default!;
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly SharedTransformSystem _transformSystem = default!;
+ [Dependency] private readonly SharedMindSystem _mind = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnMapInit);
+ }
+
+ private void OnMapInit(Entity ent, ref MapInitEvent args)
+ {
+ QueueDel(ent.Owner);
+
+ if (!_prototypeManager.TryIndex(ent.Comp.Settings, out var settings))
+ {
+ Log.Error($"Used invalid cloning settings {ent.Comp.Settings} for RandomCloneSpawner");
+ return;
+ }
+
+ var allHumans = _mind.GetAliveHumans();
+
+ if (allHumans.Count == 0)
+ return;
+
+ var bodyToClone = _random.Pick(allHumans).Comp.OwnedEntity;
+
+ if (bodyToClone != null)
+ _cloning.TryCloning(bodyToClone.Value, _transformSystem.GetMapCoordinates(ent.Owner), settings, out _);
+ }
+}
diff --git a/Content.Server/Delivery/CargoDeliveryDataComponent.cs b/Content.Server/Delivery/CargoDeliveryDataComponent.cs
new file mode 100644
index 0000000000..3bee0413fc
--- /dev/null
+++ b/Content.Server/Delivery/CargoDeliveryDataComponent.cs
@@ -0,0 +1,51 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.Delivery;
+
+///
+/// Component given to a station to indicate it can have deliveries spawn on it.
+///
+[RegisterComponent, AutoGenerateComponentPause]
+public sealed partial class CargoDeliveryDataComponent : Component
+{
+ ///
+ /// The time at which the next delivery will spawn.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField]
+ public TimeSpan NextDelivery;
+
+ ///
+ /// Minimum cooldown after a delivery spawns.
+ ///
+ [DataField]
+ public TimeSpan MinDeliveryCooldown = TimeSpan.FromMinutes(3);
+
+ ///
+ /// Maximum cooldown after a delivery spawns.
+ ///
+ [DataField]
+ public TimeSpan MaxDeliveryCooldown = TimeSpan.FromMinutes(7);
+
+
+ ///
+ /// The ratio at which deliveries will spawn, based on the amount of people in the crew manifest.
+ /// 1 delivery per X players.
+ ///
+ [DataField]
+ public float PlayerToDeliveryRatio = 7f;
+
+ ///
+ /// The minimum amount of deliveries that will spawn.
+ /// This is not per spawner unless DistributeRandomly is false.
+ ///
+ [DataField]
+ public int MinimumDeliverySpawn = 1;
+
+ ///
+ /// Should deliveries be randomly split between spawners?
+ /// If true, the amount of deliveries will be spawned randomly across all spawners.
+ /// If false, an amount of mail based on PlayerToDeliveryRatio will be spawned on all spawners.
+ ///
+ [DataField]
+ public bool DistributeRandomly = true;
+}
diff --git a/Content.Server/Delivery/DeliverySystem.Spawning.cs b/Content.Server/Delivery/DeliverySystem.Spawning.cs
new file mode 100644
index 0000000000..19087e7448
--- /dev/null
+++ b/Content.Server/Delivery/DeliverySystem.Spawning.cs
@@ -0,0 +1,131 @@
+using Content.Server.Power.EntitySystems;
+using Content.Server.StationRecords;
+using Content.Shared.Delivery;
+using Content.Shared.EntityTable;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Delivery;
+
+///
+/// System for managing deliveries spawned by the mail teleporter.
+/// This covers for spawning deliveries.
+///
+public sealed partial class DeliverySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly EntityTableSystem _entityTable = default!;
+ [Dependency] private readonly PowerReceiverSystem _power = default!;
+
+ private void InitializeSpawning()
+ {
+ SubscribeLocalEvent(OnDataMapInit);
+ }
+
+ private void OnDataMapInit(Entity ent, ref MapInitEvent args)
+ {
+ ent.Comp.NextDelivery = _timing.CurTime + ent.Comp.MinDeliveryCooldown; // We want an early wave of mail so cargo doesn't have to wait
+ }
+
+ private void SpawnDelivery(Entity ent, int amount)
+ {
+ if (!Resolve(ent.Owner, ref ent.Comp))
+ return;
+
+ var coords = Transform(ent).Coordinates;
+
+ _audio.PlayPvs(ent.Comp.SpawnSound, ent.Owner);
+
+ for (int i = 0; i < amount; i++)
+ {
+ var spawns = _entityTable.GetSpawns(ent.Comp.Table);
+
+ foreach (var id in spawns)
+ {
+ Spawn(id, coords);
+ }
+ }
+ }
+
+ private void SpawnStationDeliveries(Entity ent)
+ {
+ if (!TryComp(ent, out var records))
+ return;
+
+ var spawners = GetValidSpawners(ent);
+
+ // Skip if theres no spawners available
+ if (spawners.Count == 0)
+ return;
+
+ // Skip if there's nobody in crew manifest
+ if (records.Records.Keys.Count == 0)
+ return;
+
+ // We take the amount of mail calculated based on player amount or the minimum, whichever is higher.
+ // We don't want stations with less than the player ratio to not get mail at all
+ var initialDeliveryCount = (int)Math.Ceiling(records.Records.Keys.Count / ent.Comp.PlayerToDeliveryRatio);
+ var deliveryCount = Math.Max(initialDeliveryCount, ent.Comp.MinimumDeliverySpawn);
+
+ if (!ent.Comp.DistributeRandomly)
+ {
+ foreach (var spawner in spawners)
+ {
+ SpawnDelivery(spawner, deliveryCount);
+ }
+ }
+ else
+ {
+ int[] amounts = new int[spawners.Count];
+
+ // Distribute items randomly
+ for (int i = 0; i < deliveryCount; i++)
+ {
+ var randomListIndex = _random.Next(spawners.Count);
+ amounts[randomListIndex]++;
+ }
+ for (int j = 0; j < spawners.Count; j++)
+ {
+ SpawnDelivery(spawners[j], amounts[j]);
+ }
+ }
+
+ }
+
+ private List GetValidSpawners(Entity ent)
+ {
+ var validSpawners = new List();
+
+ var spawners = EntityQueryEnumerator();
+ while (spawners.MoveNext(out var spawnerUid, out _))
+ {
+ var spawnerStation = _station.GetOwningStation(spawnerUid);
+
+ if (spawnerStation != ent.Owner)
+ continue;
+
+ if (!_power.IsPowered(spawnerUid))
+ continue;
+
+ validSpawners.Add(spawnerUid);
+ }
+
+ return validSpawners;
+ }
+
+ private void UpdateSpawner(float frameTime)
+ {
+ var dataQuery = EntityQueryEnumerator();
+ var curTime = _timing.CurTime;
+
+ while (dataQuery.MoveNext(out var uid, out var deliveryData))
+ {
+ if (deliveryData.NextDelivery > curTime)
+ continue;
+
+ deliveryData.NextDelivery += _random.Next(deliveryData.MinDeliveryCooldown, deliveryData.MaxDeliveryCooldown); // Random cooldown between min and max
+ SpawnStationDeliveries((uid, deliveryData));
+ }
+ }
+}
diff --git a/Content.Server/Delivery/DeliverySystem.cs b/Content.Server/Delivery/DeliverySystem.cs
new file mode 100644
index 0000000000..8ac8722821
--- /dev/null
+++ b/Content.Server/Delivery/DeliverySystem.cs
@@ -0,0 +1,85 @@
+using Content.Server.Cargo.Components;
+using Content.Server.Cargo.Systems;
+using Content.Server.Station.Systems;
+using Content.Server.StationRecords.Systems;
+using Content.Shared.Delivery;
+using Content.Shared.FingerprintReader;
+using Content.Shared.Labels.EntitySystems;
+using Content.Shared.StationRecords;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Containers;
+
+namespace Content.Server.Delivery;
+
+///
+/// System for managing deliveries spawned by the mail teleporter.
+/// This covers for mail spawning, as well as granting cargo money.
+///
+public sealed partial class DeliverySystem : SharedDeliverySystem
+{
+ [Dependency] private readonly CargoSystem _cargo = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly StationRecordsSystem _records = default!;
+ [Dependency] private readonly StationSystem _station = default!;
+ [Dependency] private readonly FingerprintReaderSystem _fingerprintReader = default!;
+ [Dependency] private readonly SharedLabelSystem _label = default!;
+ [Dependency] private readonly SharedContainerSystem _container = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnMapInit);
+
+ InitializeSpawning();
+ }
+
+ private void OnMapInit(Entity ent, ref MapInitEvent args)
+ {
+ _container.EnsureContainer(ent, ent.Comp.Container);
+
+ var stationId = _station.GetStationInMap(Transform(ent).MapID);
+
+ if (stationId == null)
+ return;
+
+ _records.TryGetRandomRecord(stationId.Value, out var entry);
+
+ if (entry == null)
+ return;
+
+ ent.Comp.RecipientName = entry.Name;
+ ent.Comp.RecipientJobTitle = entry.JobTitle;
+ ent.Comp.RecipientStation = stationId.Value;
+
+ _appearance.SetData(ent, DeliveryVisuals.JobIcon, entry.JobIcon);
+
+ _label.Label(ent, ent.Comp.RecipientName);
+
+ if (TryComp(ent, out var reader) && entry.Fingerprint != null)
+ {
+ _fingerprintReader.AddAllowedFingerprint((ent.Owner, reader), entry.Fingerprint);
+ }
+
+ Dirty(ent);
+ }
+
+ protected override void GrantSpesoReward(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return;
+
+ if (!TryComp(ent.Comp.RecipientStation, out var account))
+ return;
+
+ _cargo.UpdateBankAccount((ent.Comp.RecipientStation.Value, account), ent.Comp.SpesoReward);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ UpdateSpawner(frameTime);
+ }
+}
diff --git a/Content.Server/Explosion/Components/SpawnOnTriggerComponent.cs b/Content.Server/Explosion/Components/SpawnOnTriggerComponent.cs
index a8b36fbd84..c28ec7faeb 100644
--- a/Content.Server/Explosion/Components/SpawnOnTriggerComponent.cs
+++ b/Content.Server/Explosion/Components/SpawnOnTriggerComponent.cs
@@ -1,12 +1,24 @@
using Content.Server.Explosion.EntitySystems;
using Robust.Shared.Prototypes;
-using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Server.Explosion.Components;
+///
+/// Spawns a protoype when triggered.
+///
[RegisterComponent, Access(typeof(TriggerSystem))]
public sealed partial class SpawnOnTriggerComponent : Component
{
- [ViewVariables(VVAccess.ReadWrite), DataField("proto", required: true, customTypeSerializer:typeof(PrototypeIdSerializer))]
- public string Proto = string.Empty;
+ ///
+ /// The prototype to spawn.
+ ///
+ [DataField(required: true)]
+ public EntProtoId Proto = string.Empty;
+
+ ///
+ /// Use MapCoordinates for spawning?
+ /// Set to true if you don't want the new entity parented to the spawner.
+ ///
+ [DataField]
+ public bool mapCoords;
}
diff --git a/Content.Server/Explosion/Components/TriggerOnCollideComponent.cs b/Content.Server/Explosion/Components/TriggerOnCollideComponent.cs
index 950bd3e462..28c2ed8c34 100644
--- a/Content.Server/Explosion/Components/TriggerOnCollideComponent.cs
+++ b/Content.Server/Explosion/Components/TriggerOnCollideComponent.cs
@@ -1,15 +1,20 @@
-namespace Content.Server.Explosion.Components
-{
- [RegisterComponent]
- public sealed partial class TriggerOnCollideComponent : Component
- {
- [DataField("fixtureID", required: true)]
- public string FixtureID = String.Empty;
+namespace Content.Server.Explosion.Components;
- ///
- /// Doesn't trigger if the other colliding fixture is nonhard.
- ///
- [DataField("ignoreOtherNonHard")]
- public bool IgnoreOtherNonHard = true;
- }
+///
+/// Triggers when colliding with another entity.
+///
+[RegisterComponent]
+public sealed partial class TriggerOnCollideComponent : Component
+{
+ ///
+ /// The fixture with which to collide.
+ ///
+ [DataField(required: true)]
+ public string FixtureID = string.Empty;
+
+ ///
+ /// Doesn't trigger if the other colliding fixture is nonhard.
+ ///
+ [DataField]
+ public bool IgnoreOtherNonHard = true;
}
diff --git a/Content.Server/Explosion/Components/TriggerOnUseComponent.cs b/Content.Server/Explosion/Components/TriggerOnUseComponent.cs
new file mode 100644
index 0000000000..2b44d2fbac
--- /dev/null
+++ b/Content.Server/Explosion/Components/TriggerOnUseComponent.cs
@@ -0,0 +1,7 @@
+namespace Content.Server.Explosion.Components;
+
+///
+/// Triggers on use in hand.
+///
+[RegisterComponent]
+public sealed partial class TriggerOnUseComponent : Component { }
diff --git a/Content.Server/Explosion/Components/TriggerWhitelistComponent.cs b/Content.Server/Explosion/Components/TriggerWhitelistComponent.cs
new file mode 100644
index 0000000000..80becf17cc
--- /dev/null
+++ b/Content.Server/Explosion/Components/TriggerWhitelistComponent.cs
@@ -0,0 +1,23 @@
+using Content.Shared.Whitelist;
+
+namespace Content.Server.Explosion.Components;
+
+///
+/// Checks if the user of a Trigger satisfies a whitelist and blacklist condition.
+/// Cancels the trigger otherwise.
+///
+[RegisterComponent]
+public sealed partial class TriggerWhitelistComponent : Component
+{
+ ///
+ /// Whitelist for what entites can cause this trigger.
+ ///
+ [DataField]
+ public EntityWhitelist? Whitelist;
+
+ ///
+ /// Blacklist for what entites can cause this trigger.
+ ///
+ [DataField]
+ public EntityWhitelist? Blacklist;
+}
diff --git a/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs b/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs
index c78a8923cd..8dd170e667 100644
--- a/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs
+++ b/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs
@@ -53,6 +53,9 @@ namespace Content.Server.Explosion.EntitySystems
_adminLogger.Add(LogType.Trigger, LogImpact.High,
$"A voice-trigger on {ToPrettyString(ent):entity} was triggered by {ToPrettyString(args.Source):speaker} speaking the key-phrase {component.KeyPhrase}.");
Trigger(ent, args.Source);
+
+ var voice = new VoiceTriggeredEvent(args.Source, message);
+ RaiseLocalEvent(ent, ref voice);
}
}
@@ -137,3 +140,12 @@ namespace Content.Server.Explosion.EntitySystems
}
}
}
+
+
+///
+/// Raised when a voice trigger is activated, containing the message that triggered it.
+///
+/// The EntityUid of the entity sending the message
+/// The contents of the message
+[ByRefEvent]
+public readonly record struct VoiceTriggeredEvent(EntityUid Source, string? Message);
diff --git a/Content.Server/Explosion/EntitySystems/TriggerSystem.cs b/Content.Server/Explosion/EntitySystems/TriggerSystem.cs
index 53f6dfacf8..0459730c64 100644
--- a/Content.Server/Explosion/EntitySystems/TriggerSystem.cs
+++ b/Content.Server/Explosion/EntitySystems/TriggerSystem.cs
@@ -14,6 +14,7 @@ using Content.Shared.Explosion.Components;
using Content.Shared.Explosion.Components.OnTrigger;
using Content.Shared.Implants.Components;
using Content.Shared.Interaction;
+using Content.Shared.Interaction.Events;
using Content.Shared.Inventory;
using Content.Shared.Mobs;
using Content.Shared.Mobs.Components;
@@ -23,6 +24,7 @@ using Content.Shared.Slippery;
using Content.Shared.StepTrigger.Systems;
using Content.Shared.Trigger;
using Content.Shared.Weapons.Ranged.Events;
+using Content.Shared.Whitelist;
using JetBrains.Annotations;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
@@ -31,10 +33,7 @@ using Robust.Shared.Physics.Events;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
-using Robust.Shared.Player;
-using Content.Shared.Coordinates;
using Robust.Shared.Utility;
-using Robust.Shared.Timing;
namespace Content.Server.Explosion.EntitySystems
{
@@ -53,6 +52,12 @@ namespace Content.Server.Explosion.EntitySystems
}
}
+ ///
+ /// Raised before a trigger is activated.
+ ///
+ [ByRefEvent]
+ public record struct BeforeTriggerEvent(EntityUid Triggered, EntityUid? User, bool Cancelled = false);
+
///
/// Raised when timer trigger becomes active.
///
@@ -78,6 +83,7 @@ namespace Content.Server.Explosion.EntitySystems
[Dependency] private readonly SharedSolutionContainerSystem _solutionContainerSystem = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly ElectrocutionSystem _electrocution = default!;
+ [Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
public override void Initialize()
{
@@ -93,6 +99,7 @@ namespace Content.Server.Explosion.EntitySystems
SubscribeLocalEvent(OnSpawnTriggered);
SubscribeLocalEvent(OnTriggerCollide);
SubscribeLocalEvent(OnActivate);
+ SubscribeLocalEvent(OnUse);
SubscribeLocalEvent(OnImplantTrigger);
SubscribeLocalEvent(OnStepTriggered);
SubscribeLocalEvent(OnSlipTriggered);
@@ -109,6 +116,13 @@ namespace Content.Server.Explosion.EntitySystems
SubscribeLocalEvent(OnSoundTrigger);
SubscribeLocalEvent(HandleShockTrigger);
SubscribeLocalEvent(HandleRattleTrigger);
+
+ SubscribeLocalEvent(HandleWhitelist);
+ }
+
+ private void HandleWhitelist(Entity ent, ref BeforeTriggerEvent args)
+ {
+ args.Cancelled = !_whitelist.CheckBoth(args.User, ent.Comp.Blacklist, ent.Comp.Whitelist);
}
private void OnSoundTrigger(EntityUid uid, SoundOnTriggerComponent component, TriggerEvent args)
@@ -155,16 +169,23 @@ namespace Content.Server.Explosion.EntitySystems
RemCompDeferred(uid);
}
- private void OnSpawnTrigger(EntityUid uid, SpawnOnTriggerComponent component, TriggerEvent args)
+ private void OnSpawnTrigger(Entity ent, ref TriggerEvent args)
{
- var xform = Transform(uid);
+ var xform = Transform(ent);
- var coords = xform.Coordinates;
+ if (ent.Comp.mapCoords)
+ {
+ var mapCoords = _transformSystem.GetMapCoordinates(ent, xform);
+ Spawn(ent.Comp.Proto, mapCoords);
+ }
+ else
+ {
+ var coords = xform.Coordinates;
+ if (!coords.IsValid(EntityManager))
+ return;
+ Spawn(ent.Comp.Proto, coords);
- if (!coords.IsValid(EntityManager))
- return;
-
- Spawn(component.Proto, coords);
+ }
}
private void HandleExplodeTrigger(EntityUid uid, ExplodeOnTriggerComponent component, TriggerEvent args)
@@ -248,6 +269,15 @@ namespace Content.Server.Explosion.EntitySystems
args.Handled = true;
}
+ private void OnUse(Entity ent, ref UseInHandEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ Trigger(ent.Owner, args.User);
+ args.Handled = true;
+ }
+
private void OnImplantTrigger(EntityUid uid, TriggerImplantActionComponent component, ActivateImplantEvent args)
{
args.Handled = Trigger(uid);
@@ -275,6 +305,11 @@ namespace Content.Server.Explosion.EntitySystems
public bool Trigger(EntityUid trigger, EntityUid? user = null)
{
+ var beforeTriggerEvent = new BeforeTriggerEvent(trigger, user);
+ RaiseLocalEvent(trigger, ref beforeTriggerEvent);
+ if (beforeTriggerEvent.Cancelled)
+ return false;
+
var triggerEvent = new TriggerEvent(trigger, user);
EntityManager.EventBus.RaiseLocalEvent(trigger, triggerEvent, true);
return triggerEvent.Handled;
diff --git a/Content.Server/Forensics/Systems/ForensicsSystem.cs b/Content.Server/Forensics/Systems/ForensicsSystem.cs
index f811bede7b..27df086ae1 100644
--- a/Content.Server/Forensics/Systems/ForensicsSystem.cs
+++ b/Content.Server/Forensics/Systems/ForensicsSystem.cs
@@ -1,4 +1,5 @@
using Content.Server.Body.Components;
+using Content.Server.Body.Systems;
using Content.Server.DoAfter;
using Content.Server.Fluids.EntitySystems;
using Content.Server.Forensics.Components;
@@ -32,8 +33,9 @@ namespace Content.Server.Forensics
public override void Initialize()
{
SubscribeLocalEvent(OnInteract);
- SubscribeLocalEvent(OnFingerprintInit);
- SubscribeLocalEvent(OnDNAInit);
+ SubscribeLocalEvent(OnFingerprintInit, after: new[] { typeof(BloodstreamSystem) });
+ // The solution entities are spawned on MapInit as well, so we have to wait for that to be able to set the DNA in the bloodstream correctly without ResolveSolution failing
+ SubscribeLocalEvent(OnDNAInit, after: new[] { typeof(BloodstreamSystem) });
SubscribeLocalEvent(OnBeingGibbed);
SubscribeLocalEvent(OnMeleeHit);
@@ -65,18 +67,19 @@ namespace Content.Server.Forensics
private void OnFingerprintInit(Entity ent, ref MapInitEvent args)
{
- ent.Comp.Fingerprint = GenerateFingerprint();
- Dirty(ent);
+ if (ent.Comp.Fingerprint == null)
+ RandomizeFingerprint((ent.Owner, ent.Comp));
}
- private void OnDNAInit(EntityUid uid, DnaComponent component, MapInitEvent args)
+ private void OnDNAInit(Entity ent, ref MapInitEvent args)
{
- if (component.DNA == String.Empty)
+ if (ent.Comp.DNA == null)
+ RandomizeDNA((ent.Owner, ent.Comp));
+ else
{
- component.DNA = GenerateDNA();
-
- var ev = new GenerateDnaEvent { Owner = uid, DNA = component.DNA };
- RaiseLocalEvent(uid, ref ev);
+ // If set manually (for example by cloning) we also need to inform the bloodstream of the correct DNA string so it can be updated
+ var ev = new GenerateDnaEvent { Owner = ent.Owner, DNA = ent.Comp.DNA };
+ RaiseLocalEvent(ent.Owner, ref ev);
}
}
@@ -84,7 +87,7 @@ namespace Content.Server.Forensics
{
string dna = Loc.GetString("forensics-dna-unknown");
- if (TryComp(uid, out DnaComponent? dnaComp))
+ if (TryComp(uid, out DnaComponent? dnaComp) && dnaComp.DNA != null)
dna = dnaComp.DNA;
foreach (EntityUid part in args.GibbedParts)
@@ -103,7 +106,7 @@ namespace Content.Server.Forensics
{
foreach (EntityUid hitEntity in args.HitEntities)
{
- if (TryComp(hitEntity, out var hitEntityComp))
+ if (TryComp(hitEntity, out var hitEntityComp) && hitEntityComp.DNA != null)
component.DNAs.Add(hitEntityComp.DNA);
}
}
@@ -301,6 +304,9 @@ namespace Content.Server.Forensics
private void OnTransferDnaEvent(EntityUid uid, DnaComponent component, ref TransferDnaEvent args)
{
+ if (component.DNA == null)
+ return;
+
var recipientComp = EnsureComp(args.Recipient);
recipientComp.DNAs.Add(component.DNA);
recipientComp.CanDnaBeCleaned = args.CanDnaBeCleaned;
@@ -308,6 +314,35 @@ namespace Content.Server.Forensics
#region Public API
+ ///
+ /// Give the entity a new, random DNA string and call an event to notify other systems like the bloodstream that it has been changed.
+ /// Does nothing if it does not have the DnaComponent.
+ ///
+ public void RandomizeDNA(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return;
+
+ ent.Comp.DNA = GenerateDNA();
+ Dirty(ent);
+
+ var ev = new GenerateDnaEvent { Owner = ent.Owner, DNA = ent.Comp.DNA };
+ RaiseLocalEvent(ent.Owner, ref ev);
+ }
+
+ ///
+ /// Give the entity a new, random fingerprint string.
+ /// Does nothing if it does not have the FingerprintComponent.
+ ///
+ public void RandomizeFingerprint(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return;
+
+ ent.Comp.Fingerprint = GenerateFingerprint();
+ Dirty(ent);
+ }
+
///
/// Transfer DNA from one entity onto the forensics of another
///
@@ -316,7 +351,7 @@ namespace Content.Server.Forensics
/// If this DNA be cleaned off of the recipient. e.g. cleaning a knife vs cleaning a puddle of blood
public void TransferDna(EntityUid recipient, EntityUid donor, bool canDnaBeCleaned = true)
{
- if (TryComp(donor, out var donorComp))
+ if (TryComp(donor, out var donorComp) && donorComp.DNA != null)
{
EnsureComp(recipient, out var recipientComp);
recipientComp.DNAs.Add(donorComp.DNA);
diff --git a/Content.Server/GameTicking/Rules/Components/ParadoxCloneRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/ParadoxCloneRuleComponent.cs
new file mode 100644
index 0000000000..a6ffb4e669
--- /dev/null
+++ b/Content.Server/GameTicking/Rules/Components/ParadoxCloneRuleComponent.cs
@@ -0,0 +1,23 @@
+using Content.Shared.Cloning;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.GameTicking.Rules.Components;
+
+///
+/// Gamerule component for spawning a paradox clone antagonist.
+///
+[RegisterComponent]
+public sealed partial class ParadoxCloneRuleComponent : Component
+{
+ ///
+ /// Cloning settings to be used.
+ ///
+ [DataField]
+ public ProtoId Settings = "BaseClone";
+
+ ///
+ /// Visual effect spawned when gibbing at round end.
+ ///
+ [DataField]
+ public EntProtoId GibProto = "MobParadoxTimed";
+}
diff --git a/Content.Server/GameTicking/Rules/Components/TraitorRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/TraitorRuleComponent.cs
index 6f82aa042f..bfaf87e97c 100644
--- a/Content.Server/GameTicking/Rules/Components/TraitorRuleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/TraitorRuleComponent.cs
@@ -24,13 +24,13 @@ public sealed partial class TraitorRuleComponent : Component
public ProtoId SyndicateFaction = "Syndicate";
[DataField]
- public ProtoId CodewordAdjectives = "adjectives";
+ public ProtoId CodewordAdjectives = "Adjectives";
[DataField]
- public ProtoId CodewordVerbs = "verbs";
+ public ProtoId CodewordVerbs = "Verbs";
[DataField]
- public ProtoId ObjectiveIssuers = "TraitorCorporations";
+ public ProtoId ObjectiveIssuers = "TraitorCorporations";
///
/// Give this traitor an Uplink on spawn.
diff --git a/Content.Server/GameTicking/Rules/GameRuleSystem.Utility.cs b/Content.Server/GameTicking/Rules/GameRuleSystem.Utility.cs
index 5a5eb720cf..33ee91f8a5 100644
--- a/Content.Server/GameTicking/Rules/GameRuleSystem.Utility.cs
+++ b/Content.Server/GameTicking/Rules/GameRuleSystem.Utility.cs
@@ -19,6 +19,11 @@ public abstract partial class GameRuleSystem where T: IComponent
return EntityQueryEnumerator();
}
+ protected EntityQueryEnumerator QueryDelayedRules()
+ {
+ return EntityQueryEnumerator();
+ }
+
///
/// Queries all gamerules, regardless of if they're active or not.
///
diff --git a/Content.Server/GameTicking/Rules/ParadoxCloneRuleSystem.cs b/Content.Server/GameTicking/Rules/ParadoxCloneRuleSystem.cs
new file mode 100644
index 0000000000..80fad7d2ef
--- /dev/null
+++ b/Content.Server/GameTicking/Rules/ParadoxCloneRuleSystem.cs
@@ -0,0 +1,83 @@
+using Content.Server.Antag;
+using Content.Server.Cloning;
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Objectives.Components;
+using Content.Shared.GameTicking.Components;
+using Content.Shared.Gibbing.Components;
+using Content.Shared.Mind;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server.GameTicking.Rules;
+
+public sealed class ParadoxCloneRuleSystem : GameRuleSystem
+{
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly SharedMindSystem _mind = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly CloningSystem _cloning = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnAntagSelectEntity);
+ }
+
+ protected override void Started(EntityUid uid, ParadoxCloneRuleComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args)
+ {
+ base.Started(uid, component, gameRule, args);
+
+ // check if we got enough potential cloning targets, otherwise cancel the gamerule so that the ghost role does not show up
+ var allHumans = _mind.GetAliveHumans();
+
+ if (allHumans.Count == 0)
+ {
+ Log.Info("Could not find any alive players to create a paradox clone from! Ending gamerule.");
+ ForceEndSelf(uid, gameRule);
+ }
+ }
+
+ // we have to do the spawning here so we can transfer the mind to the correct entity and can assign the objectives correctly
+ private void OnAntagSelectEntity(Entity ent, ref AntagSelectEntityEvent args)
+ {
+ if (args.Session?.AttachedEntity is not { } spawner)
+ return;
+
+ if (!_prototypeManager.TryIndex(ent.Comp.Settings, out var settings))
+ {
+ Log.Error($"Used invalid cloning settings {ent.Comp.Settings} for ParadoxCloneRule");
+ return;
+ }
+
+ // get possible targets
+ var allHumans = _mind.GetAliveHumans();
+
+ // we already checked when starting the gamerule, but someone might have died since then.
+ if (allHumans.Count == 0)
+ {
+ Log.Warning("Could not find any alive players to create a paradox clone from!");
+ return;
+ }
+
+ // pick a random player
+ var playerToClone = _random.Pick(allHumans);
+ var bodyToClone = playerToClone.Comp.OwnedEntity;
+
+ if (bodyToClone == null || !_cloning.TryCloning(bodyToClone.Value, _transform.GetMapCoordinates(spawner), settings, out var clone))
+ {
+ Log.Error($"Unable to make a paradox clone of entity {ToPrettyString(bodyToClone)}");
+ return;
+ }
+
+ var targetComp = EnsureComp(clone.Value);
+ targetComp.Target = playerToClone.Owner; // set the kill target
+
+ var gibComp = EnsureComp(clone.Value);
+ gibComp.SpawnProto = ent.Comp.GibProto;
+ gibComp.PreventGibbingObjectives = new() { "ParadoxCloneKillObjective" }; // don't gib them if they killed the original.
+
+ args.Entity = clone;
+ }
+}
diff --git a/Content.Server/GameTicking/Rules/RevolutionaryRuleSystem.cs b/Content.Server/GameTicking/Rules/RevolutionaryRuleSystem.cs
index a313b78eaf..9ec932b06f 100644
--- a/Content.Server/GameTicking/Rules/RevolutionaryRuleSystem.cs
+++ b/Content.Server/GameTicking/Rules/RevolutionaryRuleSystem.cs
@@ -189,7 +189,7 @@ public sealed class RevolutionaryRuleSystem : GameRuleSystem();
while (rev.MoveNext(out var uid, out _, out var mc))
@@ -251,34 +251,45 @@ public sealed class RevolutionaryRuleSystem : GameRuleSystemThe list of the entities
/// Bool for if you want to check if someone is in space and consider them missing in action. (Won't check when emergency shuttle arrives just in case)
/// Bool for if you don't want to count cuffed entities.
+ /// Bool for if you want to count revolutionaries.
///
- private bool IsGroupDetainedOrDead(List list, bool checkOffStation, bool countCuffed)
+ private bool IsGroupDetainedOrDead(List list, bool checkOffStation, bool countCuffed, bool countRevolutionaries)
{
var gone = 0;
+
foreach (var entity in list)
{
if (TryComp(entity, out var cuffed) && cuffed.CuffedHandCount > 0 && countCuffed)
{
gone++;
+ continue;
}
- else
+
+ if (TryComp