diff --git a/Content.IntegrationTests/Tests/Clumsy/ClumsyStatusTest.cs b/Content.IntegrationTests/Tests/Clumsy/ClumsyStatusTest.cs index cf42a3b241..b936c7cde0 100644 --- a/Content.IntegrationTests/Tests/Clumsy/ClumsyStatusTest.cs +++ b/Content.IntegrationTests/Tests/Clumsy/ClumsyStatusTest.cs @@ -8,6 +8,7 @@ using Content.Shared.Climbing.Components; using Content.Shared.Climbing.Events; using Content.Shared.Climbing.Systems; using Content.Shared.Clumsy; +using Content.Shared.Hands; using Content.Shared.Medical; using Content.Shared.Mobs.Components; using Content.Shared.StatusEffectNew; @@ -28,6 +29,7 @@ public sealed class ClumsyStatusTest : InteractionTest private sealed class DefibListenerSystem : TestListenerSystem; private sealed class GunListenerSystem : TestListenerSystem; private sealed class InjectListenerSystem : TestListenerSystem; + private sealed class BeforeEquipListenerSystem : TestListenerSystem; [SidedDependency(Side.Server)] private readonly ClimbSystem _sClimbSystem = default!; [SidedDependency(Side.Server)] private readonly StatusEffectsSystem _sStatusSystem = default!; @@ -164,4 +166,34 @@ public sealed class ClumsyStatusTest : InteractionTest Assert.That(ev.Cancelled, Is.True, "Clumsy mob didn't cancel climb event."); } } + + [Test, Description("Test that a mob with the ClumsyGrab status will be unable to hold items in hands.")] + public async Task TestClumsyGrab() + { + await Server.WaitPost(() => + { + SEntMan.EnsureComponent(SPlayer); + _sStatusSystem.TrySetStatusEffectDuration(SPlayer, ClumsyGrabProto); + }); + + Assume.That(_sStatusSystem.HasStatusEffect(SPlayer, ClumsyGrabProto), Is.True); + Assume.That(HandSys.ActiveHandIsEmpty(SPlayer), Is.True, "Clumsy mob was already holding something!"); + var freeHands = HandSys.CountFreeHands(SPlayer); //"before" free hands + + // can't use PlaceInHands, that method will fail an assertion under this status effect. + await Server.WaitPost(() => + { + var location = SEntMan.EnsureComponent(SPlayer).Coordinates; + var item = SSpawnAtPosition(ItemProto, location); + HandSys.TryPickup(SPlayer, item, handsComp: Hands); + }); + + Assert.That(HandSys.ActiveHandIsEmpty(SPlayer), Is.True, "The clumsy mob has something in their active hand (they picked up the item!?)"); + Assert.That(HandSys.CountFreeHands(SPlayer), Is.EqualTo(freeHands), "A hand became occupied ('before' and 'after' free hand count has changed)"); + + foreach (var ev in GetEvents(SPlayer)) + { + Assert.That(ev.Cancelled, Is.True, "Clumsy mob didn't cancel BeforeEquippingHandEvent."); + } + } } diff --git a/Content.IntegrationTests/Tests/Clumsy/ClumsyTestPrototypes.cs b/Content.IntegrationTests/Tests/Clumsy/ClumsyTestPrototypes.cs index 8013393c58..76eae4ff6b 100644 --- a/Content.IntegrationTests/Tests/Clumsy/ClumsyTestPrototypes.cs +++ b/Content.IntegrationTests/Tests/Clumsy/ClumsyTestPrototypes.cs @@ -9,9 +9,11 @@ public static class ClumsyTestPrototypes public static readonly EntProtoId SyringeProto = "Syringe"; public const string ClumsyStatusAll100 = "ClumsyStatusAll100"; + public const string ClumsyGrabProto = "ClumsyGrabProto"; public const string BallProto = "BallProto"; public const string DefibProto = "DefibProto"; public const string TableProto = "TableProto"; + public const string ItemProto = "ItemProto"; [TestPrototypes] public static readonly string ClumsyPrototypes = @$" @@ -30,6 +32,13 @@ public static class ClumsyTestPrototypes - type: ClumsyVaultStatusEffect clumsyChance: 1 +- type: entity + id: {ClumsyGrabProto} + components: + - type: StatusEffect + - type: ClumsyGrabStatusEffect + clumsyChance: 1 + - type: entity id: {BallProto} components: @@ -55,5 +64,10 @@ public static class ClumsyTestPrototypes components: - type: Climbable - type: Bonkable + +- type: entity + id: {ItemProto} + components: + - type: Item "; } diff --git a/Content.Server/Zombies/ZombieSystem.Transform.cs b/Content.Server/Zombies/ZombieSystem.Transform.cs index 7b126f57e8..08833cc35c 100644 --- a/Content.Server/Zombies/ZombieSystem.Transform.cs +++ b/Content.Server/Zombies/ZombieSystem.Transform.cs @@ -15,8 +15,8 @@ using Content.Shared.Body; using Content.Shared.Body.Components; using Content.Shared.CombatMode; using Content.Shared.CombatMode.Pacification; +using Content.Shared.Cuffs; using Content.Shared.Ghost.Roles.Components; -using Content.Shared.Hands.Components; using Content.Shared.Hands.EntitySystems; using Content.Shared.Humanoid; using Content.Shared.IdentityManagement; @@ -35,12 +35,15 @@ using Content.Shared.Popups; using Content.Shared.Prying.Components; using Content.Shared.Roles; using Content.Shared.Speech.EntitySystems; +using Content.Shared.StatusEffectNew; +using Content.Shared.StatusEffectNew.Components; using Content.Shared.Tag; using Content.Shared.Temperature.Components; using Content.Shared.Traits.Assorted; using Content.Shared.Weapons.Melee; using Content.Shared.Zombies; using Robust.Shared.Audio.Systems; +using Robust.Shared.Containers; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Utility; @@ -73,12 +76,16 @@ public sealed partial class ZombieSystem [Dependency] private TagSystem _tag = default!; [Dependency] private ISharedPlayerManager _player = default!; [Dependency] private BodySystem _body = default!; + [Dependency] private SharedContainerSystem _containerSystem = default!; + [Dependency] private StatusEffectsSystem _statusEffects = default!; + [Dependency] private SharedCuffableSystem _cuffable = default!; private static readonly ProtoId InvalidForGlobalSpawnSpellTag = "InvalidForGlobalSpawnSpell"; private static readonly ProtoId CannotSuicideTag = "CannotSuicide"; private static readonly ProtoId ZombieFaction = "Zombie"; private static readonly string MindRoleZombie = "MindRoleZombie"; private static readonly List> BannableZombiePrototypes = ["Zombie"]; + private static readonly EntProtoId ClumsyZombieStatus = "StatusEffectClumsyZombie"; internal static readonly HashSet AdditionalZombieLayers = [HumanoidVisualLayers.Tail, HumanoidVisualLayers.HeadSide, HumanoidVisualLayers.HeadTop, HumanoidVisualLayers.Snout]; /// @@ -328,12 +335,24 @@ public sealed partial class ZombieSystem MakeGhostRole(target); } - if (TryComp(target, out var handsComp)) + // Uncuffing the zombie + foreach (var cuff in _cuffable.GetAllCuffs(target)) { - _hands.RemoveHands(target); - RemComp(target, handsComp); + _cuffable.Uncuff(target, null, cuff); } + // forcibly empties hands (even if they contain something sticky/unremovable) + _hands.DropAll(target); // TODO refactor to force drop all if #45844 gets merged + // temp backup to get rid of unremovable items + foreach (var hand in _hands.EnumerateHands(target)) + { + if (_containerSystem.TryGetContainer(target, hand, out var handContainer)) + _containerSystem.EmptyContainer(handContainer, true); + } + + // the zombie is now clumsy. it will drop anything handed to it. + _statusEffects.TrySetStatusEffectDuration(target, ClumsyZombieStatus); + // Sloth: What the fuck? // How long until compregistry lmao. RemComp(target); @@ -345,7 +364,7 @@ public sealed partial class ZombieSystem //zombie gamemode stuff var ev = new EntityZombifiedEvent(target); RaiseLocalEvent(target, ref ev, true); - //zombies get slowdown once they convert + //zombies revert to their default movement speed. _movementSpeedModifier.RefreshMovementSpeedModifiers(target); //Need to prevent them from getting an item, they have no hands. diff --git a/Content.Server/Zombies/ZombieSystem.cs b/Content.Server/Zombies/ZombieSystem.cs index dde4c8b0c6..717a8ce370 100644 --- a/Content.Server/Zombies/ZombieSystem.cs +++ b/Content.Server/Zombies/ZombieSystem.cs @@ -291,8 +291,9 @@ namespace Content.Server.Zombies /// the entity you want to unzombify (different from source in case of cloning, for example) /// /// - /// this currently only restore the skin/eye color from before zombified + /// this currently only restores the skin/eye color from before zombified + removes clumsy zombie status effect /// TODO: completely rethink how zombies are done to allow reversal. + /// TODO: maybe move to ZombieSystem.Transform.cs? /// public bool UnZombify(EntityUid source, EntityUid target, ZombieComponent? zombiecomp) { @@ -308,6 +309,9 @@ namespace Content.Server.Zombies _bloodstream.ChangeBloodRefreshAmount(target, zombiecomp.BeforeZombifiedBloodRefresh); _bloodstream.ChangeBloodIncreaseEnabled(target, true); + // no longer a zombie, no longer *that* clumsy + _statusEffects.TryRemoveStatusEffect(target, ClumsyZombieStatus); + // Remove the tags that we added during Zombification _tag.RemoveTag(target, CannotSuicideTag); _tag.RemoveTag(target, InvalidForGlobalSpawnSpellTag); diff --git a/Content.Shared/Clumsy/ClumsyStatusEffectSystem.cs b/Content.Shared/Clumsy/ClumsyStatusEffectSystem.cs index a0adf17ecf..65e5fb922c 100644 --- a/Content.Shared/Clumsy/ClumsyStatusEffectSystem.cs +++ b/Content.Shared/Clumsy/ClumsyStatusEffectSystem.cs @@ -4,6 +4,7 @@ using Content.Shared.Climbing.Events; using Content.Shared.Climbing.Systems; using Content.Shared.Clumsy.Components; using Content.Shared.Damage.Systems; +using Content.Shared.Hands; using Content.Shared.IdentityManagement; using Content.Shared.Medical; using Content.Shared.Popups; @@ -87,6 +88,24 @@ public sealed partial class ClumsyStatusEffectSystem : EntitySystem _audio.PlayPredicted(status.Comp.ClumsySound, args.AppliedTo, args.AppliedTo); } + /// Especially clumsy people may sometimes fail to pick things up, and fail to hold on to things they are given. + [SubscribeLocalEvent] + private void OnBeforeEquippingHandEvent(Entity status, + ref StatusEffectRelayedEvent args) + { + if (args.Args.Cancelled + || !SharedRandomExtensions.PredictedProb(_timing, status.Comp.ClumsyChance, GetNetEntity(status), GetNetEntity(args.AppliedTo))) + return; + + var ev = args.Args; + ev.Cancelled = true; + args.Args = ev; + + var selfMessage = status.Comp.SelfFailedMessage == null ? null : Loc.GetString(status.Comp.SelfFailedMessage, ("item", args.Args.Item)); + var othersMessage = status.Comp.OtherFailedMessage == null ? null : Loc.GetString(status.Comp.OtherFailedMessage, ("item", args.Args.Item)); + _popup.PopupEntity(selfMessage, othersMessage, args.AppliedTo, args.AppliedTo); + } + /// Clumsy people can't be trusted with guns! [SubscribeLocalEvent] private void OnBeforeGunShotEvent(Entity status, ref StatusEffectRelayedEvent args) diff --git a/Content.Shared/Clumsy/Components/ClumsyGrabStatusEffectComponent.cs b/Content.Shared/Clumsy/Components/ClumsyGrabStatusEffectComponent.cs new file mode 100644 index 0000000000..631e9bd844 --- /dev/null +++ b/Content.Shared/Clumsy/Components/ClumsyGrabStatusEffectComponent.cs @@ -0,0 +1,39 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Clumsy.Components; + +/// +/// Afflicted entity will occasionally fail to pick up items or receive items into their hands. +/// +[RegisterComponent, NetworkedComponent] +[Access(typeof(ClumsyStatusEffectSystem))] +public sealed partial class ClumsyGrabStatusEffectComponent : Component +{ + /// + /// How often they fail. + /// + [DataField] + public float ClumsyChance = 0.5f; + + /// + /// Popup played to the afflicted when they fail to grab the item. + /// + /// Parameters passed in: + /// + /// item - The item which got dropped. + /// + /// + [DataField] + public LocId? SelfFailedMessage = "clumsy-grab-fail-message-user"; + + /// + /// Popup played to others when the afflicted fails to grab the item. + /// + /// Parameters passed in: + /// + /// item - The item which got dropped. + /// + /// + [DataField] + public LocId? OtherFailedMessage = "clumsy-grab-fail-message-others"; +} diff --git a/Content.Shared/StatusEffectNew/StatusEffectSystem.Relay.cs b/Content.Shared/StatusEffectNew/StatusEffectSystem.Relay.cs index 06f47fed55..19d2fcd8ab 100644 --- a/Content.Shared/StatusEffectNew/StatusEffectSystem.Relay.cs +++ b/Content.Shared/StatusEffectNew/StatusEffectSystem.Relay.cs @@ -9,6 +9,7 @@ using Content.Shared.Damage.Systems; using Content.Shared.Examine; using Content.Shared.Eye.Blinding.Systems; using Content.Shared.Flash; +using Content.Shared.Hands; using Content.Shared.Medical; using Content.Shared.Mobs; using Content.Shared.Mobs.Events; @@ -71,6 +72,7 @@ public sealed partial class StatusEffectsSystem SubscribeLocalEvent(RefRelayStatusEffectEvent); SubscribeLocalEvent(RelayStatusEffectEvent); + SubscribeLocalEvent(RefRelayStatusEffectEvent); SubscribeLocalEvent(RefRelayStatusEffectEvent); SubscribeLocalEvent(RefRelayStatusEffectEvent); diff --git a/Resources/Locale/en-US/clown/components/clumsy-component.ftl b/Resources/Locale/en-US/clown/components/clumsy-component.ftl index 9e84cd1736..8079d5f4bf 100644 --- a/Resources/Locale/en-US/clown/components/clumsy-component.ftl +++ b/Resources/Locale/en-US/clown/components/clumsy-component.ftl @@ -9,3 +9,6 @@ clumsy-defibrillator-fail-message = Oops! You zapped yourself. clumsy-catch-fail-message-user = { CAPITALIZE(THE($item)) } hits your head! clumsy-catch-fail-message-others = { CAPITALIZE(THE($item)) } hits { THE($catcher) }'s head! + +clumsy-grab-fail-message-user = { CAPITALIZE(THE($item)) } slips out of your hands... +clumsy-grab-fail-message-others = { CAPITALIZE(THE($item)) } slips out of their hands... diff --git a/Resources/Prototypes/Entities/StatusEffects/clumsy.yml b/Resources/Prototypes/Entities/StatusEffects/clumsy.yml index 0e70eb37a9..fb8ee8e0b3 100644 --- a/Resources/Prototypes/Entities/StatusEffects/clumsy.yml +++ b/Resources/Prototypes/Entities/StatusEffects/clumsy.yml @@ -134,4 +134,12 @@ clumsyChance: *ChanceDrunk clumsySound: *SoundGlug +# gruhhhhh (Zombies don't know how to hold things in their hands.) +- type: entity + parent: MobStatusEffectDebuff + id: StatusEffectClumsyZombie + components: + - type: ClumsyGrabStatusEffect + clumsyChance: *Chance100 + #endregion