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