From 24810d916b75da6e0a3dc1ba8ba2e3a8e3e213f3 Mon Sep 17 00:00:00 2001
From: deltanedas <39013340+deltanedas@users.noreply.github.com>
Date: Sun, 10 Sep 2023 07:20:27 +0100
Subject: [PATCH] ninja 2 electric boogaloo (#15534)
Co-authored-by: deltanedas <@deltanedas:kde.org>
---
.../Communications/CommsHackerSystem.cs | 10 +
.../Ninja/Systems/NinjaGlovesSystem.cs | 10 +
.../Ninja/Systems/NinjaSuitSystem.cs | 24 ++
Content.Client/Ninja/Systems/NinjaSystem.cs | 12 +
.../Research/ResearchStealerSystem.cs | 10 +
.../Systems/AdminVerbSystem.Antags.cs | 26 +-
.../Communications/CommsHackerSystem.cs | 89 ++++++
Content.Server/Doors/Systems/DoorSystem.cs | 1 -
.../Electrocution/ElectrocutionSystem.cs | 12 +-
Content.Server/Emp/EmpSystem.cs | 12 +
.../Components/AutomatedTimerComponent.cs | 9 +
.../EntitySystems/TriggerSystem.OnUse.cs | 2 +-
.../Rules/Components/NinjaRuleComponent.cs | 37 +++
.../GameTicking/Rules/NinjaRuleSystem.cs | 23 ++
Content.Server/Implants/AutoImplantSystem.cs | 21 ++
.../Components/AutoImplantComponent.cs | 17 +
Content.Server/Jobs/AddImplantSpecial.cs | 17 +-
Content.Server/Mind/MindSystem.cs | 13 +-
.../Ninja/Systems/BatteryDrainerSystem.cs | 100 ++++++
.../Ninja/Systems/NinjaGlovesSystem.cs | 102 ++++++
.../Ninja/Systems/NinjaSuitSystem.cs | 147 +++++++++
.../Ninja/Systems/SpaceNinjaSystem.cs | 301 ++++++++++++++++++
.../Ninja/Systems/SpiderChargeSystem.cs | 78 +++++
.../Ninja/Systems/StunProviderSystem.cs | 60 ++++
.../Conditions/DoorjackCondition.cs | 70 ++++
.../Conditions/SpiderChargeCondition.cs | 75 +++++
.../Conditions/StealResearchCondition.cs | 70 ++++
.../Objectives/Conditions/SurviveCondition.cs | 58 ++++
.../Objectives/Conditions/TerrorCondition.cs | 57 ++++
.../Requirements/NinjaRequirement.cs | 18 ++
.../SpiderChargeTargetRequirement.cs | 19 ++
.../Power/EntitySystems/BatterySystem.cs | 11 +
Content.Server/PowerCell/PowerCellSystem.cs | 10 +
.../Research/Systems/ResearchStealerSystem.cs | 39 +++
Content.Server/Roles/NinjaRoleComponent.cs | 40 +++
Content.Server/Roles/RoleSystem.cs | 1 +
.../Components/NinjaSpawnRuleComponent.cs | 16 +
.../StationEvents/Events/NinjaSpawnRule.cs | 51 +++
Content.Shared/Alert/AlertType.cs | 3 +-
.../Communications/CommsHackerComponent.cs | 47 +++
.../Communications/SharedCommsHackerSystem.cs | 28 ++
.../Containers/ItemSlot/ItemSlotsComponent.cs | 9 +
.../Containers/ItemSlot/ItemSlotsSystem.cs | 2 +-
.../SharedElectrocutionSystem.cs | 18 ++
Content.Shared/Emag/Systems/EmagSystem.cs | 15 +-
.../Implants/SharedSubdermalImplantSystem.cs | 22 ++
Content.Shared/Interaction/InteractHand.cs | 14 +
.../Interaction/SharedInteractionSystem.cs | 9 +
Content.Shared/Mind/SharedMindSystem.cs | 34 +-
.../Components/BatteryDrainerComponent.cs | 38 +++
.../Ninja/Components/DashAbilityComponent.cs | 33 ++
.../Ninja/Components/EmagProviderComponent.cs | 28 ++
.../Ninja/Components/EnergyKatanaComponent.cs | 12 +
.../Ninja/Components/NinjaGlovesComponent.cs | 45 +++
.../Ninja/Components/NinjaSuitComponent.cs | 125 ++++++++
.../Ninja/Components/SpaceNinjaComponent.cs | 38 +++
.../Ninja/Components/SpiderChargeComponent.cs | 19 ++
.../Ninja/Components/StunProviderComponent.cs | 67 ++++
.../Ninja/Systems/DashAbilitySystem.cs | 118 +++++++
.../Ninja/Systems/EmagProviderSystem.cs | 72 +++++
.../Ninja/Systems/EnergyKatanaSystem.cs | 47 +++
.../Systems/SharedBatteryDrainerSystem.cs | 69 ++++
.../Ninja/Systems/SharedNinjaGlovesSystem.cs | 116 +++++++
.../Ninja/Systems/SharedNinjaSuitSystem.cs | 139 ++++++++
.../Ninja/Systems/SharedSpaceNinjaSystem.cs | 89 ++++++
.../Ninja/Systems/SharedStunProviderSystem.cs | 32 ++
.../Components/ResearchStealerComponent.cs | 17 +
.../Systems/SharedResearchStealerSystem.cs | 63 ++++
.../Research/Systems/SharedResearchSystem.cs | 14 +-
Resources/Audio/Misc/attributions.yml | 5 +
Resources/Audio/Misc/ninja_greeting.ogg | Bin 0 -> 61293 bytes
.../Locale/en-US/administration/antag.ftl | 9 +-
Resources/Locale/en-US/alerts/alerts.ftl | 3 +
.../components/battery-drainer-component.ftl | 3 +
.../Locale/en-US/communications/terror.ftl | 2 +
Resources/Locale/en-US/guidebook/guides.ftl | 1 +
Resources/Locale/en-US/ninja/gloves.ftl | 6 +
Resources/Locale/en-US/ninja/katana.ftl | 6 +
.../Locale/en-US/ninja/ninja-actions.ftl | 21 ++
Resources/Locale/en-US/ninja/role.ftl | 8 +
.../Locale/en-US/ninja/spider-charge.ftl | 2 +
.../conditions/doorjack-condition.ftl | 2 +
.../conditions/spider-charge-condition.ftl | 3 +
.../conditions/steal-research-condition.ftl | 2 +
.../conditions/survive-condition.ftl | 2 +
.../conditions/terror-condition.ftl | 2 +
.../Locale/en-US/prototypes/roles/antags.ftl | 3 +
Resources/Prototypes/Actions/ninja.yml | 84 +++++
Resources/Prototypes/Alerts/alerts.yml | 1 +
Resources/Prototypes/Alerts/ninja.yml | 23 ++
.../Fills/Backpacks/StarterGear/satchel.yml | 14 +
.../Entities/Clothing/Eyes/glasses.yml | 12 +
.../Entities/Clothing/Hands/gloves.yml | 13 +
.../Entities/Clothing/Head/helmets.yml | 4 +-
.../Entities/Clothing/Masks/masks.yml | 14 +
.../Entities/Clothing/OuterClothing/suits.yml | 31 +-
.../Entities/Clothing/Shoes/specific.yml | 4 +
.../Entities/Clothing/Uniforms/jumpsuits.yml | 11 +
.../Prototypes/Entities/Effects/sparks.yml | 19 ++
.../Entities/Markers/Spawners/ghost_roles.yml | 19 ++
.../Prototypes/Entities/Mobs/Player/human.yml | 21 ++
.../Entities/Objects/Devices/pinpointer.yml | 8 +
.../Entities/Objects/Weapons/Bombs/spider.yml | 47 +++
.../Entities/Objects/Weapons/Melee/sword.yml | 31 ++
.../Weapons/Throwable/throwing_stars.yml | 10 +
Resources/Prototypes/GameRules/events.yml | 12 +
Resources/Prototypes/GameRules/midround.yml | 19 ++
Resources/Prototypes/Guidebook/antagonist.yml | 6 +
.../Prototypes/Objectives/ninjaObjectives.yml | 40 +++
Resources/Prototypes/Roles/Antags/ninja.yml | 6 +
.../Roles/Jobs/Fun/misc_startinggear.yml | 21 +-
.../Guidebook/Antagonist/SpaceNinja.xml | 74 +++++
.../Glasses/ninjavisor.rsi/equipped-EYES.png | Bin 0 -> 395 bytes
.../Eyes/Glasses/ninjavisor.rsi/icon.png | Bin 0 -> 201 bytes
.../Glasses/ninjavisor.rsi/inhand-left.png | Bin 0 -> 343 bytes
.../Glasses/ninjavisor.rsi/inhand-right.png | Bin 0 -> 352 bytes
.../Eyes/Glasses/ninjavisor.rsi/meta.json | 26 ++
.../spaceninja.rsi/equipped-HELMET.png | Bin 728 -> 638 bytes
.../Head/Helmets/spaceninja.rsi/icon.png | Bin 242 -> 194 bytes
.../Helmets/spaceninja.rsi/inhand-left.png | Bin 614 -> 564 bytes
.../Helmets/spaceninja.rsi/inhand-right.png | Bin 622 -> 564 bytes
.../Head/Helmets/spaceninja.rsi/meta.json | 2 +-
.../Clothing/Mask/ninja.rsi/equipped-MASK.png | Bin 0 -> 834 bytes
.../Textures/Clothing/Mask/ninja.rsi/icon.png | Bin 0 -> 647 bytes
.../Clothing/Mask/ninja.rsi/inhand-left.png | Bin 0 -> 799 bytes
.../Clothing/Mask/ninja.rsi/inhand-right.png | Bin 0 -> 750 bytes
.../Clothing/Mask/ninja.rsi/meta.json | 26 ++
.../ninja.rsi/equipped-INNERCLOTHING.png | Bin 0 -> 1429 bytes
.../Uniforms/Jumpsuit/ninja.rsi/icon.png | Bin 0 -> 468 bytes
.../Jumpsuit/ninja.rsi/inhand-left.png | Bin 0 -> 617 bytes
.../Jumpsuit/ninja.rsi/inhand-right.png | Bin 0 -> 655 bytes
.../Uniforms/Jumpsuit/ninja.rsi/meta.json | 26 ++
.../Textures/Effects/sparks.rsi/meta.json | 28 ++
.../Textures/Effects/sparks.rsi/sparks.png | Bin 0 -> 1065 bytes
.../Alerts/ninja_power.rsi/meta.json | 131 ++++++++
.../Alerts/ninja_power.rsi/power0.png | Bin 0 -> 1697 bytes
.../Alerts/ninja_power.rsi/power1.png | Bin 0 -> 1782 bytes
.../Alerts/ninja_power.rsi/power2.png | Bin 0 -> 1793 bytes
.../Alerts/ninja_power.rsi/power3.png | Bin 0 -> 1797 bytes
.../Alerts/ninja_power.rsi/power4.png | Bin 0 -> 1796 bytes
.../Alerts/ninja_power.rsi/power5.png | Bin 0 -> 1822 bytes
.../Alerts/ninja_power.rsi/power6.png | Bin 0 -> 1777 bytes
.../Alerts/ninja_power.rsi/power7.png | Bin 0 -> 1729 bytes
.../Weapons/Bombs/spidercharge.rsi/icon.png | Bin 0 -> 790 bytes
.../Bombs/spidercharge.rsi/inhand-left.png | Bin 0 -> 752 bytes
.../Bombs/spidercharge.rsi/inhand-right.png | Bin 0 -> 765 bytes
.../Weapons/Bombs/spidercharge.rsi/meta.json | 31 ++
.../Weapons/Bombs/spidercharge.rsi/primed.png | Bin 0 -> 920 bytes
.../Melee/energykatana.rsi/equipped-BELT.png | Bin 0 -> 468 bytes
.../Weapons/Melee/energykatana.rsi/icon.png | Bin 0 -> 740 bytes
.../Melee/energykatana.rsi/inhand-left.png | Bin 0 -> 875 bytes
.../Melee/energykatana.rsi/inhand-right.png | Bin 0 -> 993 bytes
.../Weapons/Melee/energykatana.rsi/meta.json | 26 ++
153 files changed, 3892 insertions(+), 78 deletions(-)
create mode 100644 Content.Client/Communications/CommsHackerSystem.cs
create mode 100644 Content.Client/Ninja/Systems/NinjaGlovesSystem.cs
create mode 100644 Content.Client/Ninja/Systems/NinjaSuitSystem.cs
create mode 100644 Content.Client/Ninja/Systems/NinjaSystem.cs
create mode 100644 Content.Client/Research/ResearchStealerSystem.cs
create mode 100644 Content.Server/Communications/CommsHackerSystem.cs
create mode 100644 Content.Server/Explosion/Components/AutomatedTimerComponent.cs
create mode 100644 Content.Server/GameTicking/Rules/Components/NinjaRuleComponent.cs
create mode 100644 Content.Server/GameTicking/Rules/NinjaRuleSystem.cs
create mode 100644 Content.Server/Implants/AutoImplantSystem.cs
create mode 100644 Content.Server/Implants/Components/AutoImplantComponent.cs
create mode 100644 Content.Server/Ninja/Systems/BatteryDrainerSystem.cs
create mode 100644 Content.Server/Ninja/Systems/NinjaGlovesSystem.cs
create mode 100644 Content.Server/Ninja/Systems/NinjaSuitSystem.cs
create mode 100644 Content.Server/Ninja/Systems/SpaceNinjaSystem.cs
create mode 100644 Content.Server/Ninja/Systems/SpiderChargeSystem.cs
create mode 100644 Content.Server/Ninja/Systems/StunProviderSystem.cs
create mode 100644 Content.Server/Objectives/Conditions/DoorjackCondition.cs
create mode 100644 Content.Server/Objectives/Conditions/SpiderChargeCondition.cs
create mode 100644 Content.Server/Objectives/Conditions/StealResearchCondition.cs
create mode 100644 Content.Server/Objectives/Conditions/SurviveCondition.cs
create mode 100644 Content.Server/Objectives/Conditions/TerrorCondition.cs
create mode 100644 Content.Server/Objectives/Requirements/NinjaRequirement.cs
create mode 100644 Content.Server/Objectives/Requirements/SpiderChargeTargetRequirement.cs
create mode 100644 Content.Server/Research/Systems/ResearchStealerSystem.cs
create mode 100644 Content.Server/Roles/NinjaRoleComponent.cs
create mode 100644 Content.Server/StationEvents/Components/NinjaSpawnRuleComponent.cs
create mode 100644 Content.Server/StationEvents/Events/NinjaSpawnRule.cs
create mode 100644 Content.Shared/Communications/CommsHackerComponent.cs
create mode 100644 Content.Shared/Communications/SharedCommsHackerSystem.cs
create mode 100644 Content.Shared/Ninja/Components/BatteryDrainerComponent.cs
create mode 100644 Content.Shared/Ninja/Components/DashAbilityComponent.cs
create mode 100644 Content.Shared/Ninja/Components/EmagProviderComponent.cs
create mode 100644 Content.Shared/Ninja/Components/EnergyKatanaComponent.cs
create mode 100644 Content.Shared/Ninja/Components/NinjaGlovesComponent.cs
create mode 100644 Content.Shared/Ninja/Components/NinjaSuitComponent.cs
create mode 100644 Content.Shared/Ninja/Components/SpaceNinjaComponent.cs
create mode 100644 Content.Shared/Ninja/Components/SpiderChargeComponent.cs
create mode 100644 Content.Shared/Ninja/Components/StunProviderComponent.cs
create mode 100644 Content.Shared/Ninja/Systems/DashAbilitySystem.cs
create mode 100644 Content.Shared/Ninja/Systems/EmagProviderSystem.cs
create mode 100644 Content.Shared/Ninja/Systems/EnergyKatanaSystem.cs
create mode 100644 Content.Shared/Ninja/Systems/SharedBatteryDrainerSystem.cs
create mode 100644 Content.Shared/Ninja/Systems/SharedNinjaGlovesSystem.cs
create mode 100644 Content.Shared/Ninja/Systems/SharedNinjaSuitSystem.cs
create mode 100644 Content.Shared/Ninja/Systems/SharedSpaceNinjaSystem.cs
create mode 100644 Content.Shared/Ninja/Systems/SharedStunProviderSystem.cs
create mode 100644 Content.Shared/Research/Components/ResearchStealerComponent.cs
create mode 100644 Content.Shared/Research/Systems/SharedResearchStealerSystem.cs
create mode 100644 Resources/Audio/Misc/ninja_greeting.ogg
create mode 100644 Resources/Locale/en-US/battery/components/battery-drainer-component.ftl
create mode 100644 Resources/Locale/en-US/communications/terror.ftl
create mode 100644 Resources/Locale/en-US/ninja/gloves.ftl
create mode 100644 Resources/Locale/en-US/ninja/katana.ftl
create mode 100644 Resources/Locale/en-US/ninja/ninja-actions.ftl
create mode 100644 Resources/Locale/en-US/ninja/role.ftl
create mode 100644 Resources/Locale/en-US/ninja/spider-charge.ftl
create mode 100644 Resources/Locale/en-US/objectives/conditions/doorjack-condition.ftl
create mode 100644 Resources/Locale/en-US/objectives/conditions/spider-charge-condition.ftl
create mode 100644 Resources/Locale/en-US/objectives/conditions/steal-research-condition.ftl
create mode 100644 Resources/Locale/en-US/objectives/conditions/survive-condition.ftl
create mode 100644 Resources/Locale/en-US/objectives/conditions/terror-condition.ftl
create mode 100644 Resources/Prototypes/Actions/ninja.yml
create mode 100644 Resources/Prototypes/Alerts/ninja.yml
create mode 100644 Resources/Prototypes/Entities/Effects/sparks.yml
create mode 100644 Resources/Prototypes/Entities/Objects/Weapons/Bombs/spider.yml
create mode 100644 Resources/Prototypes/GameRules/midround.yml
create mode 100644 Resources/Prototypes/Objectives/ninjaObjectives.yml
create mode 100644 Resources/Prototypes/Roles/Antags/ninja.yml
create mode 100644 Resources/ServerInfo/Guidebook/Antagonist/SpaceNinja.xml
create mode 100644 Resources/Textures/Clothing/Eyes/Glasses/ninjavisor.rsi/equipped-EYES.png
create mode 100644 Resources/Textures/Clothing/Eyes/Glasses/ninjavisor.rsi/icon.png
create mode 100644 Resources/Textures/Clothing/Eyes/Glasses/ninjavisor.rsi/inhand-left.png
create mode 100644 Resources/Textures/Clothing/Eyes/Glasses/ninjavisor.rsi/inhand-right.png
create mode 100644 Resources/Textures/Clothing/Eyes/Glasses/ninjavisor.rsi/meta.json
create mode 100644 Resources/Textures/Clothing/Mask/ninja.rsi/equipped-MASK.png
create mode 100644 Resources/Textures/Clothing/Mask/ninja.rsi/icon.png
create mode 100644 Resources/Textures/Clothing/Mask/ninja.rsi/inhand-left.png
create mode 100644 Resources/Textures/Clothing/Mask/ninja.rsi/inhand-right.png
create mode 100644 Resources/Textures/Clothing/Mask/ninja.rsi/meta.json
create mode 100644 Resources/Textures/Clothing/Uniforms/Jumpsuit/ninja.rsi/equipped-INNERCLOTHING.png
create mode 100644 Resources/Textures/Clothing/Uniforms/Jumpsuit/ninja.rsi/icon.png
create mode 100644 Resources/Textures/Clothing/Uniforms/Jumpsuit/ninja.rsi/inhand-left.png
create mode 100644 Resources/Textures/Clothing/Uniforms/Jumpsuit/ninja.rsi/inhand-right.png
create mode 100644 Resources/Textures/Clothing/Uniforms/Jumpsuit/ninja.rsi/meta.json
create mode 100644 Resources/Textures/Effects/sparks.rsi/meta.json
create mode 100644 Resources/Textures/Effects/sparks.rsi/sparks.png
create mode 100644 Resources/Textures/Interface/Alerts/ninja_power.rsi/meta.json
create mode 100644 Resources/Textures/Interface/Alerts/ninja_power.rsi/power0.png
create mode 100644 Resources/Textures/Interface/Alerts/ninja_power.rsi/power1.png
create mode 100644 Resources/Textures/Interface/Alerts/ninja_power.rsi/power2.png
create mode 100644 Resources/Textures/Interface/Alerts/ninja_power.rsi/power3.png
create mode 100644 Resources/Textures/Interface/Alerts/ninja_power.rsi/power4.png
create mode 100644 Resources/Textures/Interface/Alerts/ninja_power.rsi/power5.png
create mode 100644 Resources/Textures/Interface/Alerts/ninja_power.rsi/power6.png
create mode 100644 Resources/Textures/Interface/Alerts/ninja_power.rsi/power7.png
create mode 100644 Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/icon.png
create mode 100644 Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/inhand-left.png
create mode 100644 Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/inhand-right.png
create mode 100644 Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/meta.json
create mode 100644 Resources/Textures/Objects/Weapons/Bombs/spidercharge.rsi/primed.png
create mode 100644 Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/equipped-BELT.png
create mode 100644 Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/icon.png
create mode 100644 Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/inhand-left.png
create mode 100644 Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/inhand-right.png
create mode 100644 Resources/Textures/Objects/Weapons/Melee/energykatana.rsi/meta.json
diff --git a/Content.Client/Communications/CommsHackerSystem.cs b/Content.Client/Communications/CommsHackerSystem.cs
new file mode 100644
index 0000000000..3459dd1cd4
--- /dev/null
+++ b/Content.Client/Communications/CommsHackerSystem.cs
@@ -0,0 +1,10 @@
+using Content.Shared.Communications;
+
+namespace Content.Client.Communications;
+
+///
+/// Does nothing special, only exists to provide a client implementation.
+///
+public sealed class CommsHackerSystem : SharedCommsHackerSystem
+{
+}
diff --git a/Content.Client/Ninja/Systems/NinjaGlovesSystem.cs b/Content.Client/Ninja/Systems/NinjaGlovesSystem.cs
new file mode 100644
index 0000000000..7758c3d7e2
--- /dev/null
+++ b/Content.Client/Ninja/Systems/NinjaGlovesSystem.cs
@@ -0,0 +1,10 @@
+using Content.Shared.Ninja.Systems;
+
+namespace Content.Client.Ninja.Systems;
+
+///
+/// Does nothing special, only exists to provide a client implementation.
+///
+public sealed class NinjaGlovesSystem : SharedNinjaGlovesSystem
+{
+}
diff --git a/Content.Client/Ninja/Systems/NinjaSuitSystem.cs b/Content.Client/Ninja/Systems/NinjaSuitSystem.cs
new file mode 100644
index 0000000000..fde1801b37
--- /dev/null
+++ b/Content.Client/Ninja/Systems/NinjaSuitSystem.cs
@@ -0,0 +1,24 @@
+using Content.Shared.Clothing.EntitySystems;
+using Content.Shared.Ninja.Components;
+using Content.Shared.Ninja.Systems;
+
+namespace Content.Client.Ninja.Systems;
+
+///
+/// Disables cloak prediction since client has no knowledge of battery power.
+/// Cloak will still be enabled after server tells it.
+///
+public sealed class NinjaSuitSystem : SharedNinjaSuitSystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnAttemptStealth);
+ }
+
+ private void OnAttemptStealth(EntityUid uid, NinjaSuitComponent comp, AttemptStealthEvent args)
+ {
+ args.Cancel();
+ }
+}
diff --git a/Content.Client/Ninja/Systems/NinjaSystem.cs b/Content.Client/Ninja/Systems/NinjaSystem.cs
new file mode 100644
index 0000000000..aa2fa2047f
--- /dev/null
+++ b/Content.Client/Ninja/Systems/NinjaSystem.cs
@@ -0,0 +1,12 @@
+using Content.Shared.Ninja.Systems;
+
+namespace Content.Client.Ninja.Systems;
+
+///
+/// Currently does nothing special clientside.
+/// All functionality is in shared and server.
+/// Only exists to prevent crashing.
+///
+public sealed class SpaceNinjaSystem : SharedSpaceNinjaSystem
+{
+}
diff --git a/Content.Client/Research/ResearchStealerSystem.cs b/Content.Client/Research/ResearchStealerSystem.cs
new file mode 100644
index 0000000000..31909044e4
--- /dev/null
+++ b/Content.Client/Research/ResearchStealerSystem.cs
@@ -0,0 +1,10 @@
+using Content.Shared.Research.Systems;
+
+namespace Content.Client.Research;
+
+///
+/// Does nothing special, only exists to provide a client implementation.
+///
+public sealed class ResearchStealerSystem : EntitySystem
+{
+}
diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
index ccd2778008..b6e8a0d300 100644
--- a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
+++ b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
@@ -1,4 +1,5 @@
using Content.Server.GameTicking.Rules;
+using Content.Server.Ninja.Systems;
using Content.Server.Zombies;
using Content.Shared.Administration;
using Content.Shared.Database;
@@ -15,6 +16,7 @@ public sealed partial class AdminVerbSystem
{
[Dependency] private readonly ZombieSystem _zombie = default!;
[Dependency] private readonly TraitorRuleSystem _traitorRule = default!;
+ [Dependency] private readonly SpaceNinjaSystem _ninja = default!;
[Dependency] private readonly NukeopsRuleSystem _nukeopsRule = default!;
[Dependency] private readonly PiratesRuleSystem _piratesRule = default!;
[Dependency] private readonly SharedMindSystem _minds = default!;
@@ -35,7 +37,7 @@ public sealed partial class AdminVerbSystem
Verb traitor = new()
{
- Text = "Make Traitor",
+ Text = Loc.GetString("admin-verb-text-make-traitor"),
Category = VerbCategory.Antag,
Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Structures/Wallmounts/posters.rsi"), "poster5_contraband"),
Act = () =>
@@ -54,7 +56,7 @@ public sealed partial class AdminVerbSystem
Verb zombie = new()
{
- Text = "Make Zombie",
+ Text = Loc.GetString("admin-verb-text-make-zombie"),
Category = VerbCategory.Antag,
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/Actions/zombie-turn.png")),
Act = () =>
@@ -69,7 +71,7 @@ public sealed partial class AdminVerbSystem
Verb nukeOp = new()
{
- Text = "Make nuclear operative",
+ Text = Loc.GetString("admin-verb-text-make-nuclear-operative"),
Category = VerbCategory.Antag,
Icon = new SpriteSpecifier.Rsi(new("/Textures/Structures/Wallmounts/signs.rsi"), "radiation"),
Act = () =>
@@ -86,7 +88,7 @@ public sealed partial class AdminVerbSystem
Verb pirate = new()
{
- Text = "Make Pirate",
+ Text = Loc.GetString("admin-verb-text-make-pirate"),
Category = VerbCategory.Antag,
Icon = new SpriteSpecifier.Rsi(new("/Textures/Clothing/Head/Hats/pirate.rsi"), "icon"),
Act = () =>
@@ -101,5 +103,21 @@ public sealed partial class AdminVerbSystem
};
args.Verbs.Add(pirate);
+ Verb spaceNinja = new()
+ {
+ Text = Loc.GetString("admin-verb-text-make-space-ninja"),
+ Category = VerbCategory.Antag,
+ Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Objects/Weapons/Melee/energykatana.rsi"), "icon"),
+ Act = () =>
+ {
+ if (!_minds.TryGetMind(args.Target, out var mindId, out var mind))
+ return;
+
+ _ninja.MakeNinja(mindId, mind);
+ },
+ Impact = LogImpact.High,
+ Message = Loc.GetString("admin-verb-make-space-ninja"),
+ };
+ args.Verbs.Add(spaceNinja);
}
}
diff --git a/Content.Server/Communications/CommsHackerSystem.cs b/Content.Server/Communications/CommsHackerSystem.cs
new file mode 100644
index 0000000000..6ef9e1b1bb
--- /dev/null
+++ b/Content.Server/Communications/CommsHackerSystem.cs
@@ -0,0 +1,89 @@
+using Content.Server.Chat.Systems;
+using Content.Server.GameTicking;
+using Content.Server.Ninja.Systems;
+using Content.Shared.Communications;
+using Content.Shared.DoAfter;
+using Content.Shared.Interaction;
+using Robust.Shared.Random;
+using Robust.Shared.Serialization;
+
+namespace Content.Server.Communications;
+
+public sealed class CommsHackerSystem : SharedCommsHackerSystem
+{
+ [Dependency] private readonly ChatSystem _chat = default!;
+ [Dependency] private readonly GameTicker _gameTicker = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ // TODO: remove when generic check event is used
+ [Dependency] private readonly NinjaGlovesSystem _gloves = default!;
+ [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnBeforeInteractHand);
+ SubscribeLocalEvent(OnDoAfter);
+ }
+
+ ///
+ /// Start the doafter to hack a comms console
+ ///
+ private void OnBeforeInteractHand(EntityUid uid, CommsHackerComponent comp, BeforeInteractHandEvent args)
+ {
+ if (args.Handled || !HasComp(args.Target))
+ return;
+
+ // TODO: generic check event
+ if (!_gloves.AbilityCheck(uid, args, out var target))
+ return;
+
+ var doAfterArgs = new DoAfterArgs(uid, comp.Delay, new TerrorDoAfterEvent(), target: target, used: uid, eventTarget: uid)
+ {
+ BreakOnDamage = true,
+ BreakOnUserMove = true,
+ MovementThreshold = 0.5f,
+ CancelDuplicate = false
+ };
+
+ _doAfter.TryStartDoAfter(doAfterArgs);
+ args.Handled = true;
+ }
+
+ ///
+ /// Call in a random threat and do cleanup.
+ ///
+ private void OnDoAfter(EntityUid uid, CommsHackerComponent comp, TerrorDoAfterEvent args)
+ {
+ if (args.Cancelled || args.Handled || comp.Threats.Count == 0 || args.Target == null)
+ return;
+
+ var threat = _random.Pick(comp.Threats);
+ CallInThreat(threat);
+
+ // prevent calling in multiple threats
+ RemComp(uid);
+
+ var ev = new ThreatCalledInEvent(uid, args.Target.Value);
+ RaiseLocalEvent(args.User, ref ev);
+ }
+
+ ///
+ /// Makes announcement and adds game rule of the threat.
+ ///
+ public void CallInThreat(Threat threat)
+ {
+ _gameTicker.StartGameRule(threat.Rule, out _);
+ _chat.DispatchGlobalAnnouncement(Loc.GetString(threat.Announcement), playSound: true, colorOverride: Color.Red);
+ }
+}
+
+///
+/// Raised on the user when a threat is called in on the communications console.
+///
+///
+/// If you add , make sure to use this event to prevent adding it twice.
+/// For example, you could add a marker component after a threat is called in then check if the user doesn't have that marker before adding CommsHackerComponent.
+///
+[ByRefEvent]
+public record struct ThreatCalledInEvent(EntityUid Used, EntityUid Target);
diff --git a/Content.Server/Doors/Systems/DoorSystem.cs b/Content.Server/Doors/Systems/DoorSystem.cs
index 1bb8a0505f..f9918dfb0a 100644
--- a/Content.Server/Doors/Systems/DoorSystem.cs
+++ b/Content.Server/Doors/Systems/DoorSystem.cs
@@ -276,4 +276,3 @@ public sealed class DoorSystem : SharedDoorSystem
}
}
}
-
diff --git a/Content.Server/Electrocution/ElectrocutionSystem.cs b/Content.Server/Electrocution/ElectrocutionSystem.cs
index a703086ed2..c6ce90692f 100644
--- a/Content.Server/Electrocution/ElectrocutionSystem.cs
+++ b/Content.Server/Electrocution/ElectrocutionSystem.cs
@@ -303,16 +303,8 @@ public sealed class ElectrocutionSystem : SharedElectrocutionSystem
}
}
- /// Entity being electrocuted.
- /// Source entity of the electrocution.
- /// How much shock damage the entity takes.
- /// How long the entity will be stunned.
- /// Should time be refreshed (instead of accumilated) if the entity is already electrocuted?
- /// How insulated the entity is from the shock. 0 means completely insulated, and 1 means no insulation.
- /// Status effects to apply to the entity.
- /// Should the electrocution bypass the Insulated component?
- /// Whether the entity was stunned by the shock.
- public bool TryDoElectrocution(
+ ///
+ public override bool TryDoElectrocution(
EntityUid uid, EntityUid? sourceUid, int shockDamage, TimeSpan time, bool refresh, float siemensCoefficient = 1f,
StatusEffectsComponent? statusEffects = null, bool ignoreInsulation = false)
{
diff --git a/Content.Server/Emp/EmpSystem.cs b/Content.Server/Emp/EmpSystem.cs
index b327f086db..c95be2501e 100644
--- a/Content.Server/Emp/EmpSystem.cs
+++ b/Content.Server/Emp/EmpSystem.cs
@@ -31,6 +31,11 @@ public sealed class EmpSystem : SharedEmpSystem
{
foreach (var uid in _lookup.GetEntitiesInRange(coordinates, range))
{
+ var attemptEv = new EmpAttemptEvent();
+ RaiseLocalEvent(uid, attemptEv);
+ if (attemptEv.Cancelled)
+ continue;
+
var ev = new EmpPulseEvent(energyConsumption, false, false);
RaiseLocalEvent(uid, ref ev);
if (ev.Affected)
@@ -100,6 +105,13 @@ public sealed class EmpSystem : SharedEmpSystem
}
}
+///
+/// Raised on an entity before . Cancel this to prevent the emp event being raised.
+///
+public sealed partial class EmpAttemptEvent : CancellableEntityEventArgs
+{
+}
+
[ByRefEvent]
public record struct EmpPulseEvent(float EnergyConsumption, bool Affected, bool Disabled);
diff --git a/Content.Server/Explosion/Components/AutomatedTimerComponent.cs b/Content.Server/Explosion/Components/AutomatedTimerComponent.cs
new file mode 100644
index 0000000000..7019c08d43
--- /dev/null
+++ b/Content.Server/Explosion/Components/AutomatedTimerComponent.cs
@@ -0,0 +1,9 @@
+namespace Content.Server.Explosion.Components;
+
+///
+/// Disallows starting the timer by hand, must be stuck or triggered by a system.
+///
+[RegisterComponent]
+public sealed partial class AutomatedTimerComponent : Component
+{
+}
diff --git a/Content.Server/Explosion/EntitySystems/TriggerSystem.OnUse.cs b/Content.Server/Explosion/EntitySystems/TriggerSystem.OnUse.cs
index b2ef93b74d..1a3323b1ce 100644
--- a/Content.Server/Explosion/EntitySystems/TriggerSystem.OnUse.cs
+++ b/Content.Server/Explosion/EntitySystems/TriggerSystem.OnUse.cs
@@ -140,7 +140,7 @@ public sealed partial class TriggerSystem
private void OnTimerUse(EntityUid uid, OnUseTimerTriggerComponent component, UseInHandEvent args)
{
- if (args.Handled)
+ if (args.Handled || HasComp(uid))
return;
HandleTimerTrigger(
diff --git a/Content.Server/GameTicking/Rules/Components/NinjaRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/NinjaRuleComponent.cs
new file mode 100644
index 0000000000..62eee90d50
--- /dev/null
+++ b/Content.Server/GameTicking/Rules/Components/NinjaRuleComponent.cs
@@ -0,0 +1,37 @@
+using Content.Server.Ninja.Systems;
+using Content.Shared.Communications;
+using Content.Shared.Objectives;
+using Robust.Shared.Audio;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
+
+namespace Content.Server.GameTicking.Rules.Components;
+
+[RegisterComponent, Access(typeof(SpaceNinjaSystem))]
+public sealed partial class NinjaRuleComponent : Component
+{
+ ///
+ /// All ninja minds that are using this rule.
+ /// Their SpaceNinjaComponent Rule field should point back to this rule.
+ ///
+ [DataField("minds")]
+ public List Minds = new();
+
+ ///
+ /// List of objective prototype ids to add
+ ///
+ [DataField("objectives", required: true, customTypeSerializer: typeof(PrototypeIdListSerializer))]
+ public List Objectives = new();
+
+ ///
+ /// List of threats that can be called in. Copied onto when gloves are enabled.
+ ///
+ [DataField("threats", required: true)]
+ public List Threats = new();
+
+ ///
+ /// Sound played when making the player a ninja via antag control or ghost role
+ ///
+ [DataField("greetingSound", customTypeSerializer: typeof(SoundSpecifierTypeSerializer))]
+ public SoundSpecifier? GreetingSound = new SoundPathSpecifier("/Audio/Misc/ninja_greeting.ogg");
+}
diff --git a/Content.Server/GameTicking/Rules/NinjaRuleSystem.cs b/Content.Server/GameTicking/Rules/NinjaRuleSystem.cs
new file mode 100644
index 0000000000..b75241eaf4
--- /dev/null
+++ b/Content.Server/GameTicking/Rules/NinjaRuleSystem.cs
@@ -0,0 +1,23 @@
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Objectives;
+
+namespace Content.Server.GameTicking.Rules;
+
+///
+/// Only handles round end text for ninja.
+///
+public sealed class NinjaRuleSystem : GameRuleSystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnObjectivesTextGetInfo);
+ }
+
+ private void OnObjectivesTextGetInfo(EntityUid uid, NinjaRuleComponent comp, ref ObjectivesTextGetInfoEvent args)
+ {
+ args.Minds = comp.Minds;
+ args.AgentName = Loc.GetString("ninja-round-end-agent-name");
+ }
+}
diff --git a/Content.Server/Implants/AutoImplantSystem.cs b/Content.Server/Implants/AutoImplantSystem.cs
new file mode 100644
index 0000000000..b854e3ca3d
--- /dev/null
+++ b/Content.Server/Implants/AutoImplantSystem.cs
@@ -0,0 +1,21 @@
+using Content.Server.Implants.Components;
+
+namespace Content.Server.Implants;
+
+public sealed class AutoImplantSystem : EntitySystem
+{
+ [Dependency] private readonly SubdermalImplantSystem _subdermalImplant = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnMapInit);
+ }
+
+ private void OnMapInit(EntityUid uid, AutoImplantComponent comp, MapInitEvent args)
+ {
+ _subdermalImplant.AddImplants(uid, comp.Implants);
+ RemComp(uid);
+ }
+}
diff --git a/Content.Server/Implants/Components/AutoImplantComponent.cs b/Content.Server/Implants/Components/AutoImplantComponent.cs
new file mode 100644
index 0000000000..e082354d8b
--- /dev/null
+++ b/Content.Server/Implants/Components/AutoImplantComponent.cs
@@ -0,0 +1,17 @@
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
+
+namespace Content.Server.Implants.Components;
+
+///
+/// Implants an entity automatically on MapInit.
+///
+[RegisterComponent]
+public sealed partial class AutoImplantComponent : Component
+{
+ ///
+ /// List of implants to inject.
+ ///
+ [DataField("implants", required: true, customTypeSerializer: typeof(PrototypeIdListSerializer))]
+ public List Implants = new();
+}
diff --git a/Content.Server/Jobs/AddImplantSpecial.cs b/Content.Server/Jobs/AddImplantSpecial.cs
index 8eb554cc7c..83193e7056 100644
--- a/Content.Server/Jobs/AddImplantSpecial.cs
+++ b/Content.Server/Jobs/AddImplantSpecial.cs
@@ -1,5 +1,4 @@
using Content.Shared.Implants;
-using Content.Shared.Implants.Components;
using Content.Shared.Roles;
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
@@ -13,7 +12,6 @@ namespace Content.Server.Jobs;
[UsedImplicitly]
public sealed partial class AddImplantSpecial : JobSpecial
{
-
[DataField("implants", customTypeSerializer: typeof(PrototypeIdHashSetSerializer))]
public HashSet Implants { get; private set; } = new();
@@ -21,19 +19,6 @@ public sealed partial class AddImplantSpecial : JobSpecial
{
var entMan = IoCManager.Resolve();
var implantSystem = entMan.System();
- var xformQuery = entMan.GetEntityQuery();
-
- if (!xformQuery.TryGetComponent(mob, out var xform))
- return;
-
- foreach (var implantId in Implants)
- {
- var implant = entMan.SpawnEntity(implantId, xform.Coordinates);
-
- if (!entMan.TryGetComponent(implant, out var implantComp))
- return;
-
- implantSystem.ForceImplant(mob, implant, implantComp);
- }
+ implantSystem.AddImplants(mob, Implants);
}
}
diff --git a/Content.Server/Mind/MindSystem.cs b/Content.Server/Mind/MindSystem.cs
index 797e8a273b..aca5a9d485 100644
--- a/Content.Server/Mind/MindSystem.cs
+++ b/Content.Server/Mind/MindSystem.cs
@@ -18,12 +18,12 @@ namespace Content.Server.Mind;
public sealed class MindSystem : SharedMindSystem
{
[Dependency] private readonly ActorSystem _actor = default!;
- [Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly GameTicker _gameTicker = default!;
- [Dependency] private readonly SharedGhostSystem _ghosts = default!;
+ [Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly IMapManager _maps = default!;
- [Dependency] private readonly MetaDataSystem _metaData = default!;
[Dependency] private readonly IPlayerManager _players = default!;
+ [Dependency] private readonly MetaDataSystem _metaData = default!;
+ [Dependency] private readonly SharedGhostSystem _ghosts = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
public override void Initialize()
@@ -277,10 +277,13 @@ public sealed class MindSystem : SharedMindSystem
}
}
+ ///
+ /// Sets the Mind's UserId, Session, and updates the player's PlayerData. This should have no direct effect on the
+ /// entity that any mind is connected to, except as a side effect of the fact that it may change a player's
+ /// attached entity. E.g., ghosts get deleted.
+ ///
public override void SetUserId(EntityUid mindId, NetUserId? userId, MindComponent? mind = null)
{
- base.SetUserId(mindId, userId, mind);
-
if (!Resolve(mindId, ref mind))
return;
diff --git a/Content.Server/Ninja/Systems/BatteryDrainerSystem.cs b/Content.Server/Ninja/Systems/BatteryDrainerSystem.cs
new file mode 100644
index 0000000000..1910969f19
--- /dev/null
+++ b/Content.Server/Ninja/Systems/BatteryDrainerSystem.cs
@@ -0,0 +1,100 @@
+using Content.Server.Power.Components;
+using Content.Server.Power.EntitySystems;
+using Content.Shared.DoAfter;
+using Content.Shared.Interaction;
+using Content.Shared.Ninja.Components;
+using Content.Shared.Ninja.Systems;
+using Content.Shared.Popups;
+using Robust.Shared.Audio;
+
+namespace Content.Server.Ninja.Systems;
+
+///
+/// Handles the doafter and power transfer when draining.
+///
+public sealed class BatteryDrainerSystem : SharedBatteryDrainerSystem
+{
+ [Dependency] private readonly BatterySystem _battery = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnBeforeInteractHand);
+ }
+
+ ///
+ /// Start do after for draining a power source.
+ /// Can't predict PNBC existing so only done on server.
+ ///
+ private void OnBeforeInteractHand(EntityUid uid, BatteryDrainerComponent comp, BeforeInteractHandEvent args)
+ {
+ var target = args.Target;
+ if (args.Handled || comp.BatteryUid == null || !HasComp(target))
+ return;
+
+ // handles even if battery is full so you can actually see the poup
+ args.Handled = true;
+
+ if (_battery.IsFull(comp.BatteryUid.Value))
+ {
+ _popup.PopupEntity(Loc.GetString("battery-drainer-full"), uid, uid, PopupType.Medium);
+ return;
+ }
+
+ var doAfterArgs = new DoAfterArgs(uid, comp.DrainTime, new DrainDoAfterEvent(), target: target, eventTarget: uid)
+ {
+ BreakOnUserMove = true,
+ MovementThreshold = 0.5f,
+ CancelDuplicate = false,
+ AttemptFrequency = AttemptFrequency.StartAndEnd
+ };
+
+ _doAfter.TryStartDoAfter(doAfterArgs);
+ }
+
+ ///
+ protected override void OnDoAfterAttempt(EntityUid uid, BatteryDrainerComponent comp, DoAfterAttemptEvent args)
+ {
+ base.OnDoAfterAttempt(uid, comp, args);
+
+ if (comp.BatteryUid == null || _battery.IsFull(comp.BatteryUid.Value))
+ args.Cancel();
+ }
+
+ ///
+ protected override bool TryDrainPower(EntityUid uid, BatteryDrainerComponent comp, EntityUid target)
+ {
+ if (comp.BatteryUid == null || !TryComp(comp.BatteryUid.Value, out var battery))
+ return false;
+
+ if (!TryComp(target, out var targetBattery) || !TryComp(target, out var pnb))
+ return false;
+
+ if (MathHelper.CloseToPercent(targetBattery.CurrentCharge, 0))
+ {
+ _popup.PopupEntity(Loc.GetString("battery-drainer-empty", ("battery", target)), uid, uid, PopupType.Medium);
+ return false;
+ }
+
+ var available = targetBattery.CurrentCharge;
+ var required = battery.MaxCharge - battery.CurrentCharge;
+ // higher tier storages can charge more
+ var maxDrained = pnb.MaxSupply * comp.DrainTime;
+ var input = Math.Min(Math.Min(available, required / comp.DrainEfficiency), maxDrained);
+ if (!_battery.TryUseCharge(target, input, targetBattery))
+ return false;
+
+ var output = input * comp.DrainEfficiency;
+ _battery.SetCharge(comp.BatteryUid.Value, battery.CurrentCharge + output, battery);
+ Spawn("EffectSparks", Transform(target).Coordinates);
+ _audio.PlayPvs(comp.SparkSound, target);
+ _popup.PopupEntity(Loc.GetString("battery-drainer-success", ("battery", target)), uid, uid);
+
+ // repeat the doafter until battery is full
+ return !battery.IsFullyCharged;
+ }
+}
diff --git a/Content.Server/Ninja/Systems/NinjaGlovesSystem.cs b/Content.Server/Ninja/Systems/NinjaGlovesSystem.cs
new file mode 100644
index 0000000000..402650d6a6
--- /dev/null
+++ b/Content.Server/Ninja/Systems/NinjaGlovesSystem.cs
@@ -0,0 +1,102 @@
+using Content.Server.Communications;
+using Content.Server.DoAfter;
+using Content.Server.Mind;
+using Content.Server.Ninja.Systems;
+using Content.Server.Power.Components;
+using Content.Server.Roles;
+using Content.Shared.Communications;
+using Content.Shared.DoAfter;
+using Content.Shared.Interaction.Components;
+using Content.Shared.Interaction.Events;
+using Content.Shared.Ninja.Components;
+using Content.Shared.Ninja.Systems;
+using Content.Shared.Popups;
+using Content.Shared.Research.Components;
+using Content.Shared.Toggleable;
+
+namespace Content.Server.Ninja.Systems;
+
+///
+/// Handles the toggle gloves action.
+///
+public sealed class NinjaGlovesSystem : SharedNinjaGlovesSystem
+{
+ [Dependency] private readonly EmagProviderSystem _emagProvider = default!;
+ [Dependency] private readonly SharedBatteryDrainerSystem _drainer = default!;
+ [Dependency] private readonly SharedStunProviderSystem _stunProvider = default!;
+ [Dependency] private readonly SpaceNinjaSystem _ninja = default!;
+ [Dependency] private readonly CommsHackerSystem _commsHacker = default!;
+ [Dependency] private readonly MindSystem _mind = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnToggleAction);
+ }
+
+ ///
+ /// Toggle gloves, if the user is a ninja wearing a ninja suit.
+ ///
+ private void OnToggleAction(EntityUid uid, NinjaGlovesComponent comp, ToggleActionEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ args.Handled = true;
+
+ var user = args.Performer;
+ // need to wear suit to enable gloves
+ if (!TryComp(user, out var ninja)
+ || ninja.Suit == null
+ || !HasComp(ninja.Suit.Value))
+ {
+ Popup.PopupEntity(Loc.GetString("ninja-gloves-not-wearing-suit"), user, user);
+ return;
+ }
+
+ // show its state to the user
+ var enabling = comp.User == null;
+ Appearance.SetData(uid, ToggleVisuals.Toggled, enabling);
+ var message = Loc.GetString(enabling ? "ninja-gloves-on" : "ninja-gloves-off");
+ Popup.PopupEntity(message, user, user);
+
+ if (enabling)
+ {
+ EnableGloves(uid, comp, user, ninja);
+ }
+ else
+ {
+ DisableGloves(uid, comp);
+ }
+ }
+
+ private void EnableGloves(EntityUid uid, NinjaGlovesComponent comp, EntityUid user, SpaceNinjaComponent ninja)
+ {
+ comp.User = user;
+ Dirty(uid, comp);
+ _ninja.AssignGloves(user, uid, ninja);
+
+ var drainer = EnsureComp(user);
+ var stun = EnsureComp(user);
+ _stunProvider.SetNoPowerPopup(user, "ninja-no-power", stun);
+ if (_ninja.GetNinjaBattery(user, out var battery, out var _))
+ {
+ _drainer.SetBattery(user, battery, drainer);
+ _stunProvider.SetBattery(user, battery, stun);
+ }
+
+ var emag = EnsureComp(user);
+ _emagProvider.SetWhitelist(user, comp.DoorjackWhitelist, emag);
+
+ EnsureComp(user);
+ // prevent calling in multiple threats by toggling gloves after
+ if (_mind.TryGetRole(user, out var role) && !role.CalledInThreat)
+ {
+ var hacker = EnsureComp(user);
+ var rule = _ninja.NinjaRule(user);
+ if (rule != null)
+ _commsHacker.SetThreats(user, rule.Threats, hacker);
+ }
+ }
+}
diff --git a/Content.Server/Ninja/Systems/NinjaSuitSystem.cs b/Content.Server/Ninja/Systems/NinjaSuitSystem.cs
new file mode 100644
index 0000000000..f6ad646d22
--- /dev/null
+++ b/Content.Server/Ninja/Systems/NinjaSuitSystem.cs
@@ -0,0 +1,147 @@
+using Content.Server.Emp;
+using Content.Server.Popups;
+using Content.Server.Power.Components;
+using Content.Server.PowerCell;
+using Content.Shared.Actions;
+using Content.Shared.Clothing.EntitySystems;
+using Content.Shared.Hands.EntitySystems;
+using Content.Shared.Ninja.Components;
+using Content.Shared.Ninja.Systems;
+using Content.Shared.Popups;
+using Robust.Shared.Containers;
+
+namespace Content.Server.Ninja.Systems;
+
+///
+/// Handles power cell upgrading and actions.
+///
+public sealed class NinjaSuitSystem : SharedNinjaSuitSystem
+{
+ [Dependency] private readonly EmpSystem _emp = default!;
+ [Dependency] private readonly SharedHandsSystem _hands = default!;
+ [Dependency] private readonly SpaceNinjaSystem _ninja = default!;
+ [Dependency] private readonly PopupSystem _popup = default!;
+ [Dependency] private readonly PowerCellSystem _powerCell = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnSuitInsertAttempt);
+ SubscribeLocalEvent(OnEmpAttempt);
+ SubscribeLocalEvent(OnAttemptStealth);
+ SubscribeLocalEvent(OnCreateThrowingStar);
+ SubscribeLocalEvent(OnRecallKatana);
+ SubscribeLocalEvent(OnEmp);
+ }
+
+ protected override void NinjaEquippedSuit(EntityUid uid, NinjaSuitComponent comp, EntityUid user, SpaceNinjaComponent ninja)
+ {
+ base.NinjaEquippedSuit(uid, comp, user, ninja);
+
+ _ninja.SetSuitPowerAlert(user);
+ }
+
+ // TODO: if/when battery is in shared, put this there too
+ // TODO: or put MaxCharge in shared along with powercellslot
+ private void OnSuitInsertAttempt(EntityUid uid, NinjaSuitComponent comp, ContainerIsInsertingAttemptEvent args)
+ {
+ // no power cell for some reason??? allow it
+ if (!_powerCell.TryGetBatteryFromSlot(uid, out var battery))
+ return;
+
+ // can only upgrade power cell, not swap to recharge instantly otherwise ninja could just swap batteries with flashlights in maints for easy power
+ if (!TryComp(args.EntityUid, out var inserting) || inserting.MaxCharge <= battery.MaxCharge)
+ {
+ args.Cancel();
+ }
+
+ // TODO: raise event on ninja telling it to update battery
+ }
+
+ private void OnEmpAttempt(EntityUid uid, NinjaSuitComponent comp, EmpAttemptEvent args)
+ {
+ // ninja suit (battery) is immune to emp
+ // powercell relays the event to suit
+ args.Cancel();
+ }
+
+ protected override void UserUnequippedSuit(EntityUid uid, NinjaSuitComponent comp, EntityUid user)
+ {
+ base.UserUnequippedSuit(uid, comp, user);
+
+ // remove power indicator
+ _ninja.SetSuitPowerAlert(user);
+ }
+
+ private void OnAttemptStealth(EntityUid uid, NinjaSuitComponent comp, AttemptStealthEvent args)
+ {
+ var user = args.User;
+ // need 1 second of charge to turn on stealth
+ var chargeNeeded = SuitWattage(uid, comp);
+ // being attacked while cloaked gives no power message since it overloads the power supply or something
+ if (!_ninja.GetNinjaBattery(user, out var _, out var battery) || battery.CurrentCharge < chargeNeeded || UseDelay.ActiveDelay(user))
+ {
+ _popup.PopupEntity(Loc.GetString("ninja-no-power"), user, user);
+ args.Cancel();
+ return;
+ }
+
+ StealthClothing.SetEnabled(uid, user, true);
+ }
+
+ private void OnCreateThrowingStar(EntityUid uid, NinjaSuitComponent comp, CreateThrowingStarEvent args)
+ {
+ args.Handled = true;
+ var user = args.Performer;
+ if (!_ninja.TryUseCharge(user, comp.ThrowingStarCharge) || UseDelay.ActiveDelay(user))
+ {
+ _popup.PopupEntity(Loc.GetString("ninja-no-power"), user, user);
+ return;
+ }
+
+ // try to put throwing star in hand, otherwise it goes on the ground
+ var star = Spawn(comp.ThrowingStarPrototype, Transform(user).Coordinates);
+ _hands.TryPickupAnyHand(user, star);
+ }
+
+ private void OnRecallKatana(EntityUid uid, NinjaSuitComponent comp, RecallKatanaEvent args)
+ {
+ args.Handled = true;
+ var user = args.Performer;
+ if (!TryComp(user, out var ninja) || ninja.Katana == null)
+ return;
+
+ var katana = ninja.Katana.Value;
+ var coords = _transform.GetWorldPosition(katana);
+ var distance = (_transform.GetWorldPosition(user) - coords).Length();
+ var chargeNeeded = (float) distance * comp.RecallCharge;
+ if (!_ninja.TryUseCharge(user, chargeNeeded) || UseDelay.ActiveDelay(user))
+ {
+ _popup.PopupEntity(Loc.GetString("ninja-no-power"), user, user);
+ return;
+ }
+
+ // TODO: teleporting into belt slot
+ var message = _hands.TryPickupAnyHand(user, katana)
+ ? "ninja-katana-recalled"
+ : "ninja-hands-full";
+ _popup.PopupEntity(Loc.GetString(message), user, user);
+ }
+
+ private void OnEmp(EntityUid uid, NinjaSuitComponent comp, NinjaEmpEvent args)
+ {
+ args.Handled = true;
+ var user = args.Performer;
+ if (!_ninja.TryUseCharge(user, comp.EmpCharge) || UseDelay.ActiveDelay(user))
+ {
+ _popup.PopupEntity(Loc.GetString("ninja-no-power"), user, user);
+ return;
+ }
+
+ // I don't think this affects the suit battery, but if it ever does in the future add a blacklist for it
+ var coords = Transform(user).MapPosition;
+ _emp.EmpPulse(coords, comp.EmpRange, comp.EmpConsumption, comp.EmpDuration);
+ }
+}
diff --git a/Content.Server/Ninja/Systems/SpaceNinjaSystem.cs b/Content.Server/Ninja/Systems/SpaceNinjaSystem.cs
new file mode 100644
index 0000000000..4c8b20d46e
--- /dev/null
+++ b/Content.Server/Ninja/Systems/SpaceNinjaSystem.cs
@@ -0,0 +1,301 @@
+using Content.Server.Administration.Commands;
+using Content.Server.Communications;
+using Content.Server.Chat.Managers;
+using Content.Server.StationEvents.Components;
+using Content.Server.GameTicking;
+using Content.Server.GameTicking.Rules;
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Ghost.Roles.Events;
+using Content.Server.Objectives;
+using Content.Server.Power.Components;
+using Content.Server.Power.EntitySystems;
+using Content.Server.PowerCell;
+using Content.Server.Research.Systems;
+using Content.Server.Roles;
+using Content.Server.Warps;
+using Content.Shared.Alert;
+using Content.Shared.Clothing.EntitySystems;
+using Content.Shared.Doors.Components;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Mind;
+using Content.Shared.Mind.Components;
+using Content.Shared.Ninja.Components;
+using Content.Shared.Ninja.Systems;
+using Content.Shared.Popups;
+using Content.Shared.Roles;
+using Content.Shared.PowerCell.Components;
+using Content.Shared.Rounding;
+using Robust.Shared.Audio;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Player;
+using Robust.Shared.Random;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+namespace Content.Server.Ninja.Systems;
+
+// TODO: when syndiborgs are a thing have a borg converter with 6 second doafter
+// engi -> saboteur
+// medi -> idk reskin it
+// other -> assault
+// TODO: when criminal records is merged, hack it to set everyone to arrest
+
+///
+/// Main ninja system that handles ninja setup and greentext, provides helper methods for the rest of the code to use.
+///
+public sealed class SpaceNinjaSystem : SharedSpaceNinjaSystem
+{
+ [Dependency] private readonly AlertsSystem _alerts = default!;
+ [Dependency] private readonly BatterySystem _battery = default!;
+ [Dependency] private readonly GameTicker _gameTicker = default!;
+ [Dependency] private readonly IChatManager _chatMan = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly PowerCellSystem _powerCell = default!;
+ [Dependency] private readonly RoleSystem _role = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedMindSystem _mind = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+ [Dependency] private readonly StealthClothingSystem _stealthClothing = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnNinjaMindAdded);
+ SubscribeLocalEvent(OnDoorjack);
+ SubscribeLocalEvent(OnResearchStolen);
+ SubscribeLocalEvent(OnThreatCalledIn);
+ }
+
+ public override void Update(float frameTime)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var ninja))
+ {
+ UpdateNinja(uid, ninja, frameTime);
+ }
+ }
+
+ ///
+ /// Turns the player into a space ninja
+ ///
+ public void MakeNinja(EntityUid mindId, MindComponent mind)
+ {
+ if (mind.OwnedEntity == null)
+ return;
+
+ // prevent double ninja'ing
+ var user = mind.OwnedEntity.Value;
+ if (HasComp(user))
+ return;
+
+ AddComp(user);
+ SetOutfitCommand.SetOutfit(user, "SpaceNinjaGear", EntityManager);
+ GreetNinja(mindId, mind);
+ }
+
+ ///
+ /// Download the given set of nodes, returning how many new nodes were downloaded.
+ ///
+ private int Download(EntityUid uid, List ids)
+ {
+ if (!_mind.TryGetRole(uid, out var role))
+ return 0;
+
+ var oldCount = role.DownloadedNodes.Count;
+ role.DownloadedNodes.UnionWith(ids);
+ var newCount = role.DownloadedNodes.Count;
+ return newCount - oldCount;
+ }
+
+ ///
+ /// Returns a ninja's gamerule config data.
+ /// If the gamerule was not started then it will be started automatically.
+ ///
+ public NinjaRuleComponent? NinjaRule(EntityUid uid, SpaceNinjaComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp))
+ return null;
+
+ // already exists so just check it
+ if (comp.Rule != null)
+ return CompOrNull(comp.Rule);
+
+ // start it
+ _gameTicker.StartGameRule("Ninja", out var rule);
+ comp.Rule = rule;
+
+ if (!TryComp(rule, out var ninjaRule))
+ return null;
+
+ // add ninja mind to the rule's list for objective showing
+ if (TryComp(uid, out var mindContainer) && mindContainer.Mind != null)
+ {
+ ninjaRule.Minds.Add(mindContainer.Mind.Value);
+ }
+
+ return ninjaRule;
+ }
+
+ // TODO: can probably copy paste borg code here
+ ///
+ /// Update the alert for the ninja's suit power indicator.
+ ///
+ public void SetSuitPowerAlert(EntityUid uid, SpaceNinjaComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp, false) || comp.Deleted || comp.Suit == null)
+ {
+ _alerts.ClearAlert(uid, AlertType.SuitPower);
+ return;
+ }
+
+ if (GetNinjaBattery(uid, out var _, out var battery))
+ {
+ var severity = ContentHelpers.RoundToLevels(MathF.Max(0f, battery.CurrentCharge), battery.MaxCharge, 8);
+ _alerts.ShowAlert(uid, AlertType.SuitPower, (short) severity);
+ }
+ else
+ {
+ _alerts.ClearAlert(uid, AlertType.SuitPower);
+ }
+ }
+
+ ///
+ /// Get the battery component in a ninja's suit, if it's worn.
+ ///
+ public bool GetNinjaBattery(EntityUid user, [NotNullWhen(true)] out EntityUid? uid, [NotNullWhen(true)] out BatteryComponent? battery)
+ {
+ if (TryComp(user, out var ninja)
+ && ninja.Suit != null
+ && _powerCell.TryGetBatteryFromSlot(ninja.Suit.Value, out uid, out battery))
+ {
+ return true;
+ }
+
+ uid = null;
+ battery = null;
+ return false;
+ }
+
+ ///
+ public override bool TryUseCharge(EntityUid user, float charge)
+ {
+ return GetNinjaBattery(user, out var uid, out var battery) && _battery.TryUseCharge(uid.Value, charge, battery);
+ }
+
+ ///
+ /// Greets the ninja when a ghost takes over a ninja, if that happens.
+ ///
+ private void OnNinjaMindAdded(EntityUid uid, SpaceNinjaComponent comp, MindAddedMessage args)
+ {
+ if (TryComp(uid, out var mind) && mind.Mind != null)
+ GreetNinja(mind.Mind.Value);
+ }
+
+ ///
+ /// Set up everything for ninja to work and send the greeting message/sound.
+ ///
+ private void GreetNinja(EntityUid mindId, MindComponent? mind = null)
+ {
+ if (!Resolve(mindId, ref mind) || mind.OwnedEntity == null || mind.Session == null)
+ return;
+
+ var uid = mind.OwnedEntity.Value;
+ var config = NinjaRule(uid);
+ if (config == null)
+ return;
+
+ var role = new NinjaRoleComponent
+ {
+ PrototypeId = "SpaceNinja"
+ };
+ _role.MindAddRole(mindId, role, mind);
+
+ // choose spider charge detonation point
+ // currently based on warp points, something better could be done (but would likely require mapping work)
+ var warps = new List();
+ var query = EntityQueryEnumerator();
+ var map = Transform(uid).MapID;
+ while (query.MoveNext(out var warpUid, out var warp, out var xform))
+ {
+ // won't be asked to detonate the nuke disk or singularity or centcomm
+ if (warp.Location != null && !HasComp(warpUid) && xform.MapID == map)
+ warps.Add(warpUid);
+ }
+
+ if (warps.Count > 0)
+ role.SpiderChargeTarget = _random.Pick(warps);
+
+ // assign objectives - must happen after spider charge target so that the obj requirement works
+ foreach (var objective in config.Objectives)
+ {
+ if (!_mind.TryAddObjective(mindId, objective, mind))
+ {
+ Log.Error($"Failed to add {objective} to ninja {mind.OwnedEntity.Value}");
+ }
+ }
+
+ var session = mind.Session;
+ _audio.PlayGlobal(config.GreetingSound, Filter.Empty().AddPlayer(session), false, AudioParams.Default);
+ _chatMan.DispatchServerMessage(session, Loc.GetString("ninja-role-greeting"));
+ }
+
+ // TODO: PowerCellDraw, modify when cloak enabled
+ ///
+ /// Handle constant power drains from passive usage and cloak.
+ ///
+ private void UpdateNinja(EntityUid uid, SpaceNinjaComponent ninja, float frameTime)
+ {
+ if (ninja.Suit == null)
+ return;
+
+ float wattage = _suit.SuitWattage(ninja.Suit.Value);
+
+ SetSuitPowerAlert(uid, ninja);
+ if (!TryUseCharge(uid, wattage * frameTime))
+ {
+ // ran out of power, uncloak ninja
+ _stealthClothing.SetEnabled(ninja.Suit.Value, uid, false);
+ }
+ }
+
+ ///
+ /// Increment greentext when emagging a door.
+ ///
+ private void OnDoorjack(EntityUid uid, SpaceNinjaComponent comp, ref EmaggedSomethingEvent args)
+ {
+ // incase someone lets ninja emag non-doors double check it here
+ if (!HasComp(args.Target))
+ return;
+
+ // this popup is serverside since door emag logic is serverside (power funnies)
+ _popup.PopupEntity(Loc.GetString("ninja-doorjack-success", ("target", Identity.Entity(args.Target, EntityManager))), uid, uid, PopupType.Medium);
+
+ // handle greentext
+ if (_mind.TryGetRole(uid, out var role))
+ role.DoorsJacked++;
+ }
+
+ ///
+ /// Add to greentext when stealing technologies.
+ ///
+ private void OnResearchStolen(EntityUid uid, SpaceNinjaComponent comp, ref ResearchStolenEvent args)
+ {
+ var gained = Download(uid, args.Techs);
+ var str = gained == 0
+ ? Loc.GetString("ninja-research-steal-fail")
+ : Loc.GetString("ninja-research-steal-success", ("count", gained), ("server", args.Target));
+
+ _popup.PopupEntity(str, uid, uid, PopupType.Medium);
+ }
+
+ private void OnThreatCalledIn(EntityUid uid, SpaceNinjaComponent comp, ref ThreatCalledInEvent args)
+ {
+ if (_mind.TryGetRole(uid, out var role))
+ {
+ role.CalledInThreat = true;
+ }
+ }
+}
diff --git a/Content.Server/Ninja/Systems/SpiderChargeSystem.cs b/Content.Server/Ninja/Systems/SpiderChargeSystem.cs
new file mode 100644
index 0000000000..3ffc2a8ff3
--- /dev/null
+++ b/Content.Server/Ninja/Systems/SpiderChargeSystem.cs
@@ -0,0 +1,78 @@
+using Content.Server.Explosion.EntitySystems;
+using Content.Server.Mind;
+using Content.Server.Popups;
+using Content.Server.Roles;
+using Content.Server.Sticky.Events;
+using Content.Shared.Interaction;
+using Content.Shared.Ninja.Components;
+using Robust.Shared.GameObjects;
+
+namespace Content.Server.Ninja.Systems;
+
+///
+/// Prevents planting a spider charge outside of its location and handles greentext.
+///
+public sealed class SpiderChargeSystem : EntitySystem
+{
+ [Dependency] private readonly MindSystem _mind = default!;
+ [Dependency] private readonly PopupSystem _popup = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(BeforePlant);
+ SubscribeLocalEvent(OnStuck);
+ SubscribeLocalEvent(OnExplode);
+ }
+
+ ///
+ /// Require that the planter is a ninja and the charge is near the target warp point.
+ ///
+ private void BeforePlant(EntityUid uid, SpiderChargeComponent comp, BeforeRangedInteractEvent args)
+ {
+ var user = args.User;
+
+ if (!_mind.TryGetRole(user, out var role))
+ {
+ _popup.PopupEntity(Loc.GetString("spider-charge-not-ninja"), user, user);
+ args.Handled = true;
+ return;
+ }
+
+ // allow planting anywhere if there is no target, which should never happen
+ if (role.SpiderChargeTarget == null)
+ return;
+
+ // assumes warp point still exists
+ var target = Transform(role.SpiderChargeTarget.Value).MapPosition;
+ var coords = args.ClickLocation.ToMap(EntityManager, _transform);
+ if (!coords.InRange(target, comp.Range))
+ {
+ _popup.PopupEntity(Loc.GetString("spider-charge-too-far"), user, user);
+ args.Handled = true;
+ }
+ }
+
+ ///
+ /// Allows greentext to occur after exploding.
+ ///
+ private void OnStuck(EntityUid uid, SpiderChargeComponent comp, EntityStuckEvent args)
+ {
+ comp.Planter = args.User;
+ }
+
+ ///
+ /// Handles greentext after exploding.
+ /// Assumes it didn't move and the target was destroyed so be nice.
+ ///
+ private void OnExplode(EntityUid uid, SpiderChargeComponent comp, TriggerEvent args)
+ {
+ if (comp.Planter == null || !_mind.TryGetRole(comp.Planter.Value, out var role))
+ return;
+
+ // assumes the target was destroyed, that the charge wasn't moved somehow
+ role.SpiderChargeDetonated = true;
+ }
+}
diff --git a/Content.Server/Ninja/Systems/StunProviderSystem.cs b/Content.Server/Ninja/Systems/StunProviderSystem.cs
new file mode 100644
index 0000000000..21e8b2042f
--- /dev/null
+++ b/Content.Server/Ninja/Systems/StunProviderSystem.cs
@@ -0,0 +1,60 @@
+using Content.Shared.Electrocution;
+using Content.Shared.Interaction;
+using Content.Shared.Ninja.Components;
+using Content.Shared.Ninja.Systems;
+using Content.Shared.Popups;
+using Content.Shared.Whitelist;
+using Content.Server.Power.EntitySystems;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Ninja.Systems;
+
+///
+/// Shocks clicked mobs using battery charge.
+///
+public sealed class StunProviderSystem : SharedStunProviderSystem
+{
+ [Dependency] private readonly BatterySystem _battery = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly SharedElectrocutionSystem _electrocution = default!;
+ [Dependency] private readonly SharedNinjaGlovesSystem _gloves = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnBeforeInteractHand);
+ }
+
+ ///
+ /// Stun clicked mobs on the whitelist, if there is enough power.
+ ///
+ private void OnBeforeInteractHand(EntityUid uid, StunProviderComponent comp, BeforeInteractHandEvent args)
+ {
+ // TODO: generic check
+ if (args.Handled || comp.BatteryUid == null || !_gloves.AbilityCheck(uid, args, out var target))
+ return;
+
+ if (target == uid || !comp.Whitelist.IsValid(target, EntityManager))
+ return;
+
+ if (_timing.CurTime < comp.NextStun)
+ return;
+
+ // take charge from battery
+ if (!_battery.TryUseCharge(comp.BatteryUid.Value, comp.StunCharge))
+ {
+ _popup.PopupEntity(Loc.GetString(comp.NoPowerPopup), uid, uid);
+ return;
+ }
+
+ // not holding hands with target so insuls don't matter
+ _electrocution.TryDoElectrocution(target, uid, comp.StunDamage, comp.StunTime, false, ignoreInsulation: true);
+ // short cooldown to prevent instant stunlocking
+ comp.NextStun = _timing.CurTime + comp.Cooldown;
+ Dirty(uid, comp);
+
+ args.Handled = true;
+ }
+}
diff --git a/Content.Server/Objectives/Conditions/DoorjackCondition.cs b/Content.Server/Objectives/Conditions/DoorjackCondition.cs
new file mode 100644
index 0000000000..0752048554
--- /dev/null
+++ b/Content.Server/Objectives/Conditions/DoorjackCondition.cs
@@ -0,0 +1,70 @@
+using Content.Server.Roles;
+using Content.Shared.Mind;
+using Content.Shared.Objectives.Interfaces;
+using Robust.Shared.Random;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Objectives.Conditions;
+
+///
+/// Objective condition that requires the player to be a ninja and have doorjacked at least a random number of airlocks.
+///
+[DataDefinition]
+public sealed partial class DoorjackCondition : IObjectiveCondition
+{
+ private EntityUid? _mind;
+ private int _target;
+
+ public IObjectiveCondition GetAssigned(EntityUid uid, MindComponent mind)
+ {
+ // TODO: clamp to number of doors on station incase its somehow a shittle or something
+ return new DoorjackCondition {
+ _mind = uid,
+ _target = IoCManager.Resolve().Next(15, 40)
+ };
+ }
+
+ public string Title => Loc.GetString("objective-condition-doorjack-title", ("count", _target));
+
+ public string Description => Loc.GetString("objective-condition-doorjack-description", ("count", _target));
+
+ public SpriteSpecifier Icon => new SpriteSpecifier.Rsi(new ResPath("Objects/Tools/emag.rsi"), "icon");
+
+ public float Progress
+ {
+ get
+ {
+ // prevent divide-by-zero
+ if (_target == 0)
+ return 1f;
+
+ var entMan = IoCManager.Resolve();
+ if (!entMan.TryGetComponent(_mind, out var role))
+ return 0f;
+
+ if (role.DoorsJacked >= _target)
+ return 1f;
+
+ return (float) role.DoorsJacked / (float) _target;
+ }
+ }
+
+ public float Difficulty => 1.5f;
+
+ public bool Equals(IObjectiveCondition? other)
+ {
+ return other is DoorjackCondition cond && Equals(_mind, cond._mind) && _target == cond._target;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(null, obj)) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ return obj is DoorjackCondition cond && cond.Equals(this);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(_mind?.GetHashCode() ?? 0, _target);
+ }
+}
diff --git a/Content.Server/Objectives/Conditions/SpiderChargeCondition.cs b/Content.Server/Objectives/Conditions/SpiderChargeCondition.cs
new file mode 100644
index 0000000000..5209296842
--- /dev/null
+++ b/Content.Server/Objectives/Conditions/SpiderChargeCondition.cs
@@ -0,0 +1,75 @@
+using Content.Server.Roles;
+using Content.Server.Warps;
+using Content.Shared.Mind;
+using Content.Shared.Objectives.Interfaces;
+using Robust.Shared.Random;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Objectives.Conditions;
+
+///
+/// Objective condition that requires the player to be a ninja and have detonated their spider charge.
+///
+[DataDefinition]
+public sealed partial class SpiderChargeCondition : IObjectiveCondition
+{
+ private EntityUid? _mind;
+
+ public IObjectiveCondition GetAssigned(EntityUid uid, MindComponent mind)
+ {
+ return new SpiderChargeCondition {
+ _mind = uid
+ };
+ }
+
+ public string Title
+ {
+ get
+ {
+ var entMan = IoCManager.Resolve();
+ if (!entMan.TryGetComponent(_mind, out var role)
+ || role.SpiderChargeTarget == null
+ || !entMan.TryGetComponent(role.SpiderChargeTarget, out var warp)
+ || warp.Location == null)
+ // this should never really happen but eh
+ return Loc.GetString("objective-condition-spider-charge-no-target");
+
+ return Loc.GetString("objective-condition-spider-charge-title", ("location", warp.Location));
+ }
+ }
+
+ public string Description => Loc.GetString("objective-condition-spider-charge-description");
+
+ public SpriteSpecifier Icon => new SpriteSpecifier.Rsi(new ResPath("Objects/Weapons/Bombs/spidercharge.rsi"), "icon");
+
+ public float Progress
+ {
+ get
+ {
+ var entMan = IoCManager.Resolve();
+ if (!entMan.TryGetComponent(_mind, out var role))
+ return 0f;
+
+ return role.SpiderChargeDetonated ? 1f : 0f;
+ }
+ }
+
+ public float Difficulty => 2.5f;
+
+ public bool Equals(IObjectiveCondition? other)
+ {
+ return other is SpiderChargeCondition cond && Equals(_mind, cond._mind);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(null, obj)) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ return obj is SpiderChargeCondition cond && cond.Equals(this);
+ }
+
+ public override int GetHashCode()
+ {
+ return _mind?.GetHashCode() ?? 0;
+ }
+}
diff --git a/Content.Server/Objectives/Conditions/StealResearchCondition.cs b/Content.Server/Objectives/Conditions/StealResearchCondition.cs
new file mode 100644
index 0000000000..4c32f3c3fc
--- /dev/null
+++ b/Content.Server/Objectives/Conditions/StealResearchCondition.cs
@@ -0,0 +1,70 @@
+using Content.Server.Roles;
+using Content.Shared.Mind;
+using Content.Shared.Objectives.Interfaces;
+using Robust.Shared.Random;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Objectives.Conditions;
+
+///
+/// Objective condition that requires the player to be a ninja and have stolen at least a random number of technologies.
+///
+[DataDefinition]
+public sealed partial class StealResearchCondition : IObjectiveCondition
+{
+ private EntityUid? _mind;
+ private int _target;
+
+ public IObjectiveCondition GetAssigned(EntityUid uid, MindComponent mind)
+ {
+ // TODO: clamp to number of research nodes in a single discipline maybe so easily maintainable
+ return new StealResearchCondition {
+ _mind = uid,
+ _target = IoCManager.Resolve().Next(5, 10)
+ };
+ }
+
+ public string Title => Loc.GetString("objective-condition-steal-research-title", ("count", _target));
+
+ public string Description => Loc.GetString("objective-condition-steal-research-description");
+
+ public SpriteSpecifier Icon => new SpriteSpecifier.Rsi(new ResPath("Structures/Machines/server.rsi"), "server");
+
+ public float Progress
+ {
+ get
+ {
+ // prevent divide-by-zero
+ if (_target == 0)
+ return 1f;
+
+ var entMan = IoCManager.Resolve();
+ if (!entMan.TryGetComponent(_mind, out var role))
+ return 0f;
+
+ if (role.DownloadedNodes.Count >= _target)
+ return 1f;
+
+ return (float) role.DownloadedNodes.Count / (float) _target;
+ }
+ }
+
+ public float Difficulty => 2.5f;
+
+ public bool Equals(IObjectiveCondition? other)
+ {
+ return other is StealResearchCondition cond && Equals(_mind, cond._mind) && _target == cond._target;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(null, obj)) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ return obj is StealResearchCondition cond && cond.Equals(this);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(_mind?.GetHashCode() ?? 0, _target);
+ }
+}
diff --git a/Content.Server/Objectives/Conditions/SurviveCondition.cs b/Content.Server/Objectives/Conditions/SurviveCondition.cs
new file mode 100644
index 0000000000..98b5aa6c89
--- /dev/null
+++ b/Content.Server/Objectives/Conditions/SurviveCondition.cs
@@ -0,0 +1,58 @@
+using Content.Shared.Mind;
+using Content.Shared.Objectives.Interfaces;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Objectives.Conditions;
+
+///
+/// Just requires that the player is not dead, ignores evac and what not.
+///
+[DataDefinition]
+public sealed partial class SurviveCondition : IObjectiveCondition
+{
+ private EntityUid? _mind;
+
+ public IObjectiveCondition GetAssigned(EntityUid uid, MindComponent mind)
+ {
+ return new SurviveCondition {_mind = uid};
+ }
+
+ public string Title => Loc.GetString("objective-condition-survive-title");
+
+ public string Description => Loc.GetString("objective-condition-survive-description");
+
+ public SpriteSpecifier Icon => new SpriteSpecifier.Rsi(new ResPath("Clothing/Mask/ninja.rsi"), "icon");
+
+ public float Difficulty => 0.5f;
+
+ public float Progress
+ {
+ get
+ {
+ var entMan = IoCManager.Resolve();
+ if (!entMan.TryGetComponent(_mind, out var mind))
+ return 0f;
+
+ var mindSystem = entMan.System();
+ return mindSystem.IsCharacterDeadIc(mind) ? 0f : 1f;
+ }
+ }
+
+ public bool Equals(IObjectiveCondition? other)
+ {
+ return other is SurviveCondition condition && Equals(_mind, condition._mind);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(null, obj)) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ if (obj.GetType() != GetType()) return false;
+ return Equals((SurviveCondition) obj);
+ }
+
+ public override int GetHashCode()
+ {
+ return (_mind != null ? _mind.GetHashCode() : 0);
+ }
+}
diff --git a/Content.Server/Objectives/Conditions/TerrorCondition.cs b/Content.Server/Objectives/Conditions/TerrorCondition.cs
new file mode 100644
index 0000000000..28cce20aa2
--- /dev/null
+++ b/Content.Server/Objectives/Conditions/TerrorCondition.cs
@@ -0,0 +1,57 @@
+using Content.Server.Roles;
+using Content.Shared.Mind;
+using Content.Shared.Objectives.Interfaces;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Objectives.Conditions;
+
+///
+/// Objective condition that requires the player to be a ninja and have called in a threat.
+///
+[DataDefinition]
+public sealed partial class TerrorCondition : IObjectiveCondition
+{
+ private EntityUid? _mind;
+
+ public IObjectiveCondition GetAssigned(EntityUid uid, MindComponent mind)
+ {
+ return new TerrorCondition {_mind = uid};
+ }
+
+ public string Title => Loc.GetString("objective-condition-terror-title");
+
+ public string Description => Loc.GetString("objective-condition-terror-description");
+
+ public SpriteSpecifier Icon => new SpriteSpecifier.Rsi(new ResPath("Objects/Fun/Instruments/otherinstruments.rsi"), "red_phone");
+
+ public float Progress
+ {
+ get
+ {
+ var entMan = IoCManager.Resolve();
+ if (!entMan.TryGetComponent(_mind, out var role))
+ return 0f;
+
+ return role.CalledInThreat ? 1f : 0f;
+ }
+ }
+
+ public float Difficulty => 2.75f;
+
+ public bool Equals(IObjectiveCondition? other)
+ {
+ return other is TerrorCondition cond && Equals(_mind, cond._mind);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(null, obj)) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ return obj is TerrorCondition cond && cond.Equals(this);
+ }
+
+ public override int GetHashCode()
+ {
+ return _mind?.GetHashCode() ?? 0;
+ }
+}
diff --git a/Content.Server/Objectives/Requirements/NinjaRequirement.cs b/Content.Server/Objectives/Requirements/NinjaRequirement.cs
new file mode 100644
index 0000000000..8a0993bed8
--- /dev/null
+++ b/Content.Server/Objectives/Requirements/NinjaRequirement.cs
@@ -0,0 +1,18 @@
+using Content.Server.Roles;
+using Content.Shared.Mind;
+using Content.Shared.Objectives.Interfaces;
+
+namespace Content.Server.Objectives.Requirements;
+
+///
+/// Requires the player's mind to have the ninja role component, aka be a ninja.
+///
+[DataDefinition]
+public sealed partial class NinjaRequirement : IObjectiveRequirement
+{
+ public bool CanBeAssigned(EntityUid mindId, MindComponent mind)
+ {
+ var entMan = IoCManager.Resolve();
+ return entMan.HasComponent(mindId);
+ }
+}
diff --git a/Content.Server/Objectives/Requirements/SpiderChargeTargetRequirement.cs b/Content.Server/Objectives/Requirements/SpiderChargeTargetRequirement.cs
new file mode 100644
index 0000000000..6bb6bbb7a8
--- /dev/null
+++ b/Content.Server/Objectives/Requirements/SpiderChargeTargetRequirement.cs
@@ -0,0 +1,19 @@
+using Content.Server.Roles;
+using Content.Shared.Mind;
+using Content.Shared.Objectives.Interfaces;
+
+namespace Content.Server.Objectives.Requirements;
+
+///
+/// Requires the player to be a ninja that has a spider charge target assigned, which is almost always the case.
+///
+[DataDefinition]
+public sealed partial class SpiderChargeTargetRequirement : IObjectiveRequirement
+{
+ public bool CanBeAssigned(EntityUid mindId, MindComponent mind)
+ {
+ var entMan = IoCManager.Resolve();
+ entMan.TryGetComponent(mindId, out var role);
+ return role?.SpiderChargeTarget != null;
+ }
+}
diff --git a/Content.Server/Power/EntitySystems/BatterySystem.cs b/Content.Server/Power/EntitySystems/BatterySystem.cs
index 410aa30bba..c844988b06 100644
--- a/Content.Server/Power/EntitySystems/BatterySystem.cs
+++ b/Content.Server/Power/EntitySystems/BatterySystem.cs
@@ -156,5 +156,16 @@ namespace Content.Server.Power.EntitySystems
UseCharge(uid, value, battery);
return true;
}
+
+ ///
+ /// Returns whether the battery is at least 99% charged, basically full.
+ ///
+ public bool IsFull(EntityUid uid, BatteryComponent? battery = null)
+ {
+ if (!Resolve(uid, ref battery))
+ return false;
+
+ return battery.CurrentCharge / battery.MaxCharge >= 0.99f;
+ }
}
}
diff --git a/Content.Server/PowerCell/PowerCellSystem.cs b/Content.Server/PowerCell/PowerCellSystem.cs
index 6633e43d42..1cfb4d1d70 100644
--- a/Content.Server/PowerCell/PowerCellSystem.cs
+++ b/Content.Server/PowerCell/PowerCellSystem.cs
@@ -1,6 +1,7 @@
using Content.Server.Administration.Logs;
using Content.Server.Chemistry.EntitySystems;
using Content.Server.Explosion.EntitySystems;
+using Content.Server.Emp;
using Content.Server.Power.Components;
using Content.Shared.Database;
using Content.Shared.Examine;
@@ -38,6 +39,7 @@ public sealed partial class PowerCellSystem : SharedPowerCellSystem
SubscribeLocalEvent(OnChargeChanged);
SubscribeLocalEvent(OnCellExamined);
+ SubscribeLocalEvent(OnCellEmpAttempt);
SubscribeLocalEvent(OnUnpaused);
SubscribeLocalEvent(OnDrawChargeChanged);
@@ -233,6 +235,14 @@ public sealed partial class PowerCellSystem : SharedPowerCellSystem
OnBatteryExamined(uid, battery, args);
}
+ private void OnCellEmpAttempt(EntityUid uid, PowerCellComponent component, EmpAttemptEvent args)
+ {
+ var parent = Transform(uid).ParentUid;
+ // relay the attempt event to the slot so it can cancel it
+ if (HasComp(parent))
+ RaiseLocalEvent(parent, args);
+ }
+
private void OnCellSlotExamined(EntityUid uid, PowerCellSlotComponent component, ExaminedEvent args)
{
TryGetBatteryFromSlot(uid, out var battery);
diff --git a/Content.Server/Research/Systems/ResearchStealerSystem.cs b/Content.Server/Research/Systems/ResearchStealerSystem.cs
new file mode 100644
index 0000000000..5bab6048de
--- /dev/null
+++ b/Content.Server/Research/Systems/ResearchStealerSystem.cs
@@ -0,0 +1,39 @@
+using Content.Shared.Research.Components;
+using Content.Shared.Research.Systems;
+
+namespace Content.Server.Research.Systems;
+
+public sealed class ResearchStealerSystem : SharedResearchStealerSystem
+{
+ [Dependency] private readonly SharedResearchSystem _research = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnDoAfter);
+ }
+
+ private void OnDoAfter(EntityUid uid, ResearchStealerComponent comp, ResearchStealDoAfterEvent args)
+ {
+ if (args.Cancelled || args.Handled || args.Target == null)
+ return;
+
+ var target = args.Target.Value;
+
+ if (!TryComp(target, out var database))
+ return;
+
+ var ev = new ResearchStolenEvent(uid, target, database.UnlockedTechnologies);
+ RaiseLocalEvent(uid, ref ev);
+ // oops, no more advanced lasers!
+ _research.ClearTechs(target, database);
+ }
+}
+
+///
+/// Event raised on the user when research is stolen from a R&D server.
+/// Techs contains every technology id researched.
+///
+[ByRefEvent]
+public record struct ResearchStolenEvent(EntityUid Used, EntityUid Target, List Techs);
diff --git a/Content.Server/Roles/NinjaRoleComponent.cs b/Content.Server/Roles/NinjaRoleComponent.cs
new file mode 100644
index 0000000000..aa9e1cfa32
--- /dev/null
+++ b/Content.Server/Roles/NinjaRoleComponent.cs
@@ -0,0 +1,40 @@
+using Content.Shared.Roles;
+
+namespace Content.Server.Roles;
+
+///
+/// Stores the ninja's objectives on the mind so if they die the rest of the greentext persists.
+///
+[RegisterComponent]
+public sealed partial class NinjaRoleComponent : AntagonistRoleComponent
+{
+ ///
+ /// Number of doors that have been doorjacked, used for objective
+ ///
+ [DataField("doorsJacked")]
+ public int DoorsJacked;
+
+ ///
+ /// Research nodes that have been downloaded, used for objective
+ ///
+ [DataField("downloadedNodes")]
+ public HashSet DownloadedNodes = new();
+
+ ///
+ /// Warp point that the spider charge has to target
+ ///
+ [DataField("spiderChargeTarget")]
+ public EntityUid? SpiderChargeTarget;
+
+ ///
+ /// Whether the spider charge has been detonated on the target, used for objective
+ ///
+ [DataField("spiderChargeDetonated")]
+ public bool SpiderChargeDetonated;
+
+ ///
+ /// Whether the comms console has been hacked, used for objective
+ ///
+ [DataField("calledInThreat")]
+ public bool CalledInThreat;
+}
diff --git a/Content.Server/Roles/RoleSystem.cs b/Content.Server/Roles/RoleSystem.cs
index 12962b2b50..4ca6c0ac80 100644
--- a/Content.Server/Roles/RoleSystem.cs
+++ b/Content.Server/Roles/RoleSystem.cs
@@ -10,6 +10,7 @@ public sealed class RoleSystem : SharedRoleSystem
base.Initialize();
SubscribeAntagEvents();
+ SubscribeAntagEvents();
SubscribeAntagEvents();
SubscribeAntagEvents();
SubscribeAntagEvents();
diff --git a/Content.Server/StationEvents/Components/NinjaSpawnRuleComponent.cs b/Content.Server/StationEvents/Components/NinjaSpawnRuleComponent.cs
new file mode 100644
index 0000000000..d758247eca
--- /dev/null
+++ b/Content.Server/StationEvents/Components/NinjaSpawnRuleComponent.cs
@@ -0,0 +1,16 @@
+using Content.Server.StationEvents.Events;
+
+namespace Content.Server.StationEvents.Components;
+
+///
+/// Configuration component for the Space Ninja antag.
+///
+[RegisterComponent, Access(typeof(NinjaSpawnRule))]
+public sealed partial class NinjaSpawnRuleComponent : Component
+{
+ ///
+ /// Distance that the ninja spawns from the station's half AABB radius
+ ///
+ [DataField("spawnDistance")]
+ public float SpawnDistance = 20f;
+}
diff --git a/Content.Server/StationEvents/Events/NinjaSpawnRule.cs b/Content.Server/StationEvents/Events/NinjaSpawnRule.cs
new file mode 100644
index 0000000000..c60f3298e7
--- /dev/null
+++ b/Content.Server/StationEvents/Events/NinjaSpawnRule.cs
@@ -0,0 +1,51 @@
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Ninja.Systems;
+using Content.Server.Station.Components;
+using Content.Server.StationEvents.Components;
+using Robust.Server.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Random;
+
+namespace Content.Server.StationEvents.Events;
+
+///
+/// Event for spawning a Space Ninja mid-game.
+///
+public sealed class NinjaSpawnRule : StationEventSystem
+{
+ [Dependency] private readonly SpaceNinjaSystem _ninja = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+ protected override void Started(EntityUid uid, NinjaSpawnRuleComponent comp, GameRuleComponent gameRule, GameRuleStartedEvent args)
+ {
+ base.Started(uid, comp, gameRule, args);
+
+ if (!TryGetRandomStation(out var station))
+ return;
+
+ var stationData = Comp(station.Value);
+
+ // find a station grid
+ var gridUid = StationSystem.GetLargestGrid(stationData);
+ if (gridUid == null || !TryComp(gridUid, out var grid))
+ {
+ Sawmill.Warning("Chosen station has no grids, cannot spawn space ninja!");
+ return;
+ }
+
+ // figure out its AABB size and use that as a guide to how far ninja should be
+ var size = grid.LocalAABB.Size.Length() / 2;
+ var distance = size + comp.SpawnDistance;
+ var angle = RobustRandom.NextAngle();
+ // position relative to station center
+ var location = angle.ToVec() * distance;
+
+ // create the spawner, the ninja will appear when a ghost has picked the role
+ var xform = Transform(gridUid.Value);
+ var position = _transform.GetWorldPosition(xform) + location;
+ var coords = new MapCoordinates(position, xform.MapID);
+ Sawmill.Info($"Creating ninja spawnpoint at {coords}");
+ Spawn("SpawnPointGhostSpaceNinja", coords);
+ }
+}
diff --git a/Content.Shared/Alert/AlertType.cs b/Content.Shared/Alert/AlertType.cs
index 7f74612010..8ba35ae282 100644
--- a/Content.Shared/Alert/AlertType.cs
+++ b/Content.Shared/Alert/AlertType.cs
@@ -47,7 +47,8 @@ namespace Content.Shared.Alert
Debug3,
Debug4,
Debug5,
- Debug6
+ Debug6,
+ SuitPower
}
}
diff --git a/Content.Shared/Communications/CommsHackerComponent.cs b/Content.Shared/Communications/CommsHackerComponent.cs
new file mode 100644
index 0000000000..9116899cca
--- /dev/null
+++ b/Content.Shared/Communications/CommsHackerComponent.cs
@@ -0,0 +1,47 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Shared.Communications;
+
+///
+/// Component for hacking a communications console to call in a threat.
+/// Can only be done once, the component is remove afterwards.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedCommsHackerSystem))]
+public sealed partial class CommsHackerComponent : Component
+{
+ ///
+ /// Time taken to hack the console
+ ///
+ [DataField("delay")]
+ public TimeSpan Delay = TimeSpan.FromSeconds(20);
+
+ ///
+ /// Possible threats to choose from.
+ ///
+ [DataField("threats", required: true)]
+ public List Threats = new();
+}
+
+///
+/// A threat that can be called in to the station by a ninja hacking a communications console.
+/// Generally some kind of mid-round minor antag, though you could make it call in scrubber backflow if you wanted to.
+/// You wouldn't do that, right?
+///
+[DataDefinition]
+public sealed partial class Threat
+{
+ ///
+ /// Locale id for the announcement to be made from CentCom.
+ ///
+ [DataField("announcement")]
+ public string Announcement = default!;
+
+ ///
+ /// The game rule for the threat to be added, it should be able to work when added mid-round otherwise this will do nothing.
+ ///
+ [DataField("rule", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string Rule = default!;
+}
diff --git a/Content.Shared/Communications/SharedCommsHackerSystem.cs b/Content.Shared/Communications/SharedCommsHackerSystem.cs
new file mode 100644
index 0000000000..94c530878a
--- /dev/null
+++ b/Content.Shared/Communications/SharedCommsHackerSystem.cs
@@ -0,0 +1,28 @@
+using Content.Shared.DoAfter;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Communications;
+
+///
+/// Only exists in shared to provide API and for access.
+/// All logic is serverside.
+///
+public abstract class SharedCommsHackerSystem : EntitySystem
+{
+ ///
+ /// Set the list of threats to choose from when hacking a comms console.
+ ///
+ public void SetThreats(EntityUid uid, List threats, CommsHackerComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp))
+ return;
+
+ comp.Threats = threats;
+ }
+}
+
+///
+/// DoAfter event for comms console terror ability.
+///
+[Serializable, NetSerializable]
+public sealed partial class TerrorDoAfterEvent : SimpleDoAfterEvent { }
diff --git a/Content.Shared/Containers/ItemSlot/ItemSlotsComponent.cs b/Content.Shared/Containers/ItemSlot/ItemSlotsComponent.cs
index a895311fb2..effba18210 100644
--- a/Content.Shared/Containers/ItemSlot/ItemSlotsComponent.cs
+++ b/Content.Shared/Containers/ItemSlot/ItemSlotsComponent.cs
@@ -112,6 +112,15 @@ namespace Content.Shared.Containers.ItemSlots
[ViewVariables(VVAccess.ReadWrite)]
public bool Locked = false;
+ ///
+ /// Prevents adding the eject alt-verb, but still lets you swap items.
+ ///
+ ///
+ /// This does not affect EjectOnInteract, since if you do that you probably want ejecting to work.
+ ///
+ [DataField("disableEject"), ViewVariables(VVAccess.ReadWrite)]
+ public bool DisableEject = false;
+
///
/// Whether the item slots system will attempt to insert item from the user's hands into this slot when interacted with.
/// It doesn't block other insertion methods, like verbs.
diff --git a/Content.Shared/Containers/ItemSlot/ItemSlotsSystem.cs b/Content.Shared/Containers/ItemSlot/ItemSlotsSystem.cs
index f69e8d4d2f..594479d0df 100644
--- a/Content.Shared/Containers/ItemSlot/ItemSlotsSystem.cs
+++ b/Content.Shared/Containers/ItemSlot/ItemSlotsSystem.cs
@@ -477,7 +477,7 @@ namespace Content.Shared.Containers.ItemSlots
// Add the eject-item verbs
foreach (var slot in itemSlots.Slots.Values)
{
- if (slot.EjectOnInteract)
+ if (slot.EjectOnInteract || slot.DisableEject)
// For this item slot, ejecting/inserting is a primary interaction. Instead of an eject category
// alt-click verb, there will be a "Take item" primary interaction verb.
continue;
diff --git a/Content.Shared/Electrocution/SharedElectrocutionSystem.cs b/Content.Shared/Electrocution/SharedElectrocutionSystem.cs
index d7848c073b..67a395bb81 100644
--- a/Content.Shared/Electrocution/SharedElectrocutionSystem.cs
+++ b/Content.Shared/Electrocution/SharedElectrocutionSystem.cs
@@ -1,4 +1,5 @@
using Content.Shared.Inventory;
+using Content.Shared.StatusEffect;
using Robust.Shared.GameStates;
namespace Content.Shared.Electrocution
@@ -25,6 +26,23 @@ namespace Content.Shared.Electrocution
Dirty(insulated);
}
+ /// Entity being electrocuted.
+ /// Source entity of the electrocution.
+ /// How much shock damage the entity takes.
+ /// How long the entity will be stunned.
+ /// Should time be refreshed (instead of accumilated) if the entity is already electrocuted?
+ /// How insulated the entity is from the shock. 0 means completely insulated, and 1 means no insulation.
+ /// Status effects to apply to the entity.
+ /// Should the electrocution bypass the Insulated component?
+ /// Whether the entity was stunned by the shock.
+ public virtual bool TryDoElectrocution(
+ EntityUid uid, EntityUid? sourceUid, int shockDamage, TimeSpan time, bool refresh, float siemensCoefficient = 1f,
+ StatusEffectsComponent? statusEffects = null, bool ignoreInsulation = false)
+ {
+ // only done serverside
+ return false;
+ }
+
private void OnInsulatedElectrocutionAttempt(EntityUid uid, InsulatedComponent insulated, ElectrocutionAttemptEvent args)
{
args.SiemensCoefficient *= insulated.SiemensCoefficient;
diff --git a/Content.Shared/Emag/Systems/EmagSystem.cs b/Content.Shared/Emag/Systems/EmagSystem.cs
index 7d30438155..ebbd4c02ac 100644
--- a/Content.Shared/Emag/Systems/EmagSystem.cs
+++ b/Content.Shared/Emag/Systems/EmagSystem.cs
@@ -7,8 +7,6 @@ using Content.Shared.IdentityManagement;
using Content.Shared.Interaction;
using Content.Shared.Popups;
using Content.Shared.Tag;
-using Robust.Shared.Network;
-using Robust.Shared.Timing;
namespace Content.Shared.Emag.Systems;
@@ -22,10 +20,8 @@ public sealed class EmagSystem : EntitySystem
{
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly SharedChargesSystem _charges = default!;
- [Dependency] private readonly INetManager _net = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly TagSystem _tag = default!;
- [Dependency] private readonly IGameTiming _timing = default!;
public override void Initialize()
{
@@ -56,8 +52,7 @@ public sealed class EmagSystem : EntitySystem
TryComp(uid, out var charges);
if (_charges.IsEmpty(uid, charges))
{
- if (_net.IsClient && _timing.IsFirstTimePredicted)
- _popup.PopupEntity(Loc.GetString("emag-no-charges"), user, user);
+ _popup.PopupClient(Loc.GetString("emag-no-charges"), user, user);
return false;
}
@@ -65,12 +60,8 @@ public sealed class EmagSystem : EntitySystem
if (!handled)
return false;
- // only do popup on client
- if (_net.IsClient && _timing.IsFirstTimePredicted)
- {
- _popup.PopupEntity(Loc.GetString("emag-success", ("target", Identity.Entity(target, EntityManager))), user,
- user, PopupType.Medium);
- }
+ _popup.PopupClient(Loc.GetString("emag-success", ("target", Identity.Entity(target, EntityManager))), user,
+ user, PopupType.Medium);
_adminLogger.Add(LogType.Emag, LogImpact.High, $"{ToPrettyString(user):player} emagged {ToPrettyString(target):target}");
diff --git a/Content.Shared/Implants/SharedSubdermalImplantSystem.cs b/Content.Shared/Implants/SharedSubdermalImplantSystem.cs
index 15780138f9..6d43c3dea1 100644
--- a/Content.Shared/Implants/SharedSubdermalImplantSystem.cs
+++ b/Content.Shared/Implants/SharedSubdermalImplantSystem.cs
@@ -85,6 +85,28 @@ public abstract class SharedSubdermalImplantSystem : EntitySystem
}
}
+ ///
+ /// Add a list of implants to a person.
+ /// Logs any implant ids that don't have .
+ ///
+ public void AddImplants(EntityUid uid, IEnumerable implants)
+ {
+ var coords = Transform(uid).Coordinates;
+ foreach (var id in implants)
+ {
+ var ent = Spawn(id, coords);
+ if (TryComp(ent, out var implant))
+ {
+ ForceImplant(uid, ent, implant);
+ }
+ else
+ {
+ Log.Warning($"Found invalid starting implant '{id}' on {uid} {ToPrettyString(uid):implanted}");
+ Del(ent);
+ }
+ }
+ }
+
///
/// Forces an implant into a person
/// Good for on spawn related code or admin additions
diff --git a/Content.Shared/Interaction/InteractHand.cs b/Content.Shared/Interaction/InteractHand.cs
index 32e250b0f1..63ea3b6f30 100644
--- a/Content.Shared/Interaction/InteractHand.cs
+++ b/Content.Shared/Interaction/InteractHand.cs
@@ -38,6 +38,20 @@ namespace Content.Shared.Interaction
}
}
+ ///
+ /// Raised on the user before interacting on an entity with bare hand.
+ /// Interaction is cancelled if this event is handled, so set it to true if you do custom interaction logic.
+ ///
+ public sealed class BeforeInteractHandEvent : HandledEntityEventArgs
+ {
+ public EntityUid Target { get; }
+
+ public BeforeInteractHandEvent(EntityUid target)
+ {
+ Target = target;
+ }
+ }
+
///
/// Low-level interaction event used for entities without hands.
///
diff --git a/Content.Shared/Interaction/SharedInteractionSystem.cs b/Content.Shared/Interaction/SharedInteractionSystem.cs
index f00d9c8c38..bf276dff0b 100644
--- a/Content.Shared/Interaction/SharedInteractionSystem.cs
+++ b/Content.Shared/Interaction/SharedInteractionSystem.cs
@@ -380,6 +380,15 @@ namespace Content.Shared.Interaction
public void InteractHand(EntityUid user, EntityUid target)
{
+ // allow for special logic before main interaction
+ var ev = new BeforeInteractHandEvent(target);
+ RaiseLocalEvent(user, ev);
+ if (ev.Handled)
+ {
+ _adminLogger.Add(LogType.InteractHand, LogImpact.Low, $"{ToPrettyString(user):user} interacted with {ToPrettyString(target):target}, but it was handled by another system");
+ return;
+ }
+
// all interactions should only happen when in range / unobstructed, so no range check is needed
var message = new InteractHandEvent(user, target);
RaiseLocalEvent(target, message, true);
diff --git a/Content.Shared/Mind/SharedMindSystem.cs b/Content.Shared/Mind/SharedMindSystem.cs
index 7bada2303f..fc6cb8d570 100644
--- a/Content.Shared/Mind/SharedMindSystem.cs
+++ b/Content.Shared/Mind/SharedMindSystem.cs
@@ -12,14 +12,16 @@ using Content.Shared.Players;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Players;
+using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Shared.Mind;
public abstract class SharedMindSystem : EntitySystem
{
- [Dependency] private readonly MobStateSystem _mobStateSystem = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly MobStateSystem _mobStateSystem = default!;
[Dependency] private readonly SharedPlayerSystem _playerSystem = default!;
// This is dictionary is required to track the minds of disconnected players that may have had their entity deleted.
@@ -268,6 +270,23 @@ public abstract class SharedMindSystem : EntitySystem
return true;
}
+ ///
+ /// Adds an objective, by id, to this mind.
+ ///
+ public bool TryAddObjective(EntityUid mindId, string name, MindComponent? mind = null)
+ {
+ if (!Resolve(mindId, ref mind))
+ return false;
+
+ if (!_proto.TryIndex(name, out var objective))
+ {
+ Log.Error($"Tried to add unknown objective prototype: {name}");
+ return false;
+ }
+
+ return TryAddObjective(mindId, mind, objective);
+ }
+
///
/// Removes an objective to this mind.
///
@@ -340,6 +359,19 @@ public abstract class SharedMindSystem : EntitySystem
return _playerSystem.ContentData(player) is { } data && TryGetMind(data, out mindId, out mind);
}
+ ///
+ /// Gets a role component from a player's mind.
+ ///
+ /// Whether a role was found
+ public bool TryGetRole(EntityUid user, [NotNullWhen(true)] out T? role) where T : Component
+ {
+ role = null;
+ if (!TryComp(user, out var mindContainer) || mindContainer.Mind == null)
+ return false;
+
+ return TryComp(mindContainer.Mind, out role);
+ }
+
///
/// Sets the Mind's OwnedComponent and OwnedEntity
///
diff --git a/Content.Shared/Ninja/Components/BatteryDrainerComponent.cs b/Content.Shared/Ninja/Components/BatteryDrainerComponent.cs
new file mode 100644
index 0000000000..55bcdd0f0a
--- /dev/null
+++ b/Content.Shared/Ninja/Components/BatteryDrainerComponent.cs
@@ -0,0 +1,38 @@
+using Content.Shared.Ninja.Systems;
+using Robust.Shared.Audio;
+
+namespace Content.Shared.Ninja.Components;
+
+///
+/// Component for draining power from APCs/substations/SMESes, when ProviderUid is set to a battery cell.
+/// Does not rely on relay, simply being on the user and having BatteryUid set is enough.
+///
+[RegisterComponent, Access(typeof(SharedBatteryDrainerSystem))]
+public sealed partial class BatteryDrainerComponent : Component
+{
+ ///
+ /// The powercell entity to drain power into.
+ /// Determines whether draining is possible.
+ ///
+ [DataField("batteryUid"), ViewVariables(VVAccess.ReadWrite)]
+ public EntityUid? BatteryUid;
+
+ ///
+ /// Conversion rate between joules in a device and joules added to battery.
+ /// Should be very low since powercells store nothing compared to even an APC.
+ ///
+ [DataField("drainEfficiency"), ViewVariables(VVAccess.ReadWrite)]
+ public float DrainEfficiency = 0.001f;
+
+ ///
+ /// Time that the do after takes to drain charge from a battery, in seconds
+ ///
+ [DataField("drainTime"), ViewVariables(VVAccess.ReadWrite)]
+ public float DrainTime = 1f;
+
+ ///
+ /// Sound played after the doafter ends.
+ ///
+ [DataField("sparkSound")]
+ public SoundSpecifier SparkSound = new SoundCollectionSpecifier("sparks");
+}
diff --git a/Content.Shared/Ninja/Components/DashAbilityComponent.cs b/Content.Shared/Ninja/Components/DashAbilityComponent.cs
new file mode 100644
index 0000000000..85e0963af1
--- /dev/null
+++ b/Content.Shared/Ninja/Components/DashAbilityComponent.cs
@@ -0,0 +1,33 @@
+using Content.Shared.Actions;
+using Content.Shared.Ninja.Systems;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+///
+/// Adds an action to dash, teleport to clicked position, when this item is held.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(DashAbilitySystem))]
+public sealed partial class DashAbilityComponent : Component
+{
+ ///
+ /// The action id for dashing.
+ ///
+ [DataField("dashAction", required: true, customTypeSerializer: typeof(PrototypeIdSerializer)), ViewVariables(VVAccess.ReadWrite)]
+ public string DashAction = string.Empty;
+
+ [DataField("dashActionEntity")]
+ public EntityUid? DashActionEntity;
+
+ ///
+ /// Sound played when using dash action.
+ ///
+ [DataField("blinkSound"), ViewVariables(VVAccess.ReadWrite)]
+ public SoundSpecifier BlinkSound = new SoundPathSpecifier("/Audio/Magic/blink.ogg")
+ {
+ Params = AudioParams.Default.WithVolume(5f)
+ };
+}
+
+public sealed partial class DashEvent : WorldTargetActionEvent { }
diff --git a/Content.Shared/Ninja/Components/EmagProviderComponent.cs b/Content.Shared/Ninja/Components/EmagProviderComponent.cs
new file mode 100644
index 0000000000..db7678f61d
--- /dev/null
+++ b/Content.Shared/Ninja/Components/EmagProviderComponent.cs
@@ -0,0 +1,28 @@
+using Content.Shared.Ninja.Systems;
+using Content.Shared.Tag;
+using Content.Shared.Whitelist;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Shared.Ninja.Components;
+
+///
+/// Component for emagging things on click.
+/// No charges but checks against a whitelist.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(EmagProviderSystem))]
+public sealed partial class EmagProviderComponent : Component
+{
+ ///
+ /// The tag that marks an entity as immune to emagging.
+ ///
+ [DataField("emagImmuneTag", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string EmagImmuneTag = "EmagImmune";
+
+ ///
+ /// Whitelist that entities must be on to work.
+ ///
+ [DataField("whitelist"), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField]
+ public EntityWhitelist? Whitelist = null;
+}
diff --git a/Content.Shared/Ninja/Components/EnergyKatanaComponent.cs b/Content.Shared/Ninja/Components/EnergyKatanaComponent.cs
new file mode 100644
index 0000000000..33b8fc7893
--- /dev/null
+++ b/Content.Shared/Ninja/Components/EnergyKatanaComponent.cs
@@ -0,0 +1,12 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Ninja.Components;
+
+///
+/// Component for a Space Ninja's katana, controls ninja related dash logic.
+/// Requires a ninja with a suit for abilities to work.
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class EnergyKatanaComponent : Component
+{
+}
diff --git a/Content.Shared/Ninja/Components/NinjaGlovesComponent.cs b/Content.Shared/Ninja/Components/NinjaGlovesComponent.cs
new file mode 100644
index 0000000000..b104312b20
--- /dev/null
+++ b/Content.Shared/Ninja/Components/NinjaGlovesComponent.cs
@@ -0,0 +1,45 @@
+using Content.Shared.DoAfter;
+using Content.Shared.Ninja.Systems;
+using Content.Shared.Toggleable;
+using Content.Shared.Whitelist;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Ninja.Components;
+
+///
+/// Component for toggling glove powers.
+/// Powers being enabled is controlled by User not being null.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(SharedNinjaGlovesSystem))]
+public sealed partial class NinjaGlovesComponent : Component
+{
+ ///
+ /// Entity of the ninja using these gloves, usually means enabled
+ ///
+ [DataField("user"), AutoNetworkedField]
+ public EntityUid? User;
+
+ ///
+ /// The action id for toggling ninja gloves abilities
+ ///
+ [DataField("toggleAction", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string ToggleAction = "ActionToggleNinjaGloves";
+
+ [DataField("toggleActionEntity")]
+ public EntityUid? ToggleActionEntity;
+
+ ///
+ /// The whitelist used for the emag provider to emag airlocks only (not regular doors).
+ ///
+ [DataField("doorjackWhitelist")]
+ public EntityWhitelist DoorjackWhitelist = new()
+ {
+ Components = new[] {"Airlock"}
+ };
+}
diff --git a/Content.Shared/Ninja/Components/NinjaSuitComponent.cs b/Content.Shared/Ninja/Components/NinjaSuitComponent.cs
new file mode 100644
index 0000000000..816cc9d731
--- /dev/null
+++ b/Content.Shared/Ninja/Components/NinjaSuitComponent.cs
@@ -0,0 +1,125 @@
+using Content.Shared.Actions;
+using Content.Shared.Ninja.Systems;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Ninja.Components;
+
+///
+/// Component for ninja suit abilities and power consumption.
+/// As an implementation detail, dashing with katana is a suit action which isn't ideal.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedNinjaSuitSystem))]
+public sealed partial class NinjaSuitComponent : Component
+{
+ ///
+ /// Battery charge used passively, in watts. Will last 1000 seconds on a small-capacity power cell.
+ ///
+ [DataField("passiveWattage")]
+ public float PassiveWattage = 0.36f;
+
+ ///
+ /// Battery charge used while cloaked, stacks with passive. Will last 200 seconds while cloaked on a small-capacity power cell.
+ ///
+ [DataField("cloakWattage")]
+ public float CloakWattage = 1.44f;
+
+ ///
+ /// Sound played when a ninja is hit while cloaked.
+ ///
+ [DataField("revealSound")]
+ public SoundSpecifier RevealSound = new SoundPathSpecifier("/Audio/Effects/chime.ogg");
+
+ ///
+ /// How long to disable all abilities for when revealed.
+ /// This adds a UseDelay to the ninja so it should not be set by anything else.
+ ///
+ [DataField("disableTime")]
+ public TimeSpan DisableTime = TimeSpan.FromSeconds(5);
+
+ ///
+ /// The action id for creating throwing stars.
+ ///
+ [DataField("createThrowingStarAction", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string CreateThrowingStarAction = "ActionCreateThrowingStar";
+
+ [DataField("createThrowingStarActionEntity")]
+ public EntityUid? CreateThrowingStarActionEntity;
+
+ ///
+ /// Battery charge used to create a throwing star. Can do it 25 times on a small-capacity power cell.
+ ///
+ [DataField("throwingStarCharge")]
+ public float ThrowingStarCharge = 14.4f;
+
+ ///
+ /// Throwing star item to create with the action
+ ///
+ [DataField("throwingStarPrototype", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string ThrowingStarPrototype = "ThrowingStarNinja";
+
+ ///
+ /// The action id for recalling a bound energy katana
+ ///
+ [DataField("recallKatanaAction", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string RecallKatanaAction = "ActionRecallKatana";
+
+ [DataField("recallKatanaActionEntity")]
+ public EntityUid? RecallKatanaActionEntity;
+
+ ///
+ /// Battery charge used per tile the katana teleported.
+ /// Uses 1% of a default battery per tile.
+ ///
+ [DataField("recallCharge")]
+ public float RecallCharge = 3.6f;
+
+ ///
+ /// The action id for creating an EMP burst
+ ///
+ [DataField("empAction", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string EmpAction = "ActionNinjaEmp";
+
+ [DataField("empActionEntity")]
+ public EntityUid? EmpActionEntity;
+
+ ///
+ /// Battery charge used to create an EMP burst. Can do it 2 times on a small-capacity power cell.
+ ///
+ [DataField("empCharge")]
+ public float EmpCharge = 180f;
+
+ ///
+ /// Range of the EMP in tiles.
+ ///
+ [DataField("empRange")]
+ public float EmpRange = 6f;
+
+ ///
+ /// Power consumed from batteries by the EMP
+ ///
+ [DataField("empConsumption")]
+ public float EmpConsumption = 100000f;
+
+ ///
+ /// How long the EMP effects last for, in seconds
+ ///
+ [DataField("empDuration")]
+ public float EmpDuration = 60f;
+}
+
+public sealed partial class CreateThrowingStarEvent : InstantActionEvent
+{
+}
+
+public sealed partial class RecallKatanaEvent : InstantActionEvent
+{
+}
+
+public sealed partial class NinjaEmpEvent : InstantActionEvent
+{
+}
diff --git a/Content.Shared/Ninja/Components/SpaceNinjaComponent.cs b/Content.Shared/Ninja/Components/SpaceNinjaComponent.cs
new file mode 100644
index 0000000000..dff4b56aa4
--- /dev/null
+++ b/Content.Shared/Ninja/Components/SpaceNinjaComponent.cs
@@ -0,0 +1,38 @@
+using Content.Shared.Ninja.Systems;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Ninja.Components;
+
+///
+/// Component placed on a mob to make it a space ninja, able to use suit and glove powers.
+/// Contains ids of all ninja equipment and the game rule.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(SharedSpaceNinjaSystem))]
+public sealed partial class SpaceNinjaComponent : Component
+{
+ ///
+ /// The ninja game rule that spawned this ninja.
+ ///
+ [DataField("rule")]
+ public EntityUid? Rule;
+
+ ///
+ /// Currently worn suit
+ ///
+ [DataField("suit"), AutoNetworkedField]
+ public EntityUid? Suit;
+
+ ///
+ /// Currently worn gloves
+ ///
+ [DataField("gloves"), AutoNetworkedField]
+ public EntityUid? Gloves;
+
+ ///
+ /// Bound katana, set once picked up and never removed
+ ///
+ [DataField("katana"), AutoNetworkedField]
+ public EntityUid? Katana;
+}
diff --git a/Content.Shared/Ninja/Components/SpiderChargeComponent.cs b/Content.Shared/Ninja/Components/SpiderChargeComponent.cs
new file mode 100644
index 0000000000..dacf47bb23
--- /dev/null
+++ b/Content.Shared/Ninja/Components/SpiderChargeComponent.cs
@@ -0,0 +1,19 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Ninja.Components;
+
+///
+/// Component for the Space Ninja's unique Spider Charge.
+/// Only this component detonating can trigger the ninja's objective.
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class SpiderChargeComponent : Component
+{
+ /// Range for planting within the target area
+ [DataField("range")]
+ public float Range = 10f;
+
+ /// The ninja that planted this charge
+ [DataField("planter")]
+ public EntityUid? Planter = null;
+}
diff --git a/Content.Shared/Ninja/Components/StunProviderComponent.cs b/Content.Shared/Ninja/Components/StunProviderComponent.cs
new file mode 100644
index 0000000000..653b125478
--- /dev/null
+++ b/Content.Shared/Ninja/Components/StunProviderComponent.cs
@@ -0,0 +1,67 @@
+using Content.Shared.Ninja.Systems;
+using Content.Shared.Whitelist;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.Ninja.Components;
+
+///
+/// Component for stunning mobs on click outside of harm mode.
+/// Knocks them down for a bit and deals shock damage.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, Access(typeof(SharedStunProviderSystem))]
+public sealed partial class StunProviderComponent : Component
+{
+ ///
+ /// The powercell entity to take power from.
+ /// Determines whether stunning is possible.
+ ///
+ [DataField("batteryUid"), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField]
+ public EntityUid? BatteryUid;
+
+ ///
+ /// Joules required in the battery to stun someone. Defaults to 10 uses on a small battery.
+ ///
+ [DataField("stunCharge"), ViewVariables(VVAccess.ReadWrite)]
+ public float StunCharge = 36.0f;
+
+ ///
+ /// Shock damage dealt when stunning someone
+ ///
+ [DataField("stunDamage"), ViewVariables(VVAccess.ReadWrite)]
+ public int StunDamage = 5;
+
+ ///
+ /// Time that someone is stunned for, stacks if done multiple times.
+ ///
+ [DataField("stunTime"), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan StunTime = TimeSpan.FromSeconds(3);
+
+ ///
+ /// How long stunning is disabled after stunning something.
+ ///
+ [DataField("cooldown"), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan Cooldown = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Locale string to popup when there is no power
+ ///
+ [DataField("noPowerPopup", required: true), ViewVariables(VVAccess.ReadWrite)]
+ public string NoPowerPopup = string.Empty;
+
+ ///
+ /// Whitelist for what counts as a mob.
+ ///
+ [DataField("whitelist")]
+ public EntityWhitelist Whitelist = new()
+ {
+ Components = new[] {"Stamina"}
+ };
+
+ ///
+ /// When someone can next be stunned.
+ /// Essentially a UseDelay unique to this component.
+ ///
+ [DataField("nextStun", customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan NextStun = TimeSpan.Zero;
+}
diff --git a/Content.Shared/Ninja/Systems/DashAbilitySystem.cs b/Content.Shared/Ninja/Systems/DashAbilitySystem.cs
new file mode 100644
index 0000000000..bd6320de68
--- /dev/null
+++ b/Content.Shared/Ninja/Systems/DashAbilitySystem.cs
@@ -0,0 +1,118 @@
+using Content.Shared.Actions;
+using Content.Shared.Charges.Components;
+using Content.Shared.Charges.Systems;
+using Content.Shared.Hands.EntitySystems;
+using Content.Shared.Interaction;
+using Content.Shared.Ninja.Components;
+using Content.Shared.Physics;
+using Content.Shared.Popups;
+using Robust.Shared.Audio;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Timing;
+
+namespace Content.Shared.Ninja.Systems;
+
+///
+/// Handles dashing logic including charge consumption and checking attempt events.
+///
+public sealed class DashAbilitySystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedChargesSystem _charges = default!;
+ [Dependency] private readonly SharedHandsSystem _hands = default!;
+ [Dependency] private readonly SharedInteractionSystem _interaction = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnGetItemActions);
+ SubscribeLocalEvent(OnDash);
+ }
+
+ private void OnGetItemActions(EntityUid uid, DashAbilityComponent comp, GetItemActionsEvent args)
+ {
+ var ev = new AddDashActionEvent(args.User);
+ RaiseLocalEvent(uid, ev);
+
+ if (ev.Cancelled)
+ return;
+
+ args.AddAction(ref comp.DashActionEntity, comp.DashAction);
+ }
+
+ ///
+ /// Handle charges and teleport to a visible location.
+ ///
+ private void OnDash(EntityUid uid, DashAbilityComponent comp, DashEvent args)
+ {
+ if (!_timing.IsFirstTimePredicted)
+ return;
+
+ var user = args.Performer;
+ args.Handled = true;
+
+ var ev = new DashAttemptEvent(user);
+ RaiseLocalEvent(uid, ev);
+ if (ev.Cancelled)
+ return;
+
+ if (!_hands.IsHolding(user, uid, out var _))
+ {
+ _popup.PopupClient(Loc.GetString("dash-ability-not-held", ("item", uid)), user, user);
+ return;
+ }
+
+ TryComp(uid, out var charges);
+ if (_charges.IsEmpty(uid, charges))
+ {
+ _popup.PopupClient(Loc.GetString("dash-ability-no-charges", ("item", uid)), user, user);
+ return;
+ }
+
+ var origin = Transform(user).MapPosition;
+ var target = args.Target.ToMap(EntityManager, _transform);
+ // prevent collision with the user duh
+ if (!_interaction.InRangeUnobstructed(origin, target, 0f, CollisionGroup.Opaque, uid => uid == user))
+ {
+ // can only dash if the destination is visible on screen
+ _popup.PopupClient(Loc.GetString("dash-ability-cant-see", ("item", uid)), user, user);
+ return;
+ }
+
+ _transform.SetCoordinates(user, args.Target);
+ _transform.AttachToGridOrMap(user);
+ _audio.PlayPredicted(comp.BlinkSound, user, user);
+ if (charges != null)
+ _charges.UseCharge(uid, charges);
+ }
+}
+
+///
+/// Raised on the item before adding the dash action
+///
+public sealed class AddDashActionEvent : CancellableEntityEventArgs
+{
+ public EntityUid User;
+
+ public AddDashActionEvent(EntityUid user)
+ {
+ User = user;
+ }
+}
+
+///
+/// Raised on the item before dashing is done.
+///
+public sealed class DashAttemptEvent : CancellableEntityEventArgs
+{
+ public EntityUid User;
+
+ public DashAttemptEvent(EntityUid user)
+ {
+ User = user;
+ }
+}
diff --git a/Content.Shared/Ninja/Systems/EmagProviderSystem.cs b/Content.Shared/Ninja/Systems/EmagProviderSystem.cs
new file mode 100644
index 0000000000..df9cf8ac82
--- /dev/null
+++ b/Content.Shared/Ninja/Systems/EmagProviderSystem.cs
@@ -0,0 +1,72 @@
+using Content.Shared.Administration.Logs;
+using Content.Shared.Emag.Systems;
+using Content.Shared.Database;
+using Content.Shared.Interaction;
+using Content.Shared.Ninja.Components;
+using Content.Shared.Tag;
+using Content.Shared.Whitelist;
+
+namespace Content.Shared.Ninja.Systems;
+
+///
+/// Handles emagging whitelisted objects when clicked.
+///
+public sealed class EmagProviderSystem : EntitySystem
+{
+ [Dependency] private readonly EmagSystem _emag = default!;
+ [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly SharedNinjaGlovesSystem _gloves = default!;
+ [Dependency] private readonly TagSystem _tags = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnBeforeInteractHand);
+ }
+
+ ///
+ /// Emag clicked entities that are on the whitelist.
+ ///
+ private void OnBeforeInteractHand(EntityUid uid, EmagProviderComponent comp, BeforeInteractHandEvent args)
+ {
+ // TODO: change this into a generic check event thing
+ if (args.Handled || !_gloves.AbilityCheck(uid, args, out var target))
+ return;
+
+ // only allowed to emag entities on the whitelist
+ if (comp.Whitelist != null && !comp.Whitelist.IsValid(target, EntityManager))
+ return;
+
+ // only allowed to emag non-immune entities
+ if (_tags.HasTag(target, comp.EmagImmuneTag))
+ return;
+
+ var handled = _emag.DoEmagEffect(uid, target);
+ if (!handled)
+ return;
+
+ _adminLogger.Add(LogType.Emag, LogImpact.High, $"{ToPrettyString(uid):player} emagged {ToPrettyString(target):target}");
+ var ev = new EmaggedSomethingEvent(target);
+ RaiseLocalEvent(uid, ref ev);
+ args.Handled = true;
+ }
+
+ ///
+ /// Set the whitelist for emagging something outside of yaml.
+ ///
+ public void SetWhitelist(EntityUid uid, EntityWhitelist? whitelist, EmagProviderComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp))
+ return;
+
+ comp.Whitelist = whitelist;
+ Dirty(uid, comp);
+ }
+}
+
+///
+/// Raised on the player when emagging something.
+///
+[ByRefEvent]
+public record struct EmaggedSomethingEvent(EntityUid Target);
diff --git a/Content.Shared/Ninja/Systems/EnergyKatanaSystem.cs b/Content.Shared/Ninja/Systems/EnergyKatanaSystem.cs
new file mode 100644
index 0000000000..d427ffa39b
--- /dev/null
+++ b/Content.Shared/Ninja/Systems/EnergyKatanaSystem.cs
@@ -0,0 +1,47 @@
+using Content.Shared.Inventory.Events;
+using Content.Shared.Ninja.Components;
+
+namespace Content.Shared.Ninja.Systems;
+
+///
+/// System for katana binding and dash events. Recalling is handled by the suit.
+///
+public sealed class EnergyKatanaSystem : EntitySystem
+{
+ [Dependency] private readonly SharedSpaceNinjaSystem _ninja = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnEquipped);
+ SubscribeLocalEvent(OnAddDashAction);
+ SubscribeLocalEvent(OnDashAttempt);
+ }
+
+ ///
+ /// When equipped by a ninja, try to bind it.
+ ///
+ private void OnEquipped(EntityUid uid, EnergyKatanaComponent comp, GotEquippedEvent args)
+ {
+ // check if user isnt a ninja or already has a katana bound
+ var user = args.Equipee;
+ if (!TryComp(user, out var ninja) || ninja.Katana != null)
+ return;
+
+ // bind it since its unbound
+ _ninja.BindKatana(user, uid, ninja);
+ }
+
+ private void OnAddDashAction(EntityUid uid, EnergyKatanaComponent comp, AddDashActionEvent args)
+ {
+ if (!HasComp(args.User))
+ args.Cancel();
+ }
+
+ private void OnDashAttempt(EntityUid uid, EnergyKatanaComponent comp, DashAttemptEvent args)
+ {
+ if (!TryComp(args.User, out var ninja) || ninja.Katana != uid)
+ args.Cancel();
+ }
+}
diff --git a/Content.Shared/Ninja/Systems/SharedBatteryDrainerSystem.cs b/Content.Shared/Ninja/Systems/SharedBatteryDrainerSystem.cs
new file mode 100644
index 0000000000..ac11063eb7
--- /dev/null
+++ b/Content.Shared/Ninja/Systems/SharedBatteryDrainerSystem.cs
@@ -0,0 +1,69 @@
+using Content.Shared.Ninja.Components;
+using Content.Shared.DoAfter;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Ninja.Systems;
+
+///
+/// Basic draining prediction and API, all real logic is handled serverside.
+///
+public abstract class SharedBatteryDrainerSystem : EntitySystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent>(OnDoAfterAttempt);
+ SubscribeLocalEvent(OnDoAfter);
+ }
+
+ ///
+ /// Cancel any drain doafters if the battery is removed or gets filled.
+ ///
+ protected virtual void OnDoAfterAttempt(EntityUid uid, BatteryDrainerComponent comp, DoAfterAttemptEvent args)
+ {
+ if (comp.BatteryUid == null)
+ {
+ args.Cancel();
+ }
+ }
+
+ ///
+ /// Drain power from a power source (on server) and repeat if it succeeded.
+ /// Client will predict always succeeding since power is serverside.
+ ///
+ private void OnDoAfter(EntityUid uid, BatteryDrainerComponent comp, DrainDoAfterEvent args)
+ {
+ if (args.Cancelled || args.Handled || args.Target == null)
+ return;
+
+ // repeat if there is still power to drain
+ args.Repeat = TryDrainPower(uid, comp, args.Target.Value);
+ }
+
+ ///
+ /// Attempt to drain as much power as possible into the powercell.
+ /// Client always predicts this as succeeding since power is serverside and it can only fail once, when the powercell is filled or the target is emptied.
+ ///
+ protected virtual bool TryDrainPower(EntityUid uid, BatteryDrainerComponent comp, EntityUid target)
+ {
+ return true;
+ }
+
+ ///
+ /// Sets the battery field on the drainer.
+ ///
+ public void SetBattery(EntityUid uid, EntityUid? battery, BatteryDrainerComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp))
+ return;
+
+ comp.BatteryUid = battery;
+ }
+}
+
+///
+/// DoAfter event for .
+///
+[Serializable, NetSerializable]
+public sealed partial class DrainDoAfterEvent : SimpleDoAfterEvent { }
diff --git a/Content.Shared/Ninja/Systems/SharedNinjaGlovesSystem.cs b/Content.Shared/Ninja/Systems/SharedNinjaGlovesSystem.cs
new file mode 100644
index 0000000000..45c97fd1aa
--- /dev/null
+++ b/Content.Shared/Ninja/Systems/SharedNinjaGlovesSystem.cs
@@ -0,0 +1,116 @@
+using Content.Shared.Actions;
+using Content.Shared.CombatMode;
+using Content.Shared.Communications;
+using Content.Shared.Doors.Components;
+using Content.Shared.DoAfter;
+using Content.Shared.Examine;
+using Content.Shared.Hands.Components;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Interaction;
+using Content.Shared.Inventory.Events;
+using Content.Shared.Ninja.Components;
+using Content.Shared.Popups;
+using Content.Shared.Research.Components;
+using Content.Shared.Timing;
+using Content.Shared.Toggleable;
+using Robust.Shared.Timing;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Content.Shared.Ninja.Systems;
+
+///
+/// Provides the toggle action and handles examining and unequipping.
+///
+public abstract class SharedNinjaGlovesSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] protected readonly SharedAppearanceSystem Appearance = default!;
+ [Dependency] private readonly SharedCombatModeSystem _combatMode = default!;
+ [Dependency] protected readonly SharedDoAfterSystem _doAfter = default!;
+ [Dependency] protected readonly SharedInteractionSystem Interaction = default!;
+ [Dependency] private readonly SharedSpaceNinjaSystem _ninja = default!;
+ [Dependency] protected readonly SharedPopupSystem Popup = default!;
+ [Dependency] private readonly UseDelaySystem _useDelay = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnGetItemActions);
+ SubscribeLocalEvent(OnExamined);
+ SubscribeLocalEvent(OnUnequipped);
+ }
+
+ ///
+ /// Disable glove abilities and show the popup if they were enabled previously.
+ ///
+ public void DisableGloves(EntityUid uid, NinjaGlovesComponent? comp = null)
+ {
+ // already disabled?
+ if (!Resolve(uid, ref comp) || comp.User == null)
+ return;
+
+ var user = comp.User.Value;
+ comp.User = null;
+ Dirty(uid, comp);
+
+ Appearance.SetData(uid, ToggleVisuals.Toggled, false);
+ Popup.PopupClient(Loc.GetString("ninja-gloves-off"), user, user);
+
+ RemComp(user);
+ RemComp(user);
+ RemComp(user);
+ RemComp(user);
+ RemComp(user);
+ }
+
+ ///
+ /// Adds the toggle action when equipped.
+ ///
+ private void OnGetItemActions(EntityUid uid, NinjaGlovesComponent comp, GetItemActionsEvent args)
+ {
+ if (HasComp(args.User))
+ args.AddAction(ref comp.ToggleActionEntity, comp.ToggleAction);
+ }
+
+ ///
+ /// Show if the gloves are enabled when examining.
+ ///
+ private void OnExamined(EntityUid uid, NinjaGlovesComponent comp, ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ args.PushText(Loc.GetString(comp.User != null ? "ninja-gloves-examine-on" : "ninja-gloves-examine-off"));
+ }
+
+ ///
+ /// Disable gloves when unequipped and clean up ninja's gloves reference
+ ///
+ private void OnUnequipped(EntityUid uid, NinjaGlovesComponent comp, GotUnequippedEvent args)
+ {
+ if (comp.User != null)
+ {
+ var user = comp.User.Value;
+ Popup.PopupClient(Loc.GetString("ninja-gloves-off"), user, user);
+ DisableGloves(uid, comp);
+ }
+ }
+
+
+ // TODO: generic event thing
+ ///
+ /// GloveCheck but for abilities stored on the player, skips some checks.
+ /// Intended to be more generic, doesn't require the user to be a ninja or have any ninja equipment.
+ ///
+ public bool AbilityCheck(EntityUid uid, BeforeInteractHandEvent args, out EntityUid target)
+ {
+ target = args.Target;
+ return _timing.IsFirstTimePredicted
+ && !_combatMode.IsInCombatMode(uid)
+ && !_useDelay.ActiveDelay(uid)
+ && TryComp(uid, out var hands)
+ && hands.ActiveHandEntity == null
+ && Interaction.InRangeUnobstructed(uid, target);
+ }
+}
diff --git a/Content.Shared/Ninja/Systems/SharedNinjaSuitSystem.cs b/Content.Shared/Ninja/Systems/SharedNinjaSuitSystem.cs
new file mode 100644
index 0000000000..83fcba4ac6
--- /dev/null
+++ b/Content.Shared/Ninja/Systems/SharedNinjaSuitSystem.cs
@@ -0,0 +1,139 @@
+using Content.Shared.Actions;
+using Content.Shared.Clothing.Components;
+using Content.Shared.Clothing.EntitySystems;
+using Content.Shared.Inventory.Events;
+using Content.Shared.Ninja.Components;
+using Content.Shared.Timing;
+using Robust.Shared.Audio;
+using Robust.Shared.Network;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Ninja.Systems;
+
+///
+/// Handles (un)equipping and provides some API functions.
+///
+public abstract class SharedNinjaSuitSystem : EntitySystem
+{
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedNinjaGlovesSystem _gloves = default!;
+ [Dependency] protected readonly SharedSpaceNinjaSystem _ninja = default!;
+ [Dependency] protected readonly StealthClothingSystem StealthClothing = default!;
+ [Dependency] protected readonly UseDelaySystem UseDelay = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnEquipped);
+ SubscribeLocalEvent(OnGetItemActions);
+ SubscribeLocalEvent(OnAddStealthAction);
+ SubscribeLocalEvent(OnUnequipped);
+ }
+
+ ///
+ /// Call the shared and serverside code for when a ninja equips the suit.
+ ///
+ private void OnEquipped(EntityUid uid, NinjaSuitComponent comp, GotEquippedEvent args)
+ {
+ var user = args.Equipee;
+ if (!TryComp(user, out var ninja))
+ return;
+
+ NinjaEquippedSuit(uid, comp, user, ninja);
+ }
+
+ ///
+ /// Add all the actions when a suit is equipped by a ninja.
+ ///
+ private void OnGetItemActions(EntityUid uid, NinjaSuitComponent comp, GetItemActionsEvent args)
+ {
+ if (!HasComp(args.User))
+ return;
+
+ args.AddAction(ref comp.RecallKatanaActionEntity, comp.RecallKatanaAction);
+ args.AddAction(ref comp.CreateThrowingStarActionEntity, comp.CreateThrowingStarAction);
+ args.AddAction(ref comp.EmpActionEntity, comp.EmpAction);
+ }
+
+ ///
+ /// Only add stealth clothing's toggle action when equipped by a ninja.
+ ///
+ private void OnAddStealthAction(EntityUid uid, NinjaSuitComponent comp, AddStealthActionEvent args)
+ {
+ if (!HasComp(args.User))
+ args.Cancel();
+ }
+
+ ///
+ /// Call the shared and serverside code for when anyone unequips a suit.
+ ///
+ private void OnUnequipped(EntityUid uid, NinjaSuitComponent comp, GotUnequippedEvent args)
+ {
+ UserUnequippedSuit(uid, comp, args.Equipee);
+ }
+
+ ///
+ /// Called when a suit is equipped by a space ninja.
+ /// In the future it might be changed to an explicit activation toggle/verb like gloves are.
+ ///
+ protected virtual void NinjaEquippedSuit(EntityUid uid, NinjaSuitComponent comp, EntityUid user, SpaceNinjaComponent ninja)
+ {
+ // mark the user as wearing this suit, used when being attacked among other things
+ _ninja.AssignSuit(user, uid, ninja);
+
+ // initialize phase cloak, but keep it off
+ StealthClothing.SetEnabled(uid, user, false);
+ }
+
+ ///
+ /// Force uncloaks the user and disables suit abilities.
+ ///
+ public void RevealNinja(EntityUid uid, EntityUid user, NinjaSuitComponent? comp = null, StealthClothingComponent? stealthClothing = null)
+ {
+ if (!Resolve(uid, ref comp, ref stealthClothing))
+ return;
+
+ if (!StealthClothing.SetEnabled(uid, user, false, stealthClothing))
+ return;
+
+ // previously cloaked, disable abilities for a short time
+ _audio.PlayPredicted(comp.RevealSound, uid, user);
+ // all abilities check for a usedelay on the ninja
+ var useDelay = EnsureComp(user);
+ useDelay.Delay = comp.DisableTime;
+ UseDelay.BeginDelay(user, useDelay);
+ }
+
+ // TODO: modify PowerCellDrain
+ ///
+ /// Returns the power used by a suit
+ ///
+ public float SuitWattage(EntityUid uid, NinjaSuitComponent? suit = null)
+ {
+ if (!Resolve(uid, ref suit))
+ return 0f;
+
+ float wattage = suit.PassiveWattage;
+ if (TryComp(uid, out var stealthClothing) && stealthClothing.Enabled)
+ wattage += suit.CloakWattage;
+ return wattage;
+ }
+
+ ///
+ /// Called when a suit is unequipped, not necessarily by a space ninja.
+ /// In the future it might be changed to also have explicit deactivation via toggle.
+ ///
+ protected virtual void UserUnequippedSuit(EntityUid uid, NinjaSuitComponent comp, EntityUid user)
+ {
+ if (!TryComp(user, out var ninja))
+ return;
+
+ // mark the user as not wearing a suit
+ _ninja.AssignSuit(user, null, ninja);
+ // disable glove abilities
+ if (ninja.Gloves != null && TryComp(ninja.Gloves.Value, out var gloves))
+ _gloves.DisableGloves(ninja.Gloves.Value, gloves);
+ }
+}
diff --git a/Content.Shared/Ninja/Systems/SharedSpaceNinjaSystem.cs b/Content.Shared/Ninja/Systems/SharedSpaceNinjaSystem.cs
new file mode 100644
index 0000000000..d8ff07c27a
--- /dev/null
+++ b/Content.Shared/Ninja/Systems/SharedSpaceNinjaSystem.cs
@@ -0,0 +1,89 @@
+using Content.Shared.Clothing.Components;
+using Content.Shared.Ninja.Components;
+using Content.Shared.Weapons.Melee.Events;
+using Content.Shared.Weapons.Ranged.Events;
+using Content.Shared.Popups;
+
+namespace Content.Shared.Ninja.Systems;
+
+///
+/// Provides shared ninja API, handles being attacked revealing ninja and stops guns from shooting.
+///
+public abstract class SharedSpaceNinjaSystem : EntitySystem
+{
+ [Dependency] protected readonly SharedNinjaSuitSystem _suit = default!;
+ [Dependency] protected readonly SharedPopupSystem _popup = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnNinjaAttacked);
+ SubscribeLocalEvent(OnShotAttempted);
+ }
+
+ ///
+ /// Set the ninja's worn suit entity
+ ///
+ public void AssignSuit(EntityUid uid, EntityUid? suit, SpaceNinjaComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp) || comp.Suit == suit)
+ return;
+
+ comp.Suit = suit;
+ Dirty(uid, comp);
+ }
+
+ ///
+ /// Set the ninja's worn gloves entity
+ ///
+ public void AssignGloves(EntityUid uid, EntityUid? gloves, SpaceNinjaComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp) || comp.Gloves == gloves)
+ return;
+
+ comp.Gloves = gloves;
+ Dirty(uid, comp);
+ }
+
+ ///
+ /// Bind a katana entity to a ninja, letting it be recalled and dash.
+ ///
+ public void BindKatana(EntityUid uid, EntityUid? katana, SpaceNinjaComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp) || comp.Katana == katana)
+ return;
+
+ comp.Katana = katana;
+ Dirty(uid, comp);
+ }
+
+ ///
+ /// Gets the user's battery and tries to use some charge from it, returning true if successful.
+ /// Serverside only.
+ ///
+ public virtual bool TryUseCharge(EntityUid user, float charge)
+ {
+ return false;
+ }
+
+ ///
+ /// Handle revealing ninja if cloaked when attacked.
+ ///
+ private void OnNinjaAttacked(EntityUid uid, SpaceNinjaComponent comp, AttackedEvent args)
+ {
+ if (comp.Suit != null && TryComp(comp.Suit, out var stealthClothing) && stealthClothing.Enabled)
+ {
+ _suit.RevealNinja(comp.Suit.Value, uid, null, stealthClothing);
+ }
+ }
+
+ ///
+ /// Require ninja to fight with HONOR, no guns!
+ ///
+ private void OnShotAttempted(EntityUid uid, SpaceNinjaComponent comp, ref ShotAttemptedEvent args)
+ {
+ _popup.PopupClient(Loc.GetString("gun-disabled"), uid, uid);
+ args.Cancel();
+ }
+}
diff --git a/Content.Shared/Ninja/Systems/SharedStunProviderSystem.cs b/Content.Shared/Ninja/Systems/SharedStunProviderSystem.cs
new file mode 100644
index 0000000000..61b6e4313e
--- /dev/null
+++ b/Content.Shared/Ninja/Systems/SharedStunProviderSystem.cs
@@ -0,0 +1,32 @@
+using Content.Shared.Ninja.Components;
+
+namespace Content.Shared.Ninja.Systems;
+
+///
+/// All interaction logic is implemented serverside.
+/// This is in shared for API and access.
+///
+public abstract class SharedStunProviderSystem : EntitySystem
+{
+ ///
+ /// Set the battery field on the stun provider.
+ ///
+ public void SetBattery(EntityUid uid, EntityUid? battery, StunProviderComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp))
+ return;
+
+ comp.BatteryUid = battery;
+ }
+
+ ///
+ /// Set the no power popup field on the stun provider.
+ ///
+ public void SetNoPowerPopup(EntityUid uid, string popup, StunProviderComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp))
+ return;
+
+ comp.NoPowerPopup = popup;
+ }
+}
diff --git a/Content.Shared/Research/Components/ResearchStealerComponent.cs b/Content.Shared/Research/Components/ResearchStealerComponent.cs
new file mode 100644
index 0000000000..e0331fad1b
--- /dev/null
+++ b/Content.Shared/Research/Components/ResearchStealerComponent.cs
@@ -0,0 +1,17 @@
+using Content.Shared.Research.Systems;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Research.Components;
+
+///
+/// Component for stealing technologies from a R&D server, when gloves are enabled.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedResearchStealerSystem))]
+public sealed partial class ResearchStealerComponent : Component
+{
+ ///
+ /// Time taken to steal research from a server
+ ///
+ [DataField("delay"), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan Delay = TimeSpan.FromSeconds(20);
+}
diff --git a/Content.Shared/Research/Systems/SharedResearchStealerSystem.cs b/Content.Shared/Research/Systems/SharedResearchStealerSystem.cs
new file mode 100644
index 0000000000..64f596023d
--- /dev/null
+++ b/Content.Shared/Research/Systems/SharedResearchStealerSystem.cs
@@ -0,0 +1,63 @@
+using Content.Shared.DoAfter;
+using Content.Shared.Interaction;
+using Content.Shared.Ninja.Systems;
+using Content.Shared.Popups;
+using Content.Shared.Research.Components;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Research.Systems;
+
+public abstract class SharedResearchStealerSystem : EntitySystem
+{
+ [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
+ [Dependency] private readonly SharedNinjaGlovesSystem _gloves = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnBeforeInteractHand);
+ }
+
+ ///
+ /// Start do after for downloading techs from a r&d server.
+ /// Will only try if there is at least 1 tech researched.
+ ///
+ private void OnBeforeInteractHand(EntityUid uid, ResearchStealerComponent comp, BeforeInteractHandEvent args)
+ {
+ // TODO: generic event
+ if (args.Handled || !_gloves.AbilityCheck(uid, args, out var target))
+ return;
+
+ // can only hack the server, not a random console
+ if (!TryComp(target, out var database) || HasComp(target))
+ return;
+
+ args.Handled = true;
+
+ // fail fast if theres no techs to steal right now
+ if (database.UnlockedTechnologies.Count == 0)
+ {
+ _popup.PopupClient(Loc.GetString("ninja-download-fail"), uid, uid);
+ return;
+ }
+
+ var doAfterArgs = new DoAfterArgs(uid, comp.Delay, new ResearchStealDoAfterEvent(), target: target, used: uid, eventTarget: uid)
+ {
+ BreakOnDamage = true,
+ BreakOnUserMove = true,
+ MovementThreshold = 0.5f
+ };
+
+ _doAfter.TryStartDoAfter(doAfterArgs);
+ }
+}
+
+///
+/// Raised on the research stealer when the doafter completes.
+///
+[Serializable, NetSerializable]
+public sealed partial class ResearchStealDoAfterEvent : SimpleDoAfterEvent
+{
+}
diff --git a/Content.Shared/Research/Systems/SharedResearchSystem.cs b/Content.Shared/Research/Systems/SharedResearchSystem.cs
index fffbad847e..e0cc937b00 100644
--- a/Content.Shared/Research/Systems/SharedResearchSystem.cs
+++ b/Content.Shared/Research/Systems/SharedResearchSystem.cs
@@ -169,6 +169,18 @@ public abstract class SharedResearchSystem : EntitySystem
if (prototype.Tier < discipline.LockoutTier)
return;
component.MainDiscipline = prototype.Discipline;
- Dirty(component);
+ Dirty(uid, component);
+ }
+
+ ///
+ /// Clear all unlocked technologies from the database.
+ ///
+ public void ClearTechs(EntityUid uid, TechnologyDatabaseComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp) || comp.UnlockedTechnologies.Count == 0)
+ return;
+
+ comp.UnlockedTechnologies.Clear();
+ Dirty(uid, comp);
}
}
diff --git a/Resources/Audio/Misc/attributions.yml b/Resources/Audio/Misc/attributions.yml
index 773d1e6e4e..fc70e1a9ca 100644
--- a/Resources/Audio/Misc/attributions.yml
+++ b/Resources/Audio/Misc/attributions.yml
@@ -2,3 +2,8 @@
license: "CC-BY-3.0"
copyright: "Created by qwertyquerty"
source: "https://www.youtube.com/@qwertyquerty"
+
+- files: ["ninja_greeting.ogg"]
+ license: "CC-BY-SA-3.0"
+ copyright: "Taken from TG station."
+ source: "https://github.com/tgstation/tgstation/blob/b02b93ce2ab891164511a973493cdf951b4120f7/sound/effects/ninja_greeting.ogg"
diff --git a/Resources/Audio/Misc/ninja_greeting.ogg b/Resources/Audio/Misc/ninja_greeting.ogg
new file mode 100644
index 0000000000000000000000000000000000000000..e8f17bdea6cd3c93cdbd377cd8ed2fa0da16860a
GIT binary patch
literal 61293
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