From c2f4b5145d1dfde85791b7464d46a092d1613da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Walsh?= Date: Sat, 22 Nov 2025 23:44:26 +0000 Subject: [PATCH] Defibs will now also shock anyone still interacting with the target. (#35998) * Defibs will now also shock anyone still interacting with the target. * Improvements to test readability * Apply fixes to other tests * Refactor the interacting entities query to use an event. * Include pullers as interacting with the entity they are pulling * Broadcast event * Use a constant * Convert new test to InteractionTest * Convert existing test * Add behaviour note * Revert "Convert existing test" This reverts commit b8a8f2f68e3733bdb6ec254faf955a42096d47d7. * Move new test into separate (InteractionTest) test file * Use ToServer * Use a constant for prototype id * Use ToServer * Add EntProtoId constructor * Add assertion failure messages * Manual cleanup of test entities * Remove obsolete flag * Add test summaries * Remove tuple constructor * Wrap entity deletion in WaitPost * Extend DoAfter interacting test with an extra mob --- .../Tests/DoAfter/DoAfterServerTest.cs | 85 ++++++++++++++++--- .../InteractionTest.EntitySpecifier.cs | 3 + .../Tests/Puller/InteractingEntitiesTest.cs | 38 +++++++++ Content.Server/Medical/DefibrillatorSystem.cs | 15 ++++ Content.Shared/DoAfter/SharedDoAfterSystem.cs | 21 +++++ .../Interaction/SharedInteractionSystem.cs | 22 +++++ .../Movement/Pulling/Systems/PullingSystem.cs | 7 ++ 7 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 Content.IntegrationTests/Tests/Puller/InteractingEntitiesTest.cs diff --git a/Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs b/Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs index 45c384f86c..32f8b58542 100644 --- a/Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs +++ b/Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs @@ -1,4 +1,6 @@ +using System.Collections.Generic; using Content.Shared.DoAfter; +using Content.Shared.Interaction; using Robust.Shared.GameObjects; using Robust.Shared.Map; using Robust.Shared.Reflection; @@ -64,17 +66,16 @@ namespace Content.IntegrationTests.Tests.DoAfter var server = pair.Server; await server.WaitIdleAsync(); - var entityManager = server.ResolveDependency(); + var entityManager = server.EntMan; var timing = server.ResolveDependency(); - var doAfterSystem = entityManager.EntitySysManager.GetEntitySystem(); + var doAfterSystem = entityManager.System(); var ev = new TestDoAfterEvent(); // That it finishes successfully await server.WaitPost(() => { - var tickTime = 1.0f / timing.TickRate; var mob = entityManager.SpawnEntity("DoAfterDummy", MapCoordinates.Nullspace); - var args = new DoAfterArgs(entityManager, mob, tickTime / 2, ev, null) { Broadcast = true }; + var args = new DoAfterArgs(entityManager, mob, timing.TickPeriod / 2, ev, null) { Broadcast = true }; #pragma warning disable NUnit2045 // Interdependent assertions. Assert.That(doAfterSystem.TryStartDoAfter(args)); Assert.That(ev.Cancelled, Is.False); @@ -92,23 +93,17 @@ namespace Content.IntegrationTests.Tests.DoAfter { await using var pair = await PoolManager.GetServerClient(); var server = pair.Server; - var entityManager = server.ResolveDependency(); + var entityManager = server.EntMan; var timing = server.ResolveDependency(); - var doAfterSystem = entityManager.EntitySysManager.GetEntitySystem(); + var doAfterSystem = entityManager.System(); var ev = new TestDoAfterEvent(); await server.WaitPost(() => { - var tickTime = 1.0f / timing.TickRate; - var mob = entityManager.SpawnEntity("DoAfterDummy", MapCoordinates.Nullspace); - var args = new DoAfterArgs(entityManager, mob, tickTime * 2, ev, null) { Broadcast = true }; + var args = new DoAfterArgs(entityManager, mob, timing.TickPeriod * 2, ev, null) { Broadcast = true }; - if (!doAfterSystem.TryStartDoAfter(args, out var id)) - { - Assert.Fail(); - return; - } + Assert.That(doAfterSystem.TryStartDoAfter(args, out var id)); Assert.That(!ev.Cancelled); doAfterSystem.Cancel(id); @@ -121,5 +116,67 @@ namespace Content.IntegrationTests.Tests.DoAfter await pair.CleanReturnAsync(); } + + /// + /// Spawns two sets of mobs with a targeted DoAfter to check that the GetEntitiesInteractingWithTarget result + /// includes the correct interacting entities. + /// + [Test] + public async Task TestGetInteractingEntities() + { + await using var pair = await PoolManager.GetServerClient(); + var server = pair.Server; + var entityManager = server.EntMan; + var timing = server.ResolveDependency(); + var doAfterSystem = entityManager.System(); + var interactionSystem = entityManager.System(); + var ev = new TestDoAfterEvent(); + + EntityUid mob = default; + EntityUid target = default; + + EntityUid mob2 = default; + EntityUid mob3 = default; + EntityUid target2 = default; + + await server.WaitPost(() => + { + // Spawn two targets to interact with + target = entityManager.SpawnEntity("DoAfterDummy", MapCoordinates.Nullspace); + target2 = entityManager.SpawnEntity("DoAfterDummy", MapCoordinates.Nullspace); + + // Spawn a mob which is interacting with the first target + mob = entityManager.SpawnEntity("DoAfterDummy", MapCoordinates.Nullspace); + var args = new DoAfterArgs(entityManager, mob, timing.TickPeriod * 5, ev, null, target) { Broadcast = true }; + Assert.That(doAfterSystem.TryStartDoAfter(args)); + + // Spawn two more mobs which are interacting with the second target + mob2 = entityManager.SpawnEntity("DoAfterDummy", MapCoordinates.Nullspace); + var args2 = new DoAfterArgs(entityManager, mob2, timing.TickPeriod * 5, ev, null, target2) { Broadcast = true }; + Assert.That(doAfterSystem.TryStartDoAfter(args2)); + + mob3 = entityManager.SpawnEntity("DoAfterDummy", MapCoordinates.Nullspace); + var args3 = new DoAfterArgs(entityManager, mob3, timing.TickPeriod * 5, ev, null, target2) { Broadcast = true }; + Assert.That(doAfterSystem.TryStartDoAfter(args3)); + }); + + var list = new HashSet(); + interactionSystem.GetEntitiesInteractingWithTarget(target, list); + Assert.That(list, Is.EquivalentTo([mob]), $"{mob} was not considered to be interacting with {target}"); + + interactionSystem.GetEntitiesInteractingWithTarget(target2, list); + Assert.That(list, Is.EquivalentTo([mob2, mob3]), $"{mob2} and {mob3} were not considered to be interacting with {target2}"); + + await server.WaitPost(() => + { + entityManager.DeleteEntity(mob); + entityManager.DeleteEntity(mob2); + entityManager.DeleteEntity(mob3); + entityManager.DeleteEntity(target); + entityManager.DeleteEntity(target2); + }); + + await pair.CleanReturnAsync(); + } } } diff --git a/Content.IntegrationTests/Tests/Interaction/InteractionTest.EntitySpecifier.cs b/Content.IntegrationTests/Tests/Interaction/InteractionTest.EntitySpecifier.cs index 37526f39a7..6823e1ac97 100644 --- a/Content.IntegrationTests/Tests/Interaction/InteractionTest.EntitySpecifier.cs +++ b/Content.IntegrationTests/Tests/Interaction/InteractionTest.EntitySpecifier.cs @@ -49,6 +49,9 @@ public abstract partial class InteractionTest public static implicit operator EntitySpecifier(string prototype) => new(prototype, 1); + public static implicit operator EntitySpecifier(EntProtoId prototype) + => new(prototype.Id, 1); + public static implicit operator EntitySpecifier((string, int) tuple) => new(tuple.Item1, tuple.Item2); diff --git a/Content.IntegrationTests/Tests/Puller/InteractingEntitiesTest.cs b/Content.IntegrationTests/Tests/Puller/InteractingEntitiesTest.cs new file mode 100644 index 0000000000..b53a9a1aad --- /dev/null +++ b/Content.IntegrationTests/Tests/Puller/InteractingEntitiesTest.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using Content.IntegrationTests.Tests.Interaction; +using Content.Shared.Interaction; +using Content.Shared.Movement.Pulling.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Prototypes; + +namespace Content.IntegrationTests.Tests.Puller; + +#nullable enable + +public sealed class InteractingEntitiesTest : InteractionTest +{ + private static readonly EntProtoId MobHuman = "MobHuman"; + + /// + /// Spawns a Target mob, and a second mob which drags it, + /// and checks that the dragger is considered to be interacting with the dragged mob. + /// + [Test] + public async Task PullerIsConsideredInteractingTest() + { + await SpawnTarget(MobHuman); + var puller = await SpawnEntity(MobHuman, ToServer(TargetCoords)); + + var pullSys = SEntMan.System(); + await Server.WaitAssertion(() => + { + Assert.That(pullSys.TryStartPull(puller, ToServer(Target.Value)), + $"{puller} failed to start pulling {Target}"); + }); + + var list = new HashSet(); + Server.System() + .GetEntitiesInteractingWithTarget(ToServer(Target.Value), list); + Assert.That(list, Is.EquivalentTo([puller]), $"{puller} was not considered to be interacting with {Target}"); + } +} diff --git a/Content.Server/Medical/DefibrillatorSystem.cs b/Content.Server/Medical/DefibrillatorSystem.cs index f0dfceb14e..14ee155d2a 100644 --- a/Content.Server/Medical/DefibrillatorSystem.cs +++ b/Content.Server/Medical/DefibrillatorSystem.cs @@ -18,6 +18,8 @@ using Content.Shared.Mind; using Content.Shared.Mobs; using Content.Shared.Mobs.Components; using Content.Shared.Mobs.Systems; +using Content.Shared.Movement.Pulling.Components; +using Content.Shared.PowerCell; using Content.Shared.Timing; using Robust.Shared.Audio.Systems; using Robust.Shared.Player; @@ -44,6 +46,7 @@ public sealed class DefibrillatorSystem : EntitySystem [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly SharedMindSystem _mind = default!; [Dependency] private readonly UseDelaySystem _useDelay = default!; + [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!; /// public override void Initialize() @@ -179,6 +182,18 @@ public sealed class DefibrillatorSystem : EntitySystem _audio.PlayPvs(component.ZapSound, uid); _electrocution.TryDoElectrocution(target, null, component.ZapDamage, component.WritheDuration, true, ignoreInsulation: true); + + var interacters = new HashSet(); + _interactionSystem.GetEntitiesInteractingWithTarget(target, interacters); + foreach (var other in interacters) + { + if (other == user) + continue; + + // Anyone else still operating on the target gets zapped too + _electrocution.TryDoElectrocution(other, null, component.ZapDamage, component.WritheDuration, true); + } + if (!TryComp(uid, out var useDelay)) return; _useDelay.SetLength((uid, useDelay), component.ZapDelay, component.DelayId); diff --git a/Content.Shared/DoAfter/SharedDoAfterSystem.cs b/Content.Shared/DoAfter/SharedDoAfterSystem.cs index d80f65755e..0b72692ea0 100644 --- a/Content.Shared/DoAfter/SharedDoAfterSystem.cs +++ b/Content.Shared/DoAfter/SharedDoAfterSystem.cs @@ -4,6 +4,7 @@ using Content.Shared.ActionBlocker; using Content.Shared.Damage; using Content.Shared.Damage.Systems; using Content.Shared.Hands.Components; +using Content.Shared.Interaction; using Content.Shared.Tag; using Robust.Shared.GameStates; using Robust.Shared.Prototypes; @@ -35,6 +36,7 @@ public abstract partial class SharedDoAfterSystem : EntitySystem SubscribeLocalEvent(OnUnpaused); SubscribeLocalEvent(OnDoAfterGetState); SubscribeLocalEvent(OnDoAfterHandleState); + SubscribeLocalEvent(OnGetInteractingEntities); } private void OnUnpaused(EntityUid uid, DoAfterComponent component, ref EntityUnpausedEvent args) @@ -131,6 +133,25 @@ public abstract partial class SharedDoAfterSystem : EntitySystem EnsureComp(uid); } + /// + /// Adds entities which have an active DoAfter matching the target. + /// + private void OnGetInteractingEntities(ref GetInteractingEntitiesEvent args) + { + var enumerator = EntityQueryEnumerator(); + while (enumerator.MoveNext(out _, out var comp)) + { + foreach (var doAfter in comp.DoAfters.Values) + { + if (doAfter.Cancelled || doAfter.Completed) + continue; + + if (doAfter.Args.Target == args.Target) + args.InteractingEntities.Add(doAfter.Args.User); + } + } + } + #region Creation /// /// Tasks that are delayed until the specified time has passed diff --git a/Content.Shared/Interaction/SharedInteractionSystem.cs b/Content.Shared/Interaction/SharedInteractionSystem.cs index c1bb855f36..b4ee94c3aa 100644 --- a/Content.Shared/Interaction/SharedInteractionSystem.cs +++ b/Content.Shared/Interaction/SharedInteractionSystem.cs @@ -1465,6 +1465,18 @@ namespace Content.Shared.Interaction return ev.Handled; } + /// + /// Get a list of entities which are currently considered to be interacting with the specified target entity. + /// Note: the result set is cleared on call. + /// + public void GetEntitiesInteractingWithTarget(EntityUid target, HashSet result) + { + result.Clear(); + + var ev = new GetInteractingEntitiesEvent(target, result); + RaiseLocalEvent(target, ref ev, true); + } + [Obsolete("Use ActionBlockerSystem")] public bool SupportsComplexInteractions(EntityUid user) { @@ -1542,4 +1554,14 @@ namespace Content.Shared.Interaction public bool Handled; public bool InRange = false; } + + /// + /// Raised to allow systems to provide entities which are interacting with the target entity. + /// + [ByRefEvent] + public record struct GetInteractingEntitiesEvent(EntityUid Target, HashSet InteractingEntities) + { + public readonly EntityUid Target = Target; + public HashSet InteractingEntities = InteractingEntities; + } } diff --git a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs index 3784dc0402..01a65f852a 100644 --- a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs +++ b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs @@ -68,6 +68,7 @@ public sealed class PullingSystem : EntitySystem SubscribeLocalEvent(OnPullableContainerInsert); SubscribeLocalEvent(OnModifyUncuffDuration); SubscribeLocalEvent(OnStopBeingPulledAlert); + SubscribeLocalEvent(OnGetInteractingEntities); SubscribeLocalEvent(OnStateChanged, after: [typeof(MobThresholdSystem)]); SubscribeLocalEvent(OnAfterState); @@ -161,6 +162,12 @@ public sealed class PullingSystem : EntitySystem StopPulling(ent, ent); } + private void OnGetInteractingEntities(Entity ent, ref GetInteractingEntitiesEvent args) + { + if (ent.Comp.Puller != null) + args.InteractingEntities.Add(ent.Comp.Puller.Value); + } + private void OnAfterState(Entity ent, ref AfterAutoHandleStateEvent args) { if (ent.Comp.Pulling == null)