Zombies now have hands (less metagameable in the strip menu) - but they're too clumsy to hold anything. (#45721)

* Solving #43723 by making zombies clumsy (they are unable to hold anything in their hands, passing items to them will fail).

Also made zombies uncuff themselves whenever they transform (seeing as they no longer lose their hands meaning that cuffs would otherwise remain on them)

* Cleaning up the code

* Renamed ClumsyHoldStatusEffectComponent.cs to ClumsyGrabStatusEffectComponent.cs (and renamed associated files).

I think "ClumsyGrab" works better than "ClumsyHold" (as the effect happens when the item is first grabbed, not whilst the item is being held).

everything still works

Reordered appropriate functions in ClumsyStatusTest.cs and ClumsyStatusEffectSystem.cs to retain alphabetical order.

* oopsies :p

* Appeased the YAMLLinter (at the cost of a somewhat hackier implementation).

ZombieSystem.Transform.cs now applies the zombie clumsiness via creating a new PermanentStatusEffectComponent with StatusEffectClumsyZombie as its status effect.

* requested changes have been made

(also re-added newline to end of clumsy-component.ftl because it has a newline at the end of it on the master branch, I guess it's probably supposed to be there)

* reordering so private members (including new one) are before the internal member

* hands are forcibly emptied - even if something's glued to them.

* UnZombify should remove the lingering ZombieClumsy status effect

(still needs to revert the rest of the zombification process, but at least the new bit I added can be undone)

* Minor refactor of my own refactor. New order of operations:

1. Uncuffs zombie (if needed)
2. Attempts to `DropAll` items from hands
3. Iterates through hand containers to empty them (removes otherwise unremovable items from hands)

Will refactor process to force drop all items if #45844 becomes implementable + is merged.

* formatting

* how the hell did the metabolizer code go missing!?

* uncuffing now happens in a safer way

less likely to accidentally infinitely loop

* tweaking that one comment I was asked to tweak (this is unrelated to the hand malarkey)

hopefully it won't lead to any merge conflicts later on

* formatting

* file style

---------

Co-authored-by: Pok245 <113675512+Pok27@users.noreply.github.com>
This commit is contained in:
Rachel 2026-09-27 19:04:52 +00:00 • committed by GitHub
parent cdd526ee0a
commit 927ad18d81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 146 additions and 6 deletions

View file

@ -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<SelfBeforeDefibrillatorZapsEvent>;
private sealed class GunListenerSystem : TestListenerSystem<SelfBeforeGunShotEvent>;
private sealed class InjectListenerSystem : TestListenerSystem<SelfBeforeInjectEvent>;
private sealed class BeforeEquipListenerSystem : TestListenerSystem<BeforeEquippingHandEvent>;
[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<TestListenerComponent>(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<TransformComponent>(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<BeforeEquippingHandEvent>(SPlayer))
{
Assert.That(ev.Cancelled, Is.True, "Clumsy mob didn't cancel BeforeEquippingHandEvent.");
}
}
}

View file

@ -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
";
}

View file

@ -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<TagPrototype> InvalidForGlobalSpawnSpellTag = "InvalidForGlobalSpawnSpell";
private static readonly ProtoId<TagPrototype> CannotSuicideTag = "CannotSuicide";
private static readonly ProtoId<NpcFactionPrototype> ZombieFaction = "Zombie";
private static readonly string MindRoleZombie = "MindRoleZombie";
private static readonly List<ProtoId<AntagPrototype>> BannableZombiePrototypes = ["Zombie"];
private static readonly EntProtoId<StatusEffectComponent> ClumsyZombieStatus = "StatusEffectClumsyZombie";
internal static readonly HashSet<HumanoidVisualLayers> AdditionalZombieLayers = [HumanoidVisualLayers.Tail, HumanoidVisualLayers.HeadSide, HumanoidVisualLayers.HeadTop, HumanoidVisualLayers.Snout];
/// <summary>
@ -328,12 +335,24 @@ public sealed partial class ZombieSystem
MakeGhostRole(target);
}
if (TryComp<HandsComponent>(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<PullerComponent>(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.

View file

@ -291,8 +291,9 @@ namespace Content.Server.Zombies
/// <param name="target">the entity you want to unzombify (different from source in case of cloning, for example)</param>
/// <param name="zombiecomp"></param>
/// <remarks>
/// 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?
/// </remarks>
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);

View file

@ -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);
}
/// <summary> Especially clumsy people may sometimes fail to pick things up, and fail to hold on to things they are given.</summary>
[SubscribeLocalEvent]
private void OnBeforeEquippingHandEvent(Entity<ClumsyGrabStatusEffectComponent> status,
ref StatusEffectRelayedEvent<BeforeEquippingHandEvent> 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);
}
/// <summary> Clumsy people can't be trusted with guns! </summary>
[SubscribeLocalEvent]
private void OnBeforeGunShotEvent(Entity<ClumsyGunStatusEffectComponent> status, ref StatusEffectRelayedEvent<SelfBeforeGunShotEvent> args)

View file

@ -0,0 +1,39 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Clumsy.Components;
/// <summary>
/// Afflicted entity will occasionally fail to pick up items or receive items into their hands.
/// </summary>
[RegisterComponent, NetworkedComponent]
[Access(typeof(ClumsyStatusEffectSystem))]
public sealed partial class ClumsyGrabStatusEffectComponent : Component
{
/// <summary>
/// How often they fail.
/// </summary>
[DataField]
public float ClumsyChance = 0.5f;
/// <summary>
/// Popup played to the afflicted when they fail to grab the item.
/// </summary>
/// <value> Parameters passed in:
/// <list type="bullet">
/// <item><c>item</c> - The item which got dropped.</item>
/// </list>
/// </value>
[DataField]
public LocId? SelfFailedMessage = "clumsy-grab-fail-message-user";
/// <summary>
/// Popup played to others when the afflicted fails to grab the item.
/// </summary>
/// <value> Parameters passed in:
/// <list type="bullet">
/// <item><c>item</c> - The item which got dropped.</item>
/// </list>
/// </value>
[DataField]
public LocId? OtherFailedMessage = "clumsy-grab-fail-message-others";
}

View file

@ -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<StatusEffectContainerComponent, CatchAttemptEvent>(RefRelayStatusEffectEvent);
SubscribeLocalEvent<StatusEffectContainerComponent, SelfBeforeGunShotEvent>(RelayStatusEffectEvent);
SubscribeLocalEvent<StatusEffectContainerComponent, BeforeEquippingHandEvent>(RefRelayStatusEffectEvent);
SubscribeLocalEvent<StatusEffectContainerComponent, TargetHandcuffedEvent>(RefRelayStatusEffectEvent);
SubscribeLocalEvent<StatusEffectContainerComponent, BeforeTargetHandcuffedEvent>(RefRelayStatusEffectEvent);

View file

@ -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...

View file

@ -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