diff --git a/Content.Client/Entry/IgnoredComponents.cs b/Content.Client/Entry/IgnoredComponents.cs
index 23e9e9facb..492c685e29 100644
--- a/Content.Client/Entry/IgnoredComponents.cs
+++ b/Content.Client/Entry/IgnoredComponents.cs
@@ -65,6 +65,7 @@ namespace Content.Client.Entry
"Paper",
"Write",
"Bloodstream",
+ "PAI",
"TransformableContainer",
"Mind",
"StorageFill",
diff --git a/Content.Client/Ghost/Roles/UI/GhostRoleRulesWindow.xaml b/Content.Client/Ghost/Roles/UI/GhostRoleRulesWindow.xaml
new file mode 100644
index 0000000000..432f1e1bda
--- /dev/null
+++ b/Content.Client/Ghost/Roles/UI/GhostRoleRulesWindow.xaml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/Content.Client/Ghost/Roles/UI/GhostRoleRulesWindow.xaml.cs b/Content.Client/Ghost/Roles/UI/GhostRoleRulesWindow.xaml.cs
new file mode 100644
index 0000000000..decc06fda8
--- /dev/null
+++ b/Content.Client/Ghost/Roles/UI/GhostRoleRulesWindow.xaml.cs
@@ -0,0 +1,20 @@
+using System;
+using Content.Shared.Ghost.Roles;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Ghost.Roles.UI
+{
+ [GenerateTypedNameReferences]
+ public partial class GhostRoleRulesWindow : SS14Window
+ {
+ public GhostRoleRulesWindow(string rules, Action requestAction)
+ {
+ RobustXamlLoader.Load(this);
+ Title.SetMessage(rules);
+ RequestButton.OnPressed += requestAction;
+ }
+ }
+}
diff --git a/Content.Client/Ghost/Roles/UI/GhostRolesEui.cs b/Content.Client/Ghost/Roles/UI/GhostRolesEui.cs
index cd07bbda08..9769b67d44 100644
--- a/Content.Client/Ghost/Roles/UI/GhostRolesEui.cs
+++ b/Content.Client/Ghost/Roles/UI/GhostRolesEui.cs
@@ -9,14 +9,27 @@ namespace Content.Client.Ghost.Roles.UI
public class GhostRolesEui : BaseEui
{
private readonly GhostRolesWindow _window;
+ private GhostRoleRulesWindow? _windowRules = null;
+ private uint _windowRulesId = 0;
public GhostRolesEui()
{
_window = new GhostRolesWindow();
- _window.RoleRequested += id =>
+ _window.RoleRequested += info =>
{
- SendMessage(new GhostRoleTakeoverRequestMessage(id));
+ if (_windowRules != null)
+ _windowRules.Close();
+ _windowRules = new GhostRoleRulesWindow(info.Rules, _ =>
+ {
+ SendMessage(new GhostRoleTakeoverRequestMessage(info.Identifier));
+ });
+ _windowRulesId = info.Identifier;
+ _windowRules.OnClose += () =>
+ {
+ _windowRules = null;
+ };
+ _windowRules.OpenCentered();
};
_window.OnClose += () =>
@@ -35,6 +48,7 @@ namespace Content.Client.Ghost.Roles.UI
{
base.Closed();
_window.Close();
+ _windowRules?.Close();
}
public override void HandleState(EuiStateBase state)
@@ -43,11 +57,22 @@ namespace Content.Client.Ghost.Roles.UI
if (state is not GhostRolesEuiState ghostState) return;
+ var closeRulesWindow = true;
+
_window.ClearEntries();
foreach (var info in ghostState.GhostRoles)
{
_window.AddEntry(info);
+ if (info.Identifier == _windowRulesId)
+ {
+ closeRulesWindow = false;
+ }
+ }
+
+ if (closeRulesWindow)
+ {
+ _windowRules?.Close();
}
}
}
diff --git a/Content.Client/Ghost/Roles/UI/GhostRolesWindow.xaml.cs b/Content.Client/Ghost/Roles/UI/GhostRolesWindow.xaml.cs
index 6b334c0753..f1aeaecf0f 100644
--- a/Content.Client/Ghost/Roles/UI/GhostRolesWindow.xaml.cs
+++ b/Content.Client/Ghost/Roles/UI/GhostRolesWindow.xaml.cs
@@ -8,7 +8,7 @@ namespace Content.Client.Ghost.Roles.UI
[GenerateTypedNameReferences]
public partial class GhostRolesWindow : SS14Window
{
- public event Action? RoleRequested;
+ public event Action? RoleRequested;
public void ClearEntries()
{
@@ -19,7 +19,7 @@ namespace Content.Client.Ghost.Roles.UI
public void AddEntry(GhostRoleInfo info)
{
NoRolesMessage.Visible = false;
- EntryContainer.AddChild(new GhostRolesEntry(info, _ => RoleRequested?.Invoke(info.Identifier)));
+ EntryContainer.AddChild(new GhostRolesEntry(info, _ => RoleRequested?.Invoke(info)));
}
}
}
diff --git a/Content.Client/Ghost/Roles/UI/MakeGhostRoleEui.cs b/Content.Client/Ghost/Roles/UI/MakeGhostRoleEui.cs
index 63e79b0c5c..3fce0768d0 100644
--- a/Content.Client/Ghost/Roles/UI/MakeGhostRoleEui.cs
+++ b/Content.Client/Ghost/Roles/UI/MakeGhostRoleEui.cs
@@ -42,7 +42,7 @@ namespace Content.Client.Ghost.Roles.UI
_window.OpenCentered();
}
- private void OnMake(EntityUid uid, string name, string description, bool makeSentient)
+ private void OnMake(EntityUid uid, string name, string description, string rules, bool makeSentient)
{
var player = _playerManager.LocalPlayer;
if (player == null)
@@ -54,7 +54,8 @@ namespace Content.Client.Ghost.Roles.UI
$"makeghostrole " +
$"\"{CommandParsing.Escape(uid.ToString())}\" " +
$"\"{CommandParsing.Escape(name)}\" " +
- $"\"{CommandParsing.Escape(description)}\"";
+ $"\"{CommandParsing.Escape(description)}\" " +
+ $"\"{CommandParsing.Escape(rules)}\"";
_consoleHost.ExecuteCommand(player.Session, makeGhostRoleCommand);
diff --git a/Content.Client/Ghost/Roles/UI/MakeGhostRoleWindow.xaml b/Content.Client/Ghost/Roles/UI/MakeGhostRoleWindow.xaml
index 0a51ef7bfb..76e50cee7a 100644
--- a/Content.Client/Ghost/Roles/UI/MakeGhostRoleWindow.xaml
+++ b/Content.Client/Ghost/Roles/UI/MakeGhostRoleWindow.xaml
@@ -14,6 +14,10 @@
+
+
+
+
diff --git a/Content.Client/Ghost/Roles/UI/MakeGhostRoleWindow.xaml.cs b/Content.Client/Ghost/Roles/UI/MakeGhostRoleWindow.xaml.cs
index 7ec189cb43..3f4c8a1421 100644
--- a/Content.Client/Ghost/Roles/UI/MakeGhostRoleWindow.xaml.cs
+++ b/Content.Client/Ghost/Roles/UI/MakeGhostRoleWindow.xaml.cs
@@ -9,7 +9,7 @@ namespace Content.Client.Ghost.Roles.UI
[GenerateTypedNameReferences]
public partial class MakeGhostRoleWindow : SS14Window
{
- public delegate void MakeRole(EntityUid uid, string name, string description, bool makeSentient);
+ public delegate void MakeRole(EntityUid uid, string name, string description, string rules, bool makeSentient);
public MakeGhostRoleWindow()
{
@@ -21,6 +21,8 @@ namespace Content.Client.Ghost.Roles.UI
RoleName.MinSize = (300, 0);
RoleDescriptionLabel.MinSize = (150, 0);
RoleDescription.MinSize = (300, 0);
+ RoleRulesLabel.MinSize = (150, 0);
+ RoleRules.MinSize = (300, 0);
MakeButton.OnPressed += OnPressed;
}
@@ -42,7 +44,7 @@ namespace Content.Client.Ghost.Roles.UI
return;
}
- OnMake?.Invoke(EntityUid.Value, RoleName.Text, RoleDescription.Text, MakeSentientCheckbox.Pressed);
+ OnMake?.Invoke(EntityUid.Value, RoleName.Text, RoleDescription.Text, RoleRules.Text, MakeSentientCheckbox.Pressed);
}
}
}
diff --git a/Content.Server/Ghost/Roles/Components/GhostRoleComponent.cs b/Content.Server/Ghost/Roles/Components/GhostRoleComponent.cs
index b55133c923..d751b60dde 100644
--- a/Content.Server/Ghost/Roles/Components/GhostRoleComponent.cs
+++ b/Content.Server/Ghost/Roles/Components/GhostRoleComponent.cs
@@ -2,6 +2,7 @@
using Robust.Shared.GameObjects;
using Robust.Shared.Serialization.Manager.Attributes;
using Robust.Shared.ViewVariables;
+using Robust.Shared.Localization;
namespace Content.Server.Ghost.Roles.Components
{
@@ -11,6 +12,8 @@ namespace Content.Server.Ghost.Roles.Components
[DataField("description")] private string _roleDescription = "Unknown";
+ [DataField("rules")] private string _roleRules = "";
+
// We do this so updating RoleName and RoleDescription in VV updates the open EUIs.
[ViewVariables(VVAccess.ReadWrite)]
@@ -35,6 +38,17 @@ namespace Content.Server.Ghost.Roles.Components
}
}
+ [ViewVariables(VVAccess.ReadWrite)]
+ public string RoleRules
+ {
+ get => _roleRules;
+ set
+ {
+ _roleRules = value;
+ EntitySystem.Get().UpdateAllEui();
+ }
+ }
+
[ViewVariables(VVAccess.ReadOnly)]
public bool Taken { get; protected set; }
@@ -44,7 +58,8 @@ namespace Content.Server.Ghost.Roles.Components
protected override void Initialize()
{
base.Initialize();
-
+ if (_roleRules == "")
+ _roleRules = Loc.GetString("ghost-role-component-default-rules");
EntitySystem.Get().RegisterGhostRole(this);
}
diff --git a/Content.Server/Ghost/Roles/GhostRoleSystem.cs b/Content.Server/Ghost/Roles/GhostRoleSystem.cs
index 38352f7d86..6c60c2dd44 100644
--- a/Content.Server/Ghost/Roles/GhostRoleSystem.cs
+++ b/Content.Server/Ghost/Roles/GhostRoleSystem.cs
@@ -123,7 +123,7 @@ namespace Content.Server.Ghost.Roles
foreach (var (id, role) in _ghostRoles)
{
- roles[i] = new GhostRoleInfo(){Identifier = id, Name = role.RoleName, Description = role.RoleDescription};
+ roles[i] = new GhostRoleInfo(){Identifier = id, Name = role.RoleName, Description = role.RoleDescription, Rules = role.RoleRules};
i++;
}
diff --git a/Content.Server/Ghost/Roles/MakeGhostRoleCommand.cs b/Content.Server/Ghost/Roles/MakeGhostRoleCommand.cs
index 7e6e876156..5f7367d98d 100644
--- a/Content.Server/Ghost/Roles/MakeGhostRoleCommand.cs
+++ b/Content.Server/Ghost/Roles/MakeGhostRoleCommand.cs
@@ -5,6 +5,7 @@ using Content.Shared.Administration;
using Robust.Shared.Console;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
+using Robust.Shared.Localization;
namespace Content.Server.Ghost.Roles
{
@@ -13,11 +14,11 @@ namespace Content.Server.Ghost.Roles
{
public string Command => "makeghostrole";
public string Description => "Turns an entity into a ghost role.";
- public string Help => $"Usage: {Command} ";
+ public string Help => $"Usage: {Command} []";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
- if (args.Length != 3)
+ if (args.Length < 3 || args.Length > 4)
{
shell.WriteLine($"Invalid amount of arguments.\n{Help}");
return;
@@ -46,6 +47,7 @@ namespace Content.Server.Ghost.Roles
var name = args[1];
var description = args[2];
+ var rules = args.Length >= 4 ? args[3] : Loc.GetString("ghost-role-component-default-rules");
if (entity.EnsureComponent(out GhostTakeoverAvailableComponent takeOver))
{
@@ -55,6 +57,7 @@ namespace Content.Server.Ghost.Roles
takeOver.RoleName = name;
takeOver.RoleDescription = description;
+ takeOver.RoleRules = rules;
shell.WriteLine($"Made entity {entity.Name} a ghost role.");
}
diff --git a/Content.Server/PAI/PAIComponent.cs b/Content.Server/PAI/PAIComponent.cs
new file mode 100644
index 0000000000..4b8c572586
--- /dev/null
+++ b/Content.Server/PAI/PAIComponent.cs
@@ -0,0 +1,21 @@
+using Robust.Shared.GameObjects;
+
+namespace Content.Server.PAI
+{
+ ///
+ /// pAIs, or Personal AIs, are essentially portable ghost role generators.
+ /// In their current implementation in SS14, they create a ghost role anyone can access,
+ /// and that a player can also "wipe" (reset/kick out player).
+ /// Theoretically speaking pAIs are supposed to use a dedicated "offer and select" system,
+ /// with the player holding the pAI being able to choose one of the ghosts in the round.
+ /// This seems too complicated for an initial implementation, though,
+ /// and there's not always enough players and ghost roles to justify it.
+ /// All logic in PAISystem.
+ ///
+ [RegisterComponent]
+ public class PAIComponent : Component
+ {
+ public override string Name => "PAI";
+ }
+}
+
diff --git a/Content.Server/PAI/PAISystem.cs b/Content.Server/PAI/PAISystem.cs
new file mode 100644
index 0000000000..79d6838555
--- /dev/null
+++ b/Content.Server/PAI/PAISystem.cs
@@ -0,0 +1,154 @@
+using Content.Shared.Examine;
+using Content.Shared.Interaction;
+using Content.Shared.PAI;
+using Content.Shared.Verbs;
+using Content.Server.Popups;
+using Content.Server.Ghost.Roles.Components;
+using Content.Server.Mind.Components;
+using Robust.Server.GameObjects;
+using Robust.Shared.IoC;
+using Robust.Shared.Log;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Localization;
+using Robust.Shared.Player;
+
+namespace Content.Server.PAI
+{
+ ///
+ /// pAIs, or Personal AIs, are essentially portable ghost role generators.
+ /// In their current implementation, they create a ghost role anyone can access,
+ /// and that a player can also "wipe" (reset/kick out player).
+ /// Theoretically speaking pAIs are supposed to use a dedicated "offer and select" system,
+ /// with the player holding the pAI being able to choose one of the ghosts in the round.
+ /// This seems too complicated for an initial implementation, though,
+ /// and there's not always enough players and ghost roles to justify it.
+ ///
+ public class PAISystem : EntitySystem
+ {
+ [Dependency] private readonly PopupSystem _popupSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnExamined);
+ SubscribeLocalEvent(OnUseInHand);
+ SubscribeLocalEvent(OnMindAdded);
+ SubscribeLocalEvent(OnMindRemoved);
+ SubscribeLocalEvent(AddWipeVerb);
+ }
+
+ private void OnExamined(EntityUid uid, PAIComponent component, ExaminedEvent args)
+ {
+ if (args.IsInDetailsRange)
+ {
+ if (EntityManager.TryGetComponent(uid, out var mind) && mind.HasMind)
+ {
+ args.PushMarkup(Loc.GetString("pai-system-pai-installed"));
+ }
+ else if (EntityManager.HasComponent(uid))
+ {
+ args.PushMarkup(Loc.GetString("pai-system-still-searching"));
+ }
+ else
+ {
+ args.PushMarkup(Loc.GetString("pai-system-off"));
+ }
+ }
+ }
+
+ private void OnUseInHand(EntityUid uid, PAIComponent component, UseInHandEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ // Placeholder PAIs are essentially portable ghost role generators.
+
+ args.Handled = true;
+
+ // Check for pAI activation
+ if (EntityManager.TryGetComponent(uid, out var mind) && mind.HasMind)
+ {
+ _popupSystem.PopupEntity(Loc.GetString("pai-system-pai-installed"), uid, Filter.Entities(args.User.Uid));
+ return;
+ }
+ else if (EntityManager.HasComponent(uid))
+ {
+ _popupSystem.PopupEntity(Loc.GetString("pai-system-still-searching"), uid, Filter.Entities(args.User.Uid));
+ return;
+ }
+
+ var ghostFinder = EntityManager.EnsureComponent(uid);
+
+ ghostFinder.RoleName = Loc.GetString("pai-system-role-name");
+ ghostFinder.RoleDescription = Loc.GetString("pai-system-role-description");
+
+ _popupSystem.PopupEntity(Loc.GetString("pai-system-searching"), uid, Filter.Entities(args.User.Uid));
+ UpdatePAIAppearance(uid, PAIStatus.Searching);
+ }
+
+ private void OnMindRemoved(EntityUid uid, PAIComponent component, MindRemovedMessage args)
+ {
+ // Mind was removed, shutdown the PAI.
+ UpdatePAIAppearance(uid, PAIStatus.Off);
+ }
+
+ private void OnMindAdded(EntityUid uid, PAIComponent pai, MindAddedMessage args)
+ {
+ // Mind was added, shutdown the ghost role stuff so it won't get in the way
+ if (EntityManager.HasComponent(uid))
+ EntityManager.RemoveComponent(uid);
+ UpdatePAIAppearance(uid, PAIStatus.On);
+ }
+
+ private void UpdatePAIAppearance(EntityUid uid, PAIStatus status)
+ {
+ if (EntityManager.TryGetComponent(uid, out var appearance))
+ {
+ appearance.SetData(PAIVisuals.Status, status);
+ }
+ }
+
+ private void AddWipeVerb(EntityUid uid, PAIComponent pai, GetActivationVerbsEvent args)
+ {
+ if (args.User == null || !args.CanAccess || !args.CanInteract)
+ return;
+
+ if (EntityManager.TryGetComponent(uid, out var mind) && mind.HasMind)
+ {
+ Verb verb = new();
+ verb.Text = Loc.GetString("pai-system-wipe-device-verb-text");
+ verb.Act = () => {
+ if (pai.Deleted)
+ return;
+ // Wiping device :(
+ // The shutdown of the Mind should cause automatic reset of the pAI during OnMindRemoved
+ // EDIT: But it doesn't!!!! Wtf? Do stuff manually
+ if (EntityManager.HasComponent(uid))
+ {
+ EntityManager.RemoveComponent(uid);
+ _popupSystem.PopupEntity(Loc.GetString("pai-system-wiped-device"), uid, Filter.Entities(args.User.Uid));
+ UpdatePAIAppearance(uid, PAIStatus.Off);
+ }
+ };
+ args.Verbs.Add(verb);
+ }
+ else if (EntityManager.HasComponent(uid))
+ {
+ Verb verb = new();
+ verb.Text = Loc.GetString("pai-system-stop-searching-verb-text");
+ verb.Act = () => {
+ if (pai.Deleted)
+ return;
+ if (EntityManager.HasComponent(uid))
+ {
+ EntityManager.RemoveComponent(uid);
+ _popupSystem.PopupEntity(Loc.GetString("pai-system-stopped-searching"), uid, Filter.Entities(args.User.Uid));
+ UpdatePAIAppearance(uid, PAIStatus.Off);
+ }
+ };
+ args.Verbs.Add(verb);
+ }
+ }
+ }
+}
diff --git a/Content.Shared/Ghost/Roles/GhostRolesEuiMessages.cs b/Content.Shared/Ghost/Roles/GhostRolesEuiMessages.cs
index b37ebb1f47..a30c6c3a1e 100644
--- a/Content.Shared/Ghost/Roles/GhostRolesEuiMessages.cs
+++ b/Content.Shared/Ghost/Roles/GhostRolesEuiMessages.cs
@@ -10,6 +10,7 @@ namespace Content.Shared.Ghost.Roles
public uint Identifier { get; set; }
public string Name { get; set; }
public string Description { get; set; }
+ public string Rules { get; set; }
}
[NetSerializable, Serializable]
diff --git a/Content.Shared/PAI/PAIShared.cs b/Content.Shared/PAI/PAIShared.cs
new file mode 100644
index 0000000000..6820733f42
--- /dev/null
+++ b/Content.Shared/PAI/PAIShared.cs
@@ -0,0 +1,23 @@
+using System;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.Manager.Attributes;
+using Robust.Shared.ViewVariables;
+
+namespace Content.Shared.PAI
+{
+ [Serializable, NetSerializable]
+ public enum PAIVisuals : byte
+ {
+ Status
+ }
+
+ [Serializable, NetSerializable]
+ public enum PAIStatus : byte
+ {
+ Off,
+ Searching,
+ On
+ }
+}
+
diff --git a/Resources/Locale/en-US/ghost/roles/ghost-role-component.ftl b/Resources/Locale/en-US/ghost/roles/ghost-role-component.ftl
new file mode 100644
index 0000000000..609d5eacd7
--- /dev/null
+++ b/Resources/Locale/en-US/ghost/roles/ghost-role-component.ftl
@@ -0,0 +1,3 @@
+# also used in MakeGhostRuleWindow and MakeGhostRoleCommand
+ghost-role-component-default-rules = You don't remember any of your previous life unless an administrator tells you otherwise.
+
diff --git a/Resources/Locale/en-US/pai/pai-system.ftl b/Resources/Locale/en-US/pai/pai-system.ftl
new file mode 100644
index 0000000000..1406e9b5fd
--- /dev/null
+++ b/Resources/Locale/en-US/pai/pai-system.ftl
@@ -0,0 +1,15 @@
+pai-system-pai-installed = A pAI is installed.
+pai-system-off = No pAI is installed.
+pai-system-still-searching = Still searching for a pAI.
+pai-system-searching = Now searching for a pAI...
+
+pai-system-role-name = personal ai
+pai-system-role-description = Be someone's electronic pal!
+ (Memories *not* included.)
+
+pai-system-wipe-device-verb-text = Remove pAI
+pai-system-wiped-device = The pAI was wiped from the device.
+
+pai-system-stop-searching-verb-text = Stop searching
+pai-system-stopped-searching = The device stopped searching for a pAI.
+
diff --git a/Resources/Prototypes/Entities/Objects/Fun/pai.yml b/Resources/Prototypes/Entities/Objects/Fun/pai.yml
new file mode 100644
index 0000000000..f1a7d9ff85
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Fun/pai.yml
@@ -0,0 +1,38 @@
+# Placeholder PAIs, aka semi-automatic ghost roles
+
+- type: entity
+ parent: BaseItem
+ id: PersonalAI
+ name: personal ai device
+ description: Your electronic pal who's fun to be with!
+ components:
+ - type: Sprite
+ netsync: false
+ sprite: Objects/Devices/pda.rsi
+ layers:
+ - state: pda-lawyer # nobody's using this one
+ - state: pai_off_overlay
+ shader: unshaded
+ - type: Input
+ context: "human"
+ - type: PAI
+ - type: Examiner
+ - type: GhostRadio
+ - type: DoAfter
+ - type: Actions
+ # This has to be installed because otherwise they're not "alive",
+ # so they can ghost and come back.
+ # Note that the personal AI never "dies".
+ - type: MobState
+ thresholds:
+ 0: !type:NormalMobState {}
+ - type: Appearance
+ visuals:
+ - type: GenericEnumVisualizer
+ key: enum.PAIVisuals.Status
+ layer: 1
+ states:
+ enum.PAIStatus.Off: pai_off_overlay
+ enum.PAIStatus.Searching: pda-r
+ enum.PAIStatus.On: pai_overlay
+