diff --git a/Content.Client/Bed/Cryostorage/CryostorageBoundUserInterface.cs b/Content.Client/Bed/Cryostorage/CryostorageBoundUserInterface.cs
new file mode 100644
index 0000000000..ffab162548
--- /dev/null
+++ b/Content.Client/Bed/Cryostorage/CryostorageBoundUserInterface.cs
@@ -0,0 +1,56 @@
+using Content.Shared.Bed.Cryostorage;
+using JetBrains.Annotations;
+
+namespace Content.Client.Bed.Cryostorage;
+
+[UsedImplicitly]
+public sealed class CryostorageBoundUserInterface : BoundUserInterface
+{
+ [ViewVariables]
+ private CryostorageMenu? _menu;
+
+ public CryostorageBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _menu = new();
+
+ _menu.OnClose += Close;
+
+ _menu.SlotRemoveButtonPressed += (ent, slot) =>
+ {
+ SendMessage(new CryostorageRemoveItemBuiMessage(ent, slot, CryostorageRemoveItemBuiMessage.RemovalType.Inventory));
+ };
+
+ _menu.HandRemoveButtonPressed += (ent, hand) =>
+ {
+ SendMessage(new CryostorageRemoveItemBuiMessage(ent, hand, CryostorageRemoveItemBuiMessage.RemovalType.Hand));
+ };
+
+ _menu.OpenCentered();
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ switch (state)
+ {
+ case CryostorageBuiState msg:
+ _menu?.UpdateState(msg);
+ break;
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (!disposing)
+ return;
+ _menu?.Dispose();
+ }
+}
diff --git a/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml b/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml
new file mode 100644
index 0000000000..176acbf29b
--- /dev/null
+++ b/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml.cs b/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml.cs
new file mode 100644
index 0000000000..09e418fd86
--- /dev/null
+++ b/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml.cs
@@ -0,0 +1,46 @@
+using Content.Shared.Bed.Cryostorage;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Bed.Cryostorage;
+
+[GenerateTypedNameReferences]
+public sealed partial class CryostorageEntryControl : BoxContainer
+{
+ public event Action? SlotRemoveButtonPressed;
+ public event Action? HandRemoveButtonPressed;
+
+ public NetEntity Entity;
+ public bool LastOpenState;
+
+ public CryostorageEntryControl(CryostorageContainedPlayerData data)
+ {
+ RobustXamlLoader.Load(this);
+ Entity = data.PlayerEnt;
+ Update(data);
+ }
+
+ public void Update(CryostorageContainedPlayerData data)
+ {
+ LastOpenState = Collapsible.BodyVisible;
+ Heading.Title = data.PlayerName;
+ Body.Visible = data.ItemSlots.Count != 0 && data.HeldItems.Count != 0;
+
+ ItemsContainer.Children.Clear();
+ foreach (var (name, itemName) in data.ItemSlots)
+ {
+ var control = new CryostorageSlotControl(name, itemName);
+ control.Button.OnPressed += _ => SlotRemoveButtonPressed?.Invoke(name);
+ ItemsContainer.AddChild(control);
+ }
+
+ foreach (var (name, held) in data.HeldItems)
+ {
+ var control = new CryostorageSlotControl(Loc.GetString("cryostorage-ui-filler-hand"), held);
+ control.Button.OnPressed += _ => HandRemoveButtonPressed?.Invoke(name);
+ ItemsContainer.AddChild(control);
+ }
+ Collapsible.BodyVisible = LastOpenState;
+ }
+}
diff --git a/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml b/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml
new file mode 100644
index 0000000000..5360cdb38e
--- /dev/null
+++ b/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml.cs b/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml.cs
new file mode 100644
index 0000000000..51f1561939
--- /dev/null
+++ b/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml.cs
@@ -0,0 +1,54 @@
+using System.Linq;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Bed.Cryostorage;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Collections;
+using Robust.Shared.Utility;
+
+namespace Content.Client.Bed.Cryostorage;
+
+[GenerateTypedNameReferences]
+public sealed partial class CryostorageMenu : FancyWindow
+{
+ public event Action? SlotRemoveButtonPressed;
+ public event Action? HandRemoveButtonPressed;
+
+ public CryostorageMenu()
+ {
+ RobustXamlLoader.Load(this);
+ }
+
+ public void UpdateState(CryostorageBuiState state)
+ {
+ var data = state.PlayerData;
+ var nonexistentEntries = new ValueList(data);
+
+ var children = new ValueList(EntriesContainer.Children);
+ foreach (var control in children)
+ {
+ if (control is not CryostorageEntryControl entryControl)
+ continue;
+
+ if (data.Where(p => p.PlayerEnt == entryControl.Entity).FirstOrNull() is not { } datum)
+ {
+ EntriesContainer.Children.Remove(entryControl);
+ continue;
+ }
+
+ nonexistentEntries.Remove(datum);
+ entryControl.Update(datum);
+ }
+
+ foreach (var player in nonexistentEntries)
+ {
+ var control = new CryostorageEntryControl(player);
+ control.SlotRemoveButtonPressed += a => SlotRemoveButtonPressed?.Invoke(player.PlayerEnt, a);
+ control.HandRemoveButtonPressed += a => HandRemoveButtonPressed?.Invoke(player.PlayerEnt, a);
+ EntriesContainer.Children.Add(control);
+ }
+
+ EmptyLabel.Visible = data.Count == 0;
+ }
+}
diff --git a/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml b/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml
new file mode 100644
index 0000000000..b45e77cd1a
--- /dev/null
+++ b/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml.cs b/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml.cs
new file mode 100644
index 0000000000..629b958262
--- /dev/null
+++ b/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml.cs
@@ -0,0 +1,18 @@
+using Content.Client.Message;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Bed.Cryostorage;
+
+[GenerateTypedNameReferences]
+public sealed partial class CryostorageSlotControl : BoxContainer
+{
+ public CryostorageSlotControl(string name, string itemName)
+ {
+ RobustXamlLoader.Load(this);
+
+ SlotLabel.SetMarkup(Loc.GetString("cryostorage-ui-label-slot-name", ("slot", name)));
+ ItemLabel.Text = itemName;
+ }
+}
diff --git a/Content.Client/Bed/Cryostorage/CryostorageSystem.cs b/Content.Client/Bed/Cryostorage/CryostorageSystem.cs
new file mode 100644
index 0000000000..882f433841
--- /dev/null
+++ b/Content.Client/Bed/Cryostorage/CryostorageSystem.cs
@@ -0,0 +1,9 @@
+using Content.Shared.Bed.Cryostorage;
+
+namespace Content.Client.Bed.Cryostorage;
+
+///
+public sealed class CryostorageSystem : SharedCryostorageSystem
+{
+
+}
diff --git a/Content.Server/Bed/Cryostorage/CryostorageSystem.cs b/Content.Server/Bed/Cryostorage/CryostorageSystem.cs
new file mode 100644
index 0000000000..799bb82eff
--- /dev/null
+++ b/Content.Server/Bed/Cryostorage/CryostorageSystem.cs
@@ -0,0 +1,309 @@
+using System.Linq;
+using Content.Server.Chat.Managers;
+using Content.Server.GameTicking;
+using Content.Server.Hands.Systems;
+using Content.Server.Inventory;
+using Content.Server.Popups;
+using Content.Server.Station.Components;
+using Content.Server.Station.Systems;
+using Content.Server.UserInterface;
+using Content.Shared.Access.Systems;
+using Content.Shared.Bed.Cryostorage;
+using Content.Shared.Chat;
+using Content.Shared.Climbing.Systems;
+using Content.Shared.Database;
+using Content.Shared.Hands.Components;
+using Content.Shared.Mind.Components;
+using Robust.Server.Audio;
+using Robust.Server.Containers;
+using Robust.Server.GameObjects;
+using Robust.Server.Player;
+using Robust.Shared.Containers;
+using Robust.Shared.Enums;
+using Robust.Shared.Network;
+using Robust.Shared.Player;
+
+namespace Content.Server.Bed.Cryostorage;
+
+///
+public sealed class CryostorageSystem : SharedCryostorageSystem
+{
+ [Dependency] private readonly IChatManager _chatManager = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly AudioSystem _audio = default!;
+ [Dependency] private readonly AccessReaderSystem _accessReader = default!;
+ [Dependency] private readonly ClimbSystem _climb = default!;
+ [Dependency] private readonly ContainerSystem _container = default!;
+ [Dependency] private readonly GameTicker _gameTicker = default!;
+ [Dependency] private readonly HandsSystem _hands = default!;
+ [Dependency] private readonly ServerInventorySystem _inventory = default!;
+ [Dependency] private readonly PopupSystem _popup = default!;
+ [Dependency] private readonly StationSystem _station = default!;
+ [Dependency] private readonly StationJobsSystem _stationJobs = default!;
+ [Dependency] private readonly TransformSystem _transform = default!;
+ [Dependency] private readonly UserInterfaceSystem _ui = default!;
+
+ ///
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnBeforeUIOpened);
+ SubscribeLocalEvent(OnRemoveItemBuiMessage);
+
+ SubscribeLocalEvent(OnPlayerSpawned);
+ SubscribeLocalEvent(OnMindRemoved);
+
+ _playerManager.PlayerStatusChanged += PlayerStatusChanged;
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+
+ _playerManager.PlayerStatusChanged -= PlayerStatusChanged;
+ }
+
+ private void OnBeforeUIOpened(Entity ent, ref BeforeActivatableUIOpenEvent args)
+ {
+ UpdateCryostorageUIState(ent);
+ }
+
+ private void OnRemoveItemBuiMessage(Entity ent, ref CryostorageRemoveItemBuiMessage args)
+ {
+ var comp = ent.Comp;
+ if (args.Session.AttachedEntity is not { } attachedEntity)
+ return;
+
+ var cryoContained = GetEntity(args.Entity);
+
+ if (!comp.StoredPlayers.Contains(cryoContained))
+ return;
+
+ if (!HasComp(attachedEntity))
+ return;
+
+ if (!_accessReader.IsAllowed(attachedEntity, ent))
+ {
+ _popup.PopupEntity(Loc.GetString("cryostorage-popup-access-denied"), attachedEntity, attachedEntity);
+ return;
+ }
+
+ EntityUid? entity = null;
+ if (args.Type == CryostorageRemoveItemBuiMessage.RemovalType.Hand)
+ {
+ if (_hands.TryGetHand(cryoContained, args.Key, out var hand))
+ entity = hand.HeldEntity;
+ }
+ else
+ {
+ if (_inventory.TryGetSlotContainer(cryoContained, args.Key, out var slot, out _))
+ entity = slot.ContainedEntity;
+ }
+
+ if (entity == null)
+ return;
+
+ AdminLog.Add(LogType.Action, LogImpact.High,
+ $"{ToPrettyString(attachedEntity):player} removed item {ToPrettyString(entity)} from cryostorage-contained player " +
+ $"{ToPrettyString(cryoContained):player}, stored in cryostorage {ToPrettyString(ent)}");
+ _container.TryRemoveFromContainer(entity.Value);
+ _transform.SetCoordinates(entity.Value, Transform(attachedEntity).Coordinates);
+ _hands.PickupOrDrop(attachedEntity, entity.Value);
+ UpdateCryostorageUIState(ent);
+ }
+
+ private void UpdateCryostorageUIState(Entity ent)
+ {
+ var state = new CryostorageBuiState(GetAllContainedData(ent).ToList());
+ _ui.TrySetUiState(ent, CryostorageUIKey.Key, state);
+ }
+
+ private void OnPlayerSpawned(Entity ent, ref PlayerSpawnCompleteEvent args)
+ {
+ // if you spawned into cryostorage, we're not gonna round-remove you.
+ ent.Comp.GracePeriodEndTime = null;
+ }
+
+ private void OnMindRemoved(Entity ent, ref MindRemovedMessage args)
+ {
+ var comp = ent.Comp;
+
+ if (!TryComp(comp.Cryostorage, out var cryostorageComponent))
+ return;
+
+ if (comp.GracePeriodEndTime != null)
+ comp.GracePeriodEndTime = Timing.CurTime + cryostorageComponent.NoMindGracePeriod;
+ comp.UserId = args.Mind.Comp.UserId;
+ }
+
+ private void PlayerStatusChanged(object? sender, SessionStatusEventArgs args)
+ {
+ if (args.Session.AttachedEntity is not { } entity)
+ return;
+
+ if (!TryComp(entity, out var containedComponent))
+ return;
+
+ if (args.NewStatus is SessionStatus.Disconnected or SessionStatus.Zombie)
+ {
+ if (CryoSleepRejoiningEnabled)
+ containedComponent.StoredWhileDisconnected = true;
+
+ var delay = CompOrNull(containedComponent.Cryostorage)?.NoMindGracePeriod ?? TimeSpan.Zero;
+ containedComponent.GracePeriodEndTime = Timing.CurTime + delay;
+ containedComponent.UserId = args.Session.UserId;
+ }
+ else if (args.NewStatus == SessionStatus.InGame)
+ {
+ HandleCryostorageReconnection((entity, containedComponent));
+ }
+ }
+
+ public void HandleEnterCryostorage(Entity ent, NetUserId? userId)
+ {
+ var comp = ent.Comp;
+ var cryostorageEnt = ent.Comp.Cryostorage;
+ if (!TryComp(cryostorageEnt, out var cryostorageComponent))
+ return;
+
+ // if we have a session, we use that to add back in all the job slots the player had.
+ if (userId != null)
+ {
+ foreach (var station in _station.GetStationsSet())
+ {
+ if (!TryComp(station, out var stationJobs))
+ continue;
+
+ if (!_stationJobs.TryGetPlayerJobs(station, userId.Value, out var jobs, stationJobs))
+ continue;
+
+ foreach (var job in jobs)
+ {
+ _stationJobs.TryAdjustJobSlot(station, job, 1, clamp: true);
+ }
+
+ _stationJobs.TryRemovePlayerJobs(station, userId.Value, stationJobs);
+ }
+ }
+
+ _audio.PlayPvs(cryostorageComponent.RemoveSound, ent);
+
+ EnsurePausedMap();
+ if (PausedMap == null)
+ {
+ Log.Error("CryoSleep map was unexpectedly null");
+ return;
+ }
+
+ if (!comp.StoredWhileDisconnected &&
+ userId != null &&
+ Mind.TryGetMind(userId.Value, out var mind) &&
+ mind.Value.Comp.Session?.AttachedEntity == ent)
+ {
+ _gameTicker.OnGhostAttempt(mind.Value, false);
+ }
+ _transform.SetParent(ent, PausedMap.Value);
+ cryostorageComponent.StoredPlayers.Add(ent);
+ UpdateCryostorageUIState((cryostorageEnt.Value, cryostorageComponent));
+ AdminLog.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(ent):player} was entered into cryostorage inside of {ToPrettyString(cryostorageEnt.Value)}");
+ }
+
+ private void HandleCryostorageReconnection(Entity entity)
+ {
+ var (uid, comp) = entity;
+ if (!CryoSleepRejoiningEnabled || !comp.StoredWhileDisconnected)
+ return;
+
+ // how did you destroy these? they're indestructible.
+ if (comp.Cryostorage is not { } cryostorage ||
+ TerminatingOrDeleted(cryostorage) ||
+ !TryComp(comp.Cryostorage, out var cryostorageComponent))
+ {
+ QueueDel(entity);
+ return;
+ }
+
+ var cryoXform = Transform(cryostorage);
+ _transform.SetParent(uid, cryoXform.ParentUid);
+ _transform.SetCoordinates(uid, cryoXform.Coordinates);
+ if (!_container.TryGetContainer(cryostorage, cryostorageComponent.ContainerId, out var container) ||
+ !_container.Insert(uid, container, cryoXform))
+ {
+ _climb.ForciblySetClimbing(uid, cryostorage);
+ }
+
+ comp.GracePeriodEndTime = null;
+ comp.StoredWhileDisconnected = false;
+ cryostorageComponent.StoredPlayers.Remove(entity);
+ AdminLog.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(entity):player} re-entered the game from cryostorage {ToPrettyString(cryostorage)}");
+ UpdateCryostorageUIState((cryostorage, cryostorageComponent));
+ }
+
+ protected override void OnInsertedContainer(Entity ent, ref EntInsertedIntoContainerMessage args)
+ {
+ var (uid, comp) = ent;
+ if (args.Container.ID != comp.ContainerId)
+ return;
+
+ base.OnInsertedContainer(ent, ref args);
+
+ var locKey = CryoSleepRejoiningEnabled
+ ? "cryostorage-insert-message-temp"
+ : "cryostorage-insert-message-permanent";
+
+ var msg = Loc.GetString(locKey, ("time", comp.GracePeriod.TotalMinutes));
+ if (TryComp(args.Entity, out var actor))
+ _chatManager.ChatMessageToOne(ChatChannel.Server, msg, msg, uid, false, actor.PlayerSession.Channel);
+ }
+
+ private IEnumerable GetAllContainedData(Entity ent)
+ {
+ foreach (var contained in ent.Comp.StoredPlayers)
+ {
+ yield return GetContainedData(contained);
+ }
+ }
+
+ private CryostorageContainedPlayerData GetContainedData(EntityUid uid)
+ {
+ var data = new CryostorageContainedPlayerData();
+ data.PlayerName = Name(uid);
+ data.PlayerEnt = GetNetEntity(uid);
+
+ var enumerator = _inventory.GetSlotEnumerator(uid);
+ while (enumerator.NextItem(out var item, out var slotDef))
+ {
+ data.ItemSlots.Add(slotDef.Name, Name(item));
+ }
+
+ foreach (var hand in _hands.EnumerateHands(uid))
+ {
+ if (hand.HeldEntity == null)
+ continue;
+
+ data.HeldItems.Add(hand.Name, Name(hand.HeldEntity.Value));
+ }
+
+ return data;
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var containedComp))
+ {
+ if (containedComp.GracePeriodEndTime == null || containedComp.StoredWhileDisconnected)
+ continue;
+
+ if (Timing.CurTime < containedComp.GracePeriodEndTime)
+ continue;
+
+ Mind.TryGetMind(uid, out _, out var mindComp);
+ var id = mindComp?.UserId ?? containedComp.UserId;
+ HandleEnterCryostorage((uid, containedComp), id);
+ }
+ }
+}
diff --git a/Content.Server/GameTicking/GameTicker.Spawning.cs b/Content.Server/GameTicking/GameTicker.Spawning.cs
index 1a86d9fef4..07c79747c0 100644
--- a/Content.Server/GameTicking/GameTicker.Spawning.cs
+++ b/Content.Server/GameTicking/GameTicker.Spawning.cs
@@ -231,7 +231,7 @@ namespace Content.Server.GameTicking
EntityManager.AddComponent(mob);
}
- _stationJobs.TryAssignJob(station, jobPrototype);
+ _stationJobs.TryAssignJob(station, jobPrototype, player.UserId);
if (lateJoin)
_adminLogger.Add(LogType.LateJoin, LogImpact.Medium, $"Player {player.Name} late joined as {character.Name:characterName} on station {Name(station):stationName} with {ToPrettyString(mob):entity} as a {jobName:jobName}.");
diff --git a/Content.Server/Spawners/Components/ContainerSpawnPointComponent.cs b/Content.Server/Spawners/Components/ContainerSpawnPointComponent.cs
new file mode 100644
index 0000000000..5cd2ac3048
--- /dev/null
+++ b/Content.Server/Spawners/Components/ContainerSpawnPointComponent.cs
@@ -0,0 +1,30 @@
+using Content.Server.Spawners.EntitySystems;
+
+namespace Content.Server.Spawners.Components;
+
+///
+/// A spawn point that spawns a player into a target container rather than simply spawning them at a position.
+/// Occurs before regular spawn points but after arrivals.
+///
+[RegisterComponent]
+[Access(typeof(ContainerSpawnPointSystem))]
+public sealed partial class ContainerSpawnPointComponent : Component
+{
+ ///
+ /// The ID of the container that this entity will spawn players into
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public string ContainerId;
+
+ ///
+ /// An optional job specifier
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public string? Job;
+
+ ///
+ /// The type of spawn point
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public SpawnPointType SpawnType = SpawnPointType.Unset;
+}
diff --git a/Content.Server/Spawners/EntitySystems/ContainerSpawnPointSystem.cs b/Content.Server/Spawners/EntitySystems/ContainerSpawnPointSystem.cs
new file mode 100644
index 0000000000..65f1076700
--- /dev/null
+++ b/Content.Server/Spawners/EntitySystems/ContainerSpawnPointSystem.cs
@@ -0,0 +1,85 @@
+using Content.Server.GameTicking;
+using Content.Server.Shuttles.Systems;
+using Content.Server.Spawners.Components;
+using Content.Server.Station.Systems;
+using Robust.Server.Containers;
+using Robust.Shared.Containers;
+using Robust.Shared.Random;
+
+namespace Content.Server.Spawners.EntitySystems;
+
+public sealed class ContainerSpawnPointSystem : EntitySystem
+{
+ [Dependency] private readonly GameTicker _gameTicker = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly ContainerSystem _container = default!;
+ [Dependency] private readonly StationSystem _station = default!;
+ [Dependency] private readonly StationSpawningSystem _stationSpawning = default!;
+
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnSpawnPlayer, before: new[] { typeof(SpawnPointSystem), typeof(ArrivalsSystem) });
+ }
+
+ private void OnSpawnPlayer(PlayerSpawningEvent args)
+ {
+ if (args.SpawnResult != null)
+ return;
+
+ var query = EntityQueryEnumerator();
+ var possibleContainers = new List>();
+
+ while (query.MoveNext(out var uid, out var spawnPoint, out var container, out var xform))
+ {
+ if (args.Station != null && _station.GetOwningStation(uid, xform) != args.Station)
+ continue;
+
+ // If it's unset, then we allow it to be used for both roundstart and midround joins
+ if (spawnPoint.SpawnType == SpawnPointType.Unset)
+ {
+ // make sure we also check the job here for various reasons.
+ if (spawnPoint.Job == null || spawnPoint.Job == args.Job?.Prototype)
+ possibleContainers.Add((uid, spawnPoint, container, xform));
+ continue;
+ }
+
+ if (_gameTicker.RunLevel == GameRunLevel.InRound && spawnPoint.SpawnType == SpawnPointType.LateJoin)
+ {
+ possibleContainers.Add((uid, spawnPoint, container, xform));
+ }
+
+ if (_gameTicker.RunLevel != GameRunLevel.InRound &&
+ spawnPoint.SpawnType == SpawnPointType.Job &&
+ (args.Job == null || spawnPoint.Job == args.Job.Prototype))
+ {
+ possibleContainers.Add((uid, spawnPoint, container, xform));
+ }
+ }
+
+ if (possibleContainers.Count == 0)
+ return;
+ // we just need some default coords so we can spawn the player entity.
+ var baseCoords = possibleContainers[0].Comp3.Coordinates;
+
+ args.SpawnResult = _stationSpawning.SpawnPlayerMob(
+ baseCoords,
+ args.Job,
+ args.HumanoidCharacterProfile,
+ args.Station);
+
+ _random.Shuffle(possibleContainers);
+ foreach (var (uid, spawnPoint, manager, xform) in possibleContainers)
+ {
+ if (!_container.TryGetContainer(uid, spawnPoint.ContainerId, out var container, manager))
+ continue;
+
+ if (!_container.Insert(args.SpawnResult.Value, container, containerXform: xform))
+ continue;
+
+ return;
+ }
+
+ Del(args.Station);
+ args.SpawnResult = null;
+ }
+}
diff --git a/Content.Server/Station/Components/StationJobsComponent.cs b/Content.Server/Station/Components/StationJobsComponent.cs
index 677600df7e..74399bf412 100644
--- a/Content.Server/Station/Components/StationJobsComponent.cs
+++ b/Content.Server/Station/Components/StationJobsComponent.cs
@@ -1,6 +1,8 @@
using Content.Server.Station.Systems;
using Content.Shared.Roles;
using JetBrains.Annotations;
+using Robust.Shared.Network;
+using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set;
@@ -75,6 +77,13 @@ public sealed partial class StationJobsComponent : Component
[DataField("overflowJobs", customTypeSerializer: typeof(PrototypeIdHashSetSerializer))]
public HashSet OverflowJobs = new();
+ ///
+ /// A dictionary relating a NetUserId to the jobs they have on station.
+ /// An OOC way to track where job slots have gone.
+ ///
+ [DataField]
+ public Dictionary>> PlayerJobs = new();
+
[DataField("availableJobs", required: true,
customTypeSerializer: typeof(PrototypeIdDictionarySerializer, JobPrototype>))]
public Dictionary> SetupAvailableJobs = default!;
diff --git a/Content.Server/Station/Systems/StationJobsSystem.cs b/Content.Server/Station/Systems/StationJobsSystem.cs
index eeaace03b2..c13df410a0 100644
--- a/Content.Server/Station/Systems/StationJobsSystem.cs
+++ b/Content.Server/Station/Systems/StationJobsSystem.cs
@@ -9,7 +9,9 @@ using Content.Shared.Roles;
using JetBrains.Annotations;
using Robust.Server.Player;
using Robust.Shared.Configuration;
+using Robust.Shared.Network;
using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
using Robust.Shared.Random;
namespace Content.Server.Station.Systems;
@@ -84,13 +86,14 @@ public sealed partial class StationJobsSystem : EntitySystem
#region Public API
- ///
+ ///
/// Station to assign a job on.
/// Job to assign.
+ /// The net user ID of the player we're assigning this job to.
/// Resolve pattern, station jobs component of the station.
- public bool TryAssignJob(EntityUid station, JobPrototype job, StationJobsComponent? stationJobs = null)
+ public bool TryAssignJob(EntityUid station, JobPrototype job, NetUserId netUserId, StationJobsComponent? stationJobs = null)
{
- return TryAssignJob(station, job.ID, stationJobs);
+ return TryAssignJob(station, job.ID, netUserId, stationJobs);
}
///
@@ -98,12 +101,21 @@ public sealed partial class StationJobsSystem : EntitySystem
///
/// Station to assign a job on.
/// Job prototype ID to assign.
+ /// The net user ID of the player we're assigning this job to.
/// Resolve pattern, station jobs component of the station.
/// Whether or not assignment was a success.
/// Thrown when the given station is not a station.
- public bool TryAssignJob(EntityUid station, string jobPrototypeId, StationJobsComponent? stationJobs = null)
+ public bool TryAssignJob(EntityUid station, string jobPrototypeId, NetUserId netUserId, StationJobsComponent? stationJobs = null)
{
- return TryAdjustJobSlot(station, jobPrototypeId, -1, false, false, stationJobs);
+ if (!Resolve(station, ref stationJobs, false))
+ return false;
+
+ if (!TryAdjustJobSlot(station, jobPrototypeId, -1, false, false, stationJobs))
+ return false;
+
+ stationJobs.PlayerJobs.TryAdd(netUserId, new());
+ stationJobs.PlayerJobs[netUserId].Add(jobPrototypeId);
+ return true;
}
///
@@ -183,6 +195,28 @@ public sealed partial class StationJobsSystem : EntitySystem
}
}
+ public bool TryGetPlayerJobs(EntityUid station,
+ NetUserId userId,
+ [NotNullWhen(true)] out List>? jobs,
+ StationJobsComponent? jobsComponent = null)
+ {
+ jobs = null;
+ if (!Resolve(station, ref jobsComponent, false))
+ return false;
+
+ return jobsComponent.PlayerJobs.TryGetValue(userId, out jobs);
+ }
+
+ public bool TryRemovePlayerJobs(EntityUid station,
+ NetUserId userId,
+ StationJobsComponent? jobsComponent = null)
+ {
+ if (!Resolve(station, ref jobsComponent, false))
+ return false;
+
+ return jobsComponent.PlayerJobs.Remove(userId);
+ }
+
///
/// Station to adjust the job slot on.
/// Job prototype to adjust.
diff --git a/Content.Shared/Access/Components/AccessReaderComponent.cs b/Content.Shared/Access/Components/AccessReaderComponent.cs
index 5dd45b21c3..3f6c9e1c05 100644
--- a/Content.Shared/Access/Components/AccessReaderComponent.cs
+++ b/Content.Shared/Access/Components/AccessReaderComponent.cs
@@ -63,6 +63,12 @@ public sealed partial class AccessReaderComponent : Component
///
[DataField, ViewVariables(VVAccess.ReadWrite)]
public int AccessLogLimit = 20;
+
+ ///
+ /// Whether or not emag interactions have an effect on this.
+ ///
+ [DataField]
+ public bool BreakOnEmag = true;
}
[DataDefinition, Serializable, NetSerializable]
diff --git a/Content.Shared/Access/Components/IdCardConsoleComponent.cs b/Content.Shared/Access/Components/IdCardConsoleComponent.cs
index f630803446..387ca8a013 100644
--- a/Content.Shared/Access/Components/IdCardConsoleComponent.cs
+++ b/Content.Shared/Access/Components/IdCardConsoleComponent.cs
@@ -56,6 +56,7 @@ public sealed partial class IdCardConsoleComponent : Component
"ChiefEngineer",
"ChiefMedicalOfficer",
"Command",
+ "Cryogenics",
"Engineering",
"External",
"HeadOfPersonnel",
diff --git a/Content.Shared/Access/Systems/AccessReaderSystem.cs b/Content.Shared/Access/Systems/AccessReaderSystem.cs
index c5bceb4899..812a8e0487 100644
--- a/Content.Shared/Access/Systems/AccessReaderSystem.cs
+++ b/Content.Shared/Access/Systems/AccessReaderSystem.cs
@@ -76,6 +76,8 @@ public sealed class AccessReaderSystem : EntitySystem
private void OnEmagged(EntityUid uid, AccessReaderComponent reader, ref GotEmaggedEvent args)
{
+ if (!reader.BreakOnEmag)
+ return;
args.Handled = true;
reader.Enabled = false;
reader.AccessLog.Clear();
diff --git a/Content.Shared/Bed/Cryostorage/CryostorageComponent.cs b/Content.Shared/Bed/Cryostorage/CryostorageComponent.cs
new file mode 100644
index 0000000000..c7aa00c300
--- /dev/null
+++ b/Content.Shared/Bed/Cryostorage/CryostorageComponent.cs
@@ -0,0 +1,110 @@
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Bed.Cryostorage;
+
+///
+/// This is used for a container which, when a player logs out while inside of,
+/// will delete their body and redistribute their items.
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class CryostorageComponent : Component
+{
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public string ContainerId = "storage";
+
+ ///
+ /// How long a player can remain inside Cryostorage before automatically being taken care of, given that they have no mind.
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan NoMindGracePeriod = TimeSpan.FromSeconds(30f);
+
+ ///
+ /// How long a player can remain inside Cryostorage before automatically being taken care of.
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan GracePeriod = TimeSpan.FromMinutes(5f);
+
+ ///
+ /// A list of players who have actively entered cryostorage.
+ ///
+ [DataField]
+ public List StoredPlayers = new();
+
+ ///
+ /// Sound that is played when a player is removed by a cryostorage.
+ ///
+ [DataField]
+ public SoundSpecifier? RemoveSound = new SoundPathSpecifier("/Audio/Effects/teleport_departure.ogg");
+}
+
+[Serializable, NetSerializable]
+public enum CryostorageVisuals : byte
+{
+ Full
+}
+
+[Serializable, NetSerializable]
+public record struct CryostorageContainedPlayerData()
+{
+ ///
+ /// The player's IC name
+ ///
+ public string PlayerName = string.Empty;
+
+ ///
+ /// The player's entity
+ ///
+ public NetEntity PlayerEnt = NetEntity.Invalid;
+
+ ///
+ /// A dictionary relating a slot definition name to the name of the item inside of it.
+ ///
+ public Dictionary ItemSlots = new();
+
+ ///
+ /// A dictionary relating a hand ID to the hand name and the name of the item being held.
+ ///
+ public Dictionary HeldItems = new();
+}
+
+[Serializable, NetSerializable]
+public sealed class CryostorageBuiState : BoundUserInterfaceState
+{
+ public List PlayerData;
+
+ public CryostorageBuiState(List playerData)
+ {
+ PlayerData = playerData;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed class CryostorageRemoveItemBuiMessage : BoundUserInterfaceMessage
+{
+ public NetEntity Entity;
+
+ public string Key;
+
+ public RemovalType Type;
+
+ public enum RemovalType : byte
+ {
+ Hand,
+ Inventory
+ }
+
+ public CryostorageRemoveItemBuiMessage(NetEntity entity, string key, RemovalType type)
+ {
+ Entity = entity;
+ Key = key;
+ Type = type;
+ }
+}
+
+[Serializable, NetSerializable]
+public enum CryostorageUIKey : byte
+{
+ Key
+}
diff --git a/Content.Shared/Bed/Cryostorage/CryostorageContainedComponent.cs b/Content.Shared/Bed/Cryostorage/CryostorageContainedComponent.cs
new file mode 100644
index 0000000000..42a11aabe2
--- /dev/null
+++ b/Content.Shared/Bed/Cryostorage/CryostorageContainedComponent.cs
@@ -0,0 +1,34 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Network;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.Bed.Cryostorage;
+
+///
+/// This is used to track an entity that is currently being held in Cryostorage.
+///
+[RegisterComponent, NetworkedComponent]
+[AutoGenerateComponentState]
+public sealed partial class CryostorageContainedComponent : Component
+{
+ ///
+ /// Whether or not this entity is being stored on another map or is just chilling in a container
+ ///
+ [DataField, AutoNetworkedField]
+ public bool StoredWhileDisconnected;
+
+ ///
+ /// The time at which the cryostorage grace period ends.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField]
+ public TimeSpan? GracePeriodEndTime;
+
+ ///
+ /// The cryostorage this entity is 'stored' in.
+ ///
+ [DataField, AutoNetworkedField]
+ public EntityUid? Cryostorage;
+
+ [DataField]
+ public NetUserId? UserId;
+}
diff --git a/Content.Shared/Bed/Cryostorage/SharedCryostorageSystem.cs b/Content.Shared/Bed/Cryostorage/SharedCryostorageSystem.cs
new file mode 100644
index 0000000000..e781433783
--- /dev/null
+++ b/Content.Shared/Bed/Cryostorage/SharedCryostorageSystem.cs
@@ -0,0 +1,179 @@
+using Content.Shared.Administration.Logs;
+using Content.Shared.CCVar;
+using Content.Shared.DragDrop;
+using Content.Shared.GameTicking;
+using Content.Shared.Mind;
+using Content.Shared.Mind.Components;
+using Robust.Shared.Configuration;
+using Robust.Shared.Containers;
+using Robust.Shared.Map;
+using Robust.Shared.Timing;
+
+namespace Content.Shared.Bed.Cryostorage;
+
+///
+/// This handles
+///
+public abstract class SharedCryostorageSystem : EntitySystem
+{
+ [Dependency] protected readonly ISharedAdminLogManager AdminLog = default!;
+ [Dependency] private readonly IConfigurationManager _configuration = default!;
+ [Dependency] protected readonly IGameTiming Timing = default!;
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] protected readonly SharedMindSystem Mind = default!;
+
+ protected EntityUid? PausedMap { get; private set; }
+
+ protected bool CryoSleepRejoiningEnabled;
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnInsertedContainer);
+ SubscribeLocalEvent(OnRemovedContainer);
+ SubscribeLocalEvent(OnInsertAttempt);
+ SubscribeLocalEvent(OnShutdownContainer);
+ SubscribeLocalEvent(OnCanDropTarget);
+
+ SubscribeLocalEvent(OnRemovedContained);
+ SubscribeLocalEvent(OnUnpaused);
+ SubscribeLocalEvent(OnShutdownContained);
+
+ SubscribeLocalEvent(OnRoundRestart);
+
+ _configuration.OnValueChanged(CCVars.GameCryoSleepRejoining, OnCvarChanged);
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+
+ _configuration.UnsubValueChanged(CCVars.GameCryoSleepRejoining, OnCvarChanged);
+ }
+
+ private void OnCvarChanged(bool value)
+ {
+ CryoSleepRejoiningEnabled = value;
+ }
+
+ protected virtual void OnInsertedContainer(Entity ent, ref EntInsertedIntoContainerMessage args)
+ {
+ var (_, comp) = ent;
+ if (args.Container.ID != comp.ContainerId)
+ return;
+
+ _appearance.SetData(ent, CryostorageVisuals.Full, true);
+ if (!Timing.IsFirstTimePredicted)
+ return;
+
+ var containedComp = EnsureComp(args.Entity);
+ var delay = Mind.TryGetMind(args.Entity, out _, out _) ? comp.GracePeriod : comp.NoMindGracePeriod;
+ containedComp.GracePeriodEndTime = Timing.CurTime + delay;
+ containedComp.Cryostorage = ent;
+ Dirty(args.Entity, containedComp);
+ }
+
+ private void OnRemovedContainer(Entity ent, ref EntRemovedFromContainerMessage args)
+ {
+ var (_, comp) = ent;
+ if (args.Container.ID != comp.ContainerId)
+ return;
+
+ _appearance.SetData(ent, CryostorageVisuals.Full, args.Container.ContainedEntities.Count > 0);
+ }
+
+ private void OnInsertAttempt(Entity ent, ref ContainerIsInsertingAttemptEvent args)
+ {
+ var (_, comp) = ent;
+ if (args.Container.ID != comp.ContainerId)
+ return;
+
+ if (!TryComp(args.EntityUid, out var mindContainer))
+ {
+ args.Cancel();
+ return;
+ }
+
+ if (Mind.TryGetMind(args.EntityUid, out _, out var mindComp, mindContainer) &&
+ (mindComp.PreventSuicide || mindComp.PreventGhosting))
+ {
+ args.Cancel();
+ }
+ }
+
+ private void OnShutdownContainer(Entity ent, ref ComponentShutdown args)
+ {
+ var comp = ent.Comp;
+ foreach (var stored in comp.StoredPlayers)
+ {
+ if (TryComp(stored, out var containedComponent))
+ {
+ containedComponent.Cryostorage = null;
+ Dirty(stored, containedComponent);
+ }
+ }
+
+ comp.StoredPlayers.Clear();
+ Dirty(ent, comp);
+ }
+
+ private void OnCanDropTarget(Entity ent, ref CanDropTargetEvent args)
+ {
+ if (args.Dragged == args.User)
+ return;
+
+ if (!Mind.TryGetMind(args.Dragged, out _, out var mindComp) || mindComp.Session?.AttachedEntity != args.Dragged)
+ return;
+
+ args.CanDrop = false;
+ args.Handled = true;
+ }
+
+ private void OnRemovedContained(Entity ent, ref EntGotRemovedFromContainerMessage args)
+ {
+ var (_, comp) = ent;
+ if (!comp.StoredWhileDisconnected)
+ RemCompDeferred(ent, comp);
+ }
+
+ private void OnUnpaused(Entity ent, ref EntityUnpausedEvent args)
+ {
+ var comp = ent.Comp;
+ if (comp.GracePeriodEndTime != null)
+ comp.GracePeriodEndTime = comp.GracePeriodEndTime.Value + args.PausedTime;
+ }
+
+ private void OnShutdownContained(Entity ent, ref ComponentShutdown args)
+ {
+ var comp = ent.Comp;
+
+ CompOrNull(comp.Cryostorage)?.StoredPlayers.Remove(ent);
+ ent.Comp.Cryostorage = null;
+ Dirty(ent, comp);
+ }
+
+ private void OnRoundRestart(RoundRestartCleanupEvent _)
+ {
+ DeletePausedMap();
+ }
+
+ private void DeletePausedMap()
+ {
+ if (PausedMap == null || !Exists(PausedMap))
+ return;
+
+ EntityManager.DeleteEntity(PausedMap.Value);
+ PausedMap = null;
+ }
+
+ protected void EnsurePausedMap()
+ {
+ if (PausedMap != null && Exists(PausedMap))
+ return;
+
+ var map = _mapManager.CreateMap();
+ _mapManager.SetMapPaused(map, true);
+ PausedMap = _mapManager.GetMapEntityId(map);
+ }
+}
diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs
index 5f7fa41ecc..1bfca81e2a 100644
--- a/Content.Shared/CCVar/CCVars.cs
+++ b/Content.Shared/CCVar/CCVars.cs
@@ -223,6 +223,12 @@ namespace Content.Shared.CCVar
public static readonly CVarDef
GameRoleTimers = CVarDef.Create("game.role_timers", true, CVar.SERVER | CVar.REPLICATED);
+ ///
+ /// Whether or not disconnecting inside of a cryopod should remove the character or just store them until they reconnect.
+ ///
+ public static readonly CVarDef
+ GameCryoSleepRejoining = CVarDef.Create("game.cryo_sleep_rejoining", false, CVar.SERVER | CVar.REPLICATED);
+
///
/// Whether a random position offset will be applied to the station on roundstart.
///
diff --git a/Content.Shared/Climbing/Systems/ClimbSystem.cs b/Content.Shared/Climbing/Systems/ClimbSystem.cs
index 21809f4756..c54149243a 100644
--- a/Content.Shared/Climbing/Systems/ClimbSystem.cs
+++ b/Content.Shared/Climbing/Systems/ClimbSystem.cs
@@ -247,7 +247,7 @@ public sealed partial class ClimbSystem : VirtualController
if (!Resolve(uid, ref climbing, ref physics, ref fixtures, false))
return;
- if (!Resolve(climbable, ref comp))
+ if (!Resolve(climbable, ref comp, false))
return;
if (!ReplaceFixtures(uid, climbing, fixtures))
diff --git a/Content.Shared/Containers/DragInsertContainerComponent.cs b/Content.Shared/Containers/DragInsertContainerComponent.cs
new file mode 100644
index 0000000000..e4cae26fcb
--- /dev/null
+++ b/Content.Shared/Containers/DragInsertContainerComponent.cs
@@ -0,0 +1,20 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Containers;
+
+///
+/// This is used for a container that can have entities inserted into it via a
+///
+[RegisterComponent, NetworkedComponent]
+[Access(typeof(DragInsertContainerSystem))]
+public sealed partial class DragInsertContainerComponent : Component
+{
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public string ContainerId;
+
+ ///
+ /// If true, there will also be verbs for inserting / removing objects from this container.
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public bool UseVerbs = true;
+}
diff --git a/Content.Shared/Containers/DragInsertContainerSystem.cs b/Content.Shared/Containers/DragInsertContainerSystem.cs
new file mode 100644
index 0000000000..6bba26504b
--- /dev/null
+++ b/Content.Shared/Containers/DragInsertContainerSystem.cs
@@ -0,0 +1,120 @@
+using Content.Shared.ActionBlocker;
+using Content.Shared.Administration.Logs;
+using Content.Shared.Climbing.Systems;
+using Content.Shared.Database;
+using Content.Shared.DragDrop;
+using Content.Shared.Verbs;
+using Robust.Shared.Containers;
+
+namespace Content.Shared.Containers;
+
+public sealed class DragInsertContainerSystem : EntitySystem
+{
+ [Dependency] private readonly ISharedAdminLogManager _adminLog = default!;
+ [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
+ [Dependency] private readonly ClimbSystem _climb = default!;
+ [Dependency] private readonly SharedContainerSystem _container = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnDragDropOn, before: new []{ typeof(ClimbSystem)});
+ SubscribeLocalEvent(OnCanDragDropOn);
+ SubscribeLocalEvent>(OnGetAlternativeVerb);
+ }
+
+ private void OnDragDropOn(Entity ent, ref DragDropTargetEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ var (_, comp) = ent;
+ if (!_container.TryGetContainer(ent, comp.ContainerId, out var container))
+ return;
+
+ args.Handled = Insert(args.Dragged, args.User, ent, container);
+ }
+
+ private void OnCanDragDropOn(Entity ent, ref CanDropTargetEvent args)
+ {
+ var (_, comp) = ent;
+ if (!_container.TryGetContainer(ent, comp.ContainerId, out var container))
+ return;
+
+ args.Handled = true;
+ args.CanDrop |= _container.CanInsert(args.Dragged, container);
+ }
+
+ private void OnGetAlternativeVerb(Entity ent, ref GetVerbsEvent args)
+ {
+ var (uid, comp) = ent;
+ if (!comp.UseVerbs)
+ return;
+
+ if (!args.CanInteract || !args.CanAccess || args.Hands == null)
+ return;
+
+ if (!_container.TryGetContainer(uid, comp.ContainerId, out var container))
+ return;
+
+ var user = args.User;
+ if (!_actionBlocker.CanInteract(user, ent))
+ return;
+
+ // Eject verb
+ if (container.ContainedEntities.Count > 0)
+ {
+ // make sure that we can actually take stuff out of the container
+ var emptyableCount = 0;
+ foreach (var contained in container.ContainedEntities)
+ {
+ if (!_container.CanRemove(contained, container))
+ continue;
+ emptyableCount++;
+ }
+
+ if (emptyableCount > 0)
+ {
+ AlternativeVerb verb = new()
+ {
+ Act = () =>
+ {
+ _adminLog.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(user):player} emptied container {ToPrettyString(ent)}");
+ var ents = _container.EmptyContainer(container);
+ foreach (var contained in ents)
+ {
+ _climb.ForciblySetClimbing(contained, ent);
+ }
+ },
+ Category = VerbCategory.Eject,
+ Text = Loc.GetString("container-verb-text-empty"),
+ Priority = 1 // Promote to top to make ejecting the ALT-click action
+ };
+ args.Verbs.Add(verb);
+ }
+ }
+
+ // Self-insert verb
+ if (_container.CanInsert(user, container) &&
+ _actionBlocker.CanMove(user))
+ {
+ AlternativeVerb verb = new()
+ {
+ Act = () => Insert(user, user, ent, container),
+ Text = Loc.GetString("container-verb-text-enter"),
+ Priority = 2
+ };
+ args.Verbs.Add(verb);
+ }
+ }
+
+ public bool Insert(EntityUid target, EntityUid user, EntityUid containerEntity, BaseContainer container)
+ {
+ if (!_container.Insert(user, container))
+ return false;
+
+ _adminLog.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(user):player} inserted {ToPrettyString(target):player} into container {ToPrettyString(containerEntity)}");
+ return true;
+ }
+}
diff --git a/Content.Shared/Containers/ExitContainerOnMoveComponent.cs b/Content.Shared/Containers/ExitContainerOnMoveComponent.cs
new file mode 100644
index 0000000000..aae4eec710
--- /dev/null
+++ b/Content.Shared/Containers/ExitContainerOnMoveComponent.cs
@@ -0,0 +1,14 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Containers;
+
+///
+/// This is used for a container that is exited when the entity inside of it moves.
+///
+[RegisterComponent, NetworkedComponent]
+[Access(typeof(ExitContainerOnMoveSystem))]
+public sealed partial class ExitContainerOnMoveComponent : Component
+{
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public string ContainerId;
+}
diff --git a/Content.Shared/Containers/ExitContainerOnMoveSystem.cs b/Content.Shared/Containers/ExitContainerOnMoveSystem.cs
new file mode 100644
index 0000000000..8b15618649
--- /dev/null
+++ b/Content.Shared/Containers/ExitContainerOnMoveSystem.cs
@@ -0,0 +1,31 @@
+using Content.Shared.Climbing.Systems;
+using Content.Shared.Movement.Events;
+using Robust.Shared.Containers;
+
+namespace Content.Shared.Containers;
+
+public sealed class ExitContainerOnMoveSystem : EntitySystem
+{
+ [Dependency] private readonly ClimbSystem _climb = default!;
+ [Dependency] private readonly SharedContainerSystem _container = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnContainerRelay);
+ }
+
+ private void OnContainerRelay(Entity ent, ref ContainerRelayMovementEntityEvent args)
+ {
+ var (_, comp) = ent;
+ if (!TryComp(ent, out var containerManager))
+ return;
+
+ if (!_container.TryGetContainer(ent, comp.ContainerId, out var container, containerManager) || !container.Contains(args.Entity))
+ return;
+
+ _climb.ForciblySetClimbing(args.Entity, ent);
+ _container.RemoveEntity(ent, args.Entity, containerManager);
+ }
+}
diff --git a/Content.Shared/Inventory/InventorySystem.Slots.cs b/Content.Shared/Inventory/InventorySystem.Slots.cs
index 65b050c1c4..210e21c2c9 100644
--- a/Content.Shared/Inventory/InventorySystem.Slots.cs
+++ b/Content.Shared/Inventory/InventorySystem.Slots.cs
@@ -99,7 +99,7 @@ public partial class InventorySystem : EntitySystem
public InventorySlotEnumerator GetSlotEnumerator(Entity entity, SlotFlags flags = SlotFlags.All)
{
- if (!Resolve(entity.Owner, ref entity.Comp))
+ if (!Resolve(entity.Owner, ref entity.Comp, false))
return InventorySlotEnumerator.Empty;
return new InventorySlotEnumerator(entity.Comp, flags);
diff --git a/Resources/Locale/en-US/containers/containers.ftl b/Resources/Locale/en-US/containers/containers.ftl
new file mode 100644
index 0000000000..ab011f64f8
--- /dev/null
+++ b/Resources/Locale/en-US/containers/containers.ftl
@@ -0,0 +1,2 @@
+container-verb-text-enter = Enter
+container-verb-text-empty = Empty
diff --git a/Resources/Locale/en-US/prototypes/access/accesses.ftl b/Resources/Locale/en-US/prototypes/access/accesses.ftl
index b4859768ca..0e8b1d9ac7 100644
--- a/Resources/Locale/en-US/prototypes/access/accesses.ftl
+++ b/Resources/Locale/en-US/prototypes/access/accesses.ftl
@@ -1,6 +1,7 @@
id-card-access-level-command = Command
id-card-access-level-captain = Captain
id-card-access-level-head-of-personnel = Head of Personnel
+id-card-access-level-cryogenics = Cryogenics
id-card-access-level-head-of-security = Head of Security
id-card-access-level-security = Security
diff --git a/Resources/Locale/en-US/round-end/cryostorage.ftl b/Resources/Locale/en-US/round-end/cryostorage.ftl
new file mode 100644
index 0000000000..7b36b528b7
--- /dev/null
+++ b/Resources/Locale/en-US/round-end/cryostorage.ftl
@@ -0,0 +1,10 @@
+cryostorage-insert-message-permanent = [color=white]You are now inside of a [bold][color=cyan]cryogenic sleep unit[/color][/bold]. If you [bold]disconnect[/bold], [bold]ghost[/bold], or [bold]wait {$time} minutes[/bold], [color=red]your body will be removed[/color] and your job slot will be opened. You can exit at any time to prevent this.[/color]
+cryostorage-insert-message-temp = [color=white]You are now inside of a [bold][color=cyan]cryogenic sleep unit[/color][/bold]. If you [bold]ghost[/bold] or [bold]wait {$time} minutes[/bold], [color=red]your body will be removed[/color] and your job slot will be opened. If you [bold][color=cyan]disconnect[/color][/bold], your body will be safely held until you rejoin.[/color]
+
+cryostorage-ui-window-title = Cryogenic Sleep Unit
+cryostorage-ui-label-slot-name = [bold]{CAPITALIZE($slot)}:[/bold]
+cryostorage-ui-button-remove = Remove
+cryostorage-ui-filler-hand = inhand
+cryostorage-ui-label-no-bodies = No bodies in cryostorage
+
+cryostorage-popup-access-denied = Access denied!
diff --git a/Resources/Prototypes/Access/command.yml b/Resources/Prototypes/Access/command.yml
index f71ca12f3b..62193d5ffe 100644
--- a/Resources/Prototypes/Access/command.yml
+++ b/Resources/Prototypes/Access/command.yml
@@ -16,6 +16,11 @@
- Command
- Captain
- HeadOfPersonnel
+ - Cryogenics
- type: accessLevel
id: EmergencyShuttleRepealAll
+
+- type: accessLevel
+ id: Cryogenics
+ name: id-card-access-level-cryogenics
diff --git a/Resources/Prototypes/Access/misc.yml b/Resources/Prototypes/Access/misc.yml
index 848a27f413..f79f1779c2 100644
--- a/Resources/Prototypes/Access/misc.yml
+++ b/Resources/Prototypes/Access/misc.yml
@@ -9,6 +9,7 @@
- HeadOfSecurity
- ResearchDirector
- Command
+ - Cryogenics
- Security
- Detective
- Armory
diff --git a/Resources/Prototypes/Access/security.yml b/Resources/Prototypes/Access/security.yml
index ec832bc761..cfe94dd78a 100644
--- a/Resources/Prototypes/Access/security.yml
+++ b/Resources/Prototypes/Access/security.yml
@@ -26,6 +26,7 @@
- Armory
- Brig
- Detective
+ - Cryogenics
- type: accessGroup
id: Armory
diff --git a/Resources/Prototypes/Entities/Structures/cryopod.yml b/Resources/Prototypes/Entities/Structures/cryopod.yml
new file mode 100644
index 0000000000..c4d1388700
--- /dev/null
+++ b/Resources/Prototypes/Entities/Structures/cryopod.yml
@@ -0,0 +1,62 @@
+- type: entity
+ parent: BaseStructure
+ id: CryogenicSleepUnit
+ name: cryogenic sleep unit
+ description: A super-cooled container that keeps crewmates safe during space travel.
+ components:
+ - type: Sprite
+ noRot: true
+ sprite: Structures/cryostorage.rsi
+ layers:
+ - state: sleeper_0
+ map: ["base"]
+ - type: UserInterface
+ interfaces:
+ - key: enum.CryostorageUIKey.Key
+ type: CryostorageBoundUserInterface
+ - type: ActivatableUI
+ key: enum.CryostorageUIKey.Key
+ - type: AccessReader
+ breakOnEmag: false
+ access: [["Cryogenics"]]
+ - type: InteractionOutline
+ - type: Cryostorage
+ - type: Climbable
+ - type: DragInsertContainer
+ containerId: storage
+ - type: ExitContainerOnMove
+ containerId: storage
+ - type: PointLight
+ color: Lime
+ radius: 1.5
+ energy: 0.5
+ castShadows: false
+ - type: ContainerContainer
+ containers:
+ storage: !type:ContainerSlot
+ - type: Appearance
+ - type: GenericVisualizer
+ visuals:
+ enum.CryostorageVisuals.Full:
+ base:
+ True: { state: sleeper_1 }
+ False: { state: sleeper_0 }
+
+# This one handles all spawns, latejoin and roundstart.
+- type: entity
+ parent: CryogenicSleepUnit
+ id: CryogenicSleepUnitSpawner
+ suffix: Spawner, All
+ components:
+ - type: ContainerSpawnPoint
+ containerId: storage
+
+# This one only handles latejoin spawns.
+- type: entity
+ parent: CryogenicSleepUnit
+ id: CryogenicSleepUnitSpawnerLateJoin
+ suffix: Spawner, LateJoin
+ components:
+ - type: ContainerSpawnPoint
+ containerId: storage
+ spawnType: LateJoin
diff --git a/Resources/Textures/Structures/cryostorage.rsi/meta.json b/Resources/Textures/Structures/cryostorage.rsi/meta.json
new file mode 100644
index 0000000000..24426d5b81
--- /dev/null
+++ b/Resources/Textures/Structures/cryostorage.rsi/meta.json
@@ -0,0 +1,45 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from vg at commit https://github.com/vgstation-coders/vgstation13/commit/a16e41020a93479e9a7e2af343b1b74f7f2a61bd",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "sleeper_0",
+ "directions": 4
+ },
+ {
+ "name": "sleeper_1",
+ "directions": 4,
+ "delays": [
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ],
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ],
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ],
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ]
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Resources/Textures/Structures/cryostorage.rsi/sleeper_0.png b/Resources/Textures/Structures/cryostorage.rsi/sleeper_0.png
new file mode 100644
index 0000000000..7f67b0decf
Binary files /dev/null and b/Resources/Textures/Structures/cryostorage.rsi/sleeper_0.png differ
diff --git a/Resources/Textures/Structures/cryostorage.rsi/sleeper_1.png b/Resources/Textures/Structures/cryostorage.rsi/sleeper_1.png
new file mode 100644
index 0000000000..2061c3ad43
Binary files /dev/null and b/Resources/Textures/Structures/cryostorage.rsi/sleeper_1.png differ
diff --git a/SpaceStation14.sln.DotSettings b/SpaceStation14.sln.DotSettings
index 42cf8d1cab..620de42253 100644
--- a/SpaceStation14.sln.DotSettings
+++ b/SpaceStation14.sln.DotSettings
@@ -585,6 +585,7 @@ public sealed partial class $CLASS$ : Shared$CLASS$ {
True
True
True
+ True
True
True
True