diff --git a/Content.Shared/ActionBlocker/ActionBlockerSystem.cs b/Content.Shared/ActionBlocker/ActionBlockerSystem.cs index d2b12a4b29..a3cd83042e 100644 --- a/Content.Shared/ActionBlocker/ActionBlockerSystem.cs +++ b/Content.Shared/ActionBlocker/ActionBlockerSystem.cs @@ -9,6 +9,7 @@ using Content.Shared.Movement.Components; using Content.Shared.Movement.Events; using Content.Shared.Speech; using Content.Shared.Throwing; +using Content.Shared.Weapons.Melee; using JetBrains.Annotations; using Robust.Shared.Containers; @@ -145,7 +146,7 @@ namespace Content.Shared.ActionBlocker return !ev.Cancelled; } - public bool CanAttack(EntityUid uid, EntityUid? target = null) + public bool CanAttack(EntityUid uid, EntityUid? target = null, Entity? weapon = null, bool disarm = false) { _container.TryGetOuterContainer(uid, Transform(uid), out var outerContainer); if (target != null && target != outerContainer?.Owner && _container.IsEntityInContainer(uid)) @@ -155,7 +156,7 @@ namespace Content.Shared.ActionBlocker return containerEv.CanAttack; } - var ev = new AttackAttemptEvent(uid, target); + var ev = new AttackAttemptEvent(uid, target, weapon, disarm); RaiseLocalEvent(uid, ev); if (ev.Cancelled) diff --git a/Content.Shared/CombatMode/Pacification/PacificationSystem.cs b/Content.Shared/CombatMode/Pacification/PacificationSystem.cs index a1332fec76..0c5e12e6f0 100644 --- a/Content.Shared/CombatMode/Pacification/PacificationSystem.cs +++ b/Content.Shared/CombatMode/Pacification/PacificationSystem.cs @@ -1,50 +1,24 @@ +using System.Diagnostics.CodeAnalysis; using Content.Shared.Actions; using Content.Shared.Alert; +using Content.Shared.FixedPoint; using Content.Shared.IdentityManagement; using Content.Shared.Interaction.Events; using Content.Shared.Popups; using Content.Shared.Throwing; +using Content.Shared.Weapons.Ranged.Events; +using Content.Shared.Weapons.Ranged.Systems; +using Robust.Shared.Timing; namespace Content.Shared.CombatMode.Pacification; -/// -/// Raised when a Pacified entity attempts to throw something. -/// The throw is only permitted if this event is not cancelled. -/// -[ByRefEvent] -public struct AttemptPacifiedThrowEvent -{ - public EntityUid ItemUid; - public EntityUid PlayerUid; - - public AttemptPacifiedThrowEvent(EntityUid itemUid, EntityUid playerUid) - { - ItemUid = itemUid; - PlayerUid = playerUid; - } - - public bool Cancelled { get; private set; } = false; - public string? CancelReasonMessageId { get; private set; } - - /// - /// Localization string ID for the reason this event has been cancelled. - /// If null, a generic message will be shown to the player. - /// Note that any supplied localization string MUST accept a '$projectile' - /// parameter specifying the name of the thrown entity. - /// - public void Cancel(string? reasonMessageId = null) - { - Cancelled = true; - CancelReasonMessageId = reasonMessageId; - } -} - public sealed class PacificationSystem : EntitySystem { [Dependency] private readonly AlertsSystem _alertsSystem = default!; [Dependency] private readonly SharedActionsSystem _actionsSystem = default!; [Dependency] private readonly SharedCombatModeSystem _combatSystem = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; + [Dependency] private readonly IGameTiming _timing = default!; public override void Initialize() { @@ -53,10 +27,78 @@ public sealed class PacificationSystem : EntitySystem SubscribeLocalEvent(OnShutdown); SubscribeLocalEvent(OnBeforeThrow); SubscribeLocalEvent(OnAttackAttempt); + SubscribeLocalEvent(OnShootAttempt); + SubscribeLocalEvent(OnUnpaused); + SubscribeLocalEvent(OnPacifiedDangerousAttack); + } + + private void OnUnpaused(Entity ent, ref EntityUnpausedEvent args) + { + if (ent.Comp.NextPopupTime != null) + ent.Comp.NextPopupTime = ent.Comp.NextPopupTime.Value + args.PausedTime; + } + + private bool PacifiedCanAttack(EntityUid user, EntityUid target, [NotNullWhen(false)] out string? reason) + { + var ev = new AttemptPacifiedAttackEvent(user); + + RaiseLocalEvent(target, ref ev); + + if (ev.Cancelled) + { + reason = ev.Reason; + return false; + } + + reason = null; + return true; + } + + private void ShowPopup(Entity user, EntityUid target, string reason) + { + // Popup logic. + // Cooldown is needed because the input events for melee/shooting etc. will fire continuously + if (target == user.Comp.LastAttackedEntity + && !(_timing.CurTime > user.Comp.NextPopupTime)) + return; + + _popup.PopupClient(Loc.GetString(reason, ("entity", target)), user, user); + user.Comp.NextPopupTime = _timing.CurTime + user.Comp.PopupCooldown; + user.Comp.LastAttackedEntity = target; + } + + private void OnShootAttempt(Entity ent, ref ShotAttemptedEvent args) + { + // Disallow firing guns in all cases. + ShowPopup(ent, args.Used, "pacified-cannot-fire-gun"); + args.Cancel(); } private void OnAttackAttempt(EntityUid uid, PacifiedComponent component, AttackAttemptEvent args) { + if (component.DisallowAllCombat || args.Disarm && component.DisallowDisarm) + { + args.Cancel(); + return; + } + + // If it's a disarm, let it go through (unless we disallow them, which is handled earlier) + if (args.Disarm) + return; + + // Allow attacking with no target. This should be fine. + // If it's a wide swing, that will be handled with a later AttackAttemptEvent raise. + if (args.Target == null) + return; + + // If we would do zero damage, it should be fine. + if (args.Weapon != null && args.Weapon.Value.Comp.Damage.GetTotal() == FixedPoint2.Zero) + return; + + if (PacifiedCanAttack(uid, args.Target.Value, out var reason)) + return; + + ShowPopup((uid, component), args.Target.Value, reason); args.Cancel(); } @@ -65,11 +107,15 @@ public sealed class PacificationSystem : EntitySystem if (!TryComp(uid, out var combatMode)) return; - if (combatMode.CanDisarm != null) + if (component.DisallowDisarm && combatMode.CanDisarm != null) _combatSystem.SetCanDisarm(uid, false, combatMode); - _combatSystem.SetInCombatMode(uid, false, combatMode); - _actionsSystem.SetEnabled(combatMode.CombatToggleActionEntity, false); + if (component.DisallowAllCombat) + { + _combatSystem.SetInCombatMode(uid, false, combatMode); + _actionsSystem.SetEnabled(combatMode.CombatToggleActionEntity, false); + } + _alertsSystem.ShowAlert(uid, AlertType.Pacified); } @@ -103,4 +149,51 @@ public sealed class PacificationSystem : EntitySystem var cannotThrowMessage = ev.CancelReasonMessageId ?? "pacified-cannot-throw"; _popup.PopupEntity(Loc.GetString(cannotThrowMessage, ("projectile", itemName)), ent, ent); } + + private void OnPacifiedDangerousAttack(Entity ent, ref AttemptPacifiedAttackEvent args) + { + args.Cancelled = true; + args.Reason = "pacified-cannot-harm-indirect"; + } } + + +/// +/// Raised when a Pacified entity attempts to throw something. +/// The throw is only permitted if this event is not cancelled. +/// +[ByRefEvent] +public struct AttemptPacifiedThrowEvent +{ + public EntityUid ItemUid; + public EntityUid PlayerUid; + + public AttemptPacifiedThrowEvent(EntityUid itemUid, EntityUid playerUid) + { + ItemUid = itemUid; + PlayerUid = playerUid; + } + + public bool Cancelled { get; private set; } = false; + public string? CancelReasonMessageId { get; private set; } + + /// + /// Localization string ID for the reason this event has been cancelled. + /// If null, a generic message will be shown to the player. + /// Note that any supplied localization string MUST accept a '$projectile' + /// parameter specifying the name of the thrown entity. + /// + public void Cancel(string? reasonMessageId = null) + { + Cancelled = true; + CancelReasonMessageId = reasonMessageId; + } +} + +/// +/// Raised ref directed on an entity when a pacified user is attempting to attack it. +/// If is true, don't allow attacking. +/// should be a loc string, if there needs to be special text for why the user isn't able to attack this. +/// +[ByRefEvent] +public record struct AttemptPacifiedAttackEvent(EntityUid User, bool Cancelled = false, string Reason = "pacified-cannot-harm-directly"); diff --git a/Content.Shared/CombatMode/Pacification/PacifiedComponent.cs b/Content.Shared/CombatMode/Pacification/PacifiedComponent.cs index 4b6dff76a2..e271628fcb 100644 --- a/Content.Shared/CombatMode/Pacification/PacifiedComponent.cs +++ b/Content.Shared/CombatMode/Pacification/PacifiedComponent.cs @@ -3,11 +3,42 @@ using Robust.Shared.GameStates; namespace Content.Shared.CombatMode.Pacification; /// -/// Status effect that disables combat mode and restricts aggressive actions. +/// Status effect that disallows harming living things and restricts aggressive actions. +/// +/// There is a caveat with pacifism. It's not intended to be wholly encompassing: there are ways of harming people +/// while pacified--plenty of them, even! The goal is to restrict the obvious ones to make gameplay more interesting +/// while not overly limiting. +/// +/// If you want full-pacifism (no combat mode at all), you can simply set before adding. /// [RegisterComponent, NetworkedComponent] [Access(typeof(PacificationSystem))] public sealed partial class PacifiedComponent : Component { + [DataField] + public bool DisallowDisarm = false; + + /// + /// If true, this will disable combat entirely instead of only disallowing attacking living creatures and harmful things. + /// + [DataField] + public bool DisallowAllCombat = false; + + + /// + /// When attempting attack against the same entity multiple times, + /// don't spam popups every frame and instead have a cooldown. + /// + [DataField] + public TimeSpan PopupCooldown = TimeSpan.FromSeconds(3.0); + + [DataField] + public TimeSpan? NextPopupTime = null; + + /// + /// The last entity attacked, used for popup purposes (avoid spam) + /// + [DataField] + public EntityUid? LastAttackedEntity = null; } diff --git a/Content.Shared/CombatMode/Pacification/PacifismDangerousAttackComponent.cs b/Content.Shared/CombatMode/Pacification/PacifismDangerousAttackComponent.cs new file mode 100644 index 0000000000..df79d46d76 --- /dev/null +++ b/Content.Shared/CombatMode/Pacification/PacifismDangerousAttackComponent.cs @@ -0,0 +1,13 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.CombatMode.Pacification; + +/// +/// This is used for marking entities which could cause serious harm if attacked and should not be able to be harmed by +/// pacifists. +/// TODO ideally destructible is shared + converted to components so we can just check for a harmful damage trigger instead of this. +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class PacifismDangerousAttackComponent : Component +{ +} diff --git a/Content.Shared/Interaction/Events/AttackAttemptEvent.cs b/Content.Shared/Interaction/Events/AttackAttemptEvent.cs index 8fd2f75d89..a71dccdf91 100644 --- a/Content.Shared/Interaction/Events/AttackAttemptEvent.cs +++ b/Content.Shared/Interaction/Events/AttackAttemptEvent.cs @@ -1,3 +1,5 @@ +using Content.Shared.Weapons.Melee; + namespace Content.Shared.Interaction.Events { /// @@ -12,10 +14,19 @@ namespace Content.Shared.Interaction.Events public EntityUid Uid { get; } public EntityUid? Target { get; } - public AttackAttemptEvent(EntityUid uid, EntityUid? target = null) + public Entity? Weapon { get; } + + /// + /// If this attempt is a disarm as opposed to an actual attack, for things that care about the difference. + /// + public bool Disarm { get; } + + public AttackAttemptEvent(EntityUid uid, EntityUid? target = null, Entity? weapon = null, bool disarm = false) { Uid = uid; Target = target; + Weapon = weapon; + Disarm = disarm; } } diff --git a/Content.Shared/Mobs/Systems/MobStateSystem.Subscribers.cs b/Content.Shared/Mobs/Systems/MobStateSystem.Subscribers.cs index 1599932246..8ce12db518 100644 --- a/Content.Shared/Mobs/Systems/MobStateSystem.Subscribers.cs +++ b/Content.Shared/Mobs/Systems/MobStateSystem.Subscribers.cs @@ -1,4 +1,5 @@ using Content.Shared.Bed.Sleep; +using Content.Shared.CombatMode.Pacification; using Content.Shared.Damage.ForceSay; using Content.Shared.Emoting; using Content.Shared.Hands; @@ -39,6 +40,7 @@ public partial class MobStateSystem SubscribeLocalEvent(CheckAct); SubscribeLocalEvent(OnSleepAttempt); SubscribeLocalEvent(OnCombatModeShouldHandInteract); + SubscribeLocalEvent(OnAttemptPacifiedAttack); } private void OnStateExitSubscribers(EntityUid target, MobStateComponent component, MobState state) @@ -166,5 +168,10 @@ public partial class MobStateSystem args.Cancelled = true; } + private void OnAttemptPacifiedAttack(Entity ent, ref AttemptPacifiedAttackEvent args) + { + args.Cancelled = true; + } + #endregion } diff --git a/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs b/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs index 3505149564..68f625d949 100644 --- a/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs +++ b/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs @@ -28,6 +28,7 @@ using Robust.Shared.Physics.Systems; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Timing; +using Robust.Shared.Toolshed.Syntax; namespace Content.Shared.Weapons.Melee; @@ -350,7 +351,7 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem case LightAttackEvent light: var lightTarget = GetEntity(light.Target); - if (!Blocker.CanAttack(user, lightTarget)) + if (!Blocker.CanAttack(user, lightTarget, (weaponUid, weapon))) return false; // Can't self-attack if you're the weapon @@ -361,11 +362,11 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem case DisarmAttackEvent disarm: var disarmTarget = GetEntity(disarm.Target); - if (!Blocker.CanAttack(user, disarmTarget)) + if (!Blocker.CanAttack(user, disarmTarget, (weaponUid, weapon), true)) return false; break; default: - if (!Blocker.CanAttack(user)) + if (!Blocker.CanAttack(user, weapon: (weaponUid, weapon))) return false; break; } @@ -642,20 +643,27 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem foreach (var entity in targets) { + // We raise an attack attempt here as well, + // primarily because this was an untargeted wideswing: if a subscriber to that event cared about + // the potential target (such as for pacifism), they need to be made aware of the target here. + // In that case, just continue. + if (!Blocker.CanAttack(user, entity, (weapon, component))) + continue; + var attackedEvent = new AttackedEvent(meleeUid, user, GetCoordinates(ev.Coordinates)); RaiseLocalEvent(entity, attackedEvent); var modifiedDamage = DamageSpecifier.ApplyModifierSets(damage + hitEvent.BonusDamage + attackedEvent.BonusDamage, hitEvent.ModifiersList); var damageResult = Damageable.TryChangeDamage(entity, modifiedDamage, origin:user); - if (damageResult != null && damageResult.Total > FixedPoint2.Zero) + if (damageResult != null && damageResult.GetTotal() > FixedPoint2.Zero) { appliedDamage += damageResult; if (meleeUid == user) { AdminLogger.Add(LogType.MeleeHit, LogImpact.Medium, - $"{ToPrettyString(user):actor} melee attacked (heavy) {ToPrettyString(entity):subject} using their hands and dealt {damageResult.Total:damage} damage"); + $"{ToPrettyString(user):actor} melee attacked (heavy) {ToPrettyString(entity):subject} using their hands and dealt {damageResult.GetTotal():damage} damage"); } else { @@ -667,7 +675,7 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem if (entities.Count != 0) { - if (appliedDamage.Total > FixedPoint2.Zero) + if (appliedDamage.GetTotal() > FixedPoint2.Zero) { var target = entities.First(); PlayHitSound(target, user, GetHighestDamageSound(appliedDamage, _protoManager), hitEvent.HitSoundOverride, component.HitSound); @@ -685,7 +693,7 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem } } - if (appliedDamage.Total > FixedPoint2.Zero) + if (appliedDamage.GetTotal() > FixedPoint2.Zero) { DoDamageEffect(targets, user, Transform(targets[0])); } diff --git a/Resources/Locale/en-US/alerts/alerts.ftl b/Resources/Locale/en-US/alerts/alerts.ftl index bb3ca51037..1e9b8a2017 100644 --- a/Resources/Locale/en-US/alerts/alerts.ftl +++ b/Resources/Locale/en-US/alerts/alerts.ftl @@ -94,7 +94,7 @@ alerts-bleed-name = [color=red]Bleed[/color] alerts-bleed-desc = You're [color=red]bleeding[/color]. alerts-pacified-name = [color=green]Pacified[/color] -alerts-pacified-desc = You're pacified; you won't be able to attack anyone directly. +alerts-pacified-desc = You're pacified; you won't be able to harm living creatures. alerts-suit-power-name = Suit Power alerts-suit-power-desc = How much power your space ninja suit has. diff --git a/Resources/Locale/en-US/pacified/pacified.ftl b/Resources/Locale/en-US/pacified/pacified.ftl index 4d45f13bd3..e0a0e9d1c3 100644 --- a/Resources/Locale/en-US/pacified/pacified.ftl +++ b/Resources/Locale/en-US/pacified/pacified.ftl @@ -2,10 +2,14 @@ ## Messages shown to Pacified players when they try to do violence: # With projectiles: -pacified-cannot-throw = You can't bring yourself to throw { THE($projectile) }, that could hurt someone! +pacified-cannot-throw = I can't bring myself to throw { THE($projectile) }, that could hurt someone! # With embedding projectiles: -pacified-cannot-throw-embed = No way you can throw { THE($projectile) }, that could get lodged inside someone! +pacified-cannot-throw-embed = No way I could throw { THE($projectile) }, that could get lodged inside someone! # With liquid-spilling projectiles: -pacified-cannot-throw-spill = You can't possibly throw { THE($projectile) }, that could spill nasty stuff on someone! +pacified-cannot-throw-spill = I can't possibly throw { THE($projectile) }, that could spill nasty stuff on someone! # With bolas and snares: -pacified-cannot-throw-snare = You can't throw { THE($projectile) }, what if someone trips?! +pacified-cannot-throw-snare = I can't throw { THE($projectile) }, what if someone trips?! + +pacified-cannot-harm-directly = I can't bring myself to hurt { THE($entity) }! +pacified-cannot-harm-indirect = I can't damage { THE($entity) }, it could hurt someone! +pacified-cannot-fire-gun = I can't fire { THE($entity) }, it could hurt someone! diff --git a/Resources/Prototypes/Entities/Structures/Power/Generation/generators.yml b/Resources/Prototypes/Entities/Structures/Power/Generation/generators.yml index ad227956a7..2bcd65533f 100644 --- a/Resources/Prototypes/Entities/Structures/Power/Generation/generators.yml +++ b/Resources/Prototypes/Entities/Structures/Power/Generation/generators.yml @@ -54,6 +54,7 @@ - type: Damageable damageContainer: Inorganic damageModifierSet: Metallic + - type: PacifismDangerousAttack - type: Destructible thresholds: - trigger: diff --git a/Resources/Prototypes/Entities/Structures/Power/substation.yml b/Resources/Prototypes/Entities/Structures/Power/substation.yml index fe6936d411..2155baa6ad 100644 --- a/Resources/Prototypes/Entities/Structures/Power/substation.yml +++ b/Resources/Prototypes/Entities/Structures/Power/substation.yml @@ -59,6 +59,7 @@ - type: Damageable damageContainer: Inorganic damageModifierSet: StrongMetallic + - type: PacifismDangerousAttack - type: Destructible thresholds: - trigger: @@ -191,6 +192,7 @@ - type: Damageable damageContainer: Inorganic damageModifierSet: Metallic + - type: PacifismDangerousAttack - type: Destructible thresholds: - trigger: diff --git a/Resources/Prototypes/Entities/Structures/Storage/Tanks/tanks.yml b/Resources/Prototypes/Entities/Structures/Storage/Tanks/tanks.yml index 15504fe0d7..b7e41d9e9c 100644 --- a/Resources/Prototypes/Entities/Structures/Storage/Tanks/tanks.yml +++ b/Resources/Prototypes/Entities/Structures/Storage/Tanks/tanks.yml @@ -28,6 +28,7 @@ weldingDamage: types: Heat: 10 + - type: PacifismDangerousAttack - type: Explosive explosionType: Default totalIntensity: 120 # ~ 5 tile radius