From bccae54b03dc6f2b0db71405f665ac9d3b58644b Mon Sep 17 00:00:00 2001
From: slarticodefast <161409025+slarticodefast@users.noreply.github.com>
Date: Mon, 3 Nov 2025 13:02:48 +0100
Subject: [PATCH] Add DNA injector (#41271)
* add item
* Update Content.Shared/Changeling/Systems/ChangelingClonerSystem.cs
Co-authored-by: ScarKy0 <106310278+ScarKy0@users.noreply.github.com>
---------
Co-authored-by: ScarKy0 <106310278+ScarKy0@users.noreply.github.com>
---
.../Components/ChangelingClonerComponent.cs | 100 ++++++
.../Systems/ChangelingClonerSystem.cs | 308 ++++++++++++++++++
.../Systems/SharedChangelingIdentitySystem.cs | 37 ++-
.../changeling-cloner-component.ftl | 11 +
.../Syndicate_Gadgets/dna_injector.yml | 47 +++
5 files changed, 494 insertions(+), 9 deletions(-)
create mode 100644 Content.Shared/Changeling/Components/ChangelingClonerComponent.cs
create mode 100644 Content.Shared/Changeling/Systems/ChangelingClonerSystem.cs
create mode 100644 Resources/Locale/en-US/changeling/changeling-cloner-component.ftl
create mode 100644 Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/dna_injector.yml
diff --git a/Content.Shared/Changeling/Components/ChangelingClonerComponent.cs b/Content.Shared/Changeling/Components/ChangelingClonerComponent.cs
new file mode 100644
index 0000000000..20cb690835
--- /dev/null
+++ b/Content.Shared/Changeling/Components/ChangelingClonerComponent.cs
@@ -0,0 +1,100 @@
+using Content.Shared.Charges.Components;
+using Content.Shared.Cloning;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Changeling.Components;
+
+///
+/// Changeling transformation in item form!
+/// An entity with this component works like an implanter:
+/// First you use it on a humanoid to make a copy of their identity, along with all species relevant components,
+/// then use it on someone else to tranform them into a clone of them.
+/// Can be used in combination with
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class ChangelingClonerComponent : Component
+{
+ ///
+ /// A clone of the player you have copied the identity from.
+ /// This is a full humanoid backup, stored on a paused map.
+ ///
+ ///
+ /// Since this entity is stored on a separate map it will be outside PVS range.
+ ///
+ [DataField, AutoNetworkedField]
+ public EntityUid? ClonedBackup;
+
+ ///
+ /// Current state of the item.
+ ///
+ [DataField, AutoNetworkedField]
+ public ChangelingClonerState State = ChangelingClonerState.Empty;
+
+ ///
+ /// The cloning settings to use.
+ ///
+ [DataField, AutoNetworkedField]
+ public ProtoId Settings = "ChangelingCloningSettings";
+
+ ///
+ /// Doafter time for drawing and injecting.
+ ///
+ [DataField, AutoNetworkedField]
+ public TimeSpan DoAfter = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Can this item be used more than once?
+ ///
+ [DataField, AutoNetworkedField]
+ public bool Reusable = true;
+
+ ///
+ /// Whether or not to add a reset verb to purge the stored identity,
+ /// allowing you to draw a new one.
+ ///
+ [DataField, AutoNetworkedField]
+ public bool CanReset = true;
+
+ ///
+ /// Raise events when renaming the target?
+ /// This will change their ID card, crew manifest entry, and so on.
+ /// For admeme purposes.
+ ///
+ [DataField, AutoNetworkedField]
+ public bool RaiseNameChangeEvents;
+
+ ///
+ /// The sound to play when taking someone's identity with the item.
+ ///
+ [DataField, AutoNetworkedField]
+ public SoundSpecifier? DrawSound;
+
+ ///
+ /// The sound to play when someone is transformed.
+ ///
+ [DataField, AutoNetworkedField]
+ public SoundSpecifier? InjectSound;
+}
+
+///
+/// Current state of the item.
+///
+[Serializable, NetSerializable]
+public enum ChangelingClonerState : byte
+{
+ ///
+ /// No sample taken yet.
+ ///
+ Empty,
+ ///
+ /// Filled with a DNA sample.
+ ///
+ Filled,
+ ///
+ /// Has been used (single use only).
+ ///
+ Spent,
+}
diff --git a/Content.Shared/Changeling/Systems/ChangelingClonerSystem.cs b/Content.Shared/Changeling/Systems/ChangelingClonerSystem.cs
new file mode 100644
index 0000000000..d65d39ca40
--- /dev/null
+++ b/Content.Shared/Changeling/Systems/ChangelingClonerSystem.cs
@@ -0,0 +1,308 @@
+using Content.Shared.Administration.Logs;
+using Content.Shared.Changeling.Components;
+using Content.Shared.Cloning;
+using Content.Shared.Database;
+using Content.Shared.DoAfter;
+using Content.Shared.Examine;
+using Content.Shared.Forensics.Systems;
+using Content.Shared.Humanoid;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Interaction;
+using Content.Shared.Popups;
+using Content.Shared.Verbs;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Changeling.Systems;
+
+public sealed class ChangelingClonerSystem : EntitySystem
+{
+ [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
+ [Dependency] private readonly SharedHumanoidAppearanceSystem _humanoidAppearance = default!;
+ [Dependency] private readonly MetaDataSystem _metaData = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedCloningSystem _cloning = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly SharedChangelingIdentitySystem _changelingIdentity = default!;
+ [Dependency] private readonly SharedForensicsSystem _forensics = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnExamine);
+ SubscribeLocalEvent>(OnGetVerbs);
+ SubscribeLocalEvent(OnAfterInteract);
+ SubscribeLocalEvent(OnDraw);
+ SubscribeLocalEvent(OnInject);
+ SubscribeLocalEvent(OnShutDown);
+ }
+
+ private void OnShutDown(Entity ent, ref ComponentShutdown args)
+ {
+ // Delete the stored clone.
+ PredictedQueueDel(ent.Comp.ClonedBackup);
+ }
+
+ private void OnExamine(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ var msg = ent.Comp.State switch
+ {
+ ChangelingClonerState.Empty => "changeling-cloner-component-empty",
+ ChangelingClonerState.Filled => "changeling-cloner-component-filled",
+ ChangelingClonerState.Spent => "changeling-cloner-component-spent",
+ _ => "error"
+ };
+
+ args.PushMarkup(Loc.GetString(msg));
+
+ }
+
+ private void OnGetVerbs(Entity ent, ref GetVerbsEvent args)
+ {
+ if (!args.CanInteract || !args.CanAccess || args.Hands == null)
+ return;
+
+ if (!ent.Comp.CanReset || ent.Comp.State == ChangelingClonerState.Spent)
+ return;
+
+ var user = args.User;
+ args.Verbs.Add(new Verb
+ {
+ Text = Loc.GetString("changeling-cloner-component-reset-verb"),
+ Disabled = ent.Comp.ClonedBackup == null,
+ Act = () => Reset(ent.AsNullable(), user),
+ DoContactInteraction = true,
+ });
+ }
+
+ private void OnAfterInteract(Entity ent, ref AfterInteractEvent args)
+ {
+ if (args.Handled || !args.CanReach || args.Target == null)
+ return;
+
+ switch (ent.Comp.State)
+ {
+ case ChangelingClonerState.Empty:
+ args.Handled |= TryDraw(ent.AsNullable(), args.Target.Value, args.User);
+ break;
+ case ChangelingClonerState.Filled:
+ args.Handled |= TryInject(ent.AsNullable(), args.Target.Value, args.User);
+ break;
+ case ChangelingClonerState.Spent:
+ default:
+ break;
+ }
+
+ }
+
+ private void OnDraw(Entity ent, ref ClonerDrawDoAfterEvent args)
+ {
+ if (args.Handled || args.Cancelled || args.Target == null)
+ return;
+
+ Draw(ent.AsNullable(), args.Target.Value, args.User);
+ args.Handled = true;
+ }
+
+ private void OnInject(Entity ent, ref ClonerInjectDoAfterEvent args)
+ {
+ if (args.Handled || args.Cancelled || args.Target == null)
+ return;
+
+ Inject(ent.AsNullable(), args.Target.Value, args.User);
+ args.Handled = true;
+ }
+
+ ///
+ /// Start a DoAfter to draw a DNA sample from the target.
+ ///
+ public bool TryDraw(Entity ent, EntityUid target, EntityUid user)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return false;
+
+ if (ent.Comp.State != ChangelingClonerState.Empty)
+ return false;
+
+ if (!HasComp(target))
+ return false; // cloning only works for humanoids at the moment
+
+ var args = new DoAfterArgs(EntityManager, user, ent.Comp.DoAfter, new ClonerDrawDoAfterEvent(), ent, target: target, used: ent)
+ {
+ BreakOnDamage = true,
+ BreakOnMove = true,
+ NeedHand = true,
+ };
+
+ if (!_doAfter.TryStartDoAfter(args))
+ return false;
+
+ var userIdentity = Identity.Entity(user, EntityManager);
+ var targetIdentity = Identity.Entity(target, EntityManager);
+ var userMsg = Loc.GetString("changeling-cloner-component-draw-user", ("user", userIdentity), ("target", targetIdentity));
+ var targetMsg = Loc.GetString("changeling-cloner-component-draw-target", ("user", userIdentity), ("target", targetIdentity));
+ _popup.PopupClient(userMsg, target, user);
+
+ if (user != target) // don't show the warning if using the item on yourself
+ _popup.PopupEntity(targetMsg, user, target, PopupType.LargeCaution);
+
+ return true;
+ }
+
+ ///
+ /// Start a DoAfter to inject a DNA sample into someone, turning them into a clone of the original.
+ ///
+ public bool TryInject(Entity ent, EntityUid target, EntityUid user)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return false;
+
+ if (ent.Comp.State != ChangelingClonerState.Filled)
+ return false;
+
+ if (!HasComp(target))
+ return false; // cloning only works for humanoids at the moment
+
+ var args = new DoAfterArgs(EntityManager, user, ent.Comp.DoAfter, new ClonerInjectDoAfterEvent(), ent, target: target, used: ent)
+ {
+ BreakOnDamage = true,
+ BreakOnMove = true,
+ NeedHand = true,
+ };
+
+ if (!_doAfter.TryStartDoAfter(args))
+ return false;
+
+ var userIdentity = Identity.Entity(user, EntityManager);
+ var targetIdentity = Identity.Entity(target, EntityManager);
+ var userMsg = Loc.GetString("changeling-cloner-component-inject-user", ("user", userIdentity), ("target", targetIdentity));
+ var targetMsg = Loc.GetString("changeling-cloner-component-inject-target", ("user", userIdentity), ("target", targetIdentity));
+ _popup.PopupClient(userMsg, target, user);
+
+ if (user != target) // don't show the warning if using the item on yourself
+ _popup.PopupEntity(targetMsg, user, target, PopupType.LargeCaution);
+
+ return true;
+ }
+
+ ///
+ /// Draw a DNA sample from the target.
+ /// This will create a clone stored on a paused map for data storage.
+ ///
+ public void Draw(Entity ent, EntityUid target, EntityUid user)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return;
+
+ if (ent.Comp.State != ChangelingClonerState.Empty)
+ return;
+
+ if (!HasComp(target))
+ return; // cloning only works for humanoids at the moment
+
+ if (!_prototype.Resolve(ent.Comp.Settings, out var settings))
+ return;
+
+ _adminLogger.Add(LogType.Identity,
+ $"{user} is using {ent.Owner} to draw DNA from {target}.");
+
+ // Make a copy of the target on a paused map, so that we can apply their components later.
+ ent.Comp.ClonedBackup = _changelingIdentity.CloneToPausedMap(settings, target);
+ ent.Comp.State = ChangelingClonerState.Filled;
+ _appearance.SetData(ent.Owner, ChangelingClonerVisuals.State, ChangelingClonerState.Filled);
+ Dirty(ent);
+
+ _audio.PlayPredicted(ent.Comp.DrawSound, target, user);
+ _forensics.TransferDna(ent, target);
+ }
+
+ ///
+ /// Inject a DNA sample into someone, turning them into a clone of the original.
+ ///
+ public void Inject(Entity ent, EntityUid target, EntityUid user)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return;
+
+ if (ent.Comp.State != ChangelingClonerState.Filled)
+ return;
+
+ if (!HasComp(target))
+ return; // cloning only works for humanoids at the moment
+
+ if (!_prototype.Resolve(ent.Comp.Settings, out var settings))
+ return;
+
+ _audio.PlayPredicted(ent.Comp.InjectSound, target, user);
+ _forensics.TransferDna(ent, target); // transfer DNA before overwriting it
+
+ if (!ent.Comp.Reusable)
+ {
+ ent.Comp.State = ChangelingClonerState.Spent;
+ _appearance.SetData(ent.Owner, ChangelingClonerVisuals.State, ChangelingClonerState.Spent);
+ Dirty(ent);
+ }
+
+ if (!Exists(ent.Comp.ClonedBackup))
+ return; // the entity is likely out of PVS range on the client
+
+ _adminLogger.Add(LogType.Identity,
+ $"{user} is using {ent.Owner} to inject DNA into {target} changing their identity to {ent.Comp.ClonedBackup.Value}.");
+
+ // Do the actual transformation.
+ _humanoidAppearance.CloneAppearance(ent.Comp.ClonedBackup.Value, target);
+ _cloning.CloneComponents(ent.Comp.ClonedBackup.Value, target, settings);
+ _metaData.SetEntityName(target, Name(ent.Comp.ClonedBackup.Value), raiseEvents: ent.Comp.RaiseNameChangeEvents);
+
+ }
+
+ ///
+ /// Purge the stored DNA and allow to draw again.
+ ///
+ public void Reset(Entity ent, EntityUid? user)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return;
+
+ // Delete the stored clone.
+ PredictedQueueDel(ent.Comp.ClonedBackup);
+ ent.Comp.ClonedBackup = null;
+ ent.Comp.State = ChangelingClonerState.Empty;
+ _appearance.SetData(ent.Owner, ChangelingClonerVisuals.State, ChangelingClonerState.Empty);
+ Dirty(ent);
+
+ if (user == null)
+ return;
+
+ _popup.PopupClient(Loc.GetString("changeling-cloner-component-reset-popup"), user.Value, user.Value);
+ }
+}
+
+///
+/// Doafter event for drawing a DNA sample.
+///
+[Serializable, NetSerializable]
+public sealed partial class ClonerDrawDoAfterEvent : SimpleDoAfterEvent;
+
+///
+/// DoAfterEvent for injecting a DNA sample, turning a player into someone else.
+///
+[Serializable, NetSerializable]
+public sealed partial class ClonerInjectDoAfterEvent : SimpleDoAfterEvent;
+
+///
+/// Key for the generic visualizer.
+///
+[Serializable, NetSerializable]
+public enum ChangelingClonerVisuals : byte
+{
+ State,
+}
diff --git a/Content.Shared/Changeling/Systems/SharedChangelingIdentitySystem.cs b/Content.Shared/Changeling/Systems/SharedChangelingIdentitySystem.cs
index e7e46d79a1..830aed6ab6 100644
--- a/Content.Shared/Changeling/Systems/SharedChangelingIdentitySystem.cs
+++ b/Content.Shared/Changeling/Systems/SharedChangelingIdentitySystem.cs
@@ -83,20 +83,19 @@ public abstract class SharedChangelingIdentitySystem : EntitySystem
}
///
- /// Clone a target humanoid into nullspace and add it to the Changelings list of identities.
- /// It creates a perfect copy of the target and can be used to pull components down for future use
+ /// Clone a target humanoid to a paused map.
+ /// It creates a perfect copy of the target and can be used to pull components down for future use.
///
- /// the Changeling
- /// the targets uid
- public EntityUid? CloneToPausedMap(Entity ent, EntityUid target)
+ /// The settings to use for cloning.
+ /// The target to clone.
+ public EntityUid? CloneToPausedMap(CloningSettingsPrototype settings, EntityUid target)
{
// Don't create client side duplicate clones or a clientside map.
if (_net.IsClient)
return null;
if (!TryComp(target, out var humanoid)
- || !_prototype.Resolve(humanoid.Species, out var speciesPrototype)
- || !_prototype.Resolve(ent.Comp.IdentityCloningSettings, out var settings))
+ || !_prototype.Resolve(humanoid.Species, out var speciesPrototype))
return null;
EnsurePausedMap();
@@ -117,10 +116,30 @@ public abstract class SharedChangelingIdentitySystem : EntitySystem
var targetName = _nameMod.GetBaseName(target);
_metaSystem.SetEntityName(clone, targetName);
- ent.Comp.ConsumedIdentities.Add(clone);
+
+ return clone;
+ }
+
+ ///
+ /// Clone a target humanoid to a paused map and add it to the Changelings list of identities.
+ /// It creates a perfect copy of the target and can be used to pull components down for future use.
+ ///
+ /// The Changeling.
+ /// The target to clone.
+ public EntityUid? CloneToPausedMap(Entity ent, EntityUid target)
+ {
+ if (!_prototype.Resolve(ent.Comp.IdentityCloningSettings, out var settings))
+ return null;
+
+ var clone = CloneToPausedMap(settings, target);
+
+ if (clone == null)
+ return null;
+
+ ent.Comp.ConsumedIdentities.Add(clone.Value);
Dirty(ent);
- HandlePvsOverride(ent, clone);
+ HandlePvsOverride(ent, clone.Value);
return clone;
}
diff --git a/Resources/Locale/en-US/changeling/changeling-cloner-component.ftl b/Resources/Locale/en-US/changeling/changeling-cloner-component.ftl
new file mode 100644
index 0000000000..ffcab1e43c
--- /dev/null
+++ b/Resources/Locale/en-US/changeling/changeling-cloner-component.ftl
@@ -0,0 +1,11 @@
+changeling-cloner-component-empty = It is empty.
+changeling-cloner-component-filled = It has a DNA sample in it.
+changeling-cloner-component-spent = It has been used.
+
+changeling-cloner-component-reset-verb = Reset DNA
+changeling-cloner-component-reset-popup = You purge the injector's DNA storage.
+
+changeling-cloner-component-draw-user = You start drawing DNA from {THE($target)}.
+changeling-cloner-component-draw-target = {CAPITALIZE(THE($user))} starts drawing DNA from you.
+changeling-cloner-component-inject-user = You start injecting DNA into {THE($target)}.
+changeling-cloner-component-inject-target = {CAPITALIZE(THE($user))} starts injecting DNA into you.
diff --git a/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/dna_injector.yml b/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/dna_injector.yml
new file mode 100644
index 0000000000..16454c25c4
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/dna_injector.yml
@@ -0,0 +1,47 @@
+- type: entity
+ parent: BaseItem
+ id: DnaInjectorUnlimited
+ suffix: Admeme, unlimited
+ # Should not be a traitor item for several reasons:
+ # - Changeling code is still in development, and copying organs etc does not work yet.
+ # - Giving this to traitors makes them overlap with changelings or paradox clones too much.
+ # - It completely makes the voice mask redundant.
+ # - Unlike when disguising yourself as someone else, there is no way to get caught.
+ name: DNA injector
+ description: Can be used to take a DNA sample from someone and inject it into another person, turning them into a clone of the original.
+ components:
+ - type: Sprite
+ sprite: Objects/Specific/Medical/implanter.rsi
+ state: implanter0
+ layers:
+ - state: implanter0
+ map: [ "injector" ]
+ visible: true
+ - state: implanter1
+ map: [ "fillState" ]
+ visible: false
+ - type: Item
+ sprite: Objects/Specific/Medical/implanter.rsi
+ heldPrefix: implanter
+ size: Small
+ - type: Appearance
+ - type: GenericVisualizer
+ visuals:
+ enum.ChangelingClonerVisuals.State:
+ injector:
+ Empty: {state: implanter0}
+ Filled: {state: implanter0}
+ Spent: {state: broken}
+ fillState:
+ Empty: {visible: false}
+ Filled: {visible: true}
+ Spent: {visible: false}
+ - type: ChangelingCloner
+
+- type: entity
+ parent: DnaInjectorUnlimited
+ id: DnaInjector
+ suffix: Admeme, single use
+ components:
+ - type: ChangelingCloner
+ reusable: false