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