diff --git a/Content.Client/_Scp/Holding/ScpHoldingPredictionSystem.cs b/Content.Client/_Scp/Holding/ScpHoldingPredictionSystem.cs new file mode 100644 index 00000000000..a5fc75f719d --- /dev/null +++ b/Content.Client/_Scp/Holding/ScpHoldingPredictionSystem.cs @@ -0,0 +1,185 @@ +using System; +using Content.Shared._Scp.Holding; +using Content.Shared.Hands; +using Content.Shared.Hands.Components; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.Inventory.VirtualItem; +using Robust.Client.Physics; +using Robust.Client.Player; +using Robust.Shared.Timing; + +namespace Content.Client._Scp.Holding; + +public sealed class ScpHoldingPredictionSystem : EntitySystem +{ + [Dependency] private readonly SharedScpHoldingSystem _holding = default!; + [Dependency] private readonly SharedHandsSystem _hands = default!; + [Dependency] private readonly Robust.Client.Physics.PhysicsSystem _physics = default!; + [Dependency] private readonly IPlayerManager _player = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly SharedVirtualItemSystem _virtualItem = default!; + + private static readonly TimeSpan BlockerRespawnSuppressionDuration = TimeSpan.FromSeconds(0.5); + + private EntityUid? _suppressedHolder; + private EntityUid? _suppressedTarget; + private TimeSpan _suppressedUntil; + + private EntityQuery _handsQuery; + private EntityQuery _holderQuery; + + public override void Initialize() + { + base.Initialize(); + + _handsQuery = GetEntityQuery(); + _holderQuery = GetEntityQuery(); + + SubscribeLocalEvent(OnHeldAfterState); + SubscribeLocalEvent(OnBlockerUnequipped); + SubscribeLocalEvent(OnHolderAfterState); + SubscribeLocalEvent(OnUpdateHeldPredicted); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + if (_timing.CurTime >= _suppressedUntil) + ClearBlockerRespawnSuppression(); + + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out _)) + { + _physics.UpdateIsPredicted(uid); + } + + if (_player.LocalEntity is not { Valid: true } local) + { + ClearBlockerRespawnSuppression(); + return; + } + + if (ShouldSuppressBlockerRespawn(local, _suppressedTarget)) + DeleteSuppressedBlockers(local, _suppressedTarget!.Value); + + if (!_holderQuery.TryComp(local, out var localHolder)) + return; + + if (ShouldSuppressBlockerRespawn(local, localHolder.Target)) + { + DeleteSuppressedBlockers(local, localHolder.Target!.Value); + return; + } + + _holding.RefreshHolderState((local, localHolder)); + } + + private void OnHeldAfterState(Entity ent, ref AfterAutoHandleStateEvent args) + { + _holding.RefreshHeldState(ent); + } + + private void OnHolderAfterState(Entity ent, ref AfterAutoHandleStateEvent args) + { + if (ShouldSuppressBlockerRespawn(ent.Owner, ent.Comp.Target)) + { + DeleteSuppressedBlockers(ent.Owner, ent.Comp.Target!.Value); + return; + } + + _holding.RefreshHolderState(ent); + } + + private void OnBlockerUnequipped(Entity ent, ref GotUnequippedHandEvent args) + { + if (_player.LocalEntity != args.User) + return; + + if (_holderQuery.TryComp(args.User, out var holder)) + { + if (holder.Target == ent.Comp.Target) + return; + } + + SuppressBlockerRespawn(args.User, ent.Comp.Target); + } + + private void OnUpdateHeldPredicted(Entity ent, ref UpdateIsPredictedEvent args) + { + if (_player.LocalEntity is not { Valid: true } local) + return; + + if (ent.Owner == local) + { + args.IsPredicted = true; + return; + } + + if (_holderQuery.TryComp(local, out var localHolder)) + { + if (localHolder.Target == ent.Owner) + { + args.IsPredicted = true; + return; + } + } + + for (var i = 0; i < ent.Comp.Holders.Count; i++) + { + if (ent.Comp.Holders[i] != local) + continue; + + args.IsPredicted = true; + return; + } + + if (ent.Comp.Holders.Count > 0) + args.BlockPrediction = true; + } + + private void SuppressBlockerRespawn(EntityUid holder, EntityUid target) + { + _suppressedHolder = holder; + _suppressedTarget = target; + _suppressedUntil = _timing.CurTime + BlockerRespawnSuppressionDuration; + } + + private void ClearBlockerRespawnSuppression() + { + _suppressedHolder = null; + _suppressedTarget = null; + _suppressedUntil = TimeSpan.Zero; + } + + private bool ShouldSuppressBlockerRespawn(EntityUid holder, EntityUid? target) + { + return _suppressedHolder == holder && + _suppressedTarget != null && + target == _suppressedTarget && + _timing.CurTime < _suppressedUntil; + } + + private void DeleteSuppressedBlockers(EntityUid holder, EntityUid target) + { + if (!_handsQuery.TryComp(holder, out var hands)) + return; + + foreach (var heldItem in _hands.EnumerateHeld((holder, hands))) + { + if (!TryComp(heldItem, out var virtualItem)) + continue; + + if (!TryComp(heldItem, out var blocker)) + continue; + + if (virtualItem.BlockingEntity != target) + continue; + + if (blocker.Target != target) + continue; + + _virtualItem.DeleteVirtualItem((heldItem, virtualItem), holder); + } + } +} diff --git a/Content.IntegrationTests/Tests/_Scp/ScpHoldingTest.cs b/Content.IntegrationTests/Tests/_Scp/ScpHoldingTest.cs new file mode 100644 index 00000000000..26235e7791d --- /dev/null +++ b/Content.IntegrationTests/Tests/_Scp/ScpHoldingTest.cs @@ -0,0 +1,2251 @@ +#nullable enable +using System; +using System.Linq; +using System.Numerics; +using System.Reflection; +using Content.IntegrationTests.Tests.Helpers; +using Content.Shared.Alert; +using Content.Server.Body.Systems; +using Content.Shared._Scp.Holding; +using Content.Shared.Body.Components; +using Content.Shared.Body.Part; +using Content.Shared.Hands.Components; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.Interaction.Components; +using Content.Shared.Input; +using Content.Shared.Inventory.VirtualItem; +using Content.Shared.Movement.Components; +using Content.Shared.Movement.Events; +using Content.Shared.Movement.Pulling.Components; +using Content.Shared.Movement.Pulling.Systems; +using Content.Shared.Movement.Systems; +using Content.Shared.StatusEffectNew; +using Content.Shared.Throwing; +using Robust.Client.Input; +using Robust.Server.Console; +using Robust.Client.Physics; +using Robust.Shared.GameObjects; +using Robust.Shared.Input; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Physics.Components; +using Robust.Shared.Physics.Systems; +using Robust.Shared.Prototypes; +using Robust.Shared.Timing; +using Robust.UnitTesting; +using Content.Shared.Whitelist; + +namespace Content.IntegrationTests.Tests._Scp; + +[TestFixture] +public sealed class ScpHoldingTest +{ + private const string HolderPrototype = "ScpHoldingTestHolder"; + private const string HoldableWhitelistedHolderPrototype = "ScpHoldingTestHolderHoldableWhitelisted"; + private const string HoldableBlacklistedHolderPrototype = "ScpHoldingTestHolderHoldableBlacklisted"; + private const string TestListenerComponentName = "TestListener"; + private static readonly ProtoId GrabbedAlertId = "ScpHoldGrabbed"; + private static readonly FieldInfo SoftEscapeAvailableAtField = + typeof(ScpHeldComponent).GetField(nameof(ScpHeldComponent.SoftEscapeAvailableAt))!; + + private static EntityWhitelist CreateComponentWhitelist(params string[] components) + { + return new EntityWhitelist + { + Components = components, + }; + } + + [TestPrototypes] + private const string Prototypes = """ +- type: entity + id: ScpHoldingTestHolder + parent: MobHuman + components: + - type: ScpHold +- type: entity + id: ScpHoldingTestHolderHoldableWhitelisted + parent: ScpHoldingTestHolder + components: + - type: ScpHold + holdableWhitelist: + components: + - TestListener +- type: entity + id: ScpHoldingTestHolderHoldableBlacklisted + parent: ScpHoldingTestHolder + components: + - type: ScpHold + holdableBlacklist: + components: + - TestListener +"""; + + [Test] + public async Task SoftHoldBreakoutByMovementAndAlertRespectsCooldown() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var alerts = server.System(); + var timing = server.ResolveDependency(); + var statusEffects = server.System(); + var proto = server.ResolveDependency(); + var holding = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holder = default; + EntityUid target = default; + + await server.WaitPost(() => + { + holder = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = entMan.SpawnEntity("MobHuman", map.GridCoords); + StartHold(entMan, holding, holder, target); + }); + + await server.WaitAssertion(() => + { + var held = entMan.GetComponent(target); + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + Assert.That(statusEffects.HasStatusEffect(target, "StatusEffectScpHeld"), Is.True); + Assert.That(alerts.IsShowingAlert(target, "ScpHoldGrabbed"), Is.True); + }); + }); + + await server.WaitPost(() => RaiseMoveInput(entMan, target)); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + Assert.That(entMan.HasComponent(target), Is.False); + }); + + await server.WaitPost(() => + { + StartHold(entMan, holding, holder, target); + var held = entMan.GetComponent(target); + SetSoftEscapeAvailableAt(held, timing.CurTime + TimeSpan.FromSeconds(1)); + }); + + await server.WaitPost(() => RaiseMoveInput(entMan, target)); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + Assert.That(entMan.HasComponent(target), Is.True); + }); + + await server.WaitPost(() => + { + var alert = proto.Index(GrabbedAlertId); + Assert.That(alerts.ActivateAlert(target, alert), Is.True); + }); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + Assert.That(entMan.HasComponent(target), Is.True); + }); + + await server.WaitPost(() => + { + var held = entMan.GetComponent(target); + SetSoftEscapeAvailableAt(held, timing.CurTime); + var alert = proto.Index(GrabbedAlertId); + Assert.That(alerts.ActivateAlert(target, alert), Is.True); + }); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + Assert.That(entMan.HasComponent(target), Is.False); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task SoftHoldUsesCustomDragAndLeavesVanillaPullIdle() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + Fresh = true, + Connected = true, + DummyTicker = false, + }); + + var server = pair.Server; + var client = pair.Client; + var sEntMan = server.EntMan; + var cEntMan = client.EntMan; + var sPhysics = server.System(); + var cPhysics = client.System(); + var sTransform = server.System(); + var cTransform = client.System(); + var sAlerts = server.System(); + var cAlerts = client.System(); + var sStatusEffects = server.System(); + var cStatusEffects = client.System(); + var sHandsSystem = server.System(); + var cHandsSystem = client.System(); + var holding = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holder = default; + EntityUid target = default; + + await server.WaitPost(() => + { + holder = sEntMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = sEntMan.SpawnEntity("MobHuman", map.GridCoords.Offset(new Vector2(0.1f, 0f))); + StartHold(sEntMan, holding, holder, target); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + var held = sEntMan.GetComponent(target); + var holderState = sEntMan.GetComponent(holder); + var holderSpeed = sEntMan.GetComponent(holder); + var holderHands = sEntMan.GetComponent(holder); + var puller = sEntMan.GetComponent(holder); + var pullable = sEntMan.GetComponent(target); + var move = new UpdateCanMoveEvent(target); + var collide = new AttemptMobCollideEvent(); + var targetCollide = new AttemptMobTargetCollideEvent(); + var distance = GetDistance(sTransform, holder, target); + var contacts = sPhysics.GetContactingEntities(holder); + + sEntMan.EventBus.RaiseLocalEvent(target, move); + sEntMan.EventBus.RaiseLocalEvent(target, ref collide); + sEntMan.EventBus.RaiseLocalEvent(target, ref targetCollide); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.PrimaryHolder, Is.EqualTo(holder)); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + Assert.That(move.Cancelled, Is.False); + Assert.That(collide.Cancelled, Is.True); + Assert.That(targetCollide.Cancelled, Is.True); + Assert.That(distance, Is.GreaterThan(0.18f)); + Assert.That(distance, Is.LessThan(0.7f)); + Assert.That(contacts, Does.Not.Contain(target)); + Assert.That(holderState.SlowdownEnabled, Is.True); + Assert.That(holderSpeed.WalkSpeedModifier, Is.LessThan(0.6f)); + Assert.That(holderSpeed.SprintSpeedModifier, Is.LessThan(0.6f)); + Assert.That(puller.Pulling, Is.Null); + Assert.That(pullable.Puller, Is.Null); + Assert.That(CountHolderHandBlockers(sEntMan, sHandsSystem, holder, target, holderHands), Is.EqualTo(1)); + Assert.That(CountHolderTargetVirtualItems(sEntMan, sHandsSystem, holder, target, holderHands), Is.EqualTo(1)); + Assert.That(sStatusEffects.HasStatusEffect(target, "StatusEffectScpHeld"), Is.True); + Assert.That(sAlerts.IsShowingAlert(target, "ScpHoldGrabbed"), Is.True); + }); + }); + + EntityUid clientHolder = default; + EntityUid clientTarget = default; + await client.WaitAssertion(() => + { + clientHolder = ToClientEntity(sEntMan, cEntMan, holder); + clientTarget = ToClientEntity(sEntMan, cEntMan, target); + + var held = cEntMan.GetComponent(clientTarget); + var holderState = cEntMan.GetComponent(clientHolder); + var holderSpeed = cEntMan.GetComponent(clientHolder); + var holderHands = cEntMan.GetComponent(clientHolder); + var puller = cEntMan.GetComponent(clientHolder); + var pullable = cEntMan.GetComponent(clientTarget); + var collide = new AttemptMobCollideEvent(); + var targetCollide = new AttemptMobTargetCollideEvent(); + var distance = GetDistance(cTransform, clientHolder, clientTarget); + var contacts = cPhysics.GetContactingEntities(clientHolder); + + cEntMan.EventBus.RaiseLocalEvent(clientTarget, ref collide); + cEntMan.EventBus.RaiseLocalEvent(clientTarget, ref targetCollide); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.PrimaryHolder, Is.EqualTo(clientHolder)); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + Assert.That(distance, Is.GreaterThan(0.18f)); + Assert.That(distance, Is.LessThan(0.55f)); + Assert.That(collide.Cancelled, Is.True); + Assert.That(targetCollide.Cancelled, Is.True); + Assert.That(contacts, Does.Not.Contain(clientTarget)); + Assert.That(holderState.SlowdownEnabled, Is.True); + Assert.That(holderSpeed.WalkSpeedModifier, Is.LessThan(0.6f)); + Assert.That(holderSpeed.SprintSpeedModifier, Is.LessThan(0.6f)); + Assert.That(puller.Pulling, Is.Null); + Assert.That(pullable.Puller, Is.Null); + Assert.That(CountHolderHandBlockers(cEntMan, cHandsSystem, clientHolder, clientTarget, holderHands), Is.EqualTo(1)); + Assert.That(CountHolderTargetVirtualItems(cEntMan, cHandsSystem, clientHolder, clientTarget, holderHands), Is.EqualTo(1)); + Assert.That(cStatusEffects.HasStatusEffect(clientTarget, "StatusEffectScpHeld"), Is.True); + }); + }); + + var serverDistanceSamples = new float[24]; + var clientDistanceSamples = new float[24]; + await server.WaitPost(() => + { + var holderPhysics = sEntMan.GetComponent(holder); + sPhysics.SetLinearVelocity(holder, new Vector2(4f, 0f), body: holderPhysics); + }); + + for (var i = 0; i < 16; i++) + { + var sampleIndex = i; + await pair.RunTicksSync(1); + await pair.SyncTicks(targetDelta: 1); + await server.WaitPost(() => serverDistanceSamples[sampleIndex] = GetDistance(sTransform, holder, target)); + await client.WaitPost(() => clientDistanceSamples[sampleIndex] = GetDistance(cTransform, clientHolder, clientTarget)); + } + + await server.WaitPost(() => + { + var holderPhysics = sEntMan.GetComponent(holder); + sPhysics.SetLinearVelocity(holder, Vector2.Zero, body: holderPhysics); + }); + + for (var i = 16; i < serverDistanceSamples.Length; i++) + { + var sampleIndex = i; + await pair.RunTicksSync(1); + await pair.SyncTicks(targetDelta: 1); + await server.WaitPost(() => serverDistanceSamples[sampleIndex] = GetDistance(sTransform, holder, target)); + await client.WaitPost(() => clientDistanceSamples[sampleIndex] = GetDistance(cTransform, clientHolder, clientTarget)); + } + + Assert.Multiple(() => + { + Assert.That(serverDistanceSamples.Max(), Is.LessThan(0.6f)); + Assert.That(serverDistanceSamples.Min(), Is.GreaterThan(0.16f)); + Assert.That(GetLargestDistanceStep(serverDistanceSamples), Is.LessThan(0.2f)); + Assert.That(serverDistanceSamples[^1], Is.GreaterThan(0.18f)); + Assert.That(serverDistanceSamples[^1], Is.LessThan(0.4f)); + + Assert.That(clientDistanceSamples.Max(), Is.LessThan(0.6f)); + Assert.That(clientDistanceSamples.Min(), Is.GreaterThan(0.16f)); + Assert.That(GetLargestDistanceStep(clientDistanceSamples), Is.LessThan(0.2f)); + Assert.That(clientDistanceSamples[^1], Is.GreaterThan(0.18f)); + Assert.That(clientDistanceSamples[^1], Is.LessThan(0.4f)); + }); + + await server.WaitAssertion(() => + { + Assert.That(sEntMan.HasComponent(target), Is.True); + }); + + await client.WaitAssertion(() => + { + Assert.That(cEntMan.HasComponent(clientTarget), Is.True); + }); + + await server.WaitPost(() => + { + var targetCoords = sEntMan.GetComponent(target).Coordinates; + sTransform.SetCoordinates(holder, targetCoords); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + var distance = GetDistance(sTransform, holder, target); + var contacts = sPhysics.GetContactingEntities(holder); + + Assert.Multiple(() => + { + Assert.That(distance, Is.GreaterThan(0.16f)); + Assert.That(distance, Is.LessThan(0.55f)); + Assert.That(contacts, Does.Not.Contain(target)); + }); + }); + + await client.WaitAssertion(() => + { + var distance = GetDistance(cTransform, clientHolder, clientTarget); + var contacts = cPhysics.GetContactingEntities(clientHolder); + + Assert.Multiple(() => + { + Assert.That(distance, Is.GreaterThan(0.16f)); + Assert.That(distance, Is.LessThan(0.55f)); + Assert.That(contacts, Does.Not.Contain(clientTarget)); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task PullAttemptOnHoldableTargetRedirectsToHoldAndReplacesVanillaPull() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var pulling = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holder = default; + EntityUid plainTarget = default; + EntityUid holdTarget = default; + + await server.WaitPost(() => + { + holder = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + plainTarget = entMan.SpawnEntity("MobHuman", map.GridCoords.Offset(new Vector2(0.1f, 0f))); + holdTarget = entMan.SpawnEntity("MobHuman", map.GridCoords.Offset(new Vector2(-0.1f, 0f))); + + entMan.RemoveComponent(plainTarget); + + Assert.That(pulling.TryStartPull(holder, plainTarget), Is.True); + }); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + var holderPuller = entMan.GetComponent(holder); + var plainPullable = entMan.GetComponent(plainTarget); + + Assert.Multiple(() => + { + Assert.That(holderPuller.Pulling, Is.EqualTo(plainTarget)); + Assert.That(plainPullable.Puller, Is.EqualTo(holder)); + Assert.That(entMan.HasComponent(holdTarget), Is.False); + }); + }); + + await server.WaitPost(() => + { + Assert.That(pulling.TryStartPull(holder, holdTarget), Is.True); + }); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + var holderPuller = entMan.GetComponent(holder); + var holderState = entMan.GetComponent(holder); + var plainPullable = entMan.GetComponent(plainTarget); + var holdPullable = entMan.GetComponent(holdTarget); + + Assert.Multiple(() => + { + Assert.That(holderPuller.Pulling, Is.Null); + Assert.That(holderState.Target, Is.EqualTo(holdTarget)); + Assert.That(plainPullable.Puller, Is.Null); + Assert.That(holdPullable.Puller, Is.Null); + Assert.That(entMan.HasComponent(holdTarget), Is.True); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task SecondHolderEntersFullHoldAndFillsHands() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var holding = server.System(); + var handsSystem = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holderOne = default; + EntityUid holderTwo = default; + EntityUid target = default; + + await server.WaitPost(() => + { + holderOne = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + holderTwo = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = entMan.SpawnEntity("MobHuman", map.GridCoords); + + StartHold(entMan, holding, holderOne, target); + StartHold(entMan, holding, holderTwo, target); + }); + + await server.WaitAssertion(() => + { + var held = entMan.GetComponent(target); + var hands = entMan.GetComponent(target); + var holderOnePuller = entMan.GetComponent(holderOne); + var holderTwoPuller = entMan.GetComponent(holderTwo); + var pullable = entMan.GetComponent(target); + var move = new UpdateCanMoveEvent(target); + entMan.EventBus.RaiseLocalEvent(target, move); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.True); + Assert.That(held.RequiredHolderCount, Is.EqualTo(2)); + Assert.That(held.Holders, Has.Count.EqualTo(2)); + Assert.That(move.Cancelled, Is.True); + Assert.That(CountBlockingVirtualHands(entMan, handsSystem, target, hands), Is.EqualTo(hands.SortedHands.Count)); + Assert.That(VictimHandsUseHolderIcons(entMan, handsSystem, target, hands, holderOne, holderTwo), Is.True); + Assert.That(holderOnePuller.Pulling, Is.Null); + Assert.That(holderTwoPuller.Pulling, Is.Null); + Assert.That(pullable.Puller, Is.Null); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task TargetWithoutScpHoldableCannotBeHeld() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var holding = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holder = default; + EntityUid target = default; + + await server.WaitPost(() => + { + holder = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = entMan.SpawnEntity("MobHuman", map.GridCoords); + entMan.RemoveComponent(target); + }); + + await server.WaitAssertion(() => + { + var holdComp = entMan.GetComponent(holder); + + Assert.Multiple(() => + { + Assert.That(holding.CanToggleHold((holder, holdComp), target, quiet: true), Is.False); + Assert.That(holding.TryToggleHold((holder, holdComp), target), Is.False); + Assert.That(entMan.HasComponent(target), Is.False); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task HolderAndHoldableFiltersUseCheckBoth() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var holding = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid successHolder = default; + EntityUid blockedHolder = default; + EntityUid blacklistHolder = default; + EntityUid successTarget = default; + EntityUid blockedTarget = default; + EntityUid blacklistTarget = default; + EntityUid holderBlacklistTarget = default; + + await server.WaitPost(() => + { + successHolder = entMan.SpawnEntity(HoldableWhitelistedHolderPrototype, map.GridCoords); + blockedHolder = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + blacklistHolder = entMan.SpawnEntity(HoldableBlacklistedHolderPrototype, map.GridCoords); + successTarget = entMan.SpawnEntity("MobHuman", map.GridCoords); + blockedTarget = entMan.SpawnEntity("MobHuman", map.GridCoords); + blacklistTarget = entMan.SpawnEntity("MobHuman", map.GridCoords); + holderBlacklistTarget = entMan.SpawnEntity("MobHuman", map.GridCoords); + + entMan.AddComponent(successHolder); + entMan.AddComponent(blacklistHolder); + entMan.AddComponent(successTarget); + entMan.AddComponent(blacklistTarget); + entMan.AddComponent(holderBlacklistTarget); + + var successTargetHoldable = entMan.GetComponent(successTarget); + successTargetHoldable.HolderWhitelist = CreateComponentWhitelist(TestListenerComponentName); + + var holderBlacklistHoldable = entMan.GetComponent(holderBlacklistTarget); + holderBlacklistHoldable.HolderBlacklist = CreateComponentWhitelist(TestListenerComponentName); + }); + + await server.WaitAssertion(() => + { + var successHold = entMan.GetComponent(successHolder); + var blockedHold = entMan.GetComponent(blockedHolder); + var blacklistHold = entMan.GetComponent(blacklistHolder); + + Assert.Multiple(() => + { + Assert.That(holding.CanToggleHold((successHolder, successHold), blockedTarget, quiet: true), Is.False); + Assert.That(holding.TryToggleHold((successHolder, successHold), blockedTarget), Is.False); + + Assert.That(holding.CanToggleHold((blockedHolder, blockedHold), successTarget, quiet: true), Is.False); + Assert.That(holding.TryToggleHold((blockedHolder, blockedHold), successTarget), Is.False); + + Assert.That(holding.CanToggleHold((blacklistHolder, blacklistHold), blacklistTarget, quiet: true), Is.False); + Assert.That(holding.TryToggleHold((blacklistHolder, blacklistHold), blacklistTarget), Is.False); + + Assert.That(holding.CanToggleHold((successHolder, successHold), holderBlacklistTarget, quiet: true), Is.False); + Assert.That(holding.TryToggleHold((successHolder, successHold), holderBlacklistTarget), Is.False); + + Assert.That(holding.CanToggleHold((successHolder, successHold), successTarget, quiet: true), Is.True); + Assert.That(holding.TryToggleHold((successHolder, successHold), successTarget), Is.True); + + Assert.That(entMan.HasComponent(blockedTarget), Is.False); + Assert.That(entMan.HasComponent(successTarget), Is.True); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task HoldAttemptEventCanCancelGrab() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var holding = server.System(); + var attempts = server.System(); + _ = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holder = default; + EntityUid target = default; + + await server.WaitPost(() => + { + holder = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = entMan.SpawnEntity("MobHuman", map.GridCoords); + entMan.AddComponent(holder); + entMan.AddComponent(target); + entMan.AddComponent(target); + }); + + await server.WaitAssertion(() => + { + var holdComp = entMan.GetComponent(holder); + + Assert.Multiple(() => + { + Assert.That(holding.TryToggleHold((holder, holdComp), target), Is.False); + Assert.That(entMan.HasComponent(target), Is.False); + Assert.That(attempts.Count(target), Is.EqualTo(1)); + Assert.That(attempts.Count(holder), Is.EqualTo(1)); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task BreakoutEventRaisedWhenTargetEscapes() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var holding = server.System(); + var breakouts = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holder = default; + EntityUid target = default; + + await server.WaitPost(() => + { + holder = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = entMan.SpawnEntity("MobHuman", map.GridCoords); + entMan.AddComponent(target); + StartHold(entMan, holding, holder, target); + }); + + await server.WaitPost(() => RaiseMoveInput(entMan, target)); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + var breakout = breakouts.GetEvents(target).Single(); + + Assert.Multiple(() => + { + Assert.That(entMan.HasComponent(target), Is.False); + Assert.That(breakouts.Count(target), Is.EqualTo(1)); + Assert.That(breakout.ViaMovement, Is.True); + Assert.That(breakout.WasFullHold, Is.False); + Assert.That(breakout.AppliedImmunity, Is.False); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task MultiHandTargetNeedsMatchingHolderCountAndResyncsOnHandLoss() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var host = server.ResolveDependency(); + var holding = server.System(); + var handsSystem = server.System(); + var transform = server.System(); + var bodySystem = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holderOne = default; + EntityUid holderTwo = default; + EntityUid holderThree = default; + EntityUid target = default; + + await server.WaitPost(() => + { + holderOne = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + holderTwo = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + holderThree = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = entMan.SpawnEntity("MobHuman", map.GridCoords); + + host.ExecuteCommand(null, $"addhand {entMan.GetNetEntity(target)}"); + }); + await server.WaitRunTicks(2); + + await server.WaitPost(() => + { + StartHold(entMan, holding, holderOne, target); + StartHold(entMan, holding, holderTwo, target); + }); + + await server.WaitAssertion(() => + { + var held = entMan.GetComponent(target); + Assert.Multiple(() => + { + Assert.That(held.RequiredHolderCount, Is.EqualTo(3)); + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(2)); + }); + }); + + await server.WaitPost(() => + { + StartHold(entMan, holding, holderThree, target); + }); + + await server.WaitAssertion(() => + { + var held = entMan.GetComponent(target); + var hands = entMan.GetComponent(target); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.True); + Assert.That(held.RequiredHolderCount, Is.EqualTo(3)); + Assert.That(hands.SortedHands.Count, Is.EqualTo(3)); + Assert.That(CountBlockingVirtualHands(entMan, handsSystem, target, hands), Is.EqualTo(3)); + Assert.That(VictimHandsUseHolderIcons(entMan, handsSystem, target, hands, holderOne, holderTwo, holderThree), Is.True); + }); + }); + + await server.WaitPost(() => + { + var body = entMan.GetComponent(target); + var removedHand = bodySystem.GetBodyChildrenOfType(target, BodyPartType.Hand, body).First().Id; + transform.AttachToGridOrMap(removedHand); + }); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + var held = entMan.GetComponent(target); + var hands = entMan.GetComponent(target); + + Assert.Multiple(() => + { + Assert.That(held.RequiredHolderCount, Is.EqualTo(2)); + Assert.That(held.FullHold, Is.True); + Assert.That(hands.SortedHands.Count, Is.EqualTo(2)); + Assert.That(CountBlockingVirtualHands(entMan, handsSystem, target, hands), Is.EqualTo(2)); + Assert.That(VictimHandsUseHolderIcons(entMan, handsSystem, target, hands, holderOne, holderTwo, holderThree), Is.True); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task FullBreakoutByMovementAppliesImmunity() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var timing = server.ResolveDependency(); + var holding = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holderOne = default; + EntityUid holderTwo = default; + EntityUid target = default; + + await server.WaitPost(() => + { + holderOne = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + holderTwo = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = entMan.SpawnEntity("MobHuman", map.GridCoords); + + StartHold(entMan, holding, holderOne, target); + StartHold(entMan, holding, holderTwo, target); + }); + + await server.WaitPost(() => RaiseMoveInput(entMan, target)); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + var held = entMan.GetComponent(target); + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.True); + Assert.That(held.BreakoutDoAfterId, Is.Null); + }); + }); + + await server.WaitRunTicks(GetTickCount(timing, TimeSpan.FromSeconds(10))); + + await server.WaitPost(() => RaiseMoveInput(entMan, target)); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + var held = entMan.GetComponent(target); + Assert.Multiple(() => + { + Assert.That(held.BreakoutDoAfterId, Is.Not.Null); + Assert.That(CountAttachedPrototype(entMan, holderOne, "WhistleExclamation"), Is.EqualTo(1)); + Assert.That(CountAttachedPrototype(entMan, holderTwo, "WhistleExclamation"), Is.EqualTo(1)); + }); + }); + + await server.WaitRunTicks(GetTickCount(timing, TimeSpan.FromSeconds(5))); + + await server.WaitAssertion(() => + { + Assert.Multiple(() => + { + Assert.That(entMan.HasComponent(target), Is.False); + Assert.That(entMan.HasComponent(target), Is.True); + }); + }); + + await server.WaitPost(() => + { + var holdComp = entMan.GetComponent(holderOne); + Assert.That(holding.TryToggleHold((holderOne, holdComp), target), Is.False); + }); + + await server.WaitAssertion(() => + { + Assert.That(entMan.HasComponent(target), Is.False); + }); + + await server.WaitRunTicks(GetTickCount(timing, TimeSpan.FromSeconds(5))); + + await server.WaitPost(() => + { + var holdComp = entMan.GetComponent(holderOne); + Assert.That(holding.TryToggleHold((holderOne, holdComp), target), Is.True); + }); + + await server.WaitAssertion(() => + { + Assert.That(entMan.HasComponent(target), Is.True); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task FullBreakoutByAlertStartsAndCompletes() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var alerts = server.System(); + var timing = server.ResolveDependency(); + var proto = server.ResolveDependency(); + var holding = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holderOne = default; + EntityUid holderTwo = default; + EntityUid target = default; + + await server.WaitPost(() => + { + holderOne = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + holderTwo = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = entMan.SpawnEntity("MobHuman", map.GridCoords); + + StartHold(entMan, holding, holderOne, target); + StartHold(entMan, holding, holderTwo, target); + }); + + await server.WaitRunTicks(GetTickCount(timing, TimeSpan.FromSeconds(10))); + + await server.WaitPost(() => + { + var alert = proto.Index(GrabbedAlertId); + Assert.That(alerts.ActivateAlert(target, alert), Is.True); + }); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + var held = entMan.GetComponent(target); + Assert.Multiple(() => + { + Assert.That(held.BreakoutDoAfterId, Is.Not.Null); + Assert.That(CountAttachedPrototype(entMan, holderOne, "WhistleExclamation"), Is.EqualTo(1)); + Assert.That(CountAttachedPrototype(entMan, holderTwo, "WhistleExclamation"), Is.EqualTo(1)); + }); + }); + + await server.WaitRunTicks(GetTickCount(timing, TimeSpan.FromSeconds(5))); + + await server.WaitAssertion(() => + { + Assert.That(entMan.HasComponent(target), Is.False); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task DroppingOrThrowingHolderBlockerReleasesHold() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var handsSystem = server.System(); + var holding = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holder = default; + EntityUid target = default; + + await server.WaitPost(() => + { + holder = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = entMan.SpawnEntity("MobHuman", map.GridCoords.Offset(new Vector2(0.1f, 0f))); + StartHold(entMan, holding, holder, target); + }); + await server.WaitRunTicks(2); + + await server.WaitPost(() => + { + var hands = entMan.GetComponent(holder); + var blocker = FindHolderHandBlocker(entMan, handsSystem, holder, target, hands); + + Assert.That(blocker, Is.Not.EqualTo(EntityUid.Invalid)); + Assert.That(handsSystem.TryDrop((holder, hands), blocker), Is.True); + }); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + Assert.That(entMan.HasComponent(target), Is.False); + Assert.That(entMan.HasComponent(holder), Is.False); + }); + + await server.WaitPost(() => StartHold(entMan, holding, holder, target)); + await server.WaitRunTicks(2); + + await server.WaitPost(() => + { + var hands = entMan.GetComponent(holder); + var blocker = FindHolderHandBlocker(entMan, handsSystem, holder, target, hands); + var throwEvent = new BeforeThrowEvent(blocker, Vector2.UnitX, 5f, holder); + + Assert.That(blocker, Is.Not.EqualTo(EntityUid.Invalid)); + entMan.EventBus.RaiseLocalEvent(holder, ref throwEvent); + Assert.That(throwEvent.Cancelled, Is.True); + }); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + Assert.That(entMan.HasComponent(target), Is.False); + Assert.That(entMan.HasComponent(holder), Is.False); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task ClientDroppingHolderBlockerReleasesWithoutRespawnFlicker() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + Fresh = true, + Connected = true, + DummyTicker = false, + }); + + var server = pair.Server; + var client = pair.Client; + var sEntMan = server.EntMan; + var cEntMan = client.EntMan; + var sTransform = server.System(); + var sHandsSystem = server.System(); + var cHandsSystem = client.System(); + var holding = server.System(); + var map = await pair.CreateTestMap(); + + var serverPlayer = pair.Player!.AttachedEntity!.Value; + EntityUid target = default; + + await server.WaitPost(() => + { + sTransform.SetCoordinates(serverPlayer, map.GridCoords); + sEntMan.EnsureComponent(serverPlayer); + target = sEntMan.SpawnEntity("MobHuman", map.GridCoords.Offset(new Vector2(0.1f, 0f))); + StartHold(sEntMan, holding, serverPlayer, target); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + var clientPlayer = EntityUid.Invalid; + var clientTarget = EntityUid.Invalid; + await client.WaitAssertion(() => + { + clientPlayer = client.AttachedEntity!.Value; + clientTarget = ToClientEntity(sEntMan, cEntMan, target); + var hands = cEntMan.GetComponent(clientPlayer); + + Assert.Multiple(() => + { + Assert.That(cEntMan.HasComponent(clientPlayer), Is.True); + Assert.That(cEntMan.HasComponent(clientTarget), Is.True); + Assert.That(CountHolderTargetVirtualItems(cEntMan, cHandsSystem, clientPlayer, clientTarget, hands), Is.EqualTo(1)); + Assert.That(CountPrototypeEntities(cEntMan, "clientsideclone"), Is.EqualTo(0)); + }); + }); + + await client.WaitPost(() => + { + var hands = cEntMan.GetComponent(clientPlayer); + var blocker = FindHolderHandBlocker(cEntMan, cHandsSystem, clientPlayer, clientTarget, hands); + var dropLocation = new EntityCoordinates(clientPlayer, new Vector2(0.5f, 0f)); + + Assert.That(blocker, Is.Not.EqualTo(EntityUid.Invalid)); + Assert.That(cHandsSystem.TryDrop((clientPlayer, hands), blocker, dropLocation), Is.True); + Assert.Multiple(() => + { + Assert.That(cEntMan.HasComponent(clientTarget), Is.False); + Assert.That(cEntMan.HasComponent(clientPlayer), Is.False); + Assert.That(CountHolderTargetVirtualItems(cEntMan, cHandsSystem, clientPlayer, clientTarget, hands), Is.EqualTo(0)); + Assert.That(CountPrototypeEntities(cEntMan, "clientsideclone"), Is.EqualTo(0)); + }); + }); + + var maxClientBlockers = 0; + for (var i = 0; i < 12; i++) + { + await pair.RunTicksSync(1); + await pair.SyncTicks(targetDelta: 1); + await client.WaitPost(() => + { + if (!cEntMan.TryGetComponent(clientPlayer, out var hands)) + return; + + maxClientBlockers = Math.Max(maxClientBlockers, + CountHolderTargetVirtualItems(cEntMan, cHandsSystem, clientPlayer, clientTarget, hands)); + }); + } + + Assert.Multiple(() => + { + Assert.That(maxClientBlockers, Is.EqualTo(0)); + Assert.That(CountPrototypeEntities(cEntMan, "clientsideclone"), Is.EqualTo(0)); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task ClientPullAttemptPredictsSoftHoldBeforeServerAck() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + Fresh = true, + Connected = true, + DummyTicker = false, + }); + + var server = pair.Server; + var client = pair.Client; + var sEntMan = server.EntMan; + var cEntMan = client.EntMan; + var cTiming = client.ResolveDependency(); + var sPhysics = server.System(); + var cPhysics = client.System(); + var sTransform = server.System(); + var cTransform = client.System(); + var sAlerts = server.System(); + var cAlerts = client.System(); + var sStatusEffects = server.System(); + var cStatusEffects = client.System(); + var sHandsSystem = server.System(); + var cHandsSystem = client.System(); + var map = await pair.CreateTestMap(); + + var serverPlayer = pair.Player!.AttachedEntity!.Value; + EntityUid target = default; + + await server.WaitPost(() => + { + sTransform.SetCoordinates(serverPlayer, map.GridCoords); + sEntMan.EnsureComponent(serverPlayer); + target = sEntMan.SpawnEntity("MobHuman", map.GridCoords.Offset(new Vector2(0.1f, 0f))); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + var clientPlayer = EntityUid.Invalid; + var clientTarget = EntityUid.Invalid; + await client.WaitAssertion(() => + { + clientPlayer = client.AttachedEntity!.Value; + clientTarget = ToClientEntity(sEntMan, cEntMan, target); + + Assert.Multiple(() => + { + Assert.That(cEntMan.HasComponent(clientPlayer), Is.True); + Assert.That(cEntMan.EntityExists(clientTarget), Is.True); + }); + }); + + await PressClientPullKey(client, cEntMan, cTiming, clientTarget); + + await client.WaitAssertion(() => + { + var held = cEntMan.GetComponent(clientTarget); + var holderHands = cEntMan.GetComponent(clientPlayer); + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + Assert.That(cEntMan.HasComponent(clientPlayer), Is.True); + Assert.That(CountHolderHandBlockers(cEntMan, cHandsSystem, clientPlayer, clientTarget, holderHands), Is.EqualTo(1)); + Assert.That(CountHolderTargetVirtualItems(cEntMan, cHandsSystem, clientPlayer, clientTarget, holderHands), Is.EqualTo(1)); + Assert.That(cStatusEffects.HasStatusEffect(clientTarget, "StatusEffectScpHeld"), Is.True); + }); + }); + + await server.WaitAssertion(() => + { + Assert.That(sEntMan.HasComponent(target), Is.False); + }); + + var maxPredictedClientBlockers = 1; + for (var i = 0; i < 6; i++) + { + await pair.RunTicksSync(1); + await pair.SyncTicks(targetDelta: 1); + await client.WaitPost(() => + { + if (!cEntMan.TryGetComponent(clientPlayer, out var holderHands)) + return; + + maxPredictedClientBlockers = Math.Max(maxPredictedClientBlockers, + CountHolderTargetVirtualItems(cEntMan, cHandsSystem, clientPlayer, clientTarget, holderHands)); + }); + } + + Assert.That(maxPredictedClientBlockers, Is.EqualTo(1)); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + var held = sEntMan.GetComponent(target); + var holderHands = sEntMan.GetComponent(serverPlayer); + var puller = sEntMan.GetComponent(serverPlayer); + var pullable = sEntMan.GetComponent(target); + var distance = GetDistance(sTransform, serverPlayer, target); + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + Assert.That(held.PrimaryHolder, Is.EqualTo(serverPlayer)); + Assert.That(distance, Is.GreaterThan(0.18f)); + Assert.That(distance, Is.LessThan(0.55f)); + Assert.That(puller.Pulling, Is.Null); + Assert.That(pullable.Puller, Is.Null); + Assert.That(CountHolderHandBlockers(sEntMan, sHandsSystem, serverPlayer, target, holderHands), Is.EqualTo(1)); + Assert.That(CountHolderTargetVirtualItems(sEntMan, sHandsSystem, serverPlayer, target, holderHands), Is.EqualTo(1)); + Assert.That(sStatusEffects.HasStatusEffect(target, "StatusEffectScpHeld"), Is.True); + Assert.That(sAlerts.IsShowingAlert(target, "ScpHoldGrabbed"), Is.True); + }); + }); + + await client.WaitAssertion(() => + { + var held = cEntMan.GetComponent(clientTarget); + var holderHands = cEntMan.GetComponent(clientPlayer); + var puller = cEntMan.GetComponent(clientPlayer); + var pullable = cEntMan.GetComponent(clientTarget); + var distance = GetDistance(cTransform, clientPlayer, clientTarget); + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + Assert.That(held.PrimaryHolder, Is.EqualTo(clientPlayer)); + Assert.That(distance, Is.GreaterThan(0.18f)); + Assert.That(distance, Is.LessThan(0.55f)); + Assert.That(puller.Pulling, Is.Null); + Assert.That(pullable.Puller, Is.Null); + Assert.That(CountHolderHandBlockers(cEntMan, cHandsSystem, clientPlayer, clientTarget, holderHands), Is.EqualTo(1)); + Assert.That(CountHolderTargetVirtualItems(cEntMan, cHandsSystem, clientPlayer, clientTarget, holderHands), Is.EqualTo(1)); + Assert.That(cStatusEffects.HasStatusEffect(clientTarget, "StatusEffectScpHeld"), Is.True); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task ClientPullCooldownAndFullBreakoutPenaltyReplicate() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + Fresh = true, + Connected = true, + DummyTicker = false, + }); + + var server = pair.Server; + var client = pair.Client; + var sEntMan = server.EntMan; + var cEntMan = client.EntMan; + var sTiming = server.ResolveDependency(); + var cTiming = client.ResolveDependency(); + var sTransform = server.System(); + var holding = server.System(); + var map = await pair.CreateTestMap(); + + var serverPlayer = pair.Player!.AttachedEntity!.Value; + EntityUid firstTarget = default; + EntityUid breakoutTarget = default; + EntityUid holderTwo = default; + + await server.WaitPost(() => + { + sTransform.SetCoordinates(serverPlayer, map.GridCoords); + sEntMan.EnsureComponent(serverPlayer); + firstTarget = sEntMan.SpawnEntity("MobHuman", map.GridCoords.Offset(new Vector2(0.4f, 0f))); + breakoutTarget = sEntMan.SpawnEntity("MobHuman", map.GridCoords.Offset(new Vector2(0.4f, 0.6f))); + holderTwo = sEntMan.SpawnEntity(HolderPrototype, map.GridCoords.Offset(new Vector2(-0.4f, 0.6f))); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + EntityUid clientPlayer = default; + EntityUid clientFirstTarget = default; + EntityUid clientBreakoutTarget = default; + + await client.WaitAssertion(() => + { + clientPlayer = client.AttachedEntity!.Value; + clientFirstTarget = ToClientEntity(sEntMan, cEntMan, firstTarget); + clientBreakoutTarget = ToClientEntity(sEntMan, cEntMan, breakoutTarget); + }); + + await PressClientPullKey(client, cEntMan, cTiming, clientFirstTarget); + await PressClientPullKey(client, cEntMan, cTiming, clientBreakoutTarget); + + await client.WaitAssertion(() => + { + Assert.Multiple(() => + { + Assert.That(cEntMan.HasComponent(clientFirstTarget), Is.True); + Assert.That(cEntMan.HasComponent(clientBreakoutTarget), Is.False); + Assert.That(GetHoldCooldownRemaining(cEntMan, clientPlayer, cTiming), Is.GreaterThan(TimeSpan.Zero)); + }); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + Assert.Multiple(() => + { + Assert.That(sEntMan.HasComponent(firstTarget), Is.True); + Assert.That(sEntMan.HasComponent(breakoutTarget), Is.False); + Assert.That(GetHoldCooldownRemaining(sEntMan, serverPlayer, sTiming), Is.GreaterThan(TimeSpan.Zero)); + }); + }); + + await client.WaitAssertion(() => + { + Assert.That(GetHoldCooldownRemaining(cEntMan, clientPlayer, cTiming), Is.GreaterThan(TimeSpan.Zero)); + }); + + await pair.RunTicksSync(GetTickCount(sTiming, TimeSpan.FromSeconds(1)) + 1); + await pair.SyncTicks(targetDelta: 1); + + await PressClientPullKey(client, cEntMan, cTiming, clientFirstTarget); + + await client.WaitAssertion(() => + { + Assert.That(cEntMan.HasComponent(clientFirstTarget), Is.False); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + Assert.That(sEntMan.HasComponent(firstTarget), Is.False); + }); + + await pair.RunTicksSync(GetTickCount(sTiming, TimeSpan.FromSeconds(1)) + 1); + await pair.SyncTicks(targetDelta: 1); + + await PressClientPullKey(client, cEntMan, cTiming, clientBreakoutTarget); + + await client.WaitAssertion(() => + { + Assert.That(cEntMan.HasComponent(clientBreakoutTarget), Is.True); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + Assert.That(sEntMan.HasComponent(breakoutTarget), Is.True); + }); + + await server.WaitPost(() => + { + StartHold(sEntMan, holding, holderTwo, breakoutTarget); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + await pair.RunTicksSync(GetTickCount(sTiming, TimeSpan.FromSeconds(10))); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitPost(() => RaiseMoveInput(sEntMan, breakoutTarget)); + await pair.RunTicksSync(2); + await pair.SyncTicks(targetDelta: 1); + await pair.RunTicksSync(GetTickCount(sTiming, TimeSpan.FromSeconds(5)) + 5); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + Assert.Multiple(() => + { + Assert.That(sEntMan.HasComponent(breakoutTarget), Is.False); + Assert.That(GetHoldCooldownRemaining(sEntMan, serverPlayer, sTiming), Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(1.5))); + Assert.That(GetHoldCooldownRemaining(sEntMan, holderTwo, sTiming), Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(1.5))); + }); + }); + + await client.WaitAssertion(() => + { + Assert.Multiple(() => + { + Assert.That(cEntMan.HasComponent(clientBreakoutTarget), Is.False); + Assert.That(GetHoldCooldownRemaining(cEntMan, clientPlayer, cTiming), Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(1.5))); + }); + }); + + await PressClientPullKey(client, cEntMan, cTiming, clientFirstTarget); + + await client.WaitAssertion(() => + { + Assert.That(cEntMan.HasComponent(clientFirstTarget), Is.False); + }); + + await pair.RunTicksSync(5); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + Assert.That(sEntMan.HasComponent(firstTarget), Is.False); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task ClientSecondPullPredictsFullHoldBeforeServerAck() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + Fresh = true, + Connected = true, + DummyTicker = false, + }); + + var server = pair.Server; + var client = pair.Client; + var sEntMan = server.EntMan; + var cEntMan = client.EntMan; + var cTiming = client.ResolveDependency(); + var sTransform = server.System(); + var sHandsSystem = server.System(); + var cHandsSystem = client.System(); + var holding = server.System(); + var map = await pair.CreateTestMap(); + + var serverPlayer = pair.Player!.AttachedEntity!.Value; + EntityUid holderOne = default; + EntityUid target = default; + + await server.WaitPost(() => + { + sTransform.SetCoordinates(serverPlayer, map.GridCoords); + sEntMan.EnsureComponent(serverPlayer); + + holderOne = sEntMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = sEntMan.SpawnEntity("MobHuman", map.GridCoords.Offset(new Vector2(0.5f, 0f))); + StartHold(sEntMan, holding, holderOne, target); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + var clientPlayer = EntityUid.Invalid; + var clientTarget = EntityUid.Invalid; + var clientHolderOne = EntityUid.Invalid; + await client.WaitAssertion(() => + { + clientPlayer = client.AttachedEntity!.Value; + clientTarget = ToClientEntity(sEntMan, cEntMan, target); + clientHolderOne = ToClientEntity(sEntMan, cEntMan, holderOne); + + var held = cEntMan.GetComponent(clientTarget); + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + }); + }); + + await PressClientPullKey(client, cEntMan, cTiming, clientTarget); + + await client.WaitAssertion(() => + { + var held = cEntMan.GetComponent(clientTarget); + var hands = cEntMan.GetComponent(clientTarget); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.True); + Assert.That(held.Holders, Has.Count.EqualTo(2)); + Assert.That(CountBlockingVirtualHands(cEntMan, cHandsSystem, clientTarget, hands), Is.EqualTo(hands.SortedHands.Count)); + Assert.That(VictimHandsUseHolderIcons(cEntMan, cHandsSystem, clientTarget, hands, clientHolderOne, clientPlayer), Is.True); + }); + }); + + await server.WaitAssertion(() => + { + var held = sEntMan.GetComponent(target); + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + }); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + var held = sEntMan.GetComponent(target); + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.True); + Assert.That(held.Holders, Has.Count.EqualTo(2)); + }); + }); + + await client.WaitAssertion(() => + { + var held = cEntMan.GetComponent(clientTarget); + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.True); + Assert.That(held.Holders, Has.Count.EqualTo(2)); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task ClientPrimaryReassignmentKeepsCustomDragAndReconcilesCleanly() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + Fresh = true, + Connected = true, + DummyTicker = false, + }); + + var server = pair.Server; + var client = pair.Client; + var sEntMan = server.EntMan; + var cEntMan = client.EntMan; + var sPhysics = server.System(); + var cPhysics = client.System(); + var host = server.ResolveDependency(); + var sTransform = server.System(); + var cTransform = client.System(); + var holding = server.System(); + var map = await pair.CreateTestMap(); + + var serverPlayer = pair.Player!.AttachedEntity!.Value; + EntityUid holderTwo = default; + EntityUid target = default; + + await server.WaitPost(() => + { + sTransform.SetCoordinates(serverPlayer, map.GridCoords); + sEntMan.EnsureComponent(serverPlayer); + + target = sEntMan.SpawnEntity("MobHuman", map.GridCoords.Offset(new Vector2(0.95f, 0f))); + host.ExecuteCommand(null, $"addhand {sEntMan.GetNetEntity(target)}"); + + holderTwo = sEntMan.SpawnEntity(HolderPrototype, map.GridCoords.Offset(new Vector2(-0.5f, 0f))); + + StartHold(sEntMan, holding, serverPlayer, target); + StartHold(sEntMan, holding, holderTwo, target); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + var held = sEntMan.GetComponent(target); + var serverPlayerPuller = sEntMan.GetComponent(serverPlayer); + var holderTwoPuller = sEntMan.GetComponent(holderTwo); + var pullable = sEntMan.GetComponent(target); + var distance = GetDistance(sTransform, serverPlayer, target); + var contacts = sPhysics.GetContactingEntities(serverPlayer); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(2)); + Assert.That(held.PrimaryHolder, Is.EqualTo(serverPlayer)); + Assert.That(distance, Is.GreaterThan(0.18f)); + Assert.That(distance, Is.LessThan(0.5f)); + Assert.That(contacts, Does.Not.Contain(target)); + Assert.That(serverPlayerPuller.Pulling, Is.Null); + Assert.That(holderTwoPuller.Pulling, Is.Null); + Assert.That(pullable.Puller, Is.Null); + }); + }); + + var clientPlayer = EntityUid.Invalid; + var clientTarget = EntityUid.Invalid; + var clientHolderTwo = EntityUid.Invalid; + await client.WaitAssertion(() => + { + clientPlayer = client.AttachedEntity!.Value; + clientTarget = ToClientEntity(sEntMan, cEntMan, target); + clientHolderTwo = ToClientEntity(sEntMan, cEntMan, holderTwo); + + var held = cEntMan.GetComponent(clientTarget); + var playerPuller = cEntMan.GetComponent(clientPlayer); + var holderTwoPuller = cEntMan.GetComponent(clientHolderTwo); + var pullable = cEntMan.GetComponent(clientTarget); + var distance = GetDistance(cTransform, clientPlayer, clientTarget); + var contacts = cPhysics.GetContactingEntities(clientPlayer); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(2)); + Assert.That(held.PrimaryHolder, Is.EqualTo(clientPlayer)); + Assert.That(distance, Is.GreaterThan(0.18f)); + Assert.That(distance, Is.LessThan(0.5f)); + Assert.That(contacts, Does.Not.Contain(clientTarget)); + Assert.That(playerPuller.Pulling, Is.Null); + Assert.That(holderTwoPuller.Pulling, Is.Null); + Assert.That(pullable.Puller, Is.Null); + }); + }); + + await server.WaitPost(() => + { + var holdComp = sEntMan.GetComponent(serverPlayer); + Assert.That(holding.TryToggleHold((serverPlayer, holdComp), target), Is.True); + }); + + await pair.RunTicksSync(5); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + var held = sEntMan.GetComponent(target); + var pullable = sEntMan.GetComponent(target); + var holderTwoPuller = sEntMan.GetComponent(holderTwo); + var distance = GetDistance(sTransform, holderTwo, target); + var contacts = sPhysics.GetContactingEntities(holderTwo); + + Assert.Multiple(() => + { + Assert.That(held.Holders, Has.Count.EqualTo(1)); + Assert.That(held.PrimaryHolder, Is.EqualTo(holderTwo)); + Assert.That(distance, Is.GreaterThan(0.18f)); + Assert.That(distance, Is.LessThan(0.55f)); + Assert.That(contacts, Does.Not.Contain(target)); + Assert.That(holderTwoPuller.Pulling, Is.Null); + Assert.That(pullable.Puller, Is.Null); + }); + }); + + await client.WaitAssertion(() => + { + var held = cEntMan.GetComponent(clientTarget); + var pullable = cEntMan.GetComponent(clientTarget); + var holderTwoPuller = cEntMan.GetComponent(clientHolderTwo); + var distance = GetDistance(cTransform, clientHolderTwo, clientTarget); + var contacts = cPhysics.GetContactingEntities(clientHolderTwo); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + Assert.That(held.PrimaryHolder, Is.EqualTo(clientHolderTwo)); + Assert.That(distance, Is.GreaterThan(0.18f)); + Assert.That(distance, Is.LessThan(0.7f)); + Assert.That(contacts, Does.Not.Contain(clientTarget)); + Assert.That(holderTwoPuller.Pulling, Is.Null); + Assert.That(pullable.Puller, Is.Null); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task ClientFullBreakoutAlertPredictsDoAfterAndReconciles() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + Fresh = true, + Connected = true, + DummyTicker = false, + }); + + var server = pair.Server; + var client = pair.Client; + var sEntMan = server.EntMan; + var cEntMan = client.EntMan; + var timing = server.ResolveDependency(); + var sTransform = server.System(); + var holding = server.System(); + var map = await pair.CreateTestMap(); + + var serverPlayer = pair.Player!.AttachedEntity!.Value; + EntityUid holderOne = default; + EntityUid holderTwo = default; + + await server.WaitPost(() => + { + sTransform.SetCoordinates(serverPlayer, map.GridCoords); + holderOne = sEntMan.SpawnEntity(HolderPrototype, map.GridCoords.Offset(new Vector2(0.5f, 0f))); + holderTwo = sEntMan.SpawnEntity(HolderPrototype, map.GridCoords.Offset(new Vector2(-0.5f, 0f))); + StartHold(sEntMan, holding, holderOne, serverPlayer); + StartHold(sEntMan, holding, holderTwo, serverPlayer); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + await pair.RunTicksSync(GetTickCount(timing, TimeSpan.FromSeconds(10))); + await pair.SyncTicks(targetDelta: 1); + await server.WaitAssertion(() => + { + var held = sEntMan.GetComponent(serverPlayer); + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.True); + }); + }); + + var clientPlayer = EntityUid.Invalid; + var clientHolderOne = EntityUid.Invalid; + var clientHolderTwo = EntityUid.Invalid; + await client.WaitAssertion(() => + { + clientPlayer = client.AttachedEntity!.Value; + clientHolderOne = ToClientEntity(sEntMan, cEntMan, holderOne); + clientHolderTwo = ToClientEntity(sEntMan, cEntMan, holderTwo); + var held = cEntMan.GetComponent(clientPlayer); + Assert.That(held.FullHold, Is.True); + }); + + await client.WaitPost(() => + { + cEntMan.RaisePredictiveEvent(new ClickAlertEvent("ScpHoldGrabbed")); + + var held = cEntMan.GetComponent(clientPlayer); + Assert.Multiple(() => + { + Assert.That(held.BreakoutDoAfterId, Is.Not.Null); + Assert.That(cEntMan.HasComponent(clientPlayer), Is.False); + Assert.That(CountAttachedPrototype(cEntMan, clientHolderOne, "WhistleExclamation"), Is.EqualTo(1)); + Assert.That(CountAttachedPrototype(cEntMan, clientHolderTwo, "WhistleExclamation"), Is.EqualTo(1)); + }); + }); + + await server.WaitAssertion(() => + { + var held = sEntMan.GetComponent(serverPlayer); + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.True); + Assert.That(held.BreakoutDoAfterId, Is.Null); + }); + }); + + await pair.RunTicksSync(GetTickCount(timing, TimeSpan.FromSeconds(5)) + 5); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + Assert.Multiple(() => + { + Assert.That(sEntMan.HasComponent(serverPlayer), Is.False); + Assert.That(sEntMan.HasComponent(serverPlayer), Is.True); + }); + }); + + await client.WaitAssertion(() => + { + Assert.Multiple(() => + { + Assert.That(cEntMan.HasComponent(clientPlayer), Is.False); + Assert.That(cEntMan.HasComponent(clientPlayer), Is.True); + }); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task ClientGrabbedAlertPredictsSoftBreakout() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + Fresh = true, + Connected = true, + DummyTicker = false, + }); + + var server = pair.Server; + var client = pair.Client; + var sEntMan = server.EntMan; + var cEntMan = client.EntMan; + var sTransform = server.System(); + var holding = server.System(); + var sAlerts = server.System(); + var cAlerts = client.System(); + var map = await pair.CreateTestMap(); + + var serverPlayer = pair.Player!.AttachedEntity!.Value; + EntityUid holder = default; + + await server.WaitPost(() => + { + sTransform.SetCoordinates(serverPlayer, map.GridCoords); + holder = sEntMan.SpawnEntity(HolderPrototype, map.GridCoords.Offset(new Vector2(0.1f, 0f))); + StartHold(sEntMan, holding, holder, serverPlayer); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + var clientPlayer = EntityUid.Invalid; + await client.WaitAssertion(() => + { + clientPlayer = client.AttachedEntity!.Value; + + Assert.Multiple(() => + { + Assert.That(cEntMan.HasComponent(clientPlayer), Is.True); + Assert.That(cAlerts.IsShowingAlert(clientPlayer, "ScpHoldGrabbed"), Is.True); + }); + }); + + await client.WaitPost(() => + { + cEntMan.RaisePredictiveEvent(new ClickAlertEvent("ScpHoldGrabbed")); + + Assert.Multiple(() => + { + Assert.That(cEntMan.HasComponent(clientPlayer), Is.False); + Assert.That(cAlerts.IsShowingAlert(clientPlayer, "ScpHoldGrabbed"), Is.False); + }); + }); + + await server.WaitAssertion(() => + { + Assert.That(sEntMan.HasComponent(serverPlayer), Is.True); + Assert.That(sAlerts.IsShowingAlert(serverPlayer, "ScpHoldGrabbed"), Is.True); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + Assert.That(sEntMan.HasComponent(serverPlayer), Is.False); + Assert.That(sAlerts.IsShowingAlert(serverPlayer, "ScpHoldGrabbed"), Is.False); + }); + + await client.WaitAssertion(() => + { + Assert.That(cEntMan.HasComponent(clientPlayer), Is.False); + Assert.That(cAlerts.IsShowingAlert(clientPlayer, "ScpHoldGrabbed"), Is.False); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task ReleaseAndRangeLossReassignOrClearHold() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings { Fresh = true }); + var server = pair.Server; + var entMan = server.EntMan; + var host = server.ResolveDependency(); + var holding = server.System(); + var transform = server.System(); + var map = await pair.CreateTestMap(); + + EntityUid holderOne = default; + EntityUid holderTwo = default; + EntityUid target = default; + + await server.WaitPost(() => + { + holderOne = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + holderTwo = entMan.SpawnEntity(HolderPrototype, map.GridCoords); + target = entMan.SpawnEntity("MobHuman", map.GridCoords); + + host.ExecuteCommand(null, $"addhand {entMan.GetNetEntity(target)}"); + }); + await server.WaitRunTicks(2); + + await server.WaitPost(() => + { + StartHold(entMan, holding, holderOne, target); + StartHold(entMan, holding, holderTwo, target); + }); + + await server.WaitAssertion(() => + { + var held = entMan.GetComponent(target); + var holderOnePuller = entMan.GetComponent(holderOne); + var holderTwoPuller = entMan.GetComponent(holderTwo); + var pullable = entMan.GetComponent(target); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.PrimaryHolder, Is.EqualTo(holderOne)); + Assert.That(holderOnePuller.Pulling, Is.Null); + Assert.That(holderTwoPuller.Pulling, Is.Null); + Assert.That(pullable.Puller, Is.Null); + }); + }); + + await server.WaitPost(() => + { + var holderComp = entMan.GetComponent(holderOne); + Assert.That(holding.TryToggleHold((holderOne, holderComp), target), Is.True); + }); + await server.WaitRunTicks(4); + + await server.WaitAssertion(() => + { + var held = entMan.GetComponent(target); + var holderOnePuller = entMan.GetComponent(holderOne); + var holderTwoPuller = entMan.GetComponent(holderTwo); + var pullable = entMan.GetComponent(target); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + Assert.That(held.PrimaryHolder, Is.EqualTo(holderTwo)); + Assert.That(holderOnePuller.Pulling, Is.Null); + Assert.That(holderTwoPuller.Pulling, Is.Null); + Assert.That(pullable.Puller, Is.Null); + }); + }); + + await server.WaitPost(() => + { + transform.SetCoordinates(holderTwo, map.GridCoords.Offset(new Vector2(10f, 0f))); + }); + await server.WaitRunTicks(2); + + await server.WaitAssertion(() => + { + Assert.That(entMan.HasComponent(target), Is.False); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task SoftHoldTargetTeleportClearsStateOnServerAndClient() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + Fresh = true, + Connected = true, + DummyTicker = false, + }); + + var server = pair.Server; + var client = pair.Client; + var sEntMan = server.EntMan; + var cEntMan = client.EntMan; + var holding = server.System(); + var sTransform = server.System(); + var sAlerts = server.System(); + var cAlerts = client.System(); + var sStatusEffects = server.System(); + var cStatusEffects = client.System(); + var sHandsSystem = server.System(); + var cHandsSystem = client.System(); + var map = await pair.CreateTestMap(); + + var serverPlayer = pair.Player!.AttachedEntity!.Value; + EntityUid holder = default; + + await server.WaitPost(() => + { + sTransform.SetCoordinates(serverPlayer, map.GridCoords); + holder = sEntMan.SpawnEntity(HolderPrototype, map.GridCoords.Offset(new Vector2(0.1f, 0f))); + StartHold(sEntMan, holding, holder, serverPlayer); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + var held = sEntMan.GetComponent(serverPlayer); + var holderState = sEntMan.GetComponent(holder); + var holderHands = sEntMan.GetComponent(holder); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + Assert.That(holderState.Target, Is.EqualTo(serverPlayer)); + Assert.That(CountHolderHandBlockers(sEntMan, sHandsSystem, holder, serverPlayer, holderHands), Is.EqualTo(1)); + Assert.That(CountHolderTargetVirtualItems(sEntMan, sHandsSystem, holder, serverPlayer, holderHands), Is.EqualTo(1)); + Assert.That(sStatusEffects.HasStatusEffect(serverPlayer, "StatusEffectScpHeld"), Is.True); + Assert.That(sAlerts.IsShowingAlert(serverPlayer, "ScpHoldGrabbed"), Is.True); + }); + }); + + var clientPlayer = EntityUid.Invalid; + var clientHolder = EntityUid.Invalid; + await client.WaitAssertion(() => + { + clientPlayer = client.AttachedEntity!.Value; + clientHolder = ToClientEntity(sEntMan, cEntMan, holder); + + var held = cEntMan.GetComponent(clientPlayer); + var holderState = cEntMan.GetComponent(clientHolder); + var holderHands = cEntMan.GetComponent(clientHolder); + + Assert.Multiple(() => + { + Assert.That(held.FullHold, Is.False); + Assert.That(held.Holders, Has.Count.EqualTo(1)); + Assert.That(holderState.Target, Is.EqualTo(clientPlayer)); + Assert.That(CountHolderHandBlockers(cEntMan, cHandsSystem, clientHolder, clientPlayer, holderHands), Is.EqualTo(1)); + Assert.That(CountHolderTargetVirtualItems(cEntMan, cHandsSystem, clientHolder, clientPlayer, holderHands), Is.EqualTo(1)); + Assert.That(cStatusEffects.HasStatusEffect(clientPlayer, "StatusEffectScpHeld"), Is.True); + Assert.That(cAlerts.IsShowingAlert(clientPlayer, "ScpHoldGrabbed"), Is.True); + }); + }); + + await server.WaitPost(() => + { + sTransform.SetCoordinates(serverPlayer, map.GridCoords.Offset(new Vector2(10f, 0f))); + }); + + await pair.RunTicksSync(10); + await pair.SyncTicks(targetDelta: 1); + + await server.WaitAssertion(() => + { + var holderHands = sEntMan.GetComponent(holder); + + Assert.Multiple(() => + { + Assert.That(sEntMan.HasComponent(serverPlayer), Is.False); + Assert.That(sEntMan.HasComponent(serverPlayer), Is.False); + Assert.That(sEntMan.HasComponent(holder), Is.False); + Assert.That(CountHolderHandBlockers(sEntMan, sHandsSystem, holder, serverPlayer, holderHands), Is.EqualTo(0)); + Assert.That(CountHolderTargetVirtualItems(sEntMan, sHandsSystem, holder, serverPlayer, holderHands), Is.EqualTo(0)); + Assert.That(sStatusEffects.HasStatusEffect(serverPlayer, "StatusEffectScpHeld"), Is.False); + Assert.That(sAlerts.IsShowingAlert(serverPlayer, "ScpHoldGrabbed"), Is.False); + }); + }); + + await client.WaitAssertion(() => + { + var holderHands = cEntMan.GetComponent(clientHolder); + + Assert.Multiple(() => + { + Assert.That(cEntMan.HasComponent(clientPlayer), Is.False); + Assert.That(cEntMan.HasComponent(clientPlayer), Is.False); + Assert.That(cEntMan.HasComponent(clientHolder), Is.False); + Assert.That(CountHolderHandBlockers(cEntMan, cHandsSystem, clientHolder, clientPlayer, holderHands), Is.EqualTo(0)); + Assert.That(CountHolderTargetVirtualItems(cEntMan, cHandsSystem, clientHolder, clientPlayer, holderHands), Is.EqualTo(0)); + Assert.That(cStatusEffects.HasStatusEffect(clientPlayer, "StatusEffectScpHeld"), Is.False); + Assert.That(cAlerts.IsShowingAlert(clientPlayer, "ScpHoldGrabbed"), Is.False); + }); + }); + + await pair.CleanReturnAsync(); + } + + private static int CountBlockingVirtualHands(IEntityManager entMan, SharedHandsSystem handsSystem, EntityUid uid, HandsComponent hands) + { + return handsSystem.EnumerateHeld((uid, hands)).Count(item => + entMan.TryGetComponent(item, out VirtualItemComponent? virtualItem) && + entMan.TryGetComponent(item, out ScpHeldHandBlockerComponent? blocker) && + blocker.Target == uid && + blocker.Holder == virtualItem.BlockingEntity && + entMan.HasComponent(item)); + } + + private static bool VictimHandsUseHolderIcons(IEntityManager entMan, SharedHandsSystem handsSystem, EntityUid uid, HandsComponent hands, params EntityUid[] holders) + { + var expected = holders.ToHashSet(); + var count = 0; + + foreach (var item in handsSystem.EnumerateHeld((uid, hands))) + { + if (!entMan.TryGetComponent(item, out VirtualItemComponent? virtualItem) || + !entMan.TryGetComponent(item, out ScpHeldHandBlockerComponent? blocker) || + blocker.Target != uid || + blocker.Holder != virtualItem.BlockingEntity || + !entMan.HasComponent(item) || + !expected.Contains(blocker.Holder)) + { + return false; + } + + count++; + } + + return count == hands.SortedHands.Count; + } + + private static int CountAttachedPrototype(IEntityManager entMan, EntityUid parent, string prototypeId) + { + var count = 0; + var enumerator = entMan.GetComponent(parent).ChildEnumerator; + + while (enumerator.MoveNext(out var child)) + { + if (entMan.TryGetComponent(child, out MetaDataComponent? metadata) && + !metadata.Deleted && + metadata.EntityPrototype?.ID == prototypeId) + { + count++; + } + } + + return count; + } + + private static int CountPrototypeEntities(IEntityManager entMan, string prototypeId) + { + var count = 0; + var query = entMan.AllEntityQueryEnumerator(); + + while (query.MoveNext(out _, out var metadata)) + { + if (!metadata.Deleted && + metadata.EntityPrototype?.ID == prototypeId) + { + count++; + } + } + + return count; + } + + private static int CountHolderHandBlockers(IEntityManager entMan, SharedHandsSystem handsSystem, EntityUid holder, EntityUid target, HandsComponent hands) + { + return handsSystem.EnumerateHeld((holder, hands)).Count(item => + entMan.TryGetComponent(item, out VirtualItemComponent? virtualItem) && + entMan.TryGetComponent(item, out ScpHoldHandBlockerComponent? blocker) && + blocker.Target == target && + virtualItem.BlockingEntity == target); + } + + private static int CountHolderTargetVirtualItems(IEntityManager entMan, SharedHandsSystem handsSystem, EntityUid holder, EntityUid target, HandsComponent hands) + { + return handsSystem.EnumerateHeld((holder, hands)).Count(item => + entMan.TryGetComponent(item, out VirtualItemComponent? virtualItem) && + virtualItem.BlockingEntity == target); + } + + private static EntityUid FindHolderHandBlocker(IEntityManager entMan, SharedHandsSystem handsSystem, EntityUid holder, EntityUid target, HandsComponent hands) + { + foreach (var item in handsSystem.EnumerateHeld((holder, hands))) + { + if (entMan.TryGetComponent(item, out VirtualItemComponent? virtualItem) && + entMan.TryGetComponent(item, out ScpHoldHandBlockerComponent? blocker) && + blocker.Target == target && + virtualItem.BlockingEntity == target) + { + return item; + } + } + + return EntityUid.Invalid; + } + + private static float GetDistance(SharedTransformSystem transform, EntityUid first, EntityUid second) + { + return Vector2.Distance( + transform.GetMapCoordinates(first).Position, + transform.GetMapCoordinates(second).Position); + } + + private static float GetLargestDistanceStep(float[] samples) + { + var largest = 0f; + + for (var i = 1; i < samples.Length; i++) + { + largest = Math.Max(largest, MathF.Abs(samples[i] - samples[i - 1])); + } + + return largest; + } + + private static int GetTickCount(IGameTiming timing, TimeSpan duration) + { + return Math.Max(1, (int)Math.Ceiling(duration.TotalSeconds / timing.TickPeriod.TotalSeconds) + 1); + } + + private static TimeSpan GetHoldCooldownRemaining(IEntityManager entMan, EntityUid holder, IGameTiming timing) + { + if (!entMan.TryGetComponent(holder, out ScpHoldComponent? holdComp) || + holdComp.HoldAvailableAt is not { } cooldownEnd || + cooldownEnd <= timing.CurTime) + { + return TimeSpan.Zero; + } + + return cooldownEnd - timing.CurTime; + } + + private static async Task PressClientPullKey( + RobustIntegrationTest.ClientIntegrationInstance client, + IEntityManager entMan, + IGameTiming timing, + EntityUid cursorEntity) + { + await SendClientPullInput(client, entMan, timing, cursorEntity, BoundKeyState.Down); + await SendClientPullInput(client, entMan, timing, cursorEntity, BoundKeyState.Up); + } + + private static async Task SendClientPullInput( + RobustIntegrationTest.ClientIntegrationInstance client, + IEntityManager entMan, + IGameTiming timing, + EntityUid cursorEntity, + BoundKeyState state) + { + var inputManager = client.ResolveDependency(); + var funcId = inputManager.NetworkBindMap.KeyFunctionID(ContentKeyFunctions.TryPullObject); + var transform = entMan.GetComponent(cursorEntity); + var inputSystem = client.System(); + var message = new ClientFullInputCmdMessage(timing.CurTick, timing.TickFraction, funcId) + { + State = state, + Coordinates = transform.Coordinates, + Uid = cursorEntity, + }; + + await client.WaitPost(() => inputSystem.HandleInputCommand(client.Session!, ContentKeyFunctions.TryPullObject, message)); + } + + private static void SetSoftEscapeAvailableAt(ScpHeldComponent held, TimeSpan value) + { + SoftEscapeAvailableAtField.SetValue(held, value); + } + + private static void RaiseMoveInput(IEntityManager entMan, EntityUid uid) + { + var mover = entMan.GetComponent(uid); + var move = new MoveInputEvent((uid, mover), MoveButtons.None, Direction.East, true); + entMan.EventBus.RaiseLocalEvent(uid, ref move); + } + + private static void StartHold(IEntityManager entMan, SharedScpHoldingSystem holding, EntityUid holder, EntityUid target) + { + var holdComp = entMan.GetComponent(holder); + Assert.That(holding.TryToggleHold((holder, holdComp), target), Is.True); + } + + private static EntityUid ToClientEntity(IEntityManager serverEntMan, IEntityManager clientEntMan, EntityUid serverEntity) + { + return clientEntMan.GetEntity(serverEntMan.GetNetEntity(serverEntity)); + } +} + +[RegisterComponent] +public sealed partial class ScpHoldAttemptCancelTestComponent : Component; + +public sealed class ScpHoldAttemptListenerSystem : TestListenerSystem; + +public sealed class ScpHoldBreakoutListenerSystem : TestListenerSystem; + +public sealed class ScpHoldAttemptCancelSystem : EntitySystem +{ + public override void Initialize() + { + SubscribeLocalEvent(OnAttempt); + } + + private static void OnAttempt(Entity ent, ref ScpHoldAttemptEvent args) + { + args.Cancel(); + } +} diff --git a/Content.Shared/Hands/EntitySystems/SharedHandsSystem.Drop.cs b/Content.Shared/Hands/EntitySystems/SharedHandsSystem.Drop.cs index 94ab593f905..2102cb71832 100644 --- a/Content.Shared/Hands/EntitySystems/SharedHandsSystem.Drop.cs +++ b/Content.Shared/Hands/EntitySystems/SharedHandsSystem.Drop.cs @@ -77,7 +77,7 @@ public bool CanDropHeld(EntityUid uid, string handId, bool checkActionBlocker = if (!ContainerSystem.TryGetContainer(uid, handId, out var container)) return false; - if (container.ContainedEntities.FirstOrNull() is not {} held) + if (container.ContainedEntities.FirstOrNull() is not { } held) return false; if (!ContainerSystem.CanRemove(held, container)) @@ -139,9 +139,13 @@ public bool TryDrop(Entity ent, string handId, EntityCoordinate return false; // Fire added end - // if item is a fake item (like with pulling), just delete it rather than bothering with trying to drop it into the world - if (TryComp(entity, out VirtualItemComponent? @virtual)) - _virtualSystem.DeleteVirtualItem((entity.Value, @virtual), ent); + // Fire edit start - virtual items should leave the hand through the normal removal path, not through world drop handling + if (TryComp(entity, out VirtualItemComponent? _)) + { + DoDrop(ent, handId, doDropInteraction: false); + return true; + } + // Fire edit end if (TerminatingOrDeleted(entity)) return true; diff --git a/Content.Shared/Interaction/SharedInteractionSystem.Blocking.cs b/Content.Shared/Interaction/SharedInteractionSystem.Blocking.cs index ada0542c4f7..fa432ffd254 100644 --- a/Content.Shared/Interaction/SharedInteractionSystem.Blocking.cs +++ b/Content.Shared/Interaction/SharedInteractionSystem.Blocking.cs @@ -41,11 +41,16 @@ private void CancelUseEvent(Entity ent, ref UseAttemptEv private void OnMoveAttempt(EntityUid uid, BlockMovementComponent component, UpdateCanMoveEvent args) { + // Fire edit start - do not let a blocker cancel its own shutdown refresh + if (component.LifeStage > ComponentLifeStage.Running) + return; + // If we're relaying then don't cancel. if (HasComp(uid)) return; args.Cancel(); // no more scurrying around + // Fire edit end } private void CancellableInteractEvent(EntityUid uid, BlockMovementComponent component, CancellableEntityEventArgs args) diff --git a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs index 9a1222cf2b9..f7c3a5fcd44 100644 --- a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs +++ b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs @@ -39,7 +39,9 @@ namespace Content.Shared.Movement.Pulling.Systems; /// /// Allows one entity to pull another behind them via a physics distance joint. /// -public sealed class PullingSystem : EntitySystem +// Fire edit start - enable _Scp partial hook +public sealed partial class PullingSystem : EntitySystem +// Fire edit end { [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly MobStateSystem _mobState = default!; // Sunrise-edit @@ -60,6 +62,10 @@ public override void Initialize() { base.Initialize(); + // Fire added start - initialize _Scp hold redirect caches + InitializeScpHolding(); + // Fire added end + UpdatesAfter.Add(typeof(SharedPhysicsSystem)); UpdatesOutsidePrediction = true; @@ -523,6 +529,11 @@ public bool TryStartPull(EntityUid pullerUid, EntityUid pullableUid, if (pullerComp.Pulling == pullableUid) return true; + // Fire added start - redirect scp-hold-capable pull attempts into the hold flow + if (TryRedirectPullToScpHold(pullerUid, pullableUid, pullerComp, pullableComp, out var holdResult)) + return holdResult; + // Fire added end + if (!CanPull(pullerUid, pullableUid)) return false; diff --git a/Content.Shared/_Scp/Holding/PullingSystem.ScpHolding.cs b/Content.Shared/_Scp/Holding/PullingSystem.ScpHolding.cs new file mode 100644 index 00000000000..ee78115692b --- /dev/null +++ b/Content.Shared/_Scp/Holding/PullingSystem.ScpHolding.cs @@ -0,0 +1,66 @@ +using Content.Shared._Scp.Holding; +using Content.Shared.Movement.Pulling.Components; + +#pragma warning disable IDE0130 +namespace Content.Shared.Movement.Pulling.Systems; + +public sealed partial class PullingSystem +{ + [Dependency] private readonly SharedScpHoldingSystem _scpHolding = default!; + + private EntityQuery _pullableQuery; + private EntityQuery _scpHoldQuery; + private EntityQuery _scpHoldableQuery; + private EntityQuery _scpHolderQuery; + + private void InitializeScpHolding() + { + _pullableQuery = GetEntityQuery(); + _scpHoldQuery = GetEntityQuery(); + _scpHoldableQuery = GetEntityQuery(); + _scpHolderQuery = GetEntityQuery(); + } + + private bool TryRedirectPullToScpHold(EntityUid pullerUid, EntityUid pullableUid, + PullerComponent pullerComp, PullableComponent pullableComp, out bool result) + { + result = false; + + if (!_scpHoldQuery.TryComp(pullerUid, out var holdComp) || + !_scpHoldableQuery.HasComp(pullableUid)) + { + return false; + } + + var holder = (pullerUid, holdComp); + + if (_scpHolderQuery.TryComp(pullerUid, out var activeHolder) && + activeHolder.Target != null) + { + result = _scpHolding.TryToggleHold(holder, pullableUid); + return true; + } + + if (!_scpHolding.CanToggleHold(holder, + pullableUid, + ignoreHandAvailability: pullerComp.Pulling != null, + checkAttempt: true)) + return true; + + if (pullerComp.Pulling is { } currentPullUid && + _pullableQuery.TryComp(currentPullUid, out var currentPull) && + !TryStopPull(currentPullUid, currentPull, pullerUid)) + { + return true; + } + + if (pullableComp.Puller != null && + !TryStopPull(pullableUid, pullableComp, pullableComp.Puller)) + { + return true; + } + + result = _scpHolding.TryToggleHold(holder, pullableUid, attemptChecked: true); + return true; + } +} diff --git a/Content.Shared/_Scp/Holding/ScpHeldComponent.cs b/Content.Shared/_Scp/Holding/ScpHeldComponent.cs new file mode 100644 index 00000000000..5663627e850 --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHeldComponent.cs @@ -0,0 +1,91 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Shared._Scp.Holding; + +/// +/// Runtime state stored on a target while at least one holder is contributing. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(true), AutoGenerateComponentPause] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ScpHeldComponent : Component +{ + /// + /// Whether the target is currently in the immobile full hold stage. + /// + [AutoNetworkedField] + public bool FullHold; + + /// + /// Next timestamp when a soft breakout attempt may succeed. + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField] + public TimeSpan SoftEscapeAvailableAt; + + /// + /// Timestamp when the current uninterrupted full hold started. + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField] + public TimeSpan? FullHoldStartedAt; + + /// + /// Ordered holder list used for reassignment and contribution counting. + /// + [AutoNetworkedField] + public List Holders = new(); + + /// + /// Current primary holder used as the soft hold drag anchor. + /// + [AutoNetworkedField] + public EntityUid? PrimaryHolder; + + /// + /// Required contributor count for entering full hold. + /// + public int RequiredHolderCount = 2; + + /// + /// Copied soft breakout cooldown configuration from the initial holdable target. + /// + public TimeSpan SoftEscapeCooldown = TimeSpan.FromSeconds(1); + + /// + /// Copied full hold delay configuration from the initial holdable target. + /// + [AutoNetworkedField] + public TimeSpan FullHoldDelay = TimeSpan.FromSeconds(10); + + /// + /// Copied full breakout duration configuration from the initial holdable target. + /// + [AutoNetworkedField] + public TimeSpan FullBreakoutDuration = TimeSpan.FromSeconds(5); + + /// + /// Copied post-breakout immunity duration from the initial holdable target. + /// + public TimeSpan PostBreakoutImmunity = TimeSpan.FromSeconds(5); + + /// + /// Copied maximum hold range from the initial holdable target. + /// + [AutoNetworkedField] + public float HoldRange = 1f; + + /// + /// Copied walk slowdown applied through . + /// + public float WalkModifier = 0.5f; + + /// + /// Copied sprint slowdown applied through . + /// + public float SprintModifier = 0.5f; + + /// + /// Active breakout do-after id for a full hold, if one exists. + /// + [AutoNetworkedField] + public ushort? BreakoutDoAfterId; +} diff --git a/Content.Shared/_Scp/Holding/ScpHeldHandBlockerComponent.cs b/Content.Shared/_Scp/Holding/ScpHeldHandBlockerComponent.cs new file mode 100644 index 00000000000..88b1250a0ca --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHeldHandBlockerComponent.cs @@ -0,0 +1,21 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding; + +/// +/// Marks a victim hand placeholder virtual item created by SCP holding. +/// +[RegisterComponent, NetworkedComponent] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ScpHeldHandBlockerComponent : Component +{ + /// + /// Held target whose hand is occupied by this placeholder. + /// + public EntityUid Target; + + /// + /// Holder whose sprite is shown in this placeholder. + /// + public EntityUid Holder; +} diff --git a/Content.Shared/_Scp/Holding/ScpHoldComponent.cs b/Content.Shared/_Scp/Holding/ScpHoldComponent.cs new file mode 100644 index 00000000000..1b5e56bf91e --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHoldComponent.cs @@ -0,0 +1,36 @@ +using Content.Shared.Whitelist; +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding; + +/// +/// Grants the owner the ability to contribute to SCP holding. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(fieldDeltas: true), AutoGenerateComponentPause] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ScpHoldComponent : Component +{ + /// + /// Next timestamp when this entity may start a new hold contribution. + /// + [AutoNetworkedField, AutoPausedField] + public TimeSpan? HoldAvailableAt; + + /// + /// Optional whitelist of entities this holder may grab. + /// + [DataField] + public EntityWhitelist? HoldableWhitelist; + + /// + /// Optional blacklist of entities this holder may not grab. + /// + [DataField] + public EntityWhitelist? HoldableBlacklist; + + /// + /// Cooldown applied after each successful hold contribution start. + /// + [DataField] + public TimeSpan HoldActionCooldown = TimeSpan.FromSeconds(1); +} diff --git a/Content.Shared/_Scp/Holding/ScpHoldHandBlockerComponent.cs b/Content.Shared/_Scp/Holding/ScpHoldHandBlockerComponent.cs new file mode 100644 index 00000000000..606551c7168 --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHoldHandBlockerComponent.cs @@ -0,0 +1,17 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding; + +/// +/// Marks a virtual item that reserves one holder hand for an active SCP hold. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(true)] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ScpHoldHandBlockerComponent : Component +{ + /// + /// The held target represented by this virtual item. + /// + [AutoNetworkedField] + public EntityUid Target; +} diff --git a/Content.Shared/_Scp/Holding/ScpHoldImmuneComponent.cs b/Content.Shared/_Scp/Holding/ScpHoldImmuneComponent.cs new file mode 100644 index 00000000000..fc603688b31 --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHoldImmuneComponent.cs @@ -0,0 +1,18 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Shared._Scp.Holding; + +/// +/// Prevents the target from being held again for a short period after a successful full breakout. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ScpHoldImmuneComponent : Component +{ + /// + /// Timestamp when the immunity expires. + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField] + public TimeSpan ExpiresAt; +} diff --git a/Content.Shared/_Scp/Holding/ScpHoldRestrictedComponent.cs b/Content.Shared/_Scp/Holding/ScpHoldRestrictedComponent.cs new file mode 100644 index 00000000000..bbd0c66cbad --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHoldRestrictedComponent.cs @@ -0,0 +1,10 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding; + +[RegisterComponent, NetworkedComponent] +public sealed partial class ScpHoldRestrictedComponent : Component +{ + [DataField] + public ScpHoldStage Stage = ScpHoldStage.Full; +} diff --git a/Content.Shared/_Scp/Holding/ScpHoldStage.cs b/Content.Shared/_Scp/Holding/ScpHoldStage.cs new file mode 100644 index 00000000000..24c37d4be3b --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHoldStage.cs @@ -0,0 +1,7 @@ +namespace Content.Shared._Scp.Holding; + +public enum ScpHoldStage +{ + Soft, + Full, +} diff --git a/Content.Shared/_Scp/Holding/ScpHoldableComponent.cs b/Content.Shared/_Scp/Holding/ScpHoldableComponent.cs new file mode 100644 index 00000000000..0a09ba5551d --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHoldableComponent.cs @@ -0,0 +1,67 @@ +using System; +using Content.Shared.Whitelist; + +namespace Content.Shared._Scp.Holding; + +/// +/// Marks an entity as a valid target for the SCP holding mechanic and stores per-target hold tuning. +/// +[RegisterComponent] +public sealed partial class ScpHoldableComponent : Component +{ + /// + /// Optional whitelist of entities that may hold this target. + /// + [DataField] + public EntityWhitelist? HolderWhitelist; + + /// + /// Optional blacklist of entities that may not hold this target. + /// + [DataField] + public EntityWhitelist? HolderBlacklist; + + /// + /// Minimum delay between successful soft breakout attempts while the hold is active. + /// + [DataField] + public TimeSpan SoftEscapeCooldown = TimeSpan.FromSeconds(1); + + /// + /// Minimum uninterrupted full hold duration before a breakout do-after may start. + /// + [DataField] + public TimeSpan FullHoldDelay = TimeSpan.FromSeconds(10); + + /// + /// Duration of the visible breakout do-after for a full hold. + /// + [DataField] + public TimeSpan FullBreakoutDuration = TimeSpan.FromSeconds(5); + + /// + /// Duration of immunity after a successful full breakout. + /// + [DataField] + public TimeSpan PostBreakoutImmunity = TimeSpan.FromSeconds(5); + + /// + /// Maximum unobstructed range allowed between holder and target. + /// + [DataField] + public float HoldRange = 1f; + + /// + /// Walk speed modifier applied to holders while they move this target. + /// Lower values make the target heavier to move. + /// + [DataField] + public float HolderWalkModifier = 0.5f; + + /// + /// Sprint speed modifier applied to holders while they move this target. + /// Lower values make the target heavier to move. + /// + [DataField] + public float HolderSprintModifier = 0.5f; +} diff --git a/Content.Shared/_Scp/Holding/ScpHolderComponent.cs b/Content.Shared/_Scp/Holding/ScpHolderComponent.cs new file mode 100644 index 00000000000..6989c3dad0a --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHolderComponent.cs @@ -0,0 +1,35 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding; + +/// +/// Runtime contribution state stored on each active holder. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(true)] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ScpHolderComponent : Component +{ + /// + /// Target currently being contributed to. + /// + [AutoNetworkedField] + public EntityUid? Target; + + /// + /// Whether this holder should currently receive the custom slowdown. + /// + [AutoNetworkedField] + public bool SlowdownEnabled; + + /// + /// Walk speed modifier used when is true. + /// + [AutoNetworkedField] + public float WalkModifier = 1f; + + /// + /// Sprint speed modifier used when is true. + /// + [AutoNetworkedField] + public float SprintModifier = 1f; +} diff --git a/Content.Shared/_Scp/Holding/ScpHoldingEvents.cs b/Content.Shared/_Scp/Holding/ScpHoldingEvents.cs new file mode 100644 index 00000000000..41d4e815d98 --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHoldingEvents.cs @@ -0,0 +1,36 @@ +using Content.Shared.Alert; +using Content.Shared.DoAfter; +using Robust.Shared.GameObjects; +using Robust.Shared.Serialization; + +namespace Content.Shared._Scp.Holding; + +public sealed partial class ScpHoldBreakoutAlertEvent : BaseAlertEvent; + +public sealed partial class ScpHoldAttemptEvent(EntityUid holder, EntityUid target) : CancellableEntityEventArgs +{ + public EntityUid Holder { get; } = holder; + public EntityUid Target { get; } = target; +} + +public sealed partial class ScpHoldBreakoutEvent(bool viaMovement, bool wasFullHold, bool appliedImmunity) : EntityEventArgs +{ + public bool ViaMovement { get; } = viaMovement; + public bool WasFullHold { get; } = wasFullHold; + public bool AppliedImmunity { get; } = appliedImmunity; +} + +[Serializable, NetSerializable] +public sealed partial class ScpHoldBreakoutDoAfterEvent : SimpleDoAfterEvent +{ + public bool ViaMovement; + + public ScpHoldBreakoutDoAfterEvent() + { + } + + public ScpHoldBreakoutDoAfterEvent(bool viaMovement) + { + ViaMovement = viaMovement; + } +} diff --git a/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Actions.cs b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Actions.cs new file mode 100644 index 00000000000..d54b7d0986d --- /dev/null +++ b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Actions.cs @@ -0,0 +1,329 @@ +using System; +using Content.Shared.DoAfter; +using Content.Shared.Movement.Components; +using Robust.Shared.Physics; + +namespace Content.Shared._Scp.Holding; + +public sealed partial class SharedScpHoldingSystem +{ + /* + * Hold-local query caches, hold toggling API, breakout flow, and cooldown helpers. + */ + private EntityQuery _moverQuery; + private EntityQuery _holdableQuery; + + private void InitializeHoldQueries() + { + _moverQuery = GetEntityQuery(); + _holdableQuery = GetEntityQuery(); + } + + public bool TryToggleHold(Entity holder, EntityUid target, bool attemptChecked = false) + { + if (_holderQuery.TryComp(holder.Owner, out var activeHolder) && activeHolder.Target != null) + { + if (activeHolder.Target.Value == target) + { + ReleaseHolderContribution(holder.Owner, target, clearIfEmpty: true); + return true; + } + + PopupHolder(holder.Owner, "scp-hold-already-holding-other"); + return false; + } + + if (!CanStartHold(holder)) + return false; + + if (!CanToggleHold(holder, target, checkAttempt: !attemptChecked)) + return false; + + var holdable = _holdableQuery.Comp(target); + var held = EnsureHeldState(target, holdable, out var heldCreated); + AddHolderContribution(holder.Owner, held); + SyncHeldState(held); + + if (heldCreated) + Dirty(held); + + StartHoldCooldown(holder); + return true; + } + + public bool CanToggleHold( + Entity holder, + EntityUid target, + bool quiet = false, + bool ignoreHandAvailability = false, + bool checkAttempt = false) + { + if (holder.Owner == target) + return false; + + if (!CanStartHold(holder, quiet)) + return false; + + if (!_holdableQuery.TryComp(target, out var holdable)) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-target-not-holdable", ("target", target)); + return false; + } + + if (!_whitelist.CheckBoth(target, holder.Comp.HoldableBlacklist, holder.Comp.HoldableWhitelist)) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-target-invalid", ("target", target)); + return false; + } + + if (!_whitelist.CheckBoth(holder.Owner, holdable.HolderBlacklist, holdable.HolderWhitelist)) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-target-invalid", ("target", target)); + return false; + } + + if (!_moverQuery.HasComp(holder.Owner)) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-target-invalid", ("target", target)); + return false; + } + + if (!_moverQuery.HasComp(target)) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-target-invalid", ("target", target)); + return false; + } + + if (!_physicsQuery.TryComp(target, out var targetPhysics)) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-target-invalid", ("target", target)); + return false; + } + + if (targetPhysics.BodyType == BodyType.Static) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-target-invalid", ("target", target)); + return false; + } + + if (!_container.IsInSameOrNoContainer(holder.Owner, target)) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-target-invalid", ("target", target)); + return false; + } + + if (TryComp(target, out _)) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-target-immune", ("target", target)); + return false; + } + + if (!ignoreHandAvailability && !HasAvailableHolderHand(holder.Owner)) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-holder-no-free-hand", ("target", target)); + return false; + } + + var range = holdable.HoldRange; + if (_heldQuery.TryComp(target, out var held)) + { + range = held.HoldRange; + + if (held.FullHold) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-target-fully-held", ("target", target)); + return false; + } + } + + if (!_interaction.InRangeUnobstructed(holder.Owner, target, range)) + { + if (!quiet) + PopupHolder(holder.Owner, "scp-hold-target-too-far", ("target", target)); + return false; + } + + if (checkAttempt && !CanPassHoldAttempt(holder.Owner, target)) + return false; + + return true; + } + + public bool TryBreakOut(Entity held, bool viaMovement) + { + return held.Comp.FullHold + ? TryStartFullBreakout(held, viaMovement) + : TrySoftBreakOut(held, viaMovement); + } + + public bool TryForceBreakOut(Entity held, bool viaMovement = false, bool applyImmunity = false) + { + if (!Resolve(held, ref held.Comp, false)) + return false; + + BreakOut((held.Owner, held.Comp), viaMovement, applyImmunity); + return true; + } + + public void RefreshHeldState(Entity held) + { + _alerts.ShowAlert(held.Owner, "ScpHoldGrabbed"); + SyncHeldStatusEffect(held.Owner); + SyncPlaceholderHands(held); + _actionBlocker.UpdateCanMove(held.Owner); + EnsureCombatModeDisabled(held.Owner); + _physics.UpdateIsPredicted(held.Owner); + } + + public void RefreshHolderState(Entity holder) + { + SyncHolderHandBlocker(holder); + _movement.RefreshMovementSpeedModifiers(holder.Owner); + } + + private bool TrySoftBreakOut(Entity held, bool viaMovement) + { + if (_timing.CurTime < held.Comp.SoftEscapeAvailableAt) + return false; + + if (!viaMovement) + PopupTarget(held.Owner, "scp-hold-breakout-start"); + + BreakOut(held, viaMovement, applyImmunity: false); + return true; + } + + private bool TryStartFullBreakout(Entity held, bool viaMovement) + { + if (held.Comp.FullHoldStartedAt == null) + { + PopupTarget(held.Owner, "scp-hold-breakout-too-early", ("seconds", 1)); + return false; + } + + var breakoutAvailableAt = held.Comp.FullHoldStartedAt.Value + held.Comp.FullHoldDelay; + if (_timing.CurTime < breakoutAvailableAt) + { + var remaining = breakoutAvailableAt - _timing.CurTime; + var remainingSeconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds)); + PopupTarget(held.Owner, "scp-hold-breakout-too-early", ("seconds", remainingSeconds)); + return false; + } + + if (held.Comp.BreakoutDoAfterId != null) + return true; + + var doAfter = new DoAfterArgs( + EntityManager, + held.Owner, + held.Comp.FullBreakoutDuration, + new ScpHoldBreakoutDoAfterEvent(viaMovement), + held.Owner, + target: held.Owner) + { + BreakOnMove = true, + BreakOnDamage = true, + NeedHand = false, + Hidden = false, + }; + + if (!_doAfter.TryStartDoAfter(doAfter, out var id)) + return false; + + SetBreakoutDoAfterId(held, id.Value.Index); + ShowBreakoutAttemptFeedback(held); + + PopupTarget(held.Owner, "scp-hold-breakout-start"); + return true; + } + + private bool CanStartHold(Entity holder, bool quiet = false) + { + if (!IsHoldCoolingDown(holder, out var remaining)) + return true; + + if (!quiet) + { + var remainingSeconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds)); + PopupHolder(holder.Owner, "scp-hold-holder-action-on-cooldown", ("seconds", remainingSeconds)); + } + + return false; + } + + private bool IsHoldCoolingDown(Entity holder, out TimeSpan remaining) + { + remaining = TimeSpan.Zero; + + if (holder.Comp.HoldAvailableAt is not { } availableAt || availableAt <= _timing.CurTime) + return false; + + remaining = availableAt - _timing.CurTime; + return true; + } + + private void StartHoldCooldown(Entity holder) + { + SetHoldAvailableAt(holder, _timing.CurTime + holder.Comp.HoldActionCooldown); + } + + private void ApplyFullBreakoutHolderCooldown(EntityUid holderUid) + { + if (!_holdQuery.TryComp(holderUid, out var hold)) + return; + + var cooldownEnd = _timing.CurTime + TimeSpan.FromTicks(hold.HoldActionCooldown.Ticks * 2); + if (hold.HoldAvailableAt != null && hold.HoldAvailableAt.Value >= cooldownEnd) + return; + + SetHoldAvailableAt((holderUid, hold), cooldownEnd); + } + + private void SetHoldAvailableAt(Entity holder, TimeSpan? holdAvailableAt) + { + if (holder.Comp.HoldAvailableAt == holdAvailableAt) + return; + + var previousHoldAvailableAt = holder.Comp.HoldAvailableAt; + holder.Comp.HoldAvailableAt = holdAvailableAt; + + if (previousHoldAvailableAt == null || holdAvailableAt == null) + { + Dirty(holder); + return; + } + + DirtyHoldField(holder, nameof(ScpHoldComponent.HoldAvailableAt)); + } + + private bool CanPassHoldAttempt(EntityUid holderUid, EntityUid targetUid) + { + var attempt = new ScpHoldAttemptEvent(holderUid, targetUid); + RaiseLocalEvent(targetUid, attempt); + RaiseLocalEvent(holderUid, attempt); + return !attempt.Cancelled; + } + + private void RaiseBreakoutEvent(Entity held, bool viaMovement, bool applyImmunity) + { + var ev = new ScpHoldBreakoutEvent(viaMovement, held.Comp.FullHold, applyImmunity); + RaiseLocalEvent(held.Owner, ev); + } + + private void BreakOut(Entity held, bool viaMovement, bool applyImmunity) + { + RaiseBreakoutEvent(held, viaMovement, applyImmunity); + ClearHoldState(held, applyImmunity); + } +} diff --git a/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Drag.cs b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Drag.cs new file mode 100644 index 00000000000..8bd3c0ac376 --- /dev/null +++ b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Drag.cs @@ -0,0 +1,135 @@ +using System.Numerics; +using Content.Shared.Interaction; +using Robust.Shared.Physics.Components; + +namespace Content.Shared._Scp.Holding; + +public sealed partial class SharedScpHoldingSystem +{ + /* + * Drag-local dependencies, soft-drag movement, and helper calculations. + */ + [Dependency] private readonly SharedTransformSystem _transform = default!; + + private const float SoftDragDistanceFactor = 0.3f; + private const float SoftDragMinimumDistance = 0.4f; + private const float SoftDragMaximumDistance = 0.6f; + private const float SoftDragSnapTolerance = 0.03f; + private const float SoftDragSettleTolerance = 0.08f; + private const float SoftDragVelocityDirectionThreshold = 0.05f; + private const float SoftDragCatchUpTime = 0.05f; + private const float SoftDragMaximumCorrectionSpeed = 6f; + private const float SoftDragAwayVelocityStrength = 0.6f; + private const float SoftDragVelocityTolerance = 0.05f; + + private void UpdateSoftDrag(Entity held, float maintenanceRange, float desiredDistance) + { + if (held.Comp.PrimaryHolder == null) + return; + + var primaryHolder = held.Comp.PrimaryHolder.Value; + if (!_holderQuery.TryComp(primaryHolder, out var holder)) + return; + + if (holder.Target != held.Owner) + return; + + if (!_container.IsInSameOrNoContainer(primaryHolder, held.Owner)) + return; + + if (!_interaction.InRangeUnobstructed(primaryHolder, held.Owner, maintenanceRange)) + return; + + if (!_physicsQuery.TryComp(held.Owner, out var heldPhysics)) + return; + + var holderCoords = _transform.GetMapCoordinates(primaryHolder); + var heldCoords = _transform.GetMapCoordinates(held.Owner); + + if (holderCoords.MapId != heldCoords.MapId) + return; + + var offset = heldCoords.Position - holderCoords.Position; + var distance = offset.Length(); + var holderVelocity = _physicsQuery.TryComp(primaryHolder, out var holderPhysics) + ? holderPhysics.LinearVelocity + : Vector2.Zero; + var direction = GetSoftDragDirection(primaryHolder, holderVelocity, offset, distance); + var desiredPosition = holderCoords.Position + direction * desiredDistance; + var correction = desiredPosition - heldCoords.Position; + var correctionDistance = correction.Length(); + + Vector2 desiredVelocity; + if (correctionDistance <= SoftDragSettleTolerance) + { + desiredVelocity = holderVelocity.LengthSquared() > SoftDragVelocityDirectionThreshold * SoftDragVelocityDirectionThreshold + ? holderVelocity + : Vector2.Zero; + } + else + { + var correctionDirection = correction / correctionDistance; + var correctionSpeed = Math.Min(correctionDistance / GetSoftDragCatchUpTime(), SoftDragMaximumCorrectionSpeed); + desiredVelocity = holderVelocity + correctionDirection * correctionSpeed; + + var relativeVelocity = heldPhysics.LinearVelocity - holderVelocity; + var awaySpeed = MathF.Max(0f, -Vector2.Dot(relativeVelocity, correctionDirection)); + if (awaySpeed > 0f) + desiredVelocity += correctionDirection * awaySpeed * SoftDragAwayVelocityStrength; + } + + ApplyHeldVelocity(held.Owner, desiredVelocity, heldPhysics); + } + + private float GetDesiredSoftDragDistance(Entity held) + { + return GetBaseSoftDragDistance(held.Comp.HoldRange); + } + + private static float GetHoldMaintenanceRange(float configuredRange, float desiredSoftDragDistance) + { + return MathF.Max(MathF.Max(configuredRange, SharedInteractionSystem.InteractionRange), desiredSoftDragDistance + SoftDragSnapTolerance); + } + + private static float GetBaseSoftDragDistance(float holdRange) + { + return Math.Clamp(holdRange * SoftDragDistanceFactor, SoftDragMinimumDistance, SoftDragMaximumDistance); + } + + private float GetSoftDragCatchUpTime() + { + return MathF.Max((float)_timing.TickPeriod.TotalSeconds, SoftDragCatchUpTime); + } + + private Vector2 GetSoftDragDirection(EntityUid holderUid, Vector2 holderVelocity, Vector2 offset, float distance) + { + if (distance > SoftDragSnapTolerance) + return offset / distance; + + if (holderVelocity.LengthSquared() > SoftDragVelocityDirectionThreshold * SoftDragVelocityDirectionThreshold) + return -Vector2.Normalize(holderVelocity); + + return Transform(holderUid).LocalRotation.ToWorldVec(); + } + + private void ApplyHeldVelocity(EntityUid uid, Vector2 desiredVelocity, PhysicsComponent physics) + { + if (Vector2.DistanceSquared(physics.LinearVelocity, desiredVelocity) > SoftDragVelocityTolerance * SoftDragVelocityTolerance) + _physics.SetLinearVelocity(uid, desiredVelocity, body: physics); + + if (!MathHelper.CloseTo(physics.AngularVelocity, 0f)) + _physics.SetAngularVelocity(uid, 0f, body: physics); + } + + private void ZeroHeldVelocity(EntityUid uid) + { + if (!_physicsQuery.TryComp(uid, out var physics)) + return; + + if (physics.LinearVelocity == Vector2.Zero && MathHelper.CloseTo(physics.AngularVelocity, 0f)) + return; + + _physics.SetLinearVelocity(uid, Vector2.Zero, body: physics); + _physics.SetAngularVelocity(uid, 0f, body: physics); + } +} diff --git a/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Events.cs b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Events.cs new file mode 100644 index 00000000000..943f16a0f66 --- /dev/null +++ b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Events.cs @@ -0,0 +1,234 @@ +using Content.Shared.Actions.Events; +using Content.Shared.CombatMode; +using Content.Shared.Hands; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.Movement.Events; +using Content.Shared.Movement.Systems; +using Content.Shared.Throwing; +using Robust.Shared.Physics.Events; + +namespace Content.Shared._Scp.Holding; + +public sealed partial class SharedScpHoldingSystem +{ + /* + * Event subscription wiring plus routing/lifecycle reactions for held and holder entities. + */ + private void SubscribeHoldingEvents() + { + SubscribeLocalEvent(OnHoldShutdown); + + SubscribeLocalEvent(OnHeldStartup); + SubscribeLocalEvent(OnHeldShutdown); + SubscribeLocalEvent(OnBreakoutAlert); + SubscribeLocalEvent(OnBreakoutDoAfter); + SubscribeLocalEvent(OnHeldMoveInput); + SubscribeLocalEvent(OnHandCountChanged); + SubscribeLocalEvent(OnHeldUpdateCanMove); + SubscribeLocalEvent(OnHeldAttemptMobCollide); + SubscribeLocalEvent(OnHeldAttemptMobTargetCollide); + SubscribeLocalEvent(OnHeldCombatModeChanged); + SubscribeLocalEvent(OnHeldPreventCollide); + SubscribeLocalEvent(OnHoldRestrictedActionAttempt); + + SubscribeLocalEvent(OnHolderStartup); + SubscribeLocalEvent(OnHolderShutdown); + SubscribeLocalEvent(OnHolderRefreshMoveSpeed); + SubscribeLocalEvent(OnHolderBeforeThrow); + SubscribeLocalEvent(OnHolderHandsModified); + SubscribeLocalEvent(OnHolderPreventCollide); + SubscribeLocalEvent(OnHolderBlockerDropped); + } + + private void OnHoldShutdown(Entity ent, ref ComponentShutdown args) + { + if (_net.IsClient) + return; + + if (!_holderQuery.TryComp(ent.Owner, out var holder)) + return; + + if (holder.Target == null) + return; + + ReleaseHolderContribution(ent.Owner, holder.Target.Value, clearIfEmpty: true); + } + + private void OnHeldStartup(Entity ent, ref ComponentStartup args) + { + RefreshHeldState(ent); + } + + private void OnHeldShutdown(Entity ent, ref ComponentShutdown args) + { + _alerts.ClearAlert(ent.Owner, "ScpHoldGrabbed"); + _statusEffects.TryRemoveStatusEffect(ent, GrabbedStatusEffect); + DeleteHeldHandBlockers(ent); + + if (!_timing.ApplyingState) + CancelBreakoutDoAfter(ent); + + if (!_net.IsClient) + { + foreach (var holderUid in ent.Comp.Holders) + { + if (_holderQuery.HasComp(holderUid)) + RemComp(holderUid); + } + } + + _actionBlocker.UpdateCanMove(ent.Owner); + _physics.UpdateIsPredicted(ent.Owner); + } + + private void OnBreakoutAlert(Entity ent, ref ScpHoldBreakoutAlertEvent args) + { + if (args.Handled) + return; + + args.Handled = true; + TryBreakOut(ent, viaMovement: false); + } + + private void OnBreakoutDoAfter(Entity ent, ref ScpHoldBreakoutDoAfterEvent args) + { + SetBreakoutDoAfterId(ent, null); + + if (args.Handled) + return; + + if (args.Cancelled) + { + PopupTarget(ent.Owner, "scp-hold-breakout-interrupted"); + return; + } + + BreakOut(ent, args.ViaMovement, applyImmunity: true); + args.Handled = true; + } + + private void OnHeldMoveInput(Entity ent, ref MoveInputEvent args) + { + if (!args.State) + return; + + TryBreakOut(ent, viaMovement: true); + } + + private void OnHandCountChanged(Entity ent, ref HandCountChangedEvent args) + { + if (_net.IsClient) + return; + + SyncHeldState(ent); + } + + private void OnHeldUpdateCanMove(Entity ent, ref UpdateCanMoveEvent args) + { + if (ent.Comp.LifeStage > ComponentLifeStage.Running) + return; + + if (ent.Comp.FullHold) + args.Cancel(); + } + + private static void OnHeldAttemptMobCollide(Entity ent, ref AttemptMobCollideEvent args) + { + args.Cancelled = true; + } + + private static void OnHeldAttemptMobTargetCollide(Entity ent, ref AttemptMobTargetCollideEvent args) + { + args.Cancelled = true; + } + + private void OnHeldPreventCollide(Entity ent, ref PreventCollideEvent args) + { + if (args.Cancelled) + return; + + if (_holderQuery.TryComp(args.OtherEntity, out var holder) && + holder.Target == ent.Owner) + { + args.Cancelled = true; + } + } + + private void OnHolderStartup(Entity ent, ref ComponentStartup args) + { + RefreshHolderState(ent); + } + + private void OnHolderShutdown(Entity ent, ref ComponentShutdown args) + { + ent.Comp.Target = null; + ent.Comp.SlowdownEnabled = false; + DeleteHolderHandBlockers(ent.Owner); + _movement.RefreshMovementSpeedModifiers(ent.Owner); + } + + private void OnHolderRefreshMoveSpeed(Entity ent, ref RefreshMovementSpeedModifiersEvent args) + { + if (!ent.Comp.SlowdownEnabled) + return; + + args.ModifySpeed(ent.Comp.WalkModifier, ent.Comp.SprintModifier); + } + + private void OnHolderBeforeThrow(Entity ent, ref BeforeThrowEvent args) + { + if (ent.Comp.Target == null) + return; + + if (!TryComp(args.ItemUid, out var blocker)) + return; + + if (blocker.Target != ent.Comp.Target.Value) + return; + + ReleaseHolderContribution(ent.Owner, ent.Comp.Target.Value, clearIfEmpty: true); + args.Cancelled = true; + } + + private void OnHolderHandsModified(Entity ent, ref DidEquipHandEvent args) + { + if (ent.Comp.LifeStage > ComponentLifeStage.Running) + return; + + if (ent.Comp.Target == null) + return; + + if (!_heldQuery.HasComp(ent.Comp.Target.Value)) + return; + + RefreshHolderState(ent); + } + + private void OnHolderPreventCollide(Entity ent, ref PreventCollideEvent args) + { + if (args.Cancelled) + return; + + if (ent.Comp.Target == null) + return; + + if (ent.Comp.Target != args.OtherEntity) + return; + + args.Cancelled = true; + } + + private void OnHolderBlockerDropped(Entity ent, ref GettingDroppedAttemptEvent args) + { + if (!_holderQuery.TryComp(args.User, out var holder)) + return; + + if (holder.Target == null) + return; + + if (holder.Target != ent.Comp.Target) + return; + + ReleaseHolderContribution(args.User, ent.Comp.Target, clearIfEmpty: true); + } +} diff --git a/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Feedback.cs b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Feedback.cs new file mode 100644 index 00000000000..c8b825992c8 --- /dev/null +++ b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Feedback.cs @@ -0,0 +1,95 @@ +using Content.Shared.Coordinates; +using Content.Shared.Popups; +using Robust.Shared.Audio; +using Robust.Shared.Audio.Systems; + +namespace Content.Shared._Scp.Holding; + +public sealed partial class SharedScpHoldingSystem +{ + /* + * Feedback-local dependencies, breakout do-after tracking, and popup/audio helpers. + */ + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly SharedPopupSystem _popup = default!; + + private const string BreakoutAttemptEffect = "WhistleExclamation"; + private static readonly SoundSpecifier BreakoutAttemptSound = + new SoundCollectionSpecifier("storageRustle", + AudioParams.Default.WithVolume(-8f).WithMaxDistance(4f).WithVariation(0.15f)); + + private void CancelBreakoutDoAfter(Entity held) + { + if (held.Comp.BreakoutDoAfterId == null) + return; + + _doAfter.Cancel(held.Owner, held.Comp.BreakoutDoAfterId.Value); + SetBreakoutDoAfterId(held, null); + } + + private void SetBreakoutDoAfterId(Entity held, ushort? breakoutDoAfterId) + { + if (held.Comp.BreakoutDoAfterId == breakoutDoAfterId) + return; + + held.Comp.BreakoutDoAfterId = breakoutDoAfterId; + DirtyHeldField(held, nameof(ScpHeldComponent.BreakoutDoAfterId)); + } + + private void ShowBreakoutAttemptFeedback(Entity held) + { + if (_net.IsClient && !_timing.IsFirstTimePredicted) + return; + + foreach (var holderUid in held.Comp.Holders) + { + if (!_holderQuery.TryComp(holderUid, out var holder)) + continue; + + if (holder.Target != held.Owner) + continue; + + SpawnBreakoutAttemptEffect(holderUid); + } + + PlayBreakoutAttemptSound(held.Owner); + } + + private void PopupHolder(EntityUid holder, string key, params (string, object)[] args) + { + if (_net.IsClient) + return; + + _popup.PopupEntity(Loc.GetString(key, args), holder, holder); + } + + private void PopupTarget(EntityUid target, string key, params (string, object)[] args) + { + if (_net.IsClient) + return; + + _popup.PopupEntity(Loc.GetString(key, args), target, target); + } + + private void SpawnBreakoutAttemptEffect(EntityUid holderUid) + { + if (_net.IsClient) + { + PredictedSpawnAttachedTo(BreakoutAttemptEffect, holderUid.ToCoordinates()); + return; + } + + SpawnAttachedTo(BreakoutAttemptEffect, holderUid.ToCoordinates()); + } + + private void PlayBreakoutAttemptSound(EntityUid targetUid) + { + if (_net.IsClient) + { + _audio.PlayPredicted(BreakoutAttemptSound, targetUid, targetUid); + return; + } + + _audio.PlayPvs(BreakoutAttemptSound, targetUid); + } +} diff --git a/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Hands.cs b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Hands.cs new file mode 100644 index 00000000000..ffa6c6a9fd7 --- /dev/null +++ b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Hands.cs @@ -0,0 +1,199 @@ +using Content.Shared.Hands.Components; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.Interaction.Components; +using Content.Shared.Inventory.VirtualItem; +using Content.Shared.StatusEffectNew.Components; + +namespace Content.Shared._Scp.Holding; + +public sealed partial class SharedScpHoldingSystem +{ + /* + * Hand-local dependencies, caches, placeholders, virtual blockers, and held-status visuals. + */ + [Dependency] private readonly SharedHandsSystem _hands = default!; + [Dependency] private readonly SharedVirtualItemSystem _virtualItem = default!; + + private readonly List _placeholderIcons = []; + private readonly List> _virtualBlockersToDelete = []; + + private EntityQuery _handsQuery; + + private void InitializeHandQueries() + { + _handsQuery = GetEntityQuery(); + } + + private void SyncPlaceholderHands(Entity held) + { + DeleteHeldHandBlockers(held.Owner); + + if (!held.Comp.FullHold || !_handsQuery.TryComp(held.Owner, out var hands)) + return; + + foreach (var hand in _hands.EnumerateHands((held.Owner, hands))) + { + if (!_hands.TryGetHeldItem((held.Owner, hands), hand, out var heldItem)) + continue; + + if (HasComp(heldItem.Value)) + continue; + + _hands.DoDrop((held.Owner, hands), hand, doDropInteraction: true); + } + + _placeholderIcons.Clear(); + foreach (var holderUid in held.Comp.Holders) + { + if (_holderQuery.TryComp(holderUid, out var holder) && holder.Target == held.Owner) + _placeholderIcons.Add(holderUid); + } + + if (_placeholderIcons.Count == 0) + return; + + var iconIndex = 0; + while (_hands.TryGetEmptyHand((held.Owner, hands), out var emptyHand)) + { + var holderUid = _placeholderIcons[iconIndex % _placeholderIcons.Count]; + if (!_virtualItem.TrySpawnVirtualItemInHand(holderUid, held.Owner, out var virtualItem, empty: emptyHand, silent: true)) + break; + + EnsureComp(virtualItem.Value); + var blocker = EnsureComp(virtualItem.Value); + blocker.Target = held.Owner; + blocker.Holder = holderUid; + + iconIndex++; + } + } + + private void SyncHeldStatusEffect(EntityUid target) + { + if (_statusEffects.HasStatusEffect(target, GrabbedStatusEffect) || + !_statusEffects.CanAddStatusEffect(target, GrabbedStatusEffect)) + { + return; + } + + EnsureComp(target); + PredictedTrySpawnInContainer(GrabbedStatusEffect, target, StatusEffectContainerComponent.ContainerId, out _); + } + + private void SyncHolderHandBlocker(Entity holder) + { + _virtualBlockersToDelete.Clear(); + EntityUid? validBlocker = null; + var target = holder.Comp.Target; + + foreach (var heldItem in _hands.EnumerateHeld(holder.Owner)) + { + if (!TryComp(heldItem, out var virtualItem)) + { + continue; + } + + var matchesCurrentTarget = holder.Comp.LifeStage <= ComponentLifeStage.Running && + target != null && + virtualItem.BlockingEntity == target.Value; + + if (matchesCurrentTarget) + { + if (validBlocker == null) + { + validBlocker = heldItem; + RemComp(heldItem); + var existingBlockerCreated = !TryComp(heldItem, out var blocker); + blocker ??= EnsureComp(heldItem); + var currentTarget = target!.Value; + if (blocker.Target != currentTarget) + { + blocker.Target = currentTarget; + DirtyHandBlockerField((heldItem, blocker), nameof(ScpHoldHandBlockerComponent.Target)); + } + + if (existingBlockerCreated) + Dirty(heldItem, blocker); + + continue; + } + } + + if (TryComp(heldItem, out _) || matchesCurrentTarget) + _virtualBlockersToDelete.Add((heldItem, virtualItem)); + } + + foreach (var virtualItem in _virtualBlockersToDelete) + { + _virtualItem.DeleteVirtualItem(virtualItem, holder.Owner); + } + + if (holder.Comp.LifeStage > ComponentLifeStage.Running || + holder.Comp.Target == null || + validBlocker != null) + { + return; + } + + if (!_handsQuery.TryComp(holder.Owner, out var hands) || + !_hands.TryGetEmptyHand((holder.Owner, hands), out _)) + { + return; + } + + if (!_virtualItem.TrySpawnVirtualItemInHand(holder.Comp.Target.Value, holder.Owner, out var spawnedVirtualItem, silent: true)) + return; + + var blockerCreated = !TryComp(spawnedVirtualItem.Value, out var blockerComp); + blockerComp ??= EnsureComp(spawnedVirtualItem.Value); + blockerComp.Target = holder.Comp.Target.Value; + DirtyHandBlockerField((spawnedVirtualItem.Value, blockerComp), nameof(ScpHoldHandBlockerComponent.Target)); + + if (blockerCreated) + Dirty(spawnedVirtualItem.Value, blockerComp); + } + + private bool HasAvailableHolderHand(EntityUid holderUid) + { + return _handsQuery.TryComp(holderUid, out var hands) && + _hands.TryGetEmptyHand((holderUid, hands), out _); + } + + private void DeleteHolderHandBlockers(EntityUid holderUid) + { + _virtualBlockersToDelete.Clear(); + + foreach (var heldItem in _hands.EnumerateHeld(holderUid)) + { + if (TryComp(heldItem, out _) && + TryComp(heldItem, out var virtualItem)) + { + _virtualBlockersToDelete.Add((heldItem, virtualItem)); + } + } + + foreach (var virtualItem in _virtualBlockersToDelete) + { + _virtualItem.DeleteVirtualItem(virtualItem, holderUid); + } + } + + private void DeleteHeldHandBlockers(EntityUid heldUid) + { + _virtualBlockersToDelete.Clear(); + + foreach (var heldItem in _hands.EnumerateHeld(heldUid)) + { + if (TryComp(heldItem, out _) && + TryComp(heldItem, out var virtualItem)) + { + _virtualBlockersToDelete.Add((heldItem, virtualItem)); + } + } + + foreach (var virtualItem in _virtualBlockersToDelete) + { + _virtualItem.DeleteVirtualItem(virtualItem, heldUid); + } + } +} diff --git a/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Restrictions.cs b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Restrictions.cs new file mode 100644 index 00000000000..1cf7448f519 --- /dev/null +++ b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.Restrictions.cs @@ -0,0 +1,53 @@ +using Content.Shared.Actions.Events; +using Content.Shared.CombatMode; + +namespace Content.Shared._Scp.Holding; + +public sealed partial class SharedScpHoldingSystem +{ + [Dependency] private readonly SharedCombatModeSystem _combatMode = default!; + + private void OnHoldRestrictedActionAttempt(Entity ent, ref ActionAttemptEvent args) + { + if (args.Cancelled || !IsHeldAtStage(args.User, ent.Comp.Stage)) + return; + + args.Cancelled = true; + _popup.PopupClient(Loc.GetString("scp-hold-action-restricted"), args.User, args.User); + } + + private void OnHeldCombatModeChanged(Entity ent, ref CombatModeChangedEvent args) + { + if (!args.IsInCombatMode) + return; + + EnsureCombatModeDisabled(ent.Owner); + } + + public bool IsHeldAtStage(EntityUid uid, ScpHoldStage stage) + { + return _heldQuery.TryComp(uid, out var held) && IsHeldAtStage((uid, held), stage); + } + + private static bool IsHeldAtStage(Entity held, ScpHoldStage stage) + { + return stage switch + { + ScpHoldStage.Soft => true, + ScpHoldStage.Full => held.Comp.FullHold, + _ => false, + }; + } + + private void EnsureCombatModeDisabled(EntityUid uid) + { + if (!IsHeldAtStage(uid, ScpHoldStage.Soft) || + !TryComp(uid, out var combatMode) || + !combatMode.IsInCombatMode) + { + return; + } + + _combatMode.SetInCombatMode(uid, false, combatMode); + } +} diff --git a/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.State.cs b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.State.cs new file mode 100644 index 00000000000..2739efd338f --- /dev/null +++ b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.State.cs @@ -0,0 +1,372 @@ +using Content.Shared.Body.Components; +using Content.Shared.Body.Part; +using Content.Shared.Body.Systems; + +namespace Content.Shared._Scp.Holding; + +public sealed partial class SharedScpHoldingSystem +{ + /* + * State-local dependencies, caches, held lifecycle, holder membership, and full-hold transitions. + */ + [Dependency] private readonly SharedBodySystem _body = default!; + + private readonly List _holdersToRemove = []; + private readonly List _holderCooldownsToApply = []; + + private EntityQuery _bodyQuery; + + private void InitializeStateQueries() + { + _bodyQuery = GetEntityQuery(); + } + + private void UpdateHeld(Entity held) + { + if (!EnsurePrimaryHolder(held)) + { + ClearHoldState(held, applyImmunity: false); + return; + } + + var desiredSoftDragDistance = GetDesiredSoftDragDistance(held); + var maintenanceRange = GetHoldMaintenanceRange(held.Comp.HoldRange, desiredSoftDragDistance); + + if (!held.Comp.FullHold) + UpdateSoftDrag(held, maintenanceRange, desiredSoftDragDistance); + else + ZeroHeldVelocity(held.Owner); + + _holdersToRemove.Clear(); + + foreach (var holderUid in held.Comp.Holders) + { + if (ShouldReleaseHolder(holderUid, held.Owner, maintenanceRange)) + _holdersToRemove.Add(holderUid); + } + + foreach (var holderUid in _holdersToRemove) + { + ReleaseHolderContribution(holderUid, held.Owner, clearIfEmpty: false); + + if (!_heldQuery.TryComp(held.Owner, out _)) + return; + } + + if (_heldQuery.TryComp(held.Owner, out var refreshed)) + SyncHeldState((held.Owner, refreshed)); + } + + private Entity EnsureHeldState(EntityUid target, ScpHoldableComponent config, out bool created) + { + created = !_heldQuery.TryComp(target, out var held); + held ??= EnsureComp(target); + + if (created) + CopyConfig(config, held); + + held.RequiredHolderCount = GetRequiredHolderCount(target); + return (target, held); + } + + private void AddHolderContribution(EntityUid holderUid, Entity held) + { + if (!held.Comp.Holders.Contains(holderUid)) + { + held.Comp.Holders.Add(holderUid); + DirtyHeldField(held, nameof(ScpHeldComponent.Holders)); + } + + var holderCreated = !_holderQuery.TryComp(holderUid, out var holder); + holder ??= EnsureComp(holderUid); + SetHolderTarget((holderUid, holder), held.Owner); + SetHolderSlowdown((holderUid, holder), false, held.Comp.WalkModifier, held.Comp.SprintModifier); + RefreshHolderState((holderUid, holder)); + + if (holderCreated) + Dirty(holderUid, holder); + } + + private void ReleaseHolderContribution(EntityUid holderUid, EntityUid targetUid, bool clearIfEmpty) + { + if (!_heldQuery.TryComp(targetUid, out var held)) + return; + + var removed = false; + for (var i = held.Holders.Count - 1; i >= 0; i--) + { + if (held.Holders[i] == holderUid) + { + held.Holders.RemoveAt(i); + removed = true; + } + } + + if (removed) + DirtyHeldField(targetUid, held, nameof(ScpHeldComponent.Holders)); + + if (_holderQuery.HasComp(holderUid)) + RemComp(holderUid); + + if (held.PrimaryHolder == holderUid) + SetHeldPrimaryHolder((targetUid, held), null); + + if (held.Holders.Count == 0) + { + if (clearIfEmpty) + ClearHoldState((targetUid, held), applyImmunity: false); + return; + } + + SyncHeldState((targetUid, held)); + } + + private void SyncHeldState(Entity held) + { + if (!_heldQuery.TryComp(held.Owner, out var heldComp)) + return; + + held.Comp = heldComp; + held.Comp.RequiredHolderCount = GetRequiredHolderCount(held.Owner); + + if (held.Comp.Holders.Count == 0) + { + ClearHoldState(held, applyImmunity: false); + return; + } + + if (!EnsurePrimaryHolder(held)) + { + ClearHoldState(held, applyImmunity: false); + return; + } + + if (held.Comp.Holders.Count >= held.Comp.RequiredHolderCount) + { + EnterFullHold(held); + return; + } + + ExitFullHold(held); + var desiredSoftDragDistance = GetDesiredSoftDragDistance(held); + var maintenanceRange = GetHoldMaintenanceRange(held.Comp.HoldRange, desiredSoftDragDistance); + UpdateSoftDrag(held, maintenanceRange, desiredSoftDragDistance); + UpdateHolderSlowdowns(held); + SyncPlaceholderHands(held); + } + + private void EnterFullHold(Entity held) + { + if (!held.Comp.FullHold) + { + held.Comp.FullHold = true; + held.Comp.FullHoldStartedAt = _timing.CurTime; + DirtyHeldFields( + held, + nameof(ScpHeldComponent.FullHold), + nameof(ScpHeldComponent.FullHoldStartedAt)); + } + + UpdateHolderSlowdowns(held); + SyncPlaceholderHands(held); + ZeroHeldVelocity(held.Owner); + _actionBlocker.UpdateCanMove(held.Owner); + } + + private void ExitFullHold(Entity held) + { + CancelBreakoutDoAfter(held); + + if (!held.Comp.FullHold && held.Comp.FullHoldStartedAt == null) + return; + + held.Comp.FullHold = false; + held.Comp.FullHoldStartedAt = null; + DirtyHeldFields( + held, + nameof(ScpHeldComponent.FullHold), + nameof(ScpHeldComponent.FullHoldStartedAt)); + SyncPlaceholderHands(held); + _actionBlocker.UpdateCanMove(held.Owner); + } + + private bool EnsurePrimaryHolder(Entity held) + { + if (held.Comp.PrimaryHolder != null && IsValidPrimaryHolder(held, held.Comp.PrimaryHolder.Value)) + return true; + + SetHeldPrimaryHolder(held, null); + + foreach (var holderUid in held.Comp.Holders) + { + if (!_holderQuery.TryComp(holderUid, out var holder)) + continue; + + if (holder.Target != held.Owner) + continue; + + SetHeldPrimaryHolder(held, holderUid); + return true; + } + + return false; + } + + private void ClearHoldState(Entity held, bool applyImmunity) + { + if (_heldQuery.TryComp(held.Owner, out var refreshed)) + held = (held.Owner, refreshed); + + CancelBreakoutDoAfter(held); + DeleteHeldHandBlockers(held.Owner); + _actionBlocker.UpdateCanMove(held.Owner); + _holderCooldownsToApply.Clear(); + + foreach (var holderUid in held.Comp.Holders) + { + if (applyImmunity) + _holderCooldownsToApply.Add(holderUid); + + if (_holderQuery.HasComp(holderUid)) + RemComp(holderUid); + } + + held.Comp.Holders.Clear(); + held.Comp.PrimaryHolder = null; + + if (applyImmunity) + { + var immuneCreated = !TryComp(held.Owner, out var immune); + immune ??= EnsureComp(held.Owner); + immune.ExpiresAt = _timing.CurTime + held.Comp.PostBreakoutImmunity; + DirtyImmuneField((held.Owner, immune), nameof(ScpHoldImmuneComponent.ExpiresAt)); + + if (immuneCreated) + Dirty(held.Owner, immune); + } + + foreach (var holderUid in _holderCooldownsToApply) + { + ApplyFullBreakoutHolderCooldown(holderUid); + } + + RemComp(held.Owner); + } + + private void UpdateHolderSlowdowns(Entity held) + { + foreach (var holderUid in held.Comp.Holders) + { + if (!_holderQuery.TryComp(holderUid, out var holder)) + continue; + + SetHolderSlowdown((holderUid, holder), true, held.Comp.WalkModifier, held.Comp.SprintModifier); + } + } + + private void SetHolderSlowdown(Entity holder, bool enabled, float walkModifier, float sprintModifier) + { + if (holder.Comp.SlowdownEnabled == enabled && + MathHelper.CloseTo(holder.Comp.WalkModifier, walkModifier) && + MathHelper.CloseTo(holder.Comp.SprintModifier, sprintModifier)) + { + return; + } + + if (holder.Comp.SlowdownEnabled != enabled) + { + holder.Comp.SlowdownEnabled = enabled; + DirtyHolderField(holder, nameof(ScpHolderComponent.SlowdownEnabled)); + } + + if (!MathHelper.CloseTo(holder.Comp.WalkModifier, walkModifier)) + { + holder.Comp.WalkModifier = walkModifier; + DirtyHolderField(holder, nameof(ScpHolderComponent.WalkModifier)); + } + + if (!MathHelper.CloseTo(holder.Comp.SprintModifier, sprintModifier)) + { + holder.Comp.SprintModifier = sprintModifier; + DirtyHolderField(holder, nameof(ScpHolderComponent.SprintModifier)); + } + + _movement.RefreshMovementSpeedModifiers(holder.Owner); + } + + private int GetRequiredHolderCount(EntityUid target) + { + if (_bodyQuery.TryComp(target, out var body)) + { + var handCount = 0; + foreach (var _ in _body.GetBodyChildrenOfType(target, BodyPartType.Hand, body)) + { + handCount++; + } + + if (handCount > 0) + return handCount; + } + + return 2; + } + + private void CopyConfig(ScpHoldableComponent source, ScpHeldComponent target) + { + target.SoftEscapeCooldown = source.SoftEscapeCooldown; + target.FullHoldDelay = source.FullHoldDelay; + target.FullBreakoutDuration = source.FullBreakoutDuration; + target.PostBreakoutImmunity = source.PostBreakoutImmunity; + target.HoldRange = source.HoldRange; + target.WalkModifier = source.HolderWalkModifier; + target.SprintModifier = source.HolderSprintModifier; + target.SoftEscapeAvailableAt = _timing.CurTime; + target.FullHoldStartedAt = null; + } + + private bool ShouldReleaseHolder(EntityUid holderUid, EntityUid heldUid, float maintenanceRange) + { + if (!_holdQuery.HasComp(holderUid)) + return true; + + if (!_holderQuery.TryComp(holderUid, out var holder)) + return true; + + if (holder.Target != heldUid) + return true; + + if (!_container.IsInSameOrNoContainer(holderUid, heldUid)) + return true; + + return !_interaction.InRangeUnobstructed(holderUid, heldUid, maintenanceRange); + } + + private bool IsValidPrimaryHolder(Entity held, EntityUid primaryHolderUid) + { + if (!_holderQuery.TryComp(primaryHolderUid, out var holder)) + return false; + + if (holder.Target != held.Owner) + return false; + + return held.Comp.Holders.Contains(primaryHolderUid); + } + + private void SetHolderTarget(Entity holder, EntityUid? target) + { + if (holder.Comp.Target == target) + return; + + holder.Comp.Target = target; + DirtyHolderField(holder, nameof(ScpHolderComponent.Target)); + } + + private void SetHeldPrimaryHolder(Entity held, EntityUid? primaryHolder) + { + if (held.Comp.PrimaryHolder == primaryHolder) + return; + + held.Comp.PrimaryHolder = primaryHolder; + DirtyHeldField(held, nameof(ScpHeldComponent.PrimaryHolder)); + } +} diff --git a/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.cs b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.cs new file mode 100644 index 00000000000..5473f688efc --- /dev/null +++ b/Content.Shared/_Scp/Holding/SharedScpHoldingSystem.cs @@ -0,0 +1,119 @@ +using Content.Shared.ActionBlocker; +using Content.Shared.Alert; +using Content.Shared.DoAfter; +using Content.Shared.Interaction; +using Content.Shared.Movement.Systems; +using Content.Shared.StatusEffectNew; +using Content.Shared.Whitelist; +using Robust.Shared.Containers; +using Robust.Shared.Network; +using Robust.Shared.Physics.Components; +using Robust.Shared.Physics.Systems; +using Robust.Shared.Timing; + +namespace Content.Shared._Scp.Holding; + +public sealed partial class SharedScpHoldingSystem : EntitySystem +{ + /* + * Core lifecycle, dependencies, constants, and runtime caches. + */ + [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!; + [Dependency] private readonly AlertsSystem _alerts = default!; + [Dependency] private readonly SharedContainerSystem _container = default!; + [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; + [Dependency] private readonly SharedInteractionSystem _interaction = default!; + [Dependency] private readonly MovementSpeedModifierSystem _movement = default!; + [Dependency] private readonly INetManager _net = default!; + [Dependency] private readonly SharedPhysicsSystem _physics = default!; + [Dependency] private readonly StatusEffectsSystem _statusEffects = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly EntityWhitelistSystem _whitelist = default!; + + private const string GrabbedStatusEffect = "StatusEffectScpHeld"; + private EntityQuery _physicsQuery; + private EntityQuery _heldQuery; + private EntityQuery _holdQuery; + private EntityQuery _holderQuery; + + public override void Initialize() + { + base.Initialize(); + + _physicsQuery = GetEntityQuery(); + _heldQuery = GetEntityQuery(); + _holdQuery = GetEntityQuery(); + _holderQuery = GetEntityQuery(); + InitializeHoldQueries(); + InitializeHandQueries(); + InitializeStateQueries(); + SubscribeHoldingEvents(); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + var immuneQuery = EntityQueryEnumerator(); + while (immuneQuery.MoveNext(out var uid, out var immune)) + { + if (_timing.CurTime >= immune.ExpiresAt) + RemComp(uid); + } + + var heldQuery = EntityQueryEnumerator(); + while (heldQuery.MoveNext(out var uid, out var held)) + { + if (ShouldSkipHeldUpdate(uid)) + continue; + + UpdateHeld((uid, held)); + } + } + + private bool ShouldSkipHeldUpdate(EntityUid uid) + { + if (!_net.IsClient) + return false; + + if (!_physicsQuery.TryComp(uid, out var physics)) + return true; + + return !physics.Predict; + } + + private void DirtyHoldField(Entity holder, string fieldName) + { + DirtyField(holder.AsNullable(), fieldName); + } + + private void DirtyHeldField(Entity held, string fieldName) + { + Dirty(held); + } + + private void DirtyHeldField(EntityUid uid, ScpHeldComponent held, string fieldName) + { + Dirty(uid, held); + } + + private void DirtyHeldFields(Entity held, params string[] fieldNames) + { + Dirty(held); + } + + private void DirtyHolderField(Entity holder, string fieldName) + { + Dirty(holder); + } + + private void DirtyImmuneField(Entity immune, string fieldName) + { + Dirty(immune); + } + + private void DirtyHandBlockerField(Entity blocker, string fieldName) + { + Dirty(blocker); + } +} diff --git a/Content.Shared/_Scp/Scp096/Main/Components/Scp096Component.cs b/Content.Shared/_Scp/Scp096/Main/Components/Scp096Component.cs index 4c4d65bd397..c188d0df355 100644 --- a/Content.Shared/_Scp/Scp096/Main/Components/Scp096Component.cs +++ b/Content.Shared/_Scp/Scp096/Main/Components/Scp096Component.cs @@ -138,6 +138,34 @@ public sealed partial class Scp096Component : Component #endregion + #region Hold breakout + + /// + /// Урон, который получает каждый удерживающий скромника при успешном вырывании или смене состояния. + /// + [DataField] + public DamageSpecifier HoldBreakoutDamage = new() + { + DamageDict = new() + { + { "Blunt", 20 }, + }, + }; + + /// + /// Время паралича удерживающих после вырывания. + /// + [DataField] + public TimeSpan HoldBreakoutParalyzeTime = TimeSpan.FromSeconds(5f); + + /// + /// Сила отталкивания удерживающих после вырывания. + /// + [DataField] + public float HoldBreakoutImpulse = 10f; + + #endregion + #region Face skin rip /// diff --git a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Holding.cs b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Holding.cs new file mode 100644 index 00000000000..334ece1a628 --- /dev/null +++ b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Holding.cs @@ -0,0 +1,68 @@ +using System.Numerics; +using Content.Shared._Scp.Holding; +using Content.Shared._Scp.Scp096.Main.Components; +using Robust.Shared.Physics.Systems; + +namespace Content.Shared._Scp.Scp096.Main.Systems; + +public abstract partial class SharedScp096System +{ + [Dependency] private readonly SharedScpHoldingSystem _holding = default!; + [Dependency] private readonly SharedPhysicsSystem _physics = default!; + [Dependency] private readonly SharedTransformSystem _transform = default!; + + private void InitializeHolding() + { + SubscribeLocalEvent(OnHoldAttempt); + SubscribeLocalEvent(OnHoldBreakout); + } + + private void OnHoldAttempt(Entity ent, ref ScpHoldAttemptEvent args) + { + if (IsInHoldRestrictedState(ent.Owner)) + args.Cancel(); + } + + private void OnHoldBreakout(Entity ent, ref ScpHoldBreakoutEvent args) + { + if (_timing.ApplyingState) + return; + + if (!args.WasFullHold && !IsInHoldRestrictedState(ent.Owner)) + return; + + if (!TryComp(ent.Owner, out var held)) + return; + + var scpPosition = _transform.GetWorldPosition(ent.Owner); + foreach (var holderUid in held.Holders) + { + ApplyHoldBreakoutEffects(ent, holderUid, scpPosition); + } + } + + protected bool IsInHoldRestrictedState(EntityUid uid) + { + return HasComp(uid) + || HasComp(uid) + || HasComp(uid); + } + + protected void TryBreakOutOfHold(EntityUid uid) + { + _holding.TryForceBreakOut((uid, (ScpHeldComponent?) null)); + } + + private void ApplyHoldBreakoutEffects(Entity ent, EntityUid holderUid, Vector2 scpPosition) + { + _damageable.TryChangeDamage(holderUid, ent.Comp.HoldBreakoutDamage, origin: ent.Owner); + _stun.TryUpdateParalyzeDuration(holderUid, ent.Comp.HoldBreakoutParalyzeTime); + + var direction = _transform.GetWorldPosition(holderUid) - scpPosition; + direction = direction.LengthSquared() < 0.001f + ? Vector2.UnitY + : Vector2.Normalize(direction); + + _physics.ApplyLinearImpulse(holderUid, direction * ent.Comp.HoldBreakoutImpulse); + } +} diff --git a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Rage.cs b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Rage.cs index 259cdc3714c..92a186ab269 100644 --- a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Rage.cs +++ b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Rage.cs @@ -37,6 +37,8 @@ private void InitializeRage() protected virtual void OnHeatingUpStart(Entity ent, ref ComponentStartup args) { + TryBreakOutOfHold(ent.Owner); + // Устанавливаем время окончания пред-агр состояния ent.Comp.RageHeatUpEnd = _timing.CurTime + ent.Comp.RageHeatUp; @@ -79,6 +81,8 @@ protected virtual void OnHeatingUpShutdown(Entity ent, ref ComponentStartup args) { + TryBreakOutOfHold(ent.Owner); + ent.Comp.RageStartTime = _timing.CurTime; Dirty(ent); diff --git a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.WithoutFace.cs b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.WithoutFace.cs index 8a983f05ca5..834fb616a2f 100644 --- a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.WithoutFace.cs +++ b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.WithoutFace.cs @@ -29,6 +29,8 @@ private void InitializeWithoutFace() /// private void OnWithoutFaceStartup(Entity ent, ref ComponentStartup args) { + TryBreakOutOfHold(ent.Owner); + var message = Loc.GetString("scp096-face-skin-rip-full", ("name", Identity.Name(ent, EntityManager))); _popup.PopupPredicted(message, ent, ent); diff --git a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.cs b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.cs index 25535ee229e..4bbf164092a 100644 --- a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.cs +++ b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.cs @@ -99,6 +99,7 @@ public override void Initialize() InitializeTargets(); InitializeHands(); InitializeActions(); + InitializeHolding(); InitializeWithoutFace(); InitializeAppearance(); InitializeFace(); diff --git a/Resources/Locale/en-US/_strings/_scp/holding/holding.ftl b/Resources/Locale/en-US/_strings/_scp/holding/holding.ftl new file mode 100644 index 00000000000..eb92da2b002 --- /dev/null +++ b/Resources/Locale/en-US/_strings/_scp/holding/holding.ftl @@ -0,0 +1,14 @@ +scp-hold-already-holding-other = You are already holding someone else. +scp-hold-target-invalid = {CAPITALIZE(THE($target))} cannot be held. +scp-hold-target-not-holdable = {CAPITALIZE(THE($target))} cannot be grabbed with this hold. +scp-hold-target-immune = {CAPITALIZE(THE($target))} cannot be held yet. +scp-hold-target-fully-held = {CAPITALIZE(THE($target))} is already fully held. +scp-hold-target-too-far = You are too far away to hold {THE($target)}. +scp-hold-holder-no-free-hand = You need a free hand to grab {THE($target)}. +scp-hold-holder-action-on-cooldown = You can grab again in {$seconds} s. +scp-hold-breakout-too-early = You can try to break free in {$seconds} s. +scp-hold-breakout-start = You start trying to break free. +scp-hold-breakout-interrupted = Your breakout attempt was interrupted. +scp-hold-action-restricted = You cannot do that while being held. +alerts-scp-held-name = Forcefully restrained +alerts-scp-held-desc = Someone is physically gripping you. Move or click this alert to try to break free. In a hard restraint you must endure the hold before the breakout starts. diff --git a/Resources/Locale/ru-RU/_strings/_scp/holding/holding.ftl b/Resources/Locale/ru-RU/_strings/_scp/holding/holding.ftl new file mode 100644 index 00000000000..419c6565090 --- /dev/null +++ b/Resources/Locale/ru-RU/_strings/_scp/holding/holding.ftl @@ -0,0 +1,14 @@ +scp-hold-already-holding-other = Вы уже удерживаете кого-то другого. +scp-hold-target-invalid = {CAPITALIZE(THE($target))} нельзя удержать. +scp-hold-target-not-holdable = {CAPITALIZE(THE($target))} нельзя схватить этим удержанием. +scp-hold-target-immune = {CAPITALIZE(THE($target))} пока нельзя удержать. +scp-hold-target-fully-held = {CAPITALIZE(THE($target))} уже полностью удерживается. +scp-hold-target-too-far = Вы слишком далеко, чтобы удерживать {THE($target)}. +scp-hold-holder-no-free-hand = Чтобы схватить {THE($target)}, нужна свободная рука. +scp-hold-holder-action-on-cooldown = Схватить снова можно через {$seconds} с. +scp-hold-breakout-too-early = Попытаться вырваться можно через {$seconds} с. +scp-hold-breakout-start = Вы начинаете вырываться. +scp-hold-breakout-interrupted = Попытка вырваться была прервана. +scp-hold-action-restricted = Вы не можете сделать это, пока вас удерживают. +alerts-scp-held-name = Вас удерживают силой +alerts-scp-held-desc = Кто-то физически держит вас. Двигайтесь или нажмите на этот статус-эффект, чтобы попытаться вырваться. В полном удержании сначала нужно выдержать захват. diff --git a/Resources/Prototypes/Actions/types.yml b/Resources/Prototypes/Actions/types.yml index fabea2350e3..89241e15629 100644 --- a/Resources/Prototypes/Actions/types.yml +++ b/Resources/Prototypes/Actions/types.yml @@ -261,6 +261,8 @@ priority: -100 - type: InstantAction event: !type:ToggleCombatActionEvent + - type: ScpHoldRestricted # Fire edit - block combat mode while held + stage: Soft - type: entity parent: ActionCombatModeToggle diff --git a/Resources/Prototypes/Entities/Mobs/Species/base.yml b/Resources/Prototypes/Entities/Mobs/Species/base.yml index 6085f9ff1e1..2ed83ae1f95 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/base.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/base.yml @@ -278,6 +278,8 @@ - type: CritHeartbeat # Sunrise-End # Fire start + - type: ScpHold # TODO: Убрать перед мержем + - type: ScpHoldable - type: FieldOfView - type: Blinkable - type: SpeakOnEyeStateChange diff --git a/Resources/Prototypes/_Scp/Actions/scp096.yml b/Resources/Prototypes/_Scp/Actions/scp096.yml index ffd94dcacbf..5d7a61cd7ab 100644 --- a/Resources/Prototypes/_Scp/Actions/scp096.yml +++ b/Resources/Prototypes/_Scp/Actions/scp096.yml @@ -11,6 +11,8 @@ state: scream - type: InstantAction event: !type:Scp096CryOutEvent + - type: ScpHoldRestricted + stage: Full - type: SafeTimeRestricted - type: entity @@ -26,6 +28,8 @@ state: rip - type: InstantAction event: !type:Scp096FaceSkinRipEvent + - type: ScpHoldRestricted + stage: Full - type: SafeTimeRestricted - type: entity @@ -42,3 +46,5 @@ state: sit - type: InstantAction event: !type:Scp096SitDownEvent + - type: ScpHoldRestricted + stage: Full diff --git a/Resources/Prototypes/_Scp/Alerts/holding.yml b/Resources/Prototypes/_Scp/Alerts/holding.yml new file mode 100644 index 00000000000..895d0dbe0f8 --- /dev/null +++ b/Resources/Prototypes/_Scp/Alerts/holding.yml @@ -0,0 +1,8 @@ +- type: alert + id: ScpHoldGrabbed + icons: + - sprite: /Textures/Objects/Misc/handcuffs.rsi + state: handcuff + name: alerts-scp-held-name + description: alerts-scp-held-desc + clickEvent: !type:ScpHoldBreakoutAlertEvent diff --git a/Resources/Prototypes/_Scp/Entities/Mobs/Player/Scp/Main/scp096.yml b/Resources/Prototypes/_Scp/Entities/Mobs/Player/Scp/Main/scp096.yml index edbdfe09adb..3be48c69350 100644 --- a/Resources/Prototypes/_Scp/Entities/Mobs/Player/Scp/Main/scp096.yml +++ b/Resources/Prototypes/_Scp/Entities/Mobs/Player/Scp/Main/scp096.yml @@ -115,6 +115,7 @@ - type: Hands showInHands: false disableExplosionRecursion: true + - type: ScpHoldable - type: GhostPanelAntagonistMarker name: ghost-panel-antagonist-scp-name description: ghost-panel-antagonist-scp-description diff --git a/Resources/Prototypes/_Scp/Entities/StatusEffects/holding.yml b/Resources/Prototypes/_Scp/Entities/StatusEffects/holding.yml new file mode 100644 index 00000000000..91dc1b60c62 --- /dev/null +++ b/Resources/Prototypes/_Scp/Entities/StatusEffects/holding.yml @@ -0,0 +1,5 @@ +- type: entity + parent: MobStatusEffectDebuff + id: StatusEffectScpHeld + categories: [ HideSpawnMenu ] + name: forcefully restrained diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/Administration/security_commander.yml b/Resources/Prototypes/_Scp/Roles/Jobs/Administration/security_commander.yml index bcea05af290..9e80eb00110 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/Administration/security_commander.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/Administration/security_commander.yml @@ -33,6 +33,10 @@ class: B - type: AccessLevel level: Four + - type: ScpHold + holdableWhitelist: + components: + - ClassDAppearance - type: ScpAnnounceOnSpawn text: head-announce-on-spawn channels: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_commandant.yml b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_commandant.yml index 753d503f2d6..ada744bf5d5 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_commandant.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_commandant.yml @@ -28,6 +28,13 @@ class: C - type: AccessLevel level: Three + - type: ScpHold + holdableWhitelist: + components: + - HumanoidAppearance + holdableBlacklist: + components: + - Scp - type: ScpAnnounceOnSpawn text: squad-commander-announce-on-spawn channels: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_officer.yml index 10b8e31d5a5..43e61dd14f7 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_officer.yml @@ -28,6 +28,13 @@ class: C - type: AccessLevel level: Three + - type: ScpHold + holdableWhitelist: + components: + - HumanoidAppearance + holdableBlacklist: + components: + - Scp - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/field_medical_specialist.yml b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/field_medical_specialist.yml index ad1027479c3..3a06e87df35 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/field_medical_specialist.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/field_medical_specialist.yml @@ -31,6 +31,13 @@ class: C - type: AccessLevel level: Three + - type: ScpHold + holdableWhitelist: + components: + - HumanoidAppearance + holdableBlacklist: + components: + - Scp - type: Fear phobias: - Exoremophobia diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/junior_external_administrative_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/junior_external_administrative_zone_officer.yml index bcd4dc7d15c..f83018f592f 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/junior_external_administrative_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/junior_external_administrative_zone_officer.yml @@ -27,6 +27,13 @@ class: C - type: AccessLevel level: Three + - type: ScpHold + holdableWhitelist: + components: + - HumanoidAppearance + holdableBlacklist: + components: + - Scp - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/senior_external_administrative_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/senior_external_administrative_zone_officer.yml index e4353f351a1..d3709e6686d 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/senior_external_administrative_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/senior_external_administrative_zone_officer.yml @@ -28,6 +28,13 @@ class: C - type: AccessLevel level: Three + - type: ScpHold + holdableWhitelist: + components: + - HumanoidAppearance + holdableBlacklist: + components: + - Scp - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d.yml b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d.yml index 00efb76ff5b..abf63f08253 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d.yml @@ -18,6 +18,15 @@ - ExternalAdministrativeZoneSecurityService includeJobName: true requiredGameRunLevel: InRound + - type: ScpHold + holdableWhitelist: + components: + - Scp096 + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: startingGear id: ClassDGear diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_botanist.yml b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_botanist.yml index ca349700658..67eeb5fca00 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_botanist.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_botanist.yml @@ -23,6 +23,15 @@ - ExternalAdministrativeZoneSecurityService includeJobName: true requiredGameRunLevel: InRound + - type: ScpHold + holdableWhitelist: + components: + - Scp096 + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: startingGear id: ClassDBotanistGear diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_cook.yml b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_cook.yml index af37b4abf9e..fa4d0e42134 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_cook.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_cook.yml @@ -23,6 +23,15 @@ - ExternalAdministrativeZoneSecurityService includeJobName: true requiredGameRunLevel: InRound + - type: ScpHold + holdableWhitelist: + components: + - Scp096 + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: startingGear id: ClassDCookGear diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_janitor.yml b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_janitor.yml index 2eb3e3f2278..b316f0af7c1 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_janitor.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_janitor.yml @@ -20,6 +20,15 @@ - ExternalAdministrativeZoneSecurityService includeJobName: true requiredGameRunLevel: InRound + - type: ScpHold + holdableWhitelist: + components: + - Scp096 + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: startingGear id: ClassDJanitorGear diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_commandant.yml b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_commandant.yml index ab5fe8465a1..676697729c5 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_commandant.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_commandant.yml @@ -28,6 +28,16 @@ class: C - type: AccessLevel level: Three + - type: ScpHold + holdableWhitelist: + components: + - Scp096 + - ClassDAppearance + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: ScpAnnounceOnSpawn text: squad-commander-announce-on-spawn channels: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_officer.yml index c6ab3659cbd..5c988ff75ce 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_officer.yml @@ -28,6 +28,16 @@ class: C - type: AccessLevel level: Three + - type: ScpHold + holdableWhitelist: + components: + - Scp096 + - ClassDAppearance + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/junior_heavy_containment_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/junior_heavy_containment_zone_officer.yml index 99ad7d3e958..cf3914e9b3d 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/junior_heavy_containment_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/junior_heavy_containment_zone_officer.yml @@ -27,6 +27,16 @@ class: C - type: AccessLevel level: Three + - type: ScpHold + holdableWhitelist: + components: + - Scp096 + - ClassDAppearance + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/senior_heavy_containment_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/senior_heavy_containment_zone_officer.yml index b5a6901a778..82e0a99ca57 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/senior_heavy_containment_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/senior_heavy_containment_zone_officer.yml @@ -28,6 +28,16 @@ class: C - type: AccessLevel level: Three + - type: ScpHold + holdableWhitelist: + components: + - Scp096 + - ClassDAppearance + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: