diff --git a/Content.IntegrationTests/Tests/Commands/SuicideCommandTests.cs b/Content.IntegrationTests/Tests/Commands/SuicideCommandTests.cs
new file mode 100644
index 0000000000..540e86c650
--- /dev/null
+++ b/Content.IntegrationTests/Tests/Commands/SuicideCommandTests.cs
@@ -0,0 +1,365 @@
+using System.Linq;
+using Content.Shared.Damage;
+using Content.Shared.Damage.Prototypes;
+using Content.Shared.Execution;
+using Content.Shared.FixedPoint;
+using Content.Shared.Ghost;
+using Content.Shared.Hands.Components;
+using Content.Shared.Hands.EntitySystems;
+using Content.Shared.Mind;
+using Content.Shared.Mobs.Components;
+using Content.Shared.Mobs.Systems;
+using Content.Shared.Tag;
+using Robust.Server.GameObjects;
+using Robust.Server.Player;
+using Robust.Shared.Console;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Prototypes;
+
+namespace Content.IntegrationTests.Tests.Commands;
+
+[TestFixture]
+public sealed class SuicideCommandTests
+{
+
+ [TestPrototypes]
+ private const string Prototypes = @"
+- type: entity
+ id: SharpTestObject
+ name: very sharp test object
+ components:
+ - type: Item
+ - type: MeleeWeapon
+ damage:
+ types:
+ Slash: 5
+ - type: Execution
+
+- type: entity
+ id: MixedDamageTestObject
+ name: mixed damage test object
+ components:
+ - type: Item
+ - type: MeleeWeapon
+ damage:
+ types:
+ Slash: 5
+ Blunt: 5
+ - type: Execution
+
+- type: entity
+ id: TestMaterialReclaimer
+ name: test version of the material reclaimer
+ components:
+ - type: MaterialReclaimer";
+
+ ///
+ /// Run the suicide command in the console
+ /// Should successfully kill the player and ghost them
+ ///
+ [Test]
+ public async Task TestSuicide()
+ {
+ await using var pair = await PoolManager.GetServerClient(new PoolSettings
+ {
+ Connected = true,
+ Dirty = true,
+ DummyTicker = false
+ });
+ var server = pair.Server;
+ var consoleHost = server.ResolveDependency();
+ var entManager = server.ResolveDependency();
+ var playerMan = server.ResolveDependency();
+ var mindSystem = entManager.System();
+ var mobStateSystem = entManager.System();
+
+ // We need to know the player and whether they can be hurt, killed, and whether they have a mind
+ var player = playerMan.Sessions.First().AttachedEntity!.Value;
+ var mind = mindSystem.GetMind(player);
+
+ MindComponent mindComponent = default;
+ MobStateComponent mobStateComp = default;
+ await server.WaitPost(() =>
+ {
+ if (mind != null)
+ mindComponent = entManager.GetComponent(mind.Value);
+
+ mobStateComp = entManager.GetComponent(player);
+ });
+
+
+ // Check that running the suicide command kills the player
+ // and properly ghosts them without them being able to return to their body
+ await server.WaitAssertion(() =>
+ {
+ consoleHost.GetSessionShell(playerMan.Sessions.First()).ExecuteCommand("suicide");
+ Assert.Multiple(() =>
+ {
+ Assert.That(mobStateSystem.IsDead(player, mobStateComp));
+ Assert.That(entManager.TryGetComponent(mindComponent.CurrentEntity, out var ghostComp) &&
+ !ghostComp.CanReturnToBody);
+ });
+ });
+
+ await pair.CleanReturnAsync();
+ }
+
+ ///
+ /// Run the suicide command while the player is already injured
+ /// This should only deal as much damage as necessary to get to the dead threshold
+ ///
+ [Test]
+ public async Task TestSuicideWhileDamaged()
+ {
+ await using var pair = await PoolManager.GetServerClient(new PoolSettings
+ {
+ Connected = true,
+ Dirty = true,
+ DummyTicker = false
+ });
+ var server = pair.Server;
+ var consoleHost = server.ResolveDependency();
+ var entManager = server.ResolveDependency();
+ var playerMan = server.ResolveDependency();
+ var protoMan = server.ResolveDependency();
+
+ var damageableSystem = entManager.System();
+ var mindSystem = entManager.System();
+ var mobStateSystem = entManager.System();
+
+ // We need to know the player and whether they can be hurt, killed, and whether they have a mind
+ var player = playerMan.Sessions.First().AttachedEntity!.Value;
+ var mind = mindSystem.GetMind(player);
+
+ MindComponent mindComponent = default;
+ MobStateComponent mobStateComp = default;
+ MobThresholdsComponent mobThresholdsComp = default;
+ DamageableComponent damageableComp = default;
+ await server.WaitPost(() =>
+ {
+ if (mind != null)
+ mindComponent = entManager.GetComponent(mind.Value);
+
+ mobStateComp = entManager.GetComponent(player);
+ mobThresholdsComp = entManager.GetComponent(player);
+ damageableComp = entManager.GetComponent(player);
+ });
+
+ if (protoMan.TryIndex("Slash", out var slashProto))
+ damageableSystem.TryChangeDamage(player, new DamageSpecifier(slashProto, FixedPoint2.New(46.5)));
+
+ // Check that running the suicide command kills the player
+ // and properly ghosts them without them being able to return to their body
+ // and that all the damage is concentrated in the Slash category
+ await server.WaitAssertion(() =>
+ {
+ consoleHost.GetSessionShell(playerMan.Sessions.First()).ExecuteCommand("suicide");
+ var lethalDamageThreshold = mobThresholdsComp.Thresholds.Keys.Last();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(mobStateSystem.IsDead(player, mobStateComp));
+ Assert.That(entManager.TryGetComponent(mindComponent.CurrentEntity, out var ghostComp) &&
+ !ghostComp.CanReturnToBody);
+ Assert.That(damageableComp.Damage.GetTotal(), Is.EqualTo(lethalDamageThreshold));
+ });
+ });
+
+ await pair.CleanReturnAsync();
+ }
+
+ ///
+ /// Run the suicide command in the console
+ /// Should only ghost the player but not kill them
+ ///
+ [Test]
+ public async Task TestSuicideWhenCannotSuicide()
+ {
+ await using var pair = await PoolManager.GetServerClient(new PoolSettings
+ {
+ Connected = true,
+ Dirty = true,
+ DummyTicker = false
+ });
+ var server = pair.Server;
+ var consoleHost = server.ResolveDependency();
+ var entManager = server.ResolveDependency();
+ var playerMan = server.ResolveDependency();
+ var mindSystem = entManager.System();
+ var mobStateSystem = entManager.System();
+ var tagSystem = entManager.System();
+
+ // We need to know the player and whether they can be hurt, killed, and whether they have a mind
+ var player = playerMan.Sessions.First().AttachedEntity!.Value;
+ var mind = mindSystem.GetMind(player);
+ MindComponent mindComponent = default;
+ MobStateComponent mobStateComp = default;
+ await server.WaitPost(() =>
+ {
+ if (mind != null)
+ mindComponent = entManager.GetComponent(mind.Value);
+ mobStateComp = entManager.GetComponent(player);
+ });
+
+ tagSystem.AddTag(player, "CannotSuicide");
+
+ // Check that running the suicide command kills the player
+ // and properly ghosts them without them being able to return to their body
+ await server.WaitAssertion(() =>
+ {
+ consoleHost.GetSessionShell(playerMan.Sessions.First()).ExecuteCommand("suicide");
+ Assert.Multiple(() =>
+ {
+ Assert.That(mobStateSystem.IsAlive(player, mobStateComp));
+ Assert.That(entManager.TryGetComponent(mindComponent.CurrentEntity, out var ghostComp) &&
+ !ghostComp.CanReturnToBody);
+ });
+ });
+
+ await pair.CleanReturnAsync();
+ }
+
+
+ ///
+ /// Run the suicide command while the player is holding an execution-capable weapon
+ ///
+ [Test]
+ public async Task TestSuicideByHeldItem()
+ {
+ await using var pair = await PoolManager.GetServerClient(new PoolSettings
+ {
+ Connected = true,
+ Dirty = true,
+ DummyTicker = false
+ });
+ var server = pair.Server;
+ var consoleHost = server.ResolveDependency();
+ var entManager = server.ResolveDependency();
+ var playerMan = server.ResolveDependency();
+
+ var handsSystem = entManager.System();
+ var mindSystem = entManager.System();
+ var mobStateSystem = entManager.System();
+ var transformSystem = entManager.System();
+
+ // We need to know the player and whether they can be hurt, killed, and whether they have a mind
+ var player = playerMan.Sessions.First().AttachedEntity!.Value;
+ var mind = mindSystem.GetMind(player);
+
+ MindComponent mindComponent = default;
+ MobStateComponent mobStateComp = default;
+ MobThresholdsComponent mobThresholdsComp = default;
+ DamageableComponent damageableComp = default;
+ HandsComponent handsComponent = default;
+ await server.WaitPost(() =>
+ {
+ if (mind != null)
+ mindComponent = entManager.GetComponent(mind.Value);
+
+ mobStateComp = entManager.GetComponent(player);
+ mobThresholdsComp = entManager.GetComponent(player);
+ damageableComp = entManager.GetComponent(player);
+ handsComponent = entManager.GetComponent(player);
+ });
+
+ // Spawn the weapon of choice and put it in the player's hands
+ await server.WaitPost(() =>
+ {
+ var item = entManager.SpawnEntity("SharpTestObject", transformSystem.GetMapCoordinates(player));
+ Assert.That(handsSystem.TryPickup(player, item, handsComponent.ActiveHand!));
+ entManager.TryGetComponent(item, out var executionComponent);
+ Assert.That(executionComponent, Is.Not.EqualTo(null));
+ });
+
+ // Check that running the suicide command kills the player
+ // and properly ghosts them without them being able to return to their body
+ // and that all the damage is concentrated in the Slash category
+ await server.WaitAssertion(() =>
+ {
+ consoleHost.GetSessionShell(playerMan.Sessions.First()).ExecuteCommand("suicide");
+ var lethalDamageThreshold = mobThresholdsComp.Thresholds.Keys.Last();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(mobStateSystem.IsDead(player, mobStateComp));
+ Assert.That(entManager.TryGetComponent(mindComponent.CurrentEntity, out var ghostComp) &&
+ !ghostComp.CanReturnToBody);
+ Assert.That(damageableComp.Damage.DamageDict["Slash"], Is.EqualTo(lethalDamageThreshold));
+ });
+ });
+
+ await pair.CleanReturnAsync();
+ }
+
+ ///
+ /// Run the suicide command while the player is holding an execution-capable weapon
+ /// with damage spread between slash and blunt
+ ///
+ [Test]
+ public async Task TestSuicideByHeldItemSpreadDamage()
+ {
+ await using var pair = await PoolManager.GetServerClient(new PoolSettings
+ {
+ Connected = true,
+ Dirty = true,
+ DummyTicker = false
+ });
+ var server = pair.Server;
+ var consoleHost = server.ResolveDependency();
+ var entManager = server.ResolveDependency();
+ var playerMan = server.ResolveDependency();
+
+ var handsSystem = entManager.System();
+ var mindSystem = entManager.System();
+ var mobStateSystem = entManager.System();
+ var transformSystem = entManager.System();
+
+ // We need to know the player and whether they can be hurt, killed, and whether they have a mind
+ var player = playerMan.Sessions.First().AttachedEntity!.Value;
+ var mind = mindSystem.GetMind(player);
+
+ MindComponent mindComponent = default;
+ MobStateComponent mobStateComp = default;
+ MobThresholdsComponent mobThresholdsComp = default;
+ DamageableComponent damageableComp = default;
+ HandsComponent handsComponent = default;
+ await server.WaitPost(() =>
+ {
+ if (mind != null)
+ mindComponent = entManager.GetComponent(mind.Value);
+
+ mobStateComp = entManager.GetComponent(player);
+ mobThresholdsComp = entManager.GetComponent(player);
+ damageableComp = entManager.GetComponent(player);
+ handsComponent = entManager.GetComponent(player);
+ });
+
+ // Spawn the weapon of choice and put it in the player's hands
+ await server.WaitPost(() =>
+ {
+ var item = entManager.SpawnEntity("MixedDamageTestObject", transformSystem.GetMapCoordinates(player));
+ Assert.That(handsSystem.TryPickup(player, item, handsComponent.ActiveHand!));
+ entManager.TryGetComponent(item, out var executionComponent);
+ Assert.That(executionComponent, Is.Not.EqualTo(null));
+ });
+
+ // Check that running the suicide command kills the player
+ // and properly ghosts them without them being able to return to their body
+ // and that slash damage is split in half
+ await server.WaitAssertion(() =>
+ {
+ consoleHost.GetSessionShell(playerMan.Sessions.First()).ExecuteCommand("suicide");
+ var lethalDamageThreshold = mobThresholdsComp.Thresholds.Keys.Last();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(mobStateSystem.IsDead(player, mobStateComp));
+ Assert.That(entManager.TryGetComponent(mindComponent.CurrentEntity, out var ghostComp) &&
+ !ghostComp.CanReturnToBody);
+ Assert.That(damageableComp.Damage.DamageDict["Slash"], Is.EqualTo(lethalDamageThreshold / 2));
+ });
+ });
+
+ await pair.CleanReturnAsync();
+ }
+}
diff --git a/Content.Server/Chat/Commands/SuicideCommand.cs b/Content.Server/Chat/Commands/SuicideCommand.cs
index 0db03fec79..ea45158e44 100644
--- a/Content.Server/Chat/Commands/SuicideCommand.cs
+++ b/Content.Server/Chat/Commands/SuicideCommand.cs
@@ -1,6 +1,7 @@
using Content.Server.GameTicking;
using Content.Server.Popups;
using Content.Shared.Administration;
+using Content.Shared.Chat;
using Content.Shared.Mind;
using Robust.Shared.Console;
using Robust.Shared.Enums;
@@ -32,15 +33,13 @@ namespace Content.Server.Chat.Commands
var minds = _e.System();
// This check also proves mind not-null for at the end when the mob is ghosted.
- if (!minds.TryGetMind(player, out var mindId, out var mind) ||
- mind.OwnedEntity is not { Valid: true } victim)
+ if (!minds.TryGetMind(player, out var mindId, out var mindComp) ||
+ mindComp.OwnedEntity is not { Valid: true } victim)
{
shell.WriteLine(Loc.GetString("suicide-command-no-mind"));
return;
}
-
- var gameTicker = _e.System();
var suicideSystem = _e.System();
if (_e.HasComponent(victim))
@@ -53,14 +52,6 @@ namespace Content.Server.Chat.Commands
}
if (suicideSystem.Suicide(victim))
- {
- // Prevent the player from returning to the body.
- // Note that mind cannot be null because otherwise victim would be null.
- gameTicker.OnGhostAttempt(mindId, false, mind: mind);
- return;
- }
-
- if (gameTicker.OnGhostAttempt(mindId, true, mind: mind))
return;
shell.WriteLine(Loc.GetString("ghost-command-denied"));
diff --git a/Content.Server/Chat/SuicideSystem.cs b/Content.Server/Chat/SuicideSystem.cs
index 131d19c523..884292b0fa 100644
--- a/Content.Server/Chat/SuicideSystem.cs
+++ b/Content.Server/Chat/SuicideSystem.cs
@@ -1,141 +1,154 @@
-using Content.Server.Administration.Logs;
-using Content.Server.Popups;
+using Content.Server.GameTicking;
using Content.Shared.Damage;
-using Content.Shared.Damage.Prototypes;
using Content.Shared.Database;
using Content.Shared.Hands.Components;
using Content.Shared.Interaction.Events;
using Content.Shared.Item;
+using Content.Shared.Mind;
using Content.Shared.Mobs.Components;
using Content.Shared.Mobs.Systems;
using Content.Shared.Popups;
using Content.Shared.Tag;
using Robust.Shared.Player;
-using Robust.Shared.Prototypes;
+using Content.Shared.Administration.Logs;
+using Content.Shared.Chat;
+using Content.Shared.Mind.Components;
-namespace Content.Server.Chat
+namespace Content.Server.Chat;
+
+public sealed class SuicideSystem : EntitySystem
{
- public sealed class SuicideSystem : EntitySystem
+ [Dependency] private readonly EntityLookupSystem _entityLookupSystem = default!;
+ [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly TagSystem _tagSystem = default!;
+ [Dependency] private readonly MobStateSystem _mobState = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly GameTicker _gameTicker = default!;
+ [Dependency] private readonly SharedSuicideSystem _suicide = default!;
+
+ public override void Initialize()
{
- [Dependency] private readonly DamageableSystem _damageableSystem = default!;
- [Dependency] private readonly EntityLookupSystem _entityLookupSystem = default!;
- [Dependency] private readonly IAdminLogManager _adminLogger = default!;
- [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
- [Dependency] private readonly TagSystem _tagSystem = default!;
- [Dependency] private readonly MobStateSystem _mobState = default!;
- [Dependency] private readonly SharedPopupSystem _popup = default!;
+ base.Initialize();
- public bool Suicide(EntityUid victim)
- {
- // Checks to see if the CannotSuicide tag exits, ghosts instead.
- if (_tagSystem.HasTag(victim, "CannotSuicide"))
- return false;
-
- // Checks to see if the player is dead.
- if (!TryComp(victim, out var mobState) || _mobState.IsDead(victim, mobState))
- return false;
-
- _adminLogger.Add(LogType.Mind, $"{EntityManager.ToPrettyString(victim):player} is attempting to suicide");
-
- var suicideEvent = new SuicideEvent(victim);
-
- //Check to see if there were any systems blocking this suicide
- if (SuicideAttemptBlocked(victim, suicideEvent))
- return false;
-
- bool environmentSuicide = false;
- // If you are critical, you wouldn't be able to use your surroundings to suicide, so you do the default suicide
- if (!_mobState.IsCritical(victim, mobState))
- {
- environmentSuicide = EnvironmentSuicideHandler(victim, suicideEvent);
- }
-
- if (suicideEvent.AttemptBlocked)
- return false;
-
- DefaultSuicideHandler(victim, suicideEvent);
-
- ApplyDeath(victim, suicideEvent.Kind!.Value);
- _adminLogger.Add(LogType.Mind, $"{EntityManager.ToPrettyString(victim):player} suicided{(environmentSuicide ? " (environment)" : "")}");
- return true;
- }
-
- ///
- /// If not handled, does the default suicide, which is biting your own tongue
- ///
- private void DefaultSuicideHandler(EntityUid victim, SuicideEvent suicideEvent)
- {
- if (suicideEvent.Handled)
- return;
-
- var othersMessage = Loc.GetString("suicide-command-default-text-others", ("name", victim));
- _popup.PopupEntity(othersMessage, victim, Filter.PvsExcept(victim), true);
-
- var selfMessage = Loc.GetString("suicide-command-default-text-self");
- _popup.PopupEntity(selfMessage, victim, victim);
- suicideEvent.SetHandled(SuicideKind.Bloodloss);
- }
-
- ///
- /// Checks to see if there are any other systems that prevent suicide
- ///
- /// Returns true if there was a blocked attempt
- private bool SuicideAttemptBlocked(EntityUid victim, SuicideEvent suicideEvent)
- {
- RaiseLocalEvent(victim, suicideEvent, true);
-
- if (suicideEvent.AttemptBlocked)
- return true;
+ SubscribeLocalEvent(OnDamageableSuicide);
+ SubscribeLocalEvent(OnEnvironmentalSuicide);
+ SubscribeLocalEvent(OnSuicideGhost);
+ }
+ ///
+ /// Calling this function will attempt to kill the user by suiciding on objects in the surrounding area
+ /// or by applying a lethal amount of damage to the user with the default method.
+ /// Used when writing /suicide
+ ///
+ public bool Suicide(EntityUid victim)
+ {
+ // Can't suicide if we're already dead
+ if (!TryComp(victim, out var mobState) || _mobState.IsDead(victim, mobState))
return false;
- }
- ///
- /// Raise event to attempt to use held item, or surrounding entities to attempt to commit suicide
- ///
- private bool EnvironmentSuicideHandler(EntityUid victim, SuicideEvent suicideEvent)
- {
- var itemQuery = GetEntityQuery();
-
- // Suicide by held item
- if (EntityManager.TryGetComponent(victim, out HandsComponent? handsComponent)
- && handsComponent.ActiveHandEntity is { } item)
- {
- RaiseLocalEvent(item, suicideEvent, false);
-
- if (suicideEvent.Handled)
- return true;
- }
-
- // Suicide by nearby entity (ex: Microwave)
- foreach (var entity in _entityLookupSystem.GetEntitiesInRange(victim, 1, LookupFlags.Approximate | LookupFlags.Static))
- {
- // Skip any nearby items that can be picked up, we already checked the active held item above
- if (itemQuery.HasComponent(entity))
- continue;
-
- RaiseLocalEvent(entity, suicideEvent);
-
- if (suicideEvent.Handled)
- return true;
- }
+ var suicideGhostEvent = new SuicideGhostEvent(victim);
+ RaiseLocalEvent(victim, suicideGhostEvent);
+ // Suicide is considered a fail if the user wasn't able to ghost
+ // Suiciding with the CannotSuicide tag will ghost the player but not kill the body
+ if (!suicideGhostEvent.Handled || _tagSystem.HasTag(victim, "CannotSuicide"))
return false;
+
+ _adminLogger.Add(LogType.Mind, $"{EntityManager.ToPrettyString(victim):player} is attempting to suicide");
+ var suicideEvent = new SuicideEvent(victim);
+ RaiseLocalEvent(victim, suicideEvent);
+
+ _adminLogger.Add(LogType.Mind, $"{EntityManager.ToPrettyString(victim):player} suicided.");
+ return true;
+ }
+
+ ///
+ /// Event subscription created to handle the ghosting aspect relating to suicides
+ /// Mainly useful when you can raise an event in Shared and can't call Suicide() directly
+ ///
+ private void OnSuicideGhost(Entity victim, ref SuicideGhostEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ if (victim.Comp.Mind == null)
+ return;
+
+ if (!TryComp(victim.Comp.Mind, out var mindComponent))
+ return;
+
+ // CannotSuicide tag will allow the user to ghost, but also return to their mind
+ // This is kind of weird, not sure what it applies to?
+ if (_tagSystem.HasTag(victim, "CannotSuicide"))
+ args.CanReturnToBody = true;
+
+ if (_gameTicker.OnGhostAttempt(victim.Comp.Mind.Value, args.CanReturnToBody, mind: mindComponent))
+ args.Handled = true;
+ }
+
+ ///
+ /// Raise event to attempt to use held item, or surrounding entities to attempt to commit suicide
+ ///
+ private void OnEnvironmentalSuicide(Entity victim, ref SuicideEvent args)
+ {
+ if (args.Handled || _mobState.IsCritical(victim))
+ return;
+
+ var suicideByEnvironmentEvent = new SuicideByEnvironmentEvent(victim);
+
+ // Try to suicide by raising an event on the held item
+ if (EntityManager.TryGetComponent(victim, out HandsComponent? handsComponent)
+ && handsComponent.ActiveHandEntity is { } item)
+ {
+ RaiseLocalEvent(item, suicideByEnvironmentEvent);
+ if (suicideByEnvironmentEvent.Handled)
+ {
+ args.Handled = suicideByEnvironmentEvent.Handled;
+ return;
+ }
}
- private void ApplyDeath(EntityUid target, SuicideKind kind)
+ // Try to suicide by nearby entities, like Microwaves or Crematoriums, by raising an event on it
+ // Returns upon being handled by any entity
+ var itemQuery = GetEntityQuery();
+ foreach (var entity in _entityLookupSystem.GetEntitiesInRange(victim, 1, LookupFlags.Approximate | LookupFlags.Static))
{
- if (kind == SuicideKind.Special)
- return;
+ // Skip any nearby items that can be picked up, we already checked the active held item above
+ if (itemQuery.HasComponent(entity))
+ continue;
- if (!_prototypeManager.TryIndex(kind.ToString(), out var damagePrototype))
- {
- const SuicideKind fallback = SuicideKind.Blunt;
- Log.Error($"{nameof(SuicideSystem)} could not find the damage type prototype associated with {kind}. Falling back to {fallback}");
- damagePrototype = _prototypeManager.Index(fallback.ToString());
- }
- const int lethalAmountOfDamage = 200; // TODO: Would be nice to get this number from somewhere else
- _damageableSystem.TryChangeDamage(target, new(damagePrototype, lethalAmountOfDamage), true, origin: target);
+ RaiseLocalEvent(entity, suicideByEnvironmentEvent);
+ if (!suicideByEnvironmentEvent.Handled)
+ continue;
+
+ args.Handled = suicideByEnvironmentEvent.Handled;
+ return;
}
}
+
+ ///
+ /// Default suicide behavior for any kind of entity that can take damage
+ ///
+ private void OnDamageableSuicide(Entity victim, ref SuicideEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ var othersMessage = Loc.GetString("suicide-command-default-text-others", ("name", victim));
+ _popup.PopupEntity(othersMessage, victim, Filter.PvsExcept(victim), true);
+
+ var selfMessage = Loc.GetString("suicide-command-default-text-self");
+ _popup.PopupEntity(selfMessage, victim, victim);
+
+ if (args.DamageSpecifier != null)
+ {
+ _suicide.ApplyLethalDamage(victim, args.DamageSpecifier);
+ args.Handled = true;
+ return;
+ }
+
+ args.DamageType ??= "Bloodloss";
+ _suicide.ApplyLethalDamage(victim, args.DamageType);
+ args.Handled = true;
+ }
}
diff --git a/Content.Server/Explosion/EntitySystems/TriggerSystem.Mobstate.cs b/Content.Server/Explosion/EntitySystems/TriggerSystem.Mobstate.cs
index 45198662ec..ccd2a6e3df 100644
--- a/Content.Server/Explosion/EntitySystems/TriggerSystem.Mobstate.cs
+++ b/Content.Server/Explosion/EntitySystems/TriggerSystem.Mobstate.cs
@@ -38,16 +38,20 @@ public sealed partial class TriggerSystem
Trigger(uid);
}
+ ///
+ /// Checks if the user has any implants that prevent suicide to avoid some cheesy strategies
+ /// Prevents suicide by handling the event without killing the user
+ ///
private void OnSuicide(EntityUid uid, TriggerOnMobstateChangeComponent component, SuicideEvent args)
{
if (args.Handled)
return;
- if (component.PreventSuicide)
- {
- _popupSystem.PopupEntity(Loc.GetString("suicide-prevented"), args.Victim, args.Victim);
- args.BlockSuicideAttempt(component.PreventSuicide);
- }
+ if (!component.PreventSuicide)
+ return;
+
+ _popupSystem.PopupEntity(Loc.GetString("suicide-prevented"), args.Victim, args.Victim);
+ args.Handled = true;
}
private void OnSuicideRelay(EntityUid uid, TriggerOnMobstateChangeComponent component, ImplantRelayEvent args)
diff --git a/Content.Server/Kitchen/EntitySystems/KitchenSpikeSystem.cs b/Content.Server/Kitchen/EntitySystems/KitchenSpikeSystem.cs
index d89af7e94f..fec65430c1 100644
--- a/Content.Server/Kitchen/EntitySystems/KitchenSpikeSystem.cs
+++ b/Content.Server/Kitchen/EntitySystems/KitchenSpikeSystem.cs
@@ -2,6 +2,8 @@ using Content.Server.Administration.Logs;
using Content.Server.Body.Systems;
using Content.Server.Kitchen.Components;
using Content.Server.Popups;
+using Content.Shared.Chat;
+using Content.Shared.Damage;
using Content.Shared.Database;
using Content.Shared.DoAfter;
using Content.Shared.DragDrop;
@@ -16,7 +18,6 @@ using Content.Shared.Nutrition.Components;
using Content.Shared.Popups;
using Content.Shared.Storage;
using Robust.Server.GameObjects;
-using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Player;
using Robust.Shared.Random;
@@ -36,6 +37,7 @@ namespace Content.Server.Kitchen.EntitySystems
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly MetaDataSystem _metaData = default!;
+ [Dependency] private readonly SharedSuicideSystem _suicide = default!;
public override void Initialize()
{
@@ -48,31 +50,38 @@ namespace Content.Server.Kitchen.EntitySystems
//DoAfter
SubscribeLocalEvent(OnDoAfter);
- SubscribeLocalEvent(OnSuicide);
+ SubscribeLocalEvent(OnSuicideByEnvironment);
SubscribeLocalEvent(OnButcherableCanDrop);
}
- private void OnButcherableCanDrop(EntityUid uid, ButcherableComponent component, ref CanDropDraggedEvent args)
+ private void OnButcherableCanDrop(Entity entity, ref CanDropDraggedEvent args)
{
args.Handled = true;
- args.CanDrop |= component.Type != ButcheringType.Knife;
+ args.CanDrop |= entity.Comp.Type != ButcheringType.Knife;
}
- private void OnSuicide(EntityUid uid, KitchenSpikeComponent component, SuicideEvent args)
+ ///
+ /// TODO: Update this so it actually meatspikes the user instead of applying lethal damage to them.
+ ///
+ private void OnSuicideByEnvironment(Entity entity, ref SuicideByEnvironmentEvent args)
{
if (args.Handled)
return;
- args.SetHandled(SuicideKind.Piercing);
- var victim = args.Victim;
- var othersMessage = Loc.GetString("comp-kitchen-spike-suicide-other", ("victim", victim));
- _popupSystem.PopupEntity(othersMessage, victim);
+
+ if (!TryComp(args.Victim, out var damageableComponent))
+ return;
+
+ _suicide.ApplyLethalDamage((args.Victim, damageableComponent), "Piercing");
+ var othersMessage = Loc.GetString("comp-kitchen-spike-suicide-other", ("victim", args.Victim));
+ _popupSystem.PopupEntity(othersMessage, args.Victim, Filter.PvsExcept(args.Victim), true);
var selfMessage = Loc.GetString("comp-kitchen-spike-suicide-self");
- _popupSystem.PopupEntity(selfMessage, victim, victim);
+ _popupSystem.PopupEntity(selfMessage, args.Victim, args.Victim);
+ args.Handled = true;
}
- private void OnDoAfter(EntityUid uid, KitchenSpikeComponent component, DoAfterEvent args)
+ private void OnDoAfter(Entity entity, ref SpikeDoAfterEvent args)
{
if (args.Args.Target == null)
return;
@@ -82,49 +91,49 @@ namespace Content.Server.Kitchen.EntitySystems
if (args.Cancelled)
{
- component.InUse = false;
+ entity.Comp.InUse = false;
return;
}
if (args.Handled)
return;
- if (Spikeable(uid, args.Args.User, args.Args.Target.Value, component, butcherable))
- Spike(uid, args.Args.User, args.Args.Target.Value, component);
+ if (Spikeable(entity, args.Args.User, args.Args.Target.Value, entity.Comp, butcherable))
+ Spike(entity, args.Args.User, args.Args.Target.Value, entity.Comp);
- component.InUse = false;
+ entity.Comp.InUse = false;
args.Handled = true;
}
- private void OnDragDrop(EntityUid uid, KitchenSpikeComponent component, ref DragDropTargetEvent args)
+ private void OnDragDrop(Entity entity, ref DragDropTargetEvent args)
{
if (args.Handled)
return;
args.Handled = true;
- if (Spikeable(uid, args.User, args.Dragged, component))
- TrySpike(uid, args.User, args.Dragged, component);
+ if (Spikeable(entity, args.User, args.Dragged, entity.Comp))
+ TrySpike(entity, args.User, args.Dragged, entity.Comp);
}
- private void OnInteractHand(EntityUid uid, KitchenSpikeComponent component, InteractHandEvent args)
+ private void OnInteractHand(Entity entity, ref InteractHandEvent args)
{
if (args.Handled)
return;
- if (component.PrototypesToSpawn?.Count > 0)
+ if (entity.Comp.PrototypesToSpawn?.Count > 0)
{
- _popupSystem.PopupEntity(Loc.GetString("comp-kitchen-spike-knife-needed"), uid, args.User);
+ _popupSystem.PopupEntity(Loc.GetString("comp-kitchen-spike-knife-needed"), entity, args.User);
args.Handled = true;
}
}
- private void OnInteractUsing(EntityUid uid, KitchenSpikeComponent component, InteractUsingEvent args)
+ private void OnInteractUsing(Entity entity, ref InteractUsingEvent args)
{
if (args.Handled)
return;
- if (TryGetPiece(uid, args.User, args.Used))
+ if (TryGetPiece(entity, args.User, args.Used))
args.Handled = true;
}
diff --git a/Content.Server/Kitchen/EntitySystems/MicrowaveSystem.cs b/Content.Server/Kitchen/EntitySystems/MicrowaveSystem.cs
index 98c875e773..c05c679f17 100644
--- a/Content.Server/Kitchen/EntitySystems/MicrowaveSystem.cs
+++ b/Content.Server/Kitchen/EntitySystems/MicrowaveSystem.cs
@@ -39,6 +39,8 @@ using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
using Content.Shared.Stacks;
using Content.Server.Construction.Components;
+using Content.Shared.Chat;
+using Content.Shared.Damage;
namespace Content.Server.Kitchen.EntitySystems
{
@@ -65,6 +67,7 @@ namespace Content.Server.Kitchen.EntitySystems
[Dependency] private readonly SharedStackSystem _stack = default!;
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly SharedSuicideSystem _suicide = default!;
[ValidatePrototypeId]
private const string MalfunctionSpark = "Spark";
@@ -83,7 +86,7 @@ namespace Content.Server.Kitchen.EntitySystems
SubscribeLocalEvent(OnBreak);
SubscribeLocalEvent(OnPowerChanged);
SubscribeLocalEvent(OnAnchorChanged);
- SubscribeLocalEvent(OnSuicide);
+ SubscribeLocalEvent(OnSuicideByEnvironment);
SubscribeLocalEvent(OnSignalReceived);
@@ -260,12 +263,22 @@ namespace Content.Server.Kitchen.EntitySystems
_deviceLink.EnsureSinkPorts(ent, ent.Comp.OnPort);
}
- private void OnSuicide(Entity ent, ref SuicideEvent args)
+ ///
+ /// Kills the user by microwaving their head
+ /// TODO: Make this not awful, it keeps any items attached to your head still on and you can revive someone and cogni them so you have some dumb headless fuck running around. I've seen it happen.
+ ///
+ private void OnSuicideByEnvironment(Entity ent, ref SuicideByEnvironmentEvent args)
{
if (args.Handled)
return;
- args.SetHandled(SuicideKind.Heat);
+ // The act of getting your head microwaved doesn't actually kill you
+ if (!TryComp(args.Victim, out var damageableComponent))
+ return;
+
+ // The application of lethal damage is what kills you...
+ _suicide.ApplyLethalDamage((args.Victim, damageableComponent), "Heat");
+
var victim = args.Victim;
var headCount = 0;
@@ -295,6 +308,7 @@ namespace Content.Server.Kitchen.EntitySystems
ent.Comp.CurrentCookTimerTime = 10;
Wzhzhzh(ent.Owner, ent.Comp, args.Victim);
UpdateUserInterfaceState(ent.Owner, ent.Comp);
+ args.Handled = true;
}
private void OnSolutionChange(Entity ent, ref SolutionContainerChangedEvent args)
diff --git a/Content.Server/Materials/MaterialReclaimerSystem.cs b/Content.Server/Materials/MaterialReclaimerSystem.cs
index 0d6d27777a..b962af2b41 100644
--- a/Content.Server/Materials/MaterialReclaimerSystem.cs
+++ b/Content.Server/Materials/MaterialReclaimerSystem.cs
@@ -1,4 +1,4 @@
-using Content.Server.Chemistry.Containers.EntitySystems;
+using Content.Server.Chemistry.Containers.EntitySystems;
using Content.Server.Fluids.EntitySystems;
using Content.Server.GameTicking;
using Content.Server.Popups;
@@ -48,7 +48,7 @@ public sealed class MaterialReclaimerSystem : SharedMaterialReclaimerSystem
SubscribeLocalEvent(OnPowerChanged);
SubscribeLocalEvent(OnInteractUsing,
before: new []{typeof(WiresSystem), typeof(SolutionTransferSystem)});
- SubscribeLocalEvent(OnSuicide);
+ SubscribeLocalEvent(OnSuicideByEnvironment);
SubscribeLocalEvent(OnActivePowerChanged);
}
private void OnStartup(Entity entity, ref ComponentStartup args)
@@ -86,12 +86,11 @@ public sealed class MaterialReclaimerSystem : SharedMaterialReclaimerSystem
args.Handled = TryStartProcessItem(entity.Owner, args.Used, entity.Comp, args.User);
}
- private void OnSuicide(Entity entity, ref SuicideEvent args)
+ private void OnSuicideByEnvironment(Entity entity, ref SuicideByEnvironmentEvent args)
{
if (args.Handled)
return;
- args.SetHandled(SuicideKind.Bloodloss);
var victim = args.Victim;
if (TryComp(victim, out ActorComponent? actor) &&
_mind.TryGetMind(actor.PlayerSession, out var mindId, out var mind))
@@ -103,12 +102,15 @@ public sealed class MaterialReclaimerSystem : SharedMaterialReclaimerSystem
}
}
- _popup.PopupEntity(Loc.GetString("recycler-component-suicide-message-others", ("victim", Identity.Entity(victim, EntityManager))),
+ _popup.PopupEntity(Loc.GetString("recycler-component-suicide-message-others",
+ ("victim", Identity.Entity(victim, EntityManager))),
victim,
- Filter.PvsExcept(victim, entityManager: EntityManager), true);
+ Filter.PvsExcept(victim, entityManager: EntityManager),
+ true);
_body.GibBody(victim, true);
_appearance.SetData(entity.Owner, RecyclerVisuals.Bloody, true);
+ args.Handled = true;
}
private void OnActivePowerChanged(Entity entity, ref PowerChangedEvent args)
diff --git a/Content.Server/Medical/BiomassReclaimer/BiomassReclaimerSystem.cs b/Content.Server/Medical/BiomassReclaimer/BiomassReclaimerSystem.cs
index 5be93f7fbc..c5beed718e 100644
--- a/Content.Server/Medical/BiomassReclaimer/BiomassReclaimerSystem.cs
+++ b/Content.Server/Medical/BiomassReclaimer/BiomassReclaimerSystem.cs
@@ -106,11 +106,11 @@ namespace Content.Server.Medical.BiomassReclaimer
SubscribeLocalEvent(OnAfterInteractUsing);
SubscribeLocalEvent(OnClimbedOn);
SubscribeLocalEvent(OnPowerChanged);
- SubscribeLocalEvent(OnSuicide);
+ SubscribeLocalEvent(OnSuicideByEnvironment);
SubscribeLocalEvent(OnDoAfter);
}
- private void OnSuicide(Entity ent, ref SuicideEvent args)
+ private void OnSuicideByEnvironment(Entity ent, ref SuicideByEnvironmentEvent args)
{
if (args.Handled)
return;
@@ -123,7 +123,7 @@ namespace Content.Server.Medical.BiomassReclaimer
_popup.PopupEntity(Loc.GetString("biomass-reclaimer-suicide-others", ("victim", args.Victim)), ent, PopupType.LargeCaution);
StartProcessing(args.Victim, ent);
- args.SetHandled(SuicideKind.Blunt);
+ args.Handled = true;
}
private void OnInit(EntityUid uid, ActiveBiomassReclaimerComponent component, ComponentInit args)
diff --git a/Content.Server/Morgue/CrematoriumSystem.cs b/Content.Server/Morgue/CrematoriumSystem.cs
index 54b47cff84..f6859b610a 100644
--- a/Content.Server/Morgue/CrematoriumSystem.cs
+++ b/Content.Server/Morgue/CrematoriumSystem.cs
@@ -38,7 +38,7 @@ public sealed class CrematoriumSystem : EntitySystem
SubscribeLocalEvent(OnExamine);
SubscribeLocalEvent>(AddCremateVerb);
- SubscribeLocalEvent(OnSuicide);
+ SubscribeLocalEvent(OnSuicideByEnvironment);
SubscribeLocalEvent(OnAttemptOpen);
}
@@ -146,11 +146,10 @@ public sealed class CrematoriumSystem : EntitySystem
_audio.PlayPvs(component.CremateFinishSound, uid);
}
- private void OnSuicide(EntityUid uid, CrematoriumComponent component, SuicideEvent args)
+ private void OnSuicideByEnvironment(EntityUid uid, CrematoriumComponent component, SuicideByEnvironmentEvent args)
{
if (args.Handled)
return;
- args.SetHandled(SuicideKind.Heat);
var victim = args.Victim;
if (TryComp(victim, out ActorComponent? actor) && _minds.TryGetMind(victim, out var mindId, out var mind))
@@ -179,6 +178,7 @@ public sealed class CrematoriumSystem : EntitySystem
}
_entityStorage.CloseStorage(uid);
Cremate(uid, component);
+ args.Handled = true;
}
public override void Update(float frameTime)
diff --git a/Content.Shared/Chat/SharedSuicideSystem.cs b/Content.Shared/Chat/SharedSuicideSystem.cs
new file mode 100644
index 0000000000..d341ea89a8
--- /dev/null
+++ b/Content.Shared/Chat/SharedSuicideSystem.cs
@@ -0,0 +1,67 @@
+using Content.Shared.Damage;
+using Content.Shared.Damage.Prototypes;
+using Content.Shared.Mobs.Components;
+using Robust.Shared.Prototypes;
+using System.Linq;
+
+namespace Content.Shared.Chat;
+
+public sealed class SharedSuicideSystem : EntitySystem
+{
+ [Dependency] private readonly DamageableSystem _damageableSystem = default!;
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+
+ ///
+ /// Applies lethal damage spread out across the damage types given.
+ ///
+ public void ApplyLethalDamage(Entity target, DamageSpecifier damageSpecifier)
+ {
+ // Create a new damageSpecifier so that we don't make alterations to the original DamageSpecifier
+ // Failing to do this will permanently change a weapon's damage making it insta-kill people
+ var appliedDamageSpecifier = new DamageSpecifier(damageSpecifier);
+ if (!TryComp(target, out var mobThresholds))
+ return;
+
+ // Mob thresholds are sorted from alive -> crit -> dead,
+ // grabbing the last key will give us how much damage is needed to kill a target from zero
+ // The exact lethal damage amount is adjusted based on their current damage taken
+ var lethalAmountOfDamage = mobThresholds.Thresholds.Keys.Last() - target.Comp.TotalDamage;
+ var totalDamage = appliedDamageSpecifier.GetTotal();
+
+ // Removing structural because it causes issues against entities that cannot take structural damage,
+ // then getting the total to use in calculations for spreading out damage.
+ appliedDamageSpecifier.DamageDict.Remove("Structural");
+
+ // Split the total amount of damage needed to kill the target by every damage type in the DamageSpecifier
+ foreach (var (key, value) in appliedDamageSpecifier.DamageDict)
+ {
+ appliedDamageSpecifier.DamageDict[key] = Math.Ceiling((double) (value * lethalAmountOfDamage / totalDamage));
+ }
+
+ _damageableSystem.TryChangeDamage(target, appliedDamageSpecifier, true, origin: target);
+ }
+
+ ///
+ /// Applies lethal damage in a single type, specified by a single damage type.
+ ///
+ public void ApplyLethalDamage(Entity target, ProtoId? damageType)
+ {
+ if (!TryComp(target, out var mobThresholds))
+ return;
+
+ // Mob thresholds are sorted from alive -> crit -> dead,
+ // grabbing the last key will give us how much damage is needed to kill a target from zero
+ // The exact lethal damage amount is adjusted based on their current damage taken
+ var lethalAmountOfDamage = mobThresholds.Thresholds.Keys.Last() - target.Comp.TotalDamage;
+
+ // We don't want structural damage for the same reasons listed above
+ if (!_prototypeManager.TryIndex(damageType, out var damagePrototype) || damagePrototype.ID == "Structural")
+ {
+ Log.Error($"{nameof(SharedSuicideSystem)} could not find the damage type prototype associated with {damageType}. Falling back to Blunt");
+ damagePrototype = _prototypeManager.Index("Blunt");
+ }
+
+ var damage = new DamageSpecifier(damagePrototype, lethalAmountOfDamage);
+ _damageableSystem.TryChangeDamage(target, damage, true, origin: target);
+ }
+}
diff --git a/Content.Shared/Execution/DoAfterEvent.cs b/Content.Shared/Execution/DoAfterEvent.cs
new file mode 100644
index 0000000000..7854974527
--- /dev/null
+++ b/Content.Shared/Execution/DoAfterEvent.cs
@@ -0,0 +1,9 @@
+using Content.Shared.DoAfter;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Execution;
+
+[Serializable, NetSerializable]
+public sealed partial class ExecutionDoAfterEvent : SimpleDoAfterEvent
+{
+}
diff --git a/Content.Shared/Execution/ExecutionComponent.cs b/Content.Shared/Execution/ExecutionComponent.cs
new file mode 100644
index 0000000000..31477ead69
--- /dev/null
+++ b/Content.Shared/Execution/ExecutionComponent.cs
@@ -0,0 +1,77 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Execution;
+
+///
+/// Added to entities that can be used to execute another target.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class ExecutionComponent : Component
+{
+ ///
+ /// How long the execution duration lasts.
+ ///
+ [DataField, AutoNetworkedField]
+ public float DoAfterDuration = 5f;
+
+ ///
+ /// Arbitrarily chosen number to multiply damage by, used to deal reasonable amounts of damage to a victim of an execution.
+ /// ///
+ [DataField, AutoNetworkedField]
+ public float DamageMultiplier = 9f;
+
+ ///
+ /// Shown to the person performing the melee execution (attacker) upon starting a melee execution.
+ ///
+ [DataField]
+ public LocId InternalMeleeExecutionMessage = "execution-popup-melee-initial-internal";
+
+ ///
+ /// Shown to bystanders and the victim of a melee execution when a melee execution is started.
+ ///
+ [DataField]
+ public LocId ExternalMeleeExecutionMessage = "execution-popup-melee-initial-external";
+
+ ///
+ /// Shown to the attacker upon completion of a melee execution.
+ ///
+ [DataField]
+ public LocId CompleteInternalMeleeExecutionMessage = "execution-popup-melee-complete-internal";
+
+ ///
+ /// Shown to bystanders and the victim of a melee execution when a melee execution is completed.
+ ///
+ [DataField]
+ public LocId CompleteExternalMeleeExecutionMessage = "execution-popup-melee-complete-external";
+
+ ///
+ /// Shown to the person performing the self execution when starting one.
+ ///
+ [DataField]
+ public LocId InternalSelfExecutionMessage = "execution-popup-self-initial-internal";
+
+ ///
+ /// Shown to bystanders near a self execution when one is started.
+ ///
+ [DataField]
+ public LocId ExternalSelfExecutionMessage = "execution-popup-self-initial-external";
+
+ ///
+ /// Shown to the person performing a self execution upon completion of a do-after or on use of /suicide with a weapon that has the Execution component.
+ ///
+ [DataField]
+ public LocId CompleteInternalSelfExecutionMessage = "execution-popup-self-complete-internal";
+
+ ///
+ /// Shown to bystanders when a self execution is completed or a suicide via execution weapon happens nearby.
+ ///
+ [DataField]
+ public LocId CompleteExternalSelfExecutionMessage = "execution-popup-self-complete-external";
+
+ // Not networked because this is transient inside of a tick.
+ ///
+ /// True if it is currently executing for handlers.
+ ///
+ [DataField]
+ public bool Executing = false;
+}
diff --git a/Content.Shared/Execution/SharedExecutionSystem.cs b/Content.Shared/Execution/SharedExecutionSystem.cs
new file mode 100644
index 0000000000..a1105dd644
--- /dev/null
+++ b/Content.Shared/Execution/SharedExecutionSystem.cs
@@ -0,0 +1,234 @@
+using Content.Shared.ActionBlocker;
+using Content.Shared.Chat;
+using Content.Shared.CombatMode;
+using Content.Shared.Damage;
+using Content.Shared.Database;
+using Content.Shared.DoAfter;
+using Content.Shared.Mobs.Components;
+using Content.Shared.Mobs.Systems;
+using Content.Shared.Popups;
+using Content.Shared.Verbs;
+using Content.Shared.Weapons.Melee;
+using Content.Shared.Weapons.Melee.Events;
+using Content.Shared.Interaction.Events;
+using Content.Shared.Mind;
+using Robust.Shared.Player;
+using Robust.Shared.Audio.Systems;
+
+namespace Content.Shared.Execution;
+
+///
+/// Verb for violently murdering cuffed creatures.
+///
+public sealed class SharedExecutionSystem : EntitySystem
+{
+ [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
+ [Dependency] private readonly MobStateSystem _mobState = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly SharedSuicideSystem _suicide = default!;
+ [Dependency] private readonly SharedCombatModeSystem _combat = default!;
+ [Dependency] private readonly SharedExecutionSystem _execution = default!;
+ [Dependency] private readonly SharedMeleeWeaponSystem _melee = default!;
+ [Dependency] private readonly SharedMindSystem _mind = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+ ///
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent>(OnGetInteractionsVerbs);
+ SubscribeLocalEvent(OnGetMeleeDamage);
+ SubscribeLocalEvent(OnSuicideByEnvironment);
+ SubscribeLocalEvent(OnExecutionDoAfter);
+ }
+
+ private void OnGetInteractionsVerbs(EntityUid uid, ExecutionComponent comp, GetVerbsEvent args)
+ {
+ if (args.Hands == null || args.Using == null || !args.CanAccess || !args.CanInteract)
+ return;
+
+ var attacker = args.User;
+ var weapon = args.Using.Value;
+ var victim = args.Target;
+
+ if (!CanBeExecuted(victim, attacker))
+ return;
+
+ UtilityVerb verb = new()
+ {
+ Act = () => TryStartExecutionDoAfter(weapon, victim, attacker, comp),
+ Impact = LogImpact.High,
+ Text = Loc.GetString("execution-verb-name"),
+ Message = Loc.GetString("execution-verb-message"),
+ };
+
+ args.Verbs.Add(verb);
+ }
+
+ private void TryStartExecutionDoAfter(EntityUid weapon, EntityUid victim, EntityUid attacker, ExecutionComponent comp)
+ {
+ if (!CanBeExecuted(victim, attacker))
+ return;
+
+ if (attacker == victim)
+ {
+ ShowExecutionInternalPopup(comp.InternalSelfExecutionMessage, attacker, victim, weapon);
+ ShowExecutionExternalPopup(comp.ExternalSelfExecutionMessage, attacker, victim, weapon);
+ }
+ else
+ {
+ ShowExecutionInternalPopup(comp.InternalMeleeExecutionMessage, attacker, victim, weapon);
+ ShowExecutionExternalPopup(comp.ExternalMeleeExecutionMessage, attacker, victim, weapon);
+ }
+
+ var doAfter =
+ new DoAfterArgs(EntityManager, attacker, comp.DoAfterDuration, new ExecutionDoAfterEvent(), weapon, target: victim, used: weapon)
+ {
+ BreakOnMove = true,
+ BreakOnDamage = true,
+ NeedHand = true
+ };
+
+ _doAfter.TryStartDoAfter(doAfter);
+
+ }
+
+ public bool CanBeExecuted(EntityUid victim, EntityUid attacker)
+ {
+ // No point executing someone if they can't take damage
+ if (!HasComp(victim))
+ return false;
+
+ // You can't execute something that cannot die
+ if (!TryComp(victim, out var mobState))
+ return false;
+
+ // You're not allowed to execute dead people (no fun allowed)
+ if (_mobState.IsDead(victim, mobState))
+ return false;
+
+ // You must be able to attack people to execute
+ if (!_actionBlocker.CanAttack(attacker, victim))
+ return false;
+
+ // The victim must be incapacitated to be executed
+ if (victim != attacker && _actionBlocker.CanInteract(victim, null))
+ return false;
+
+ // All checks passed
+ return true;
+ }
+
+ private void OnGetMeleeDamage(Entity entity, ref GetMeleeDamageEvent args)
+ {
+ if (!TryComp(entity, out var melee) || !entity.Comp.Executing)
+ {
+ return;
+ }
+
+ var bonus = melee.Damage * entity.Comp.DamageMultiplier - melee.Damage;
+ args.Damage += bonus;
+ args.ResistanceBypass = true;
+ }
+
+ private void OnSuicideByEnvironment(Entity entity, ref SuicideByEnvironmentEvent args)
+ {
+ if (!TryComp(entity, out var melee))
+ return;
+
+ string? internalMsg = entity.Comp.CompleteInternalSelfExecutionMessage;
+ string? externalMsg = entity.Comp.CompleteExternalSelfExecutionMessage;
+
+ if (!TryComp(args.Victim, out var damageableComponent))
+ return;
+
+ ShowExecutionInternalPopup(internalMsg, args.Victim, args.Victim, entity, false);
+ ShowExecutionExternalPopup(externalMsg, args.Victim, args.Victim, entity);
+ _audio.PlayPredicted(melee.HitSound, args.Victim, args.Victim);
+ _suicide.ApplyLethalDamage((args.Victim, damageableComponent), melee.Damage);
+ args.Handled = true;
+ }
+
+ private void ShowExecutionInternalPopup(string locString, EntityUid attacker, EntityUid victim, EntityUid weapon, bool predict = true)
+ {
+ if (predict)
+ {
+ _popup.PopupClient(
+ Loc.GetString(locString, ("attacker", attacker), ("victim", victim), ("weapon", weapon)),
+ attacker,
+ attacker,
+ PopupType.MediumCaution
+ );
+ }
+ else
+ {
+ _popup.PopupEntity(
+ Loc.GetString(locString, ("attacker", attacker), ("victim", victim), ("weapon", weapon)),
+ attacker,
+ attacker,
+ PopupType.MediumCaution
+ );
+ }
+ }
+
+ private void ShowExecutionExternalPopup(string locString, EntityUid attacker, EntityUid victim, EntityUid weapon)
+ {
+ _popup.PopupEntity(
+ Loc.GetString(locString, ("attacker", attacker), ("victim", victim), ("weapon", weapon)),
+ attacker,
+ Filter.PvsExcept(attacker),
+ true,
+ PopupType.MediumCaution
+ );
+ }
+
+ private void OnExecutionDoAfter(Entity entity, ref ExecutionDoAfterEvent args)
+ {
+ if (args.Handled || args.Cancelled || args.Used == null || args.Target == null)
+ return;
+
+ if (!TryComp(entity, out var meleeWeaponComp))
+ return;
+
+ var attacker = args.User;
+ var victim = args.Target.Value;
+ var weapon = args.Used.Value;
+
+ if (!_execution.CanBeExecuted(victim, attacker))
+ return;
+
+ // This is needed so the melee system does not stop it.
+ var prev = _combat.IsInCombatMode(attacker);
+ _combat.SetInCombatMode(attacker, true);
+ entity.Comp.Executing = true;
+
+ var internalMsg = entity.Comp.CompleteInternalMeleeExecutionMessage;
+ var externalMsg = entity.Comp.CompleteExternalMeleeExecutionMessage;
+
+ if (attacker == victim)
+ {
+ var suicideEvent = new SuicideEvent(victim);
+ RaiseLocalEvent(victim, suicideEvent);
+
+ var suicideGhostEvent = new SuicideGhostEvent(victim);
+ RaiseLocalEvent(victim, suicideGhostEvent);
+ }
+ else
+ {
+ _melee.AttemptLightAttack(attacker, weapon, meleeWeaponComp, victim);
+ }
+
+ _combat.SetInCombatMode(attacker, prev);
+ entity.Comp.Executing = false;
+ args.Handled = true;
+
+ if (attacker != victim)
+ {
+ _execution.ShowExecutionInternalPopup(internalMsg, attacker, victim, entity);
+ _execution.ShowExecutionExternalPopup(externalMsg, attacker, victim, entity);
+ }
+ }
+}
diff --git a/Content.Shared/Interaction/Events/SuicideEvent.cs b/Content.Shared/Interaction/Events/SuicideEvent.cs
index 7b9c1efe0d..bcd5df67ab 100644
--- a/Content.Shared/Interaction/Events/SuicideEvent.cs
+++ b/Content.Shared/Interaction/Events/SuicideEvent.cs
@@ -1,48 +1,41 @@
-namespace Content.Shared.Interaction.Events
+using Content.Shared.Damage;
+using Content.Shared.Damage.Prototypes;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.Interaction.Events;
+
+///
+/// Raised Directed at an entity to check whether they will handle the suicide.
+///
+public sealed class SuicideEvent : HandledEntityEventArgs
{
- ///
- /// Raised Directed at an entity to check whether they will handle the suicide.
- ///
- public sealed class SuicideEvent : EntityEventArgs
+ public SuicideEvent(EntityUid victim)
{
- public SuicideEvent(EntityUid victim)
- {
- Victim = victim;
- }
- public void SetHandled(SuicideKind kind)
- {
- if (Handled)
- throw new InvalidOperationException("Suicide was already handled");
-
- Kind = kind;
- }
-
- public void BlockSuicideAttempt(bool suicideAttempt)
- {
- if (suicideAttempt)
- AttemptBlocked = suicideAttempt;
- }
-
- public SuicideKind? Kind { get; private set; }
- public EntityUid Victim { get; private set; }
- public bool AttemptBlocked { get; private set; }
- public bool Handled => Kind != null;
+ Victim = victim;
}
- public enum SuicideKind
- {
- Special, //Doesn't damage the mob, used for "weird" suicides like gibbing
-
- //Damage type suicides
- Blunt,
- Slash,
- Piercing,
- Heat,
- Shock,
- Cold,
- Poison,
- Radiation,
- Asphyxiation,
- Bloodloss
- }
+ public DamageSpecifier? DamageSpecifier;
+ public ProtoId? DamageType;
+ public EntityUid Victim { get; private set; }
+}
+
+public sealed class SuicideByEnvironmentEvent : HandledEntityEventArgs
+{
+ public SuicideByEnvironmentEvent(EntityUid victim)
+ {
+ Victim = victim;
+ }
+
+ public EntityUid Victim { get; set; }
+}
+
+public sealed class SuicideGhostEvent : HandledEntityEventArgs
+{
+ public SuicideGhostEvent(EntityUid victim)
+ {
+ Victim = victim;
+ }
+
+ public EntityUid Victim { get; set; }
+ public bool CanReturnToBody;
}
diff --git a/Content.Shared/Mind/SharedMindSystem.cs b/Content.Shared/Mind/SharedMindSystem.cs
index ba365daf15..24b47b6412 100644
--- a/Content.Shared/Mind/SharedMindSystem.cs
+++ b/Content.Shared/Mind/SharedMindSystem.cs
@@ -168,15 +168,17 @@ public abstract class SharedMindSystem : EntitySystem
args.PushMarkup($"[color=yellow]{Loc.GetString("comp-mind-examined-ssd", ("ent", uid))}[/color]");
}
+ ///
+ /// Checks to see if the user's mind prevents them from suicide
+ /// Handles the suicide event without killing the user if true
+ ///
private void OnSuicide(EntityUid uid, MindContainerComponent component, SuicideEvent args)
{
if (args.Handled)
return;
if (TryComp(component.Mind, out MindComponent? mind) && mind.PreventSuicide)
- {
- args.BlockSuicideAttempt(true);
- }
+ args.Handled = true;
}
public EntityUid? GetMind(EntityUid uid, MindContainerComponent? mind = null)
diff --git a/Content.Shared/Weapons/Melee/Events/MeleeHitEvent.cs b/Content.Shared/Weapons/Melee/Events/MeleeHitEvent.cs
index 55c01c1d6f..75c85790de 100644
--- a/Content.Shared/Weapons/Melee/Events/MeleeHitEvent.cs
+++ b/Content.Shared/Weapons/Melee/Events/MeleeHitEvent.cs
@@ -80,7 +80,7 @@ public sealed class MeleeHitEvent : HandledEntityEventArgs
/// Raised on a melee weapon to calculate potential damage bonuses or decreases.
///
[ByRefEvent]
-public record struct GetMeleeDamageEvent(EntityUid Weapon, DamageSpecifier Damage, List Modifiers, EntityUid User);
+public record struct GetMeleeDamageEvent(EntityUid Weapon, DamageSpecifier Damage, List Modifiers, EntityUid User, bool ResistanceBypass = false);
///
/// Raised on a melee weapon to calculate the attack rate.
diff --git a/Content.Shared/Weapons/Melee/MeleeWeaponComponent.cs b/Content.Shared/Weapons/Melee/MeleeWeaponComponent.cs
index fa5e0b3a90..212c03475c 100644
--- a/Content.Shared/Weapons/Melee/MeleeWeaponComponent.cs
+++ b/Content.Shared/Weapons/Melee/MeleeWeaponComponent.cs
@@ -67,6 +67,12 @@ public sealed partial class MeleeWeaponComponent : Component
[DataField, ViewVariables(VVAccess.ReadWrite), AutoNetworkedField]
public bool AutoAttack;
+ ///
+ /// If true, attacks will bypass armor resistances.
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite), AutoNetworkedField]
+ public bool ResistanceBypass = false;
+
///
/// Base damage for this weapon. Can be modified via heavy damage or other means.
///
diff --git a/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs b/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs
index ae0ebfe74c..bc19235cd3 100644
--- a/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs
+++ b/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs
@@ -216,7 +216,7 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem
if (!Resolve(uid, ref component, false))
return new DamageSpecifier();
- var ev = new GetMeleeDamageEvent(uid, new (component.Damage), new(), user);
+ var ev = new GetMeleeDamageEvent(uid, new(component.Damage), new(), user, component.ResistanceBypass);
RaiseLocalEvent(uid, ref ev);
return DamageSpecifier.ApplyModifierSets(ev.Damage, ev.Modifiers);
@@ -244,6 +244,17 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem
return ev.DamageModifier * ev.Multipliers;
}
+ public bool GetResistanceBypass(EntityUid uid, EntityUid user, MeleeWeaponComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return false;
+
+ var ev = new GetMeleeDamageEvent(uid, new(component.Damage), new(), user, component.ResistanceBypass);
+ RaiseLocalEvent(uid, ref ev);
+
+ return ev.ResistanceBypass;
+ }
+
public bool TryGetWeapon(EntityUid entity, out EntityUid weaponUid, [NotNullWhen(true)] out MeleeWeaponComponent? melee)
{
weaponUid = default;
@@ -441,6 +452,7 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem
// If I do not come back later to fix Light Attacks being Heavy Attacks you can throw me in the spider pit -Errant
var damage = GetDamage(meleeUid, user, component) * GetHeavyDamageModifier(meleeUid, user, component);
var target = GetEntity(ev.Target);
+ var resistanceBypass = GetResistanceBypass(meleeUid, user, component);
// For consistency with wide attacks stuff needs damageable.
if (Deleted(target) ||
@@ -497,7 +509,7 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem
RaiseLocalEvent(target.Value, attackedEvent);
var modifiedDamage = DamageSpecifier.ApplyModifierSets(damage + hitEvent.BonusDamage + attackedEvent.BonusDamage, hitEvent.ModifiersList);
- var damageResult = Damageable.TryChangeDamage(target, modifiedDamage, origin:user);
+ var damageResult = Damageable.TryChangeDamage(target, modifiedDamage, origin:user, ignoreResistances:resistanceBypass);
if (damageResult is {Empty: false})
{
diff --git a/Resources/Locale/en-US/execution/execution.ftl b/Resources/Locale/en-US/execution/execution.ftl
new file mode 100644
index 0000000000..08f9a06dd4
--- /dev/null
+++ b/Resources/Locale/en-US/execution/execution.ftl
@@ -0,0 +1,17 @@
+execution-verb-name = Execute
+execution-verb-message = Use your weapon to execute someone.
+
+# All the below localisation strings have access to the following variables
+# attacker (the person committing the execution)
+# victim (the person being executed)
+# weapon (the weapon used for the execution)
+
+execution-popup-melee-initial-internal = You ready {THE($weapon)} against {$victim}'s throat.
+execution-popup-melee-initial-external = {$attacker} readies {POSS-ADJ($attacker)} {$weapon} against the throat of {$victim}.
+execution-popup-melee-complete-internal = You slit the throat of {$victim}!
+execution-popup-melee-complete-external = {$attacker} slits the throat of {$victim}!
+
+execution-popup-self-initial-internal = You ready {THE($weapon)} against your own throat.
+execution-popup-self-initial-external = {$attacker} readies {POSS-ADJ($attacker)} {$weapon} against their own throat.
+execution-popup-self-complete-internal = You slit your own throat!
+execution-popup-self-complete-external = {$attacker} slits their own throat!
\ No newline at end of file
diff --git a/Resources/Prototypes/Entities/Objects/Materials/crystal_shard.yml b/Resources/Prototypes/Entities/Objects/Materials/crystal_shard.yml
index 8f522abce4..47828ed8f5 100644
--- a/Resources/Prototypes/Entities/Objects/Materials/crystal_shard.yml
+++ b/Resources/Prototypes/Entities/Objects/Materials/crystal_shard.yml
@@ -6,6 +6,8 @@
description: A small piece of crystal.
components:
- type: Sharp
+ - type: Execution
+ doAfterDuration: 4.0
- type: Sprite
layers:
- sprite: Objects/Materials/Shards/crystal.rsi
diff --git a/Resources/Prototypes/Entities/Objects/Materials/shards.yml b/Resources/Prototypes/Entities/Objects/Materials/shards.yml
index fa6937dac3..5668661e08 100644
--- a/Resources/Prototypes/Entities/Objects/Materials/shards.yml
+++ b/Resources/Prototypes/Entities/Objects/Materials/shards.yml
@@ -5,6 +5,8 @@
description: It's a shard of some unknown material.
components:
- type: Sharp
+ - type: Execution
+ doAfterDuration: 4.0
- type: Sprite
layers:
- sprite: Objects/Materials/Shards/shard.rsi
diff --git a/Resources/Prototypes/Entities/Objects/Misc/broken_bottle.yml b/Resources/Prototypes/Entities/Objects/Misc/broken_bottle.yml
index 9d3ef6c424..b458f0ae21 100644
--- a/Resources/Prototypes/Entities/Objects/Misc/broken_bottle.yml
+++ b/Resources/Prototypes/Entities/Objects/Misc/broken_bottle.yml
@@ -5,6 +5,8 @@
description: In Space Glasgow this is called a conversation starter.
components:
- type: Sharp
+ - type: Execution
+ doAfterDuration: 4.0
- type: MeleeWeapon
attackRate: 1.5
damage:
diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Melee/armblade.yml b/Resources/Prototypes/Entities/Objects/Weapons/Melee/armblade.yml
index 49b22c000d..398c04aee6 100644
--- a/Resources/Prototypes/Entities/Objects/Weapons/Melee/armblade.yml
+++ b/Resources/Prototypes/Entities/Objects/Weapons/Melee/armblade.yml
@@ -5,6 +5,8 @@
description: A grotesque blade made out of bone and flesh that cleaves through people as a hot knife through butter.
components:
- type: Sharp
+ - type: Execution
+ doAfterDuration: 4.0
- type: Sprite
sprite: Objects/Weapons/Melee/armblade.rsi
state: icon
diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Melee/fireaxe.yml b/Resources/Prototypes/Entities/Objects/Weapons/Melee/fireaxe.yml
index 93765ec40c..f6a4749654 100644
--- a/Resources/Prototypes/Entities/Objects/Weapons/Melee/fireaxe.yml
+++ b/Resources/Prototypes/Entities/Objects/Weapons/Melee/fireaxe.yml
@@ -8,6 +8,8 @@
tags:
- FireAxe
- type: Sharp
+ - type: Execution
+ doAfterDuration: 4.0
- type: Sprite
sprite: Objects/Weapons/Melee/fireaxe.rsi
state: icon
diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Melee/knife.yml b/Resources/Prototypes/Entities/Objects/Weapons/Melee/knife.yml
index 39a04120e0..8270a50bd6 100644
--- a/Resources/Prototypes/Entities/Objects/Weapons/Melee/knife.yml
+++ b/Resources/Prototypes/Entities/Objects/Weapons/Melee/knife.yml
@@ -7,6 +7,8 @@
tags:
- Knife
- type: Sharp
+ - type: Execution
+ doAfterDuration: 4.0
- type: Utensil
types:
- Knife
diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Melee/sword.yml b/Resources/Prototypes/Entities/Objects/Weapons/Melee/sword.yml
index 838cde619f..c2449a6bcb 100644
--- a/Resources/Prototypes/Entities/Objects/Weapons/Melee/sword.yml
+++ b/Resources/Prototypes/Entities/Objects/Weapons/Melee/sword.yml
@@ -4,6 +4,8 @@
abstract: true
components:
- type: Sharp
+ - type: Execution
+ doAfterDuration: 4.0
- type: MeleeWeapon
wideAnimationRotation: -135
- type: Sprite