diff --git a/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml b/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml
new file mode 100644
index 0000000000..a5725b2013
--- /dev/null
+++ b/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
diff --git a/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml.cs b/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml.cs
new file mode 100644
index 0000000000..d00ebc0c91
--- /dev/null
+++ b/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml.cs
@@ -0,0 +1,21 @@
+using System;
+using Content.Shared.GameObjects.Components.Observer;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.GameObjects.Components.Observer
+{
+ [GenerateTypedNameReferences]
+ public partial class GhostRolesEntry : VBoxContainer
+ {
+ public GhostRolesEntry(GhostRoleInfo info, Action requestAction)
+ {
+ RobustXamlLoader.Load(this);
+
+ Title.SetMessage(info.Name);
+ Description.SetMessage(info.Description);
+ RequestButton.OnPressed += requestAction;
+ }
+ }
+}
diff --git a/Content.Client/GameObjects/Components/Observer/GhostRolesEui.cs b/Content.Client/GameObjects/Components/Observer/GhostRolesEui.cs
new file mode 100644
index 0000000000..e5d5c188b2
--- /dev/null
+++ b/Content.Client/GameObjects/Components/Observer/GhostRolesEui.cs
@@ -0,0 +1,54 @@
+using Content.Client.Eui;
+using Content.Shared.Eui;
+using Content.Shared.GameObjects.Components.Observer;
+using JetBrains.Annotations;
+
+namespace Content.Client.GameObjects.Components.Observer
+{
+ [UsedImplicitly]
+ public class GhostRolesEui : BaseEui
+ {
+ private readonly GhostRolesWindow _window;
+
+ public GhostRolesEui()
+ {
+ _window = new GhostRolesWindow();
+
+ _window.RoleRequested += id =>
+ {
+ SendMessage(new GhostRoleTakeoverRequestMessage(id));
+ };
+
+ _window.OnClose += () =>
+ {
+ SendMessage(new GhostRoleWindowCloseMessage());
+ };
+ }
+
+ public override void Opened()
+ {
+ base.Opened();
+ _window.OpenCentered();
+ }
+
+ public override void Closed()
+ {
+ base.Closed();
+ _window.Close();
+ }
+
+ public override void HandleState(EuiStateBase state)
+ {
+ base.HandleState(state);
+
+ if (state is not GhostRolesEuiState ghostState) return;
+
+ _window.ClearEntries();
+
+ foreach (var info in ghostState.GhostRoles)
+ {
+ _window.AddEntry(info);
+ }
+ }
+ }
+}
diff --git a/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml b/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml
new file mode 100644
index 0000000000..ab26c90d8c
--- /dev/null
+++ b/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml.cs b/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml.cs
new file mode 100644
index 0000000000..b67fa4782b
--- /dev/null
+++ b/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml.cs
@@ -0,0 +1,28 @@
+using System;
+using Content.Shared.GameObjects.Components.Observer;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Shared.Maths;
+
+namespace Content.Client.GameObjects.Components.Observer
+{
+ [GenerateTypedNameReferences]
+ public partial class GhostRolesWindow : SS14Window
+ {
+ public event Action RoleRequested;
+
+ protected override Vector2 CalculateMinimumSize() => (350, 275);
+
+ public void ClearEntries()
+ {
+ EntryContainer.DisposeAllChildren();
+ NoRolesMessage.Visible = true;
+ }
+
+ public void AddEntry(GhostRoleInfo info)
+ {
+ NoRolesMessage.Visible = false;
+ EntryContainer.AddChild(new GhostRolesEntry(info, _ => RoleRequested?.Invoke(info.Identifier)));
+ }
+ }
+}
diff --git a/Content.Client/UserInterface/GhostGui.cs b/Content.Client/UserInterface/GhostGui.cs
index 661e1c8313..7cd2fcb60d 100644
--- a/Content.Client/UserInterface/GhostGui.cs
+++ b/Content.Client/UserInterface/GhostGui.cs
@@ -1,4 +1,5 @@
using Content.Client.GameObjects.Components.Observer;
+using Robust.Client.Console;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
@@ -12,6 +13,7 @@ namespace Content.Client.UserInterface
{
private readonly Button _returnToBody = new() {Text = Loc.GetString("Return to body")};
private readonly Button _ghostWarp = new() {Text = Loc.GetString("Ghost Warp")};
+ private readonly Button _ghostRoles = new() {Text = Loc.GetString("Ghost Roles")};
private readonly GhostComponent _owner;
public GhostGui(GhostComponent owner)
@@ -26,13 +28,15 @@ namespace Content.Client.UserInterface
_ghostWarp.OnPressed += args => targetMenu.Populate();
_returnToBody.OnPressed += args => owner.SendReturnToBodyMessage();
+ _ghostRoles.OnPressed += _ => IoCManager.Resolve().RemoteExecuteCommand(null, "ghostroles");
AddChild(new HBoxContainer
{
Children =
{
_returnToBody,
- _ghostWarp
+ _ghostWarp,
+ _ghostRoles,
}
});
diff --git a/Content.Client/UserInterface/GhostRoleWindow.cs b/Content.Client/UserInterface/GhostRoleWindow.cs
new file mode 100644
index 0000000000..9157e78a7d
--- /dev/null
+++ b/Content.Client/UserInterface/GhostRoleWindow.cs
@@ -0,0 +1,14 @@
+using Content.Client.GameObjects.EntitySystems;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Shared.GameObjects.Systems;
+
+namespace Content.Client.UserInterface
+{
+ public class GhostRoleWindow : SS14Window
+ {
+ protected override void Opened()
+ {
+ base.Opened();
+ }
+ }
+}
diff --git a/Content.Server/Commands/MakeSentientCommand.cs b/Content.Server/Commands/MakeSentientCommand.cs
index eb969ecf49..e180b17a60 100644
--- a/Content.Server/Commands/MakeSentientCommand.cs
+++ b/Content.Server/Commands/MakeSentientCommand.cs
@@ -1,4 +1,5 @@
#nullable enable
+using System.Threading;
using Content.Server.Administration;
using Content.Server.GameObjects.Components.Mobs;
using Content.Server.GameObjects.Components.Movement;
@@ -7,6 +8,7 @@ using Content.Shared.GameObjects.Components.Mobs.Speech;
using Robust.Shared.Console;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
+using Timer = Robust.Shared.Timers.Timer;
namespace Content.Server.Commands
{
@@ -44,10 +46,14 @@ namespace Content.Server.Commands
if(entity.HasComponent())
entity.RemoveComponent();
- entity.EnsureComponent();
- entity.EnsureComponent();
- entity.EnsureComponent();
- entity.EnsureComponent();
+ // Delay spawning these components to avoid race conditions with the deferred removal of AiController.
+ Timer.Spawn(100, () =>
+ {
+ entity.EnsureComponent();
+ entity.EnsureComponent();
+ entity.EnsureComponent();
+ entity.EnsureComponent();
+ });
}
}
}
diff --git a/Content.Server/GameObjects/Components/Observer/GhostRoleComponent.cs b/Content.Server/GameObjects/Components/Observer/GhostRoleComponent.cs
new file mode 100644
index 0000000000..a021a190d9
--- /dev/null
+++ b/Content.Server/GameObjects/Components/Observer/GhostRoleComponent.cs
@@ -0,0 +1,75 @@
+using Content.Server.GameObjects.EntitySystems;
+using Robust.Server.Interfaces.Player;
+using Robust.Shared.GameObjects;
+using Robust.Shared.GameObjects.Systems;
+using Robust.Shared.Serialization;
+using Robust.Shared.ViewVariables;
+
+namespace Content.Server.GameObjects.Components.Observer
+{
+ public abstract class GhostRoleComponent : Component
+ {
+ private string _roleName;
+ private string _roleDescription;
+
+ // We do this so updating RoleName and RoleDescription in VV updates the open EUIs.
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ public string RoleName
+ {
+ get
+ {
+ return _roleName;
+ }
+ private set
+ {
+ _roleName = value;
+ EntitySystem.Get().UpdateAllEui();
+ }
+ }
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ public string RoleDescription
+ {
+ get
+ {
+ return _roleDescription;
+ }
+ private set
+ {
+ _roleDescription = value;
+ EntitySystem.Get().UpdateAllEui();
+ }
+ }
+
+ [ViewVariables(VVAccess.ReadOnly)]
+ public bool Taken { get; protected set; }
+
+ [ViewVariables]
+ public uint Identifier { get; set; }
+
+ public override void ExposeData(ObjectSerializer serializer)
+ {
+ base.ExposeData(serializer);
+
+ serializer.DataField(ref _roleName, "name", "Unknown");
+ serializer.DataField(ref _roleDescription, "description", "Unknown");
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ EntitySystem.Get().RegisterGhostRole(this);
+ }
+
+ protected override void Shutdown()
+ {
+ base.Shutdown();
+
+ EntitySystem.Get().UnregisterGhostRole(this);
+ }
+
+ public abstract bool Take(IPlayerSession session);
+ }
+}
diff --git a/Content.Server/GameObjects/Components/Observer/GhostRoleMobSpawnerComponent.cs b/Content.Server/GameObjects/Components/Observer/GhostRoleMobSpawnerComponent.cs
new file mode 100644
index 0000000000..421a97144f
--- /dev/null
+++ b/Content.Server/GameObjects/Components/Observer/GhostRoleMobSpawnerComponent.cs
@@ -0,0 +1,69 @@
+using System;
+using Content.Server.GameObjects.Components.Mobs;
+using Content.Server.Players;
+using JetBrains.Annotations;
+using Robust.Server.Interfaces.GameObjects;
+using Robust.Server.Interfaces.Player;
+using Robust.Shared.GameObjects;
+using Robust.Shared.IoC;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+using Robust.Shared.ViewVariables;
+
+namespace Content.Server.GameObjects.Components.Observer
+{
+ ///
+ /// Allows a ghost to take this role, spawning a new entity.
+ ///
+ [RegisterComponent, ComponentReference(typeof(GhostRoleComponent))]
+ public class GhostRoleMobSpawnerComponent : GhostRoleComponent
+ {
+ public override string Name => "GhostRoleMobSpawner";
+
+
+ [ViewVariables]
+ private bool _deleteOnSpawn = true;
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ private int _availableTakeovers = 1;
+
+ [ViewVariables]
+ private int _currentTakeovers = 0;
+
+ [CanBeNull, ViewVariables(VVAccess.ReadWrite)] public string Prototype { get; private set; }
+
+ public override void ExposeData(ObjectSerializer serializer)
+ {
+ base.ExposeData(serializer);
+
+ serializer.DataField(this, x => x.Prototype, "prototype", null);
+ serializer.DataField(ref _deleteOnSpawn, "deleteOnSpawn", true);
+ serializer.DataField(ref _availableTakeovers, "availableTakeovers", 1);
+ }
+
+ public override bool Take(IPlayerSession session)
+ {
+ if (Taken)
+ return false;
+
+ if(string.IsNullOrEmpty(Prototype))
+ throw new NullReferenceException("Prototype string cannot be null or empty!");
+
+ var mob = Owner.EntityManager.SpawnEntity(Prototype, Owner.Transform.Coordinates);
+
+ mob.EnsureComponent();
+ session.ContentData().Mind.TransferTo(mob);
+
+ if (++_currentTakeovers < _availableTakeovers) return true;
+
+ Taken = true;
+
+ if (_deleteOnSpawn)
+ Owner.Delete();
+
+
+ return true;
+
+ }
+ }
+}
diff --git a/Content.Server/GameObjects/Components/Observer/GhostRolesEui.cs b/Content.Server/GameObjects/Components/Observer/GhostRolesEui.cs
new file mode 100644
index 0000000000..a074aa2400
--- /dev/null
+++ b/Content.Server/GameObjects/Components/Observer/GhostRolesEui.cs
@@ -0,0 +1,39 @@
+using Content.Server.Eui;
+using Content.Server.GameObjects.EntitySystems;
+using Content.Shared.Eui;
+using Content.Shared.GameObjects.Components.Observer;
+using Robust.Shared.GameObjects.Systems;
+
+namespace Content.Server.GameObjects.Components.Observer
+{
+ public class GhostRolesEui : BaseEui
+ {
+ public override GhostRolesEuiState GetNewState()
+ {
+ return new(EntitySystem.Get().GetGhostRolesInfo());
+ }
+
+ public override void HandleMessage(EuiMessageBase msg)
+ {
+ base.HandleMessage(msg);
+
+ switch (msg)
+ {
+ case GhostRoleTakeoverRequestMessage req:
+ EntitySystem.Get().Takeover(Player, req.Identifier);
+ break;
+
+ case GhostRoleWindowCloseMessage _:
+ Closed();
+ break;
+ }
+ }
+
+ public override void Closed()
+ {
+ base.Closed();
+
+ EntitySystem.Get().CloseEui(Player);
+ }
+ }
+}
diff --git a/Content.Server/GameObjects/Components/Observer/GhostTakeoverAvailableComponent.cs b/Content.Server/GameObjects/Components/Observer/GhostTakeoverAvailableComponent.cs
new file mode 100644
index 0000000000..a0adda9907
--- /dev/null
+++ b/Content.Server/GameObjects/Components/Observer/GhostTakeoverAvailableComponent.cs
@@ -0,0 +1,38 @@
+using System;
+using Content.Server.GameObjects.Components.Mobs;
+using Content.Server.GameObjects.EntitySystems;
+using Content.Server.Players;
+using Robust.Server.Interfaces.Player;
+using Robust.Shared.GameObjects;
+using Robust.Shared.GameObjects.Systems;
+
+namespace Content.Server.GameObjects.Components.Observer
+{
+ ///
+ /// Allows a ghost to take over the Owner entity.
+ ///
+ [RegisterComponent, ComponentReference(typeof(GhostRoleComponent))]
+ public class GhostTakeoverAvailableComponent : GhostRoleComponent
+ {
+ public override string Name => "GhostTakeoverAvailable";
+
+ public override bool Take(IPlayerSession session)
+ {
+ if (Taken)
+ return false;
+
+ Taken = true;
+
+ var mind = Owner.EnsureComponent();
+
+ if(mind.HasMind)
+ throw new Exception("MindComponent already has a mind!");
+
+ session.ContentData().Mind.TransferTo(Owner);
+
+ EntitySystem.Get().UnregisterGhostRole(this);
+
+ return true;
+ }
+ }
+}
diff --git a/Content.Server/GameObjects/EntitySystems/GhostRoleSystem.cs b/Content.Server/GameObjects/EntitySystems/GhostRoleSystem.cs
new file mode 100644
index 0000000000..f39d6d628c
--- /dev/null
+++ b/Content.Server/GameObjects/EntitySystems/GhostRoleSystem.cs
@@ -0,0 +1,144 @@
+using System.Collections.Generic;
+using System.Drawing;
+using Content.Server.Administration;
+using Content.Server.Eui;
+using Content.Server.GameObjects.Components.Observer;
+using Content.Shared.GameObjects.Components.Observer;
+using Content.Shared.GameObjects.EntitySystemMessages;
+using Content.Shared.GameTicking;
+using JetBrains.Annotations;
+using Robust.Server.GameObjects;
+using Robust.Server.Interfaces.Player;
+using Robust.Shared.Console;
+using Robust.Shared.GameObjects.Systems;
+using Robust.Shared.IoC;
+using Robust.Shared.Utility;
+using Robust.Shared.ViewVariables;
+
+namespace Content.Server.GameObjects.EntitySystems
+{
+ [UsedImplicitly]
+ public class GhostRoleSystem : EntitySystem, IResettingEntitySystem
+ {
+ [Dependency] private readonly EuiManager _euiManager = default!;
+
+ private uint _nextRoleIdentifier = 0;
+ private readonly Dictionary _ghostRoles = new();
+ private readonly Dictionary _openUis = new();
+
+ [ViewVariables]
+ public IReadOnlyCollection GhostRoles => _ghostRoles.Values;
+
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnPlayerAttached);
+ }
+
+ private uint GetNextRoleIdentifier()
+ {
+ return unchecked(_nextRoleIdentifier++);
+ }
+
+ public void OpenEui(IPlayerSession session)
+ {
+ if (session.AttachedEntity == null || !session.AttachedEntity.HasComponent())
+ return;
+
+ if(_openUis.ContainsKey(session))
+ CloseEui(session);
+
+ var eui = _openUis[session] = new GhostRolesEui();
+ _euiManager.OpenEui(eui, session);
+ eui.StateDirty();
+ }
+
+ public void CloseEui(IPlayerSession session)
+ {
+ if (!_openUis.ContainsKey(session)) return;
+
+ _openUis.Remove(session, out var eui);
+
+ eui?.Close();
+ }
+
+ public void UpdateAllEui()
+ {
+ foreach (var eui in _openUis.Values)
+ {
+ eui.StateDirty();
+ }
+ }
+
+ public void RegisterGhostRole(GhostRoleComponent role)
+ {
+ if (_ghostRoles.ContainsValue(role)) return;
+ _ghostRoles[role.Identifier = GetNextRoleIdentifier()] = role;
+ UpdateAllEui();
+
+ }
+
+ public void UnregisterGhostRole(GhostRoleComponent role)
+ {
+ if (!_ghostRoles.ContainsKey(role.Identifier) || _ghostRoles[role.Identifier] != role) return;
+ _ghostRoles.Remove(role.Identifier);
+ UpdateAllEui();
+ }
+
+ public void Takeover(IPlayerSession player, uint identifier)
+ {
+ if (!_ghostRoles.TryGetValue(identifier, out var role)) return;
+ if (!role.Take(player)) return;
+ CloseEui(player);
+ }
+
+ public GhostRoleInfo[] GetGhostRolesInfo()
+ {
+ var roles = new GhostRoleInfo[_ghostRoles.Count];
+
+ var i = 0;
+
+ foreach (var (id, role) in _ghostRoles)
+ {
+ roles[i] = new GhostRoleInfo(){Identifier = id, Name = role.RoleName, Description = role.RoleDescription};
+ i++;
+ }
+
+ return roles;
+ }
+
+ private void OnPlayerAttached(PlayerAttachSystemMessage message)
+ {
+ // Close the session of any player that has a ghost roles window open and isn't a ghost anymore.
+ if (!_openUis.ContainsKey(message.NewPlayer)) return;
+ if (message.Entity.HasComponent()) return;
+ CloseEui(message.NewPlayer);
+ }
+
+ public void Reset()
+ {
+ foreach (var session in _openUis.Keys)
+ {
+ CloseEui(session);
+ }
+
+ _openUis.Clear();
+ _ghostRoles.Clear();
+ _nextRoleIdentifier = 0;
+ }
+ }
+
+ [AnyCommand]
+ public class GhostRoles : IConsoleCommand
+ {
+ public string Command => "ghostroles";
+ public string Description => "Opens the ghost role request window.";
+ public string Help => $"{Command}";
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if(shell.Player != null)
+ EntitySystem.Get().OpenEui((IPlayerSession)shell.Player);
+ else
+ shell.WriteLine("You can only open the ghost roles UI on a client.");
+ }
+ }
+}
diff --git a/Content.Shared/GameObjects/Components/Observer/GhostRolesEuiMessages.cs b/Content.Shared/GameObjects/Components/Observer/GhostRolesEuiMessages.cs
new file mode 100644
index 0000000000..ce646e8d8b
--- /dev/null
+++ b/Content.Shared/GameObjects/Components/Observer/GhostRolesEuiMessages.cs
@@ -0,0 +1,41 @@
+using System;
+using Content.Shared.Eui;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.GameObjects.Components.Observer
+{
+ [NetSerializable, Serializable]
+ public struct GhostRoleInfo
+ {
+ public uint Identifier { get; set; }
+ public string Name { get; set; }
+ public string Description { get; set; }
+ }
+
+ [NetSerializable, Serializable]
+ public class GhostRolesEuiState : EuiStateBase
+ {
+ public GhostRoleInfo[] GhostRoles { get; }
+
+ public GhostRolesEuiState(GhostRoleInfo[] ghostRoles)
+ {
+ GhostRoles = ghostRoles;
+ }
+ }
+
+ [NetSerializable, Serializable]
+ public class GhostRoleTakeoverRequestMessage : EuiMessageBase
+ {
+ public uint Identifier { get; }
+
+ public GhostRoleTakeoverRequestMessage(uint identifier)
+ {
+ Identifier = identifier;
+ }
+ }
+
+ [NetSerializable, Serializable]
+ public class GhostRoleWindowCloseMessage : EuiMessageBase
+ {
+ }
+}
diff --git a/Content.Shared/GameObjects/EntitySystems/SharedGhostRoleSystem.cs b/Content.Shared/GameObjects/EntitySystems/SharedGhostRoleSystem.cs
new file mode 100644
index 0000000000..1aa269c4ea
--- /dev/null
+++ b/Content.Shared/GameObjects/EntitySystems/SharedGhostRoleSystem.cs
@@ -0,0 +1,20 @@
+using System;
+using Robust.Shared.GameObjects;
+using Robust.Shared.GameObjects.Systems;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.GameObjects.EntitySystems
+{
+ public abstract class SharedGhostRoleSystem : EntitySystem
+ {
+
+ }
+
+ [Serializable, NetSerializable]
+ public class GhostRole
+ {
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public EntityUid Id;
+ }
+}