diff --git a/Content.Server/_EE/Carrying/BeingCarriedComponent.cs b/Content.Server/_EE/Carrying/BeingCarriedComponent.cs
new file mode 100644
index 000000000000..afc78978c867
--- /dev/null
+++ b/Content.Server/_EE/Carrying/BeingCarriedComponent.cs
@@ -0,0 +1,11 @@
+namespace Content.Server.Carrying
+{
+ ///
+ /// Stores the carrier of an entity being carried.
+ ///
+ [RegisterComponent]
+ public sealed partial class BeingCarriedComponent : Component
+ {
+ public EntityUid Carrier = default!;
+ }
+}
diff --git a/Content.Server/_EE/Carrying/CarriableComponent.cs b/Content.Server/_EE/Carrying/CarriableComponent.cs
new file mode 100644
index 000000000000..f1ed950797b8
--- /dev/null
+++ b/Content.Server/_EE/Carrying/CarriableComponent.cs
@@ -0,0 +1,40 @@
+using System.Threading;
+
+namespace Content.Server.Carrying
+{
+ [RegisterComponent]
+ public sealed partial class CarriableComponent : Component
+ {
+ ///
+ /// Number of free hands required
+ /// to carry the entity
+ ///
+ [DataField]
+ public int FreeHandsRequired = 2;
+
+ public CancellationTokenSource? CancelToken;
+
+ ///
+ /// The base duration (In Seconds) of how long it should take to pick up this entity
+ /// before Contests are considered.
+ ///
+ [DataField]
+ public float PickupDuration = 3;
+
+ // Frontier: min/max sanitization
+ ///
+ /// The minimum duration (in seconds) of how long it should take to pick up this entity.
+ /// When the strongest, heaviest entity picks this up, it should roughly take this long.
+ ///
+ [DataField]
+ public float MinPickupDuration = 1.5f;
+
+ ///
+ /// The maximum duration (in seconds) of how long it should take to pick up this entity.
+ /// When an object picks up the heaviest object it can lift, it should be at most this.
+ ///
+ [DataField]
+ public float MaxPickupDuration = 6.0f;
+ // End Frontier
+ }
+}
diff --git a/Content.Server/_EE/Carrying/CarryingComponent.cs b/Content.Server/_EE/Carrying/CarryingComponent.cs
new file mode 100644
index 000000000000..e79460595b99
--- /dev/null
+++ b/Content.Server/_EE/Carrying/CarryingComponent.cs
@@ -0,0 +1,11 @@
+namespace Content.Server.Carrying
+{
+ ///
+ /// Added to an entity when they are carrying somebody.
+ ///
+ [RegisterComponent]
+ public sealed partial class CarryingComponent : Component
+ {
+ public EntityUid Carried = default!;
+ }
+}
diff --git a/Content.Server/_EE/Carrying/CarryingSystem.cs b/Content.Server/_EE/Carrying/CarryingSystem.cs
new file mode 100644
index 000000000000..c2ac4ffa294c
--- /dev/null
+++ b/Content.Server/_EE/Carrying/CarryingSystem.cs
@@ -0,0 +1,359 @@
+using System.Numerics;
+using System.Threading;
+using Content.Server.DoAfter;
+using Content.Server.Resist;
+using Content.Server.Popups;
+using Content.Server.Inventory;
+using Content.Shared.Mobs;
+using Content.Shared.DoAfter;
+using Content.Shared.Buckle.Components;
+using Content.Shared.Hands.Components;
+using Content.Shared.Hands;
+using Content.Shared.Stunnable;
+using Content.Shared.Interaction.Events;
+using Content.Shared.Verbs;
+using Content.Shared.Climbing.Events;
+using Content.Shared.Carrying;
+using Content.Shared.Contests;
+using Content.Shared.Movement.Events;
+using Content.Shared.Movement.Systems;
+using Content.Shared.Standing;
+using Content.Shared.ActionBlocker;
+using Content.Shared.Inventory.VirtualItem;
+using Content.Shared.Item;
+using Content.Shared.Throwing;
+using Content.Shared.Movement.Pulling.Components;
+using Content.Shared.Movement.Pulling.Events;
+using Content.Shared.Movement.Pulling.Systems;
+using Content.Shared.Mobs.Systems;
+using Content.Shared.Storage;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Physics.Components;
+using Robust.Server.GameObjects;
+
+namespace Content.Server.Carrying
+{
+ public sealed class CarryingSystem : EntitySystem
+ {
+ [Dependency] private readonly VirtualItemSystem _virtualItemSystem = default!;
+ [Dependency] private readonly CarryingSlowdownSystem _slowdown = default!;
+ [Dependency] private readonly DoAfterSystem _doAfterSystem = default!;
+ [Dependency] private readonly StandingStateSystem _standingState = default!;
+ [Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
+ [Dependency] private readonly PullingSystem _pullingSystem = default!;
+ [Dependency] private readonly MobStateSystem _mobStateSystem = default!;
+ [Dependency] private readonly EscapeInventorySystem _escapeInventorySystem = default!;
+ [Dependency] private readonly PopupSystem _popupSystem = default!;
+ [Dependency] private readonly MovementSpeedModifierSystem _movementSpeed = default!;
+ [Dependency] private readonly ContestsSystem _contests = default!;
+ [Dependency] private readonly TransformSystem _transform = default!;
+
+ public const float BaseDistanceCoeff = 0.5f; // Frontier: default throwing speed reduction
+ public const float MaxDistanceCoeff = 1.0f; // Frontier: default throwing speed reduction
+ public const float DefaultMaxThrowDistance = 4.0f; // Frontier: maximum throwing distance
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent>(AddCarryVerb);
+ SubscribeLocalEvent(OnVirtualItemDeleted);
+ SubscribeLocalEvent(OnThrow);
+ SubscribeLocalEvent(OnParentChanged);
+ SubscribeLocalEvent(OnMobStateChanged);
+ SubscribeLocalEvent(OnInteractionAttempt);
+ SubscribeLocalEvent(OnMoveInput);
+ SubscribeLocalEvent(OnMoveAttempt);
+ SubscribeLocalEvent(OnStandAttempt);
+ SubscribeLocalEvent(OnInteractedWith);
+ SubscribeLocalEvent(OnPullAttempt);
+ SubscribeLocalEvent(OnStartClimb);
+ SubscribeLocalEvent(OnBuckleChange);
+ SubscribeLocalEvent(OnBuckleChange);
+ SubscribeLocalEvent(OnBuckleChange);
+ SubscribeLocalEvent(OnBuckleChange);
+ SubscribeLocalEvent(OnDoAfter);
+ }
+
+ private void AddCarryVerb(EntityUid uid, CarriableComponent component, GetVerbsEvent args)
+ {
+ if (!args.CanInteract || !args.CanAccess || !_mobStateSystem.IsAlive(args.User)
+ || !CanCarry(args.User, uid, component)
+ || HasComp(args.User)
+ || HasComp(args.User) || HasComp(args.Target)
+ || args.User == args.Target)
+ return;
+
+ AlternativeVerb verb = new()
+ {
+ Act = () =>
+ {
+ StartCarryDoAfter(args.User, uid, component);
+ },
+ Text = Loc.GetString("carry-verb"),
+ Priority = 2
+ };
+ args.Verbs.Add(verb);
+ }
+
+ ///
+ /// Since the carried entity is stored as 2 virtual items, when deleted we want to drop them.
+ ///
+ private void OnVirtualItemDeleted(EntityUid uid, CarryingComponent component, VirtualItemDeletedEvent args)
+ {
+ if (!HasComp(args.BlockingEntity))
+ return;
+
+ DropCarried(uid, args.BlockingEntity);
+ }
+
+ ///
+ /// Basically using virtual item passthrough to throw the carried person. A new age!
+ /// Maybe other things besides throwing should use virt items like this...
+ ///
+ private void OnThrow(EntityUid uid, CarryingComponent component, ref BeforeThrowEvent args)
+ {
+ if (!TryComp(args.ItemUid, out var virtItem)
+ || !HasComp(virtItem.BlockingEntity))
+ return;
+
+ args.ItemUid = virtItem.BlockingEntity;
+
+ var contestCoeff = _contests.MassContest(uid, virtItem.BlockingEntity, false, 2f) // Frontier: "args.throwSpeed *="<"var contestCoeff ="
+ * _contests.StaminaContest(uid, virtItem.BlockingEntity);
+
+ // Frontier: sanitize our range regardless of CVar values - TODO: variable throw distance ranges (via traits, etc.)
+ contestCoeff = float.Min(BaseDistanceCoeff * contestCoeff, MaxDistanceCoeff);
+ if (args.Direction.Length() > DefaultMaxThrowDistance * contestCoeff)
+ args.Direction = args.Direction.Normalized() * DefaultMaxThrowDistance * contestCoeff;
+ // End Frontier
+ }
+
+ private void OnParentChanged(EntityUid uid, CarryingComponent component, ref EntParentChangedMessage args)
+ {
+ var xform = Transform(uid);
+ if (xform.MapUid != args.OldMapId || xform.ParentUid == xform.GridUid)
+ return;
+
+ DropCarried(uid, component.Carried);
+ }
+
+ private void OnMobStateChanged(EntityUid uid, CarryingComponent component, MobStateChangedEvent args)
+ {
+ DropCarried(uid, component.Carried);
+ }
+
+ ///
+ /// Only let the person being carried interact with their carrier and things on their person.
+ ///
+ private void OnInteractionAttempt(EntityUid uid, BeingCarriedComponent component, InteractionAttemptEvent args)
+ {
+ if (args.Target == null)
+ return;
+
+ var targetParent = Transform(args.Target.Value).ParentUid;
+
+ if (args.Target.Value != component.Carrier && targetParent != component.Carrier && targetParent != uid)
+ args.Cancelled = true;
+ }
+
+ ///
+ /// Try to escape via the escape inventory system.
+ ///
+ private void OnMoveInput(EntityUid uid, BeingCarriedComponent component, ref MoveInputEvent args)
+ {
+ if (!TryComp(uid, out var escape)
+ || !args.HasDirectionalMovement)
+ return;
+
+ // Check if the victim is in any way incapacitated, and if not make an escape attempt.
+ // Escape time scales with the inverse of a mass contest. Being lighter makes escape harder.
+ if (_actionBlockerSystem.CanInteract(uid, component.Carrier))
+ {
+ var disadvantage = _contests.MassContest(component.Carrier, uid, false, 2f);
+ _escapeInventorySystem.AttemptEscape(uid, component.Carrier, escape, disadvantage);
+ }
+ }
+
+ private void OnMoveAttempt(EntityUid uid, BeingCarriedComponent component, UpdateCanMoveEvent args)
+ {
+ args.Cancel();
+ }
+
+ private void OnStandAttempt(EntityUid uid, BeingCarriedComponent component, StandAttemptEvent args)
+ {
+ args.Cancel();
+ }
+
+ private void OnInteractedWith(EntityUid uid, BeingCarriedComponent component, GettingInteractedWithAttemptEvent args)
+ {
+ if (args.Uid != component.Carrier)
+ args.Cancelled = true;
+ }
+
+ private void OnPullAttempt(EntityUid uid, BeingCarriedComponent component, PullAttemptEvent args)
+ {
+ args.Cancelled = true;
+ }
+
+ private void OnStartClimb(EntityUid uid, BeingCarriedComponent component, ref StartClimbEvent args)
+ {
+ DropCarried(component.Carrier, uid);
+ }
+
+ private void OnBuckleChange(EntityUid uid, BeingCarriedComponent component, TEvent args)
+ {
+ DropCarried(component.Carrier, uid);
+ }
+
+ private void OnDoAfter(EntityUid uid, CarriableComponent component, CarryDoAfterEvent args)
+ {
+ component.CancelToken = null;
+ if (args.Handled || args.Cancelled
+ || !CanCarry(args.Args.User, uid, component))
+ return;
+
+ Carry(args.Args.User, uid);
+ args.Handled = true;
+ }
+ private void StartCarryDoAfter(EntityUid carrier, EntityUid carried, CarriableComponent component)
+ {
+ if (!TryComp(carrier, out var carrierPhysics)
+ || !TryComp(carried, out var carriedPhysics)
+ || carriedPhysics.Mass > carrierPhysics.Mass * 2f)
+ {
+ _popupSystem.PopupEntity(Loc.GetString("carry-too-heavy"), carried, carrier, Shared.Popups.PopupType.SmallCaution);
+ return;
+ }
+
+ var length = component.PickupDuration // Frontier: removed outer TimeSpan.FromSeconds()
+ * _contests.MassContest(carriedPhysics, carrierPhysics, false, 4f)
+ * _contests.StaminaContest(carrier, carried)
+ * (_standingState.IsDown(carried) ? 0.5f : 1);
+
+ // Frontier: sanitize pickup time duration regardless of CVars - no near-instant pickups.
+ var duration = TimeSpan.FromSeconds(
+ float.Clamp(length,
+ component.MinPickupDuration,
+ component.MaxPickupDuration));
+ // End Frontier
+
+ component.CancelToken = new CancellationTokenSource();
+
+ var ev = new CarryDoAfterEvent();
+ var args = new DoAfterArgs(EntityManager, carrier, duration, ev, carried, target: carried) // Frontier: length(carried, out var pullable))
+ _pullingSystem.TryStopPull(carried, pullable);
+
+ _transform.AttachToGridOrMap(carrier);
+ _transform.AttachToGridOrMap(carried);
+ _transform.SetCoordinates(carried, Transform(carrier).Coordinates);
+ _transform.SetParent(carried, carrier);
+
+ _virtualItemSystem.TrySpawnVirtualItemInHand(carried, carrier);
+ _virtualItemSystem.TrySpawnVirtualItemInHand(carried, carrier);
+ var carryingComp = EnsureComp(carrier);
+ ApplyCarrySlowdown(carrier, carried);
+ var carriedComp = EnsureComp(carried);
+ EnsureComp(carried);
+
+ carryingComp.Carried = carried;
+ carriedComp.Carrier = carrier;
+
+ _actionBlockerSystem.UpdateCanMove(carried);
+ }
+
+ public bool TryCarry(EntityUid carrier, EntityUid toCarry, CarriableComponent? carriedComp = null)
+ {
+ if (!Resolve(toCarry, ref carriedComp, false)
+ || !CanCarry(carrier, toCarry, carriedComp)
+ || HasComp(carrier)
+ || HasComp(carrier)
+ || TryComp(carrier, out var carrierPhysics)
+ && TryComp(toCarry, out var toCarryPhysics)
+ && carrierPhysics.Mass < toCarryPhysics.Mass * 2f)
+ return false;
+
+ Carry(carrier, toCarry);
+
+ return true;
+ }
+
+ public void DropCarried(EntityUid carrier, EntityUid carried)
+ {
+ RemComp(carrier); // get rid of this first so we don't recursively fire that event
+ RemComp(carrier);
+ RemComp(carried);
+ RemComp(carried);
+ _actionBlockerSystem.UpdateCanMove(carried);
+ _virtualItemSystem.DeleteInHandsMatching(carrier, carried);
+ _transform.AttachToGridOrMap(carried);
+ _standingState.Stand(carried);
+ _movementSpeed.RefreshMovementSpeedModifiers(carrier);
+ }
+
+ private void ApplyCarrySlowdown(EntityUid carrier, EntityUid carried)
+ {
+ var massRatio = _contests.MassContest(carrier, carried, true);
+ var massRatioSq = MathF.Pow(massRatio, 2);
+ var modifier = 1 - 0.15f / massRatioSq;
+ modifier = Math.Max(0.1f, modifier);
+
+ var slowdownComp = EnsureComp(carrier);
+ _slowdown.SetModifier(carrier, modifier, modifier, slowdownComp);
+ }
+
+ public bool CanCarry(EntityUid carrier, EntityUid carried, CarriableComponent? carriedComp = null)
+ {
+ if (!Resolve(carried, ref carriedComp, false)
+ || carriedComp.CancelToken != null
+ || !HasComp(Transform(carrier).ParentUid)
+ || HasComp(carrier)
+ || HasComp(carried)
+ || !TryComp(carrier, out var hands)
+ || hands.CountFreeHands() < carriedComp.FreeHandsRequired)
+ return false;
+
+ return true;
+ }
+
+ public override void Update(float frameTime)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var carried, out var comp))
+ {
+ var carrier = comp.Carrier;
+ if (carrier is not { Valid: true } || carried is not { Valid: true })
+ continue;
+
+ // SOMETIMES - when an entity is inserted into disposals, or a cryosleep chamber - it can get re-parented without a proper reparent event
+ // when this happens, it needs to be dropped because it leads to weird behavior
+ if (Transform(carried).ParentUid != carrier)
+ {
+ DropCarried(carrier, carried);
+ continue;
+ }
+
+ // Make sure the carried entity is always centered relative to the carrier, as gravity pulls can offset it otherwise
+ var xform = Transform(carried);
+ if (!xform.LocalPosition.Equals(Vector2.Zero))
+ {
+ xform.LocalPosition = Vector2.Zero;
+ }
+ }
+ query.Dispose();
+ }
+ }
+}
diff --git a/Content.Shared/_EE/Carrying/CarryingDoAfterEvent.cs b/Content.Shared/_EE/Carrying/CarryingDoAfterEvent.cs
new file mode 100644
index 000000000000..fb7225461cb8
--- /dev/null
+++ b/Content.Shared/_EE/Carrying/CarryingDoAfterEvent.cs
@@ -0,0 +1,8 @@
+using Robust.Shared.Serialization;
+using Content.Shared.DoAfter;
+
+namespace Content.Shared.Carrying
+{
+ [Serializable, NetSerializable]
+ public sealed partial class CarryDoAfterEvent : SimpleDoAfterEvent { }
+}
diff --git a/Content.Shared/_EE/Carrying/CarryingSlowdownComponent.cs b/Content.Shared/_EE/Carrying/CarryingSlowdownComponent.cs
new file mode 100644
index 000000000000..597edc2a7956
--- /dev/null
+++ b/Content.Shared/_EE/Carrying/CarryingSlowdownComponent.cs
@@ -0,0 +1,28 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Carrying
+{
+ [RegisterComponent, NetworkedComponent, Access(typeof(CarryingSlowdownSystem))]
+
+ public sealed partial class CarryingSlowdownComponent : Component
+ {
+ [DataField(required: true)]
+ public float WalkModifier = 1.0f;
+
+ [DataField(required: true)]
+ public float SprintModifier = 1.0f;
+ }
+
+ [Serializable, NetSerializable]
+ public sealed class CarryingSlowdownComponentState : ComponentState
+ {
+ public float WalkModifier;
+ public float SprintModifier;
+ public CarryingSlowdownComponentState(float walkModifier, float sprintModifier)
+ {
+ WalkModifier = walkModifier;
+ SprintModifier = sprintModifier;
+ }
+ }
+}
diff --git a/Content.Shared/_EE/Carrying/CarryingSlowdownSystem.cs b/Content.Shared/_EE/Carrying/CarryingSlowdownSystem.cs
new file mode 100644
index 000000000000..04b714fdd786
--- /dev/null
+++ b/Content.Shared/_EE/Carrying/CarryingSlowdownSystem.cs
@@ -0,0 +1,46 @@
+using Content.Shared.Movement.Systems;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Carrying
+{
+ public sealed class CarryingSlowdownSystem : EntitySystem
+ {
+ [Dependency] private readonly MovementSpeedModifierSystem _movementSpeed = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnGetState);
+ SubscribeLocalEvent(OnHandleState);
+ SubscribeLocalEvent(OnRefreshMoveSpeed);
+ }
+
+ public void SetModifier(EntityUid uid, float walkSpeedModifier, float sprintSpeedModifier, CarryingSlowdownComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ component.WalkModifier = walkSpeedModifier;
+ component.SprintModifier = sprintSpeedModifier;
+ _movementSpeed.RefreshMovementSpeedModifiers(uid);
+ }
+ private void OnGetState(EntityUid uid, CarryingSlowdownComponent component, ref ComponentGetState args)
+ {
+ args.State = new CarryingSlowdownComponentState(component.WalkModifier, component.SprintModifier);
+ }
+
+ private void OnHandleState(EntityUid uid, CarryingSlowdownComponent component, ref ComponentHandleState args)
+ {
+ if (args.Current is not CarryingSlowdownComponentState state)
+ return;
+
+ component.WalkModifier = state.WalkModifier;
+ component.SprintModifier = state.SprintModifier;
+ _movementSpeed.RefreshMovementSpeedModifiers(uid);
+ }
+ private void OnRefreshMoveSpeed(EntityUid uid, CarryingSlowdownComponent component, RefreshMovementSpeedModifiersEvent args)
+ {
+ args.ModifySpeed(component.WalkModifier, component.SprintModifier);
+ }
+ }
+}