diff --git a/Content.Shared/Teleportation/Components/SwapTeleporterComponent.cs b/Content.Shared/Teleportation/Components/SwapTeleporterComponent.cs
new file mode 100644
index 0000000000..7a7bac83f4
--- /dev/null
+++ b/Content.Shared/Teleportation/Components/SwapTeleporterComponent.cs
@@ -0,0 +1,65 @@
+using Content.Shared.Teleportation.Systems;
+using Content.Shared.Whitelist;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.Teleportation.Components;
+
+///
+/// This is used for an entity that, when linked to another valid entity, allows the two to swap positions,
+/// additionally swapping the positions of the parents.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(SwapTeleporterSystem))]
+public sealed partial class SwapTeleporterComponent : Component
+{
+ ///
+ /// The other SwapTeleporterComponent that this one is linked to
+ ///
+ [DataField, AutoNetworkedField]
+ public EntityUid? LinkedEnt;
+
+ ///
+ /// the time at which ends and the teleportation occurs
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField]
+ public TimeSpan? TeleportTime;
+
+ ///
+ /// Delay after starting the teleport and it occuring.
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan TeleportDelay = TimeSpan.FromSeconds(2.5f);
+
+ ///
+ /// The time at which ends and teleportation can occur again.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField]
+ public TimeSpan NextTeleportUse;
+
+ ///
+ /// A minimum waiting period inbetween teleports.
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan Cooldown = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Sound played when teleportation begins
+ ///
+ [DataField]
+ public SoundSpecifier? TeleportSound = new SoundPathSpecifier("/Audio/Weapons/flash.ogg");
+
+ ///
+ /// A whitelist for what entities are valid for .
+ ///
+ [DataField]
+ public EntityWhitelist TeleporterWhitelist = new();
+}
+
+[Serializable, NetSerializable]
+public enum SwapTeleporterVisuals : byte
+{
+ Linked
+}
diff --git a/Content.Shared/Teleportation/Systems/SwapTeleporterSystem.cs b/Content.Shared/Teleportation/Systems/SwapTeleporterSystem.cs
new file mode 100644
index 0000000000..e900700e11
--- /dev/null
+++ b/Content.Shared/Teleportation/Systems/SwapTeleporterSystem.cs
@@ -0,0 +1,246 @@
+using Content.Shared.Examine;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Interaction;
+using Content.Shared.Popups;
+using Content.Shared.Teleportation.Components;
+using Content.Shared.Verbs;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Physics;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Timing;
+
+namespace Content.Shared.Teleportation.Systems;
+
+///
+/// This handles
+///
+public sealed class SwapTeleporterSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+ private EntityQuery _xformQuery;
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnInteract);
+ SubscribeLocalEvent>(OnGetAltVerb);
+ SubscribeLocalEvent(OnActivateInWorld);
+ SubscribeLocalEvent(OnExamined);
+
+ SubscribeLocalEvent(OnUnpaused);
+ SubscribeLocalEvent(OnShutdown);
+
+ _xformQuery = GetEntityQuery();
+ }
+
+ private void OnInteract(Entity ent, ref AfterInteractEvent args)
+ {
+ var (uid, comp) = ent;
+ if (args.Target == null)
+ return;
+
+ var target = args.Target.Value;
+
+ if (!TryComp(target, out var targetComp))
+ return;
+
+ if (!comp.TeleporterWhitelist.IsValid(target, EntityManager) ||
+ !targetComp.TeleporterWhitelist.IsValid(uid, EntityManager))
+ {
+ return;
+ }
+
+ if (comp.LinkedEnt != null)
+ {
+ _popup.PopupClient(Loc.GetString("swap-teleporter-popup-link-fail-already"), uid, args.User);
+ return;
+ }
+
+ if (targetComp.LinkedEnt != null)
+ {
+ _popup.PopupClient(Loc.GetString("swap-teleporter-popup-link-fail-already-other"), uid, args.User);
+ return;
+ }
+
+ comp.LinkedEnt = target;
+ targetComp.LinkedEnt = uid;
+ Dirty(uid, comp);
+ Dirty(target, targetComp);
+ _appearance.SetData(uid, SwapTeleporterVisuals.Linked, true);
+ _appearance.SetData(target, SwapTeleporterVisuals.Linked, true);
+ _popup.PopupClient(Loc.GetString("swap-teleporter-popup-link-create"), uid, args.User);
+ }
+
+ private void OnGetAltVerb(Entity ent, ref GetVerbsEvent args)
+ {
+ var (uid, comp) = ent;
+ if (!args.CanAccess || !args.CanInteract || args.Hands == null || comp.TeleportTime != null)
+ return;
+
+ if (!TryComp(comp.LinkedEnt, out var otherComp) || otherComp.TeleportTime != null)
+ return;
+
+ var user = args.User;
+ args.Verbs.Add(new AlternativeVerb
+ {
+ Text = Loc.GetString("swap-teleporter-verb-destroy-link"),
+ Priority = 1,
+ Act = () =>
+ {
+ DestroyLink((uid, comp), user);
+ }
+ });
+ }
+
+ private void OnActivateInWorld(Entity ent, ref ActivateInWorldEvent args)
+ {
+ var (uid, comp) = ent;
+ var user = args.User;
+ if (comp.TeleportTime != null)
+ return;
+
+ if (comp.LinkedEnt == null)
+ {
+ _popup.PopupClient(Loc.GetString("swap-teleporter-popup-teleport-cancel-link"), ent, user);
+ return;
+ }
+
+ // don't allow teleporting to happen if the linked one is already teleporting
+ if (!TryComp(comp.LinkedEnt, out var otherComp)
+ || otherComp.TeleportTime != null)
+ {
+ return;
+ }
+
+ if (_timing.CurTime < comp.NextTeleportUse)
+ {
+ _popup.PopupClient(Loc.GetString("swap-teleporter-popup-teleport-cancel-time"), ent, user);
+ return;
+ }
+
+ _audio.PlayPredicted(comp.TeleportSound, uid, user);
+ _audio.PlayPredicted(otherComp.TeleportSound, comp.LinkedEnt.Value, user);
+ comp.NextTeleportUse = _timing.CurTime + comp.Cooldown;
+ comp.TeleportTime = _timing.CurTime + comp.TeleportDelay;
+ Dirty(uid, comp);
+ }
+
+ public void DoTeleport(Entity ent)
+ {
+ var (uid, comp, xform) = ent;
+
+ comp.TeleportTime = null;
+
+ Dirty(uid, comp);
+ if (comp.LinkedEnt is not { } linkedEnt)
+ {
+ return;
+ }
+
+ var teleEnt = GetTeleportingEntity((uid, xform));
+ var teleEntXform = Transform(teleEnt);
+ var otherTeleEnt = GetTeleportingEntity((linkedEnt, Transform(linkedEnt)));
+ var otherTeleEntXform = Transform(otherTeleEnt);
+
+ _popup.PopupEntity(Loc.GetString("swap-teleporter-popup-teleport-other",
+ ("entity", Identity.Entity(linkedEnt, EntityManager))),
+ otherTeleEnt,
+ otherTeleEnt,
+ PopupType.MediumCaution);
+ var pos = teleEntXform.Coordinates;
+ var otherPos = otherTeleEntXform.Coordinates;
+
+ _transform.SetCoordinates(teleEnt, otherPos);
+ _transform.SetCoordinates(otherTeleEnt, pos);
+ }
+
+ ///
+ /// HYAH -link
+ ///
+ public void DestroyLink(Entity ent, EntityUid? user)
+ {
+ if (!Resolve(ent, ref ent.Comp, false))
+ return;
+ var linkedNullable = ent.Comp.LinkedEnt;
+
+ ent.Comp.LinkedEnt = null;
+ ent.Comp.TeleportTime = null;
+ _appearance.SetData(ent, SwapTeleporterVisuals.Linked, false);
+ Dirty(ent, ent.Comp);
+
+ if (user != null)
+ _popup.PopupClient(Loc.GetString("swap-teleporter-popup-link-destroyed"), ent, user.Value);
+ else
+ _popup.PopupEntity(Loc.GetString("swap-teleporter-popup-link-destroyed"), ent);
+
+ if (linkedNullable is {} linked)
+ DestroyLink(linked, user); // the linked one is shown globally
+ }
+
+ private EntityUid GetTeleportingEntity(Entity ent)
+ {
+ var parent = ent.Comp.ParentUid;
+
+ if (HasComp(parent) || HasComp(parent))
+ return ent;
+
+ if (!_xformQuery.TryGetComponent(parent, out var parentXform) || parentXform.Anchored)
+ return ent;
+
+ if (!TryComp(parent, out var body) || body.BodyType == BodyType.Static)
+ return ent;
+
+ return GetTeleportingEntity((parent, parentXform));
+ }
+
+ private void OnExamined(Entity ent, ref ExaminedEvent args)
+ {
+ var (_, comp) = ent;
+ using (args.PushGroup(nameof(SwapTeleporterComponent)))
+ {
+ var locale = comp.LinkedEnt == null
+ ? "swap-teleporter-examine-link-absent"
+ : "swap-teleporter-examine-link-present";
+ args.PushMarkup(Loc.GetString(locale));
+
+ if (_timing.CurTime < comp.NextTeleportUse)
+ {
+ args.PushMarkup(Loc.GetString("swap-teleporter-examine-time-remaining",
+ ("second", (int) ((comp.NextTeleportUse - _timing.CurTime).TotalSeconds + 0.5f))));
+ }
+ }
+ }
+
+ private void OnUnpaused(Entity ent, ref EntityUnpausedEvent args)
+ {
+ ent.Comp.NextTeleportUse += args.PausedTime;
+ }
+
+ private void OnShutdown(Entity ent, ref ComponentShutdown args)
+ {
+ DestroyLink((ent, ent), null);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp, out var xform))
+ {
+ if (comp.TeleportTime == null)
+ continue;
+
+ if (_timing.CurTime < comp.TeleportTime)
+ continue;
+
+ DoTeleport((uid, comp, xform));
+ }
+ }
+}
diff --git a/Resources/Locale/en-US/portal/swap-teleporter.ftl b/Resources/Locale/en-US/portal/swap-teleporter.ftl
new file mode 100644
index 0000000000..f13fa9be42
--- /dev/null
+++ b/Resources/Locale/en-US/portal/swap-teleporter.ftl
@@ -0,0 +1,17 @@
+swap-teleporter-popup-link-create = Quantum link established!
+swap-teleporter-popup-link-fail-already = Quantum link failed! Link already present on device.
+swap-teleporter-popup-link-fail-already-other = Quantum link failed! Link already present on secondary device.
+swap-teleporter-popup-link-destroyed = Quantum link destroyed!
+swap-teleporter-popup-teleport-cancel-time = It's still recharging!
+swap-teleporter-popup-teleport-cancel-link = It's not linked with another device!
+swap-teleporter-popup-teleport-other = {CAPITALIZE(THE($entity))} activates, and you find yourself somewhere else.
+
+swap-teleporter-verb-destroy-link = Destroy Quantum Link
+
+swap-teleporter-examine-link-present = [color=forestgreen]It is linked to another device.[/color] Alt-Click to break the quantum link.
+swap-teleporter-examine-link-absent = [color=yellow]It is not currently linked.[/color] Use on another device to establish a quantum link.
+swap-teleporter-examine-time-remaining = Time left to recharge: [color=purple]{$second} second{$second ->
+ [one].
+ *[other]s.
+}[/color]
+
diff --git a/Resources/Locale/en-US/research/technologies.ftl b/Resources/Locale/en-US/research/technologies.ftl
index 99d0c20c65..973ef360a9 100644
--- a/Resources/Locale/en-US/research/technologies.ftl
+++ b/Resources/Locale/en-US/research/technologies.ftl
@@ -56,6 +56,7 @@ research-technology-anomaly-harnessing = Anomaly Core Harnessing
research-technology-grappling = Grappling
research-technology-abnormal-artifact-manipulation = Abnormal Artifact Manipulation
research-technology-gravity-manipulation = Gravity Manipulation
+research-technology-quantum-leaping = Quantum Leaping
research-technology-advanced-anomaly-research = Advanced Anomaly Research
research-technology-rped = Rapid Part Exchange
research-technology-super-parts = Super Parts
diff --git a/Resources/Prototypes/Entities/Objects/Devices/swapper.yml b/Resources/Prototypes/Entities/Objects/Devices/swapper.yml
new file mode 100644
index 0000000000..8a743f4796
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Devices/swapper.yml
@@ -0,0 +1,27 @@
+- type: entity
+ parent: BaseItem
+ id: DeviceQuantumSpinInverter
+ name: quantum spin inverter
+ description: An experimental device that is able to swap the locations of two entities by switching their particles' spin values. Must be linked to another device to function.
+ components:
+ - type: Sprite
+ sprite: Objects/Devices/swapper.rsi
+ layers:
+ - state: icon
+ map: ["base"]
+ - type: Item
+ size: Small
+ - type: Appearance
+ - type: SwapTeleporter
+ teleporterWhitelist:
+ tags:
+ - QuantumSpinInverter
+ - type: GenericVisualizer
+ visuals:
+ enum.SwapTeleporterVisuals.Linked:
+ base:
+ True: { state: linked }
+ False: { state: icon }
+ - type: Tag
+ tags:
+ - QuantumSpinInverter
diff --git a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
index fe9dcd1d87..0d9e3ad767 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
@@ -300,6 +300,7 @@
- FauxTileAstroGrass
- FauxTileAstroIce
- OreBagOfHolding
+ - DeviceQuantumSpinInverter
- type: EmagLatheRecipes
emagDynamicRecipes:
- ExplosivePayload
diff --git a/Resources/Prototypes/Recipes/Lathes/devices.yml b/Resources/Prototypes/Recipes/Lathes/devices.yml
index d541c36353..1a1f5326e3 100644
--- a/Resources/Prototypes/Recipes/Lathes/devices.yml
+++ b/Resources/Prototypes/Recipes/Lathes/devices.yml
@@ -188,6 +188,15 @@
Glass: 400
Silver: 200
+- type: latheRecipe
+ id: DeviceQuantumSpinInverter
+ result: DeviceQuantumSpinInverter
+ completetime: 5
+ materials:
+ Steel: 700
+ Glass: 100
+ Uranium: 100
+
- type: latheRecipe
id: WeaponProtoKineticAccelerator
result: WeaponProtoKineticAccelerator
diff --git a/Resources/Prototypes/Research/experimental.yml b/Resources/Prototypes/Research/experimental.yml
index c6289fa9c7..bbb6ce568c 100644
--- a/Resources/Prototypes/Research/experimental.yml
+++ b/Resources/Prototypes/Research/experimental.yml
@@ -152,3 +152,15 @@
recipeUnlocks:
- WeaponForceGun
- WeaponTetherGun
+
+- type: technology
+ id: QuantumLeaping
+ name: research-technology-quantum-leaping
+ icon:
+ sprite: Objects/Devices/swapper.rsi
+ state: icon
+ discipline: Experimental
+ tier: 3
+ cost: 10000
+ recipeUnlocks:
+ - DeviceQuantumSpinInverter
diff --git a/Resources/Prototypes/tags.yml b/Resources/Prototypes/tags.yml
index abc5cfa483..d982cfbd5d 100644
--- a/Resources/Prototypes/tags.yml
+++ b/Resources/Prototypes/tags.yml
@@ -931,6 +931,9 @@
- type: Tag
id: ProximitySensor
+- type: Tag
+ id: QuantumSpinInverter
+
- type: Tag
id: Radio
diff --git a/Resources/Textures/Objects/Devices/swapper.rsi/icon.png b/Resources/Textures/Objects/Devices/swapper.rsi/icon.png
new file mode 100644
index 0000000000..8aef40e4b8
Binary files /dev/null and b/Resources/Textures/Objects/Devices/swapper.rsi/icon.png differ
diff --git a/Resources/Textures/Objects/Devices/swapper.rsi/inhand-left.png b/Resources/Textures/Objects/Devices/swapper.rsi/inhand-left.png
new file mode 100644
index 0000000000..08b70fe4c8
Binary files /dev/null and b/Resources/Textures/Objects/Devices/swapper.rsi/inhand-left.png differ
diff --git a/Resources/Textures/Objects/Devices/swapper.rsi/inhand-right.png b/Resources/Textures/Objects/Devices/swapper.rsi/inhand-right.png
new file mode 100644
index 0000000000..39b94bfa0a
Binary files /dev/null and b/Resources/Textures/Objects/Devices/swapper.rsi/inhand-right.png differ
diff --git a/Resources/Textures/Objects/Devices/swapper.rsi/linked.png b/Resources/Textures/Objects/Devices/swapper.rsi/linked.png
new file mode 100644
index 0000000000..6c57487797
Binary files /dev/null and b/Resources/Textures/Objects/Devices/swapper.rsi/linked.png differ
diff --git a/Resources/Textures/Objects/Devices/swapper.rsi/meta.json b/Resources/Textures/Objects/Devices/swapper.rsi/meta.json
new file mode 100644
index 0000000000..40b7f3a037
--- /dev/null
+++ b/Resources/Textures/Objects/Devices/swapper.rsi/meta.json
@@ -0,0 +1,36 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "tgstation at https://github.com/tgstation/tgstation/commit/71a1fee2f13730adee5302d34bfa0f0262314d63",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "icon"
+ },
+ {
+ "name": "linked",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "inhand-left",
+ "directions": 4
+ },
+ {
+ "name": "inhand-right",
+ "directions": 4
+ }
+ ]
+}