diff --git a/Content.Client/Clothing/Systems/CursedMaskSystem.cs b/Content.Client/Clothing/Systems/CursedMaskSystem.cs
new file mode 100644
index 0000000000..bc931d15fd
--- /dev/null
+++ b/Content.Client/Clothing/Systems/CursedMaskSystem.cs
@@ -0,0 +1,6 @@
+using Content.Shared.Clothing;
+
+namespace Content.Client.Clothing.Systems;
+
+///
+public sealed class CursedMaskSystem : SharedCursedMaskSystem;
diff --git a/Content.Server/Clothing/Systems/CursedMaskSystem.cs b/Content.Server/Clothing/Systems/CursedMaskSystem.cs
new file mode 100644
index 0000000000..2045ff5ccd
--- /dev/null
+++ b/Content.Server/Clothing/Systems/CursedMaskSystem.cs
@@ -0,0 +1,92 @@
+using Content.Server.Administration.Logs;
+using Content.Server.GameTicking;
+using Content.Server.Mind;
+using Content.Server.NPC;
+using Content.Server.NPC.HTN;
+using Content.Server.NPC.Systems;
+using Content.Server.Popups;
+using Content.Shared.Clothing;
+using Content.Shared.Clothing.Components;
+using Content.Shared.Database;
+using Content.Shared.NPC.Components;
+using Content.Shared.NPC.Systems;
+using Content.Shared.Players;
+using Content.Shared.Popups;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Clothing.Systems;
+
+///
+public sealed class CursedMaskSystem : SharedCursedMaskSystem
+{
+ [Dependency] private readonly IAdminLogManager _adminLog = default!;
+ [Dependency] private readonly GameTicker _ticker = default!;
+ [Dependency] private readonly HTNSystem _htn = default!;
+ [Dependency] private readonly MindSystem _mind = default!;
+ [Dependency] private readonly NPCSystem _npc = default!;
+ [Dependency] private readonly NpcFactionSystem _npcFaction = default!;
+ [Dependency] private readonly PopupSystem _popup = default!;
+
+ // We can't store this info on the component easily
+ private static readonly ProtoId TakeoverRootTask = "SimpleHostileCompound";
+
+ protected override void TryTakeover(Entity ent, EntityUid wearer)
+ {
+ if (ent.Comp.CurrentState != CursedMaskExpression.Anger)
+ return;
+
+ if (TryComp(wearer, out var actor) && actor.PlayerSession.GetMind() is { } mind)
+ {
+ var session = actor.PlayerSession;
+ if (!_ticker.OnGhostAttempt(mind, false))
+ return;
+
+ ent.Comp.StolenMind = mind;
+
+ _popup.PopupEntity(Loc.GetString("cursed-mask-takeover-popup"), wearer, session, PopupType.LargeCaution);
+ _adminLog.Add(LogType.Action,
+ LogImpact.Extreme,
+ $"{ToPrettyString(wearer):player} had their body taken over and turned into an enemy through the cursed mask {ToPrettyString(ent):entity}");
+ }
+
+ var npcFaction = EnsureComp(wearer);
+ ent.Comp.OldFactions = npcFaction.Factions;
+ _npcFaction.ClearFactions((wearer, npcFaction), false);
+ _npcFaction.AddFaction((wearer, npcFaction), ent.Comp.CursedMaskFaction);
+
+ ent.Comp.HasNpc = !EnsureComp(wearer, out var htn);
+ htn.RootTask = new HTNCompoundTask { Task = TakeoverRootTask };
+ htn.Blackboard.SetValue(NPCBlackboard.Owner, wearer);
+ _npc.WakeNPC(wearer, htn);
+ _htn.Replan(htn);
+ }
+
+ protected override void OnClothingUnequip(Entity ent, ref ClothingGotUnequippedEvent args)
+ {
+ // If we are taking off the cursed mask
+ if (ent.Comp.CurrentState == CursedMaskExpression.Anger)
+ {
+ if (ent.Comp.HasNpc)
+ RemComp(args.Wearer);
+
+ var npcFaction = EnsureComp(args.Wearer);
+ _npcFaction.RemoveFaction((args.Wearer, npcFaction), ent.Comp.CursedMaskFaction, false);
+ _npcFaction.AddFactions((args.Wearer, npcFaction), ent.Comp.OldFactions);
+
+ ent.Comp.HasNpc = false;
+ ent.Comp.OldFactions.Clear();
+
+ if (Exists(ent.Comp.StolenMind))
+ {
+ _mind.TransferTo(ent.Comp.StolenMind.Value, args.Wearer);
+ _adminLog.Add(LogType.Action,
+ LogImpact.Extreme,
+ $"{ToPrettyString(args.Wearer):player} was restored to their body after the removal of {ToPrettyString(ent):entity}.");
+ ent.Comp.StolenMind = null;
+ }
+ }
+
+ RandomizeCursedMask(ent, args.Wearer);
+ }
+}
diff --git a/Content.Shared/Clothing/Components/CursedMaskComponent.cs b/Content.Shared/Clothing/Components/CursedMaskComponent.cs
new file mode 100644
index 0000000000..6073bdf5fc
--- /dev/null
+++ b/Content.Shared/Clothing/Components/CursedMaskComponent.cs
@@ -0,0 +1,65 @@
+using Content.Shared.Damage;
+using Content.Shared.NPC.Prototypes;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Clothing.Components;
+
+///
+/// This is used for a mask that takes over the host when worn.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedCursedMaskSystem))]
+public sealed partial class CursedMaskComponent : Component
+{
+ ///
+ /// The current expression shown. Used to determine which effect is applied.
+ ///
+ [DataField]
+ public CursedMaskExpression CurrentState = CursedMaskExpression.Neutral;
+
+ ///
+ /// Speed modifier applied when the "Joy" expression is present.
+ ///
+ [DataField]
+ public float JoySpeedModifier = 1.15f;
+
+ ///
+ /// Damage modifier applied when the "Despair" expression is present.
+ ///
+ [DataField]
+ public DamageModifierSet DespairDamageModifier = new();
+
+ ///
+ /// Whether or not the mask is currently attached to an NPC.
+ ///
+ [DataField]
+ public bool HasNpc;
+
+ ///
+ /// The mind that was booted from the wearer when the mask took over.
+ ///
+ [DataField]
+ public EntityUid? StolenMind;
+
+ [DataField]
+ public ProtoId CursedMaskFaction = "SimpleHostile";
+
+ [DataField]
+ public HashSet> OldFactions = new();
+}
+
+[Serializable, NetSerializable]
+public enum CursedMaskVisuals : byte
+{
+ State
+}
+
+[Serializable, NetSerializable]
+public enum CursedMaskExpression : byte
+{
+ Neutral,
+ Joy,
+ Despair,
+ Anger
+}
diff --git a/Content.Shared/Clothing/SharedCursedMaskSystem.cs b/Content.Shared/Clothing/SharedCursedMaskSystem.cs
new file mode 100644
index 0000000000..8ba83be151
--- /dev/null
+++ b/Content.Shared/Clothing/SharedCursedMaskSystem.cs
@@ -0,0 +1,73 @@
+using Content.Shared.Clothing.Components;
+using Content.Shared.Damage;
+using Content.Shared.Examine;
+using Content.Shared.Inventory;
+using Content.Shared.Movement.Systems;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Shared.Clothing;
+
+///
+/// This handles
+///
+public abstract class SharedCursedMaskSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly MovementSpeedModifierSystem _movementSpeedModifier = default!;
+
+ ///
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnClothingEquip);
+ SubscribeLocalEvent(OnClothingUnequip);
+ SubscribeLocalEvent(OnExamine);
+
+ SubscribeLocalEvent>(OnMovementSpeedModifier);
+ SubscribeLocalEvent>(OnModifyDamage);
+ }
+
+ private void OnClothingEquip(Entity ent, ref ClothingGotEquippedEvent args)
+ {
+ RandomizeCursedMask(ent, args.Wearer);
+ TryTakeover(ent, args.Wearer);
+ }
+
+ protected virtual void OnClothingUnequip(Entity ent, ref ClothingGotUnequippedEvent args)
+ {
+ RandomizeCursedMask(ent, args.Wearer);
+ }
+
+ private void OnExamine(Entity ent, ref ExaminedEvent args)
+ {
+ args.PushMarkup(Loc.GetString($"cursed-mask-examine-{ent.Comp.CurrentState.ToString()}"));
+ }
+
+ private void OnMovementSpeedModifier(Entity ent, ref InventoryRelayedEvent args)
+ {
+ if (ent.Comp.CurrentState == CursedMaskExpression.Joy)
+ args.Args.ModifySpeed(ent.Comp.JoySpeedModifier);
+ }
+
+ private void OnModifyDamage(Entity ent, ref InventoryRelayedEvent args)
+ {
+ if (ent.Comp.CurrentState == CursedMaskExpression.Despair)
+ args.Args.Damage = DamageSpecifier.ApplyModifierSet(args.Args.Damage, ent.Comp.DespairDamageModifier);
+ }
+
+ protected void RandomizeCursedMask(Entity ent, EntityUid wearer)
+ {
+ var random = new System.Random((int) _timing.CurTick.Value);
+ ent.Comp.CurrentState = random.Pick(Enum.GetValues());
+ _appearance.SetData(ent, CursedMaskVisuals.State, ent.Comp.CurrentState);
+ _movementSpeedModifier.RefreshMovementSpeedModifiers(wearer);
+ }
+
+ protected virtual void TryTakeover(Entity ent, EntityUid wearer)
+ {
+
+ }
+}
diff --git a/Content.Shared/Inventory/Events/UnequipAttemptEvent.cs b/Content.Shared/Inventory/Events/UnequipAttemptEvent.cs
index d8d0a2a23b..b647ee8e92 100644
--- a/Content.Shared/Inventory/Events/UnequipAttemptEvent.cs
+++ b/Content.Shared/Inventory/Events/UnequipAttemptEvent.cs
@@ -17,6 +17,11 @@ public abstract class UnequipAttemptEventBase : CancellableEntityEventArgs
///
public readonly EntityUid Equipment;
+ ///
+ /// The slotFlags of the slot this item is being removed from.
+ ///
+ public readonly SlotFlags SlotFlags;
+
///
/// The slot the entity is being unequipped from.
///
@@ -33,6 +38,7 @@ public abstract class UnequipAttemptEventBase : CancellableEntityEventArgs
UnEquipTarget = unEquipTarget;
Equipment = equipment;
Unequipee = unequipee;
+ SlotFlags = slotDefinition.SlotFlags;
Slot = slotDefinition.Name;
}
}
diff --git a/Content.Shared/Inventory/SelfEquipOnlyComponent.cs b/Content.Shared/Inventory/SelfEquipOnlyComponent.cs
new file mode 100644
index 0000000000..ee1980ef8a
--- /dev/null
+++ b/Content.Shared/Inventory/SelfEquipOnlyComponent.cs
@@ -0,0 +1,16 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Inventory;
+
+///
+/// This is used for an item that can only be equipped/unequipped by the user.
+///
+[RegisterComponent, NetworkedComponent, Access(typeof(SelfEquipOnlySystem))]
+public sealed partial class SelfEquipOnlyComponent : Component
+{
+ ///
+ /// Whether or not the self-equip only condition requires the person to be conscious.
+ ///
+ [DataField]
+ public bool UnequipRequireConscious = true;
+}
diff --git a/Content.Shared/Inventory/SelfEquipOnlySystem.cs b/Content.Shared/Inventory/SelfEquipOnlySystem.cs
new file mode 100644
index 0000000000..2bd113e22b
--- /dev/null
+++ b/Content.Shared/Inventory/SelfEquipOnlySystem.cs
@@ -0,0 +1,45 @@
+using Content.Shared.ActionBlocker;
+using Content.Shared.Clothing.Components;
+using Content.Shared.Inventory.Events;
+
+namespace Content.Shared.Inventory;
+
+public sealed class SelfEquipOnlySystem : EntitySystem
+{
+ [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnBeingEquipped);
+ SubscribeLocalEvent(OnBeingUnequipped);
+ }
+
+ private void OnBeingEquipped(Entity ent, ref BeingEquippedAttemptEvent args)
+ {
+ if (args.Cancelled)
+ return;
+
+ if (TryComp(ent, out var clothing) && (clothing.Slots & args.SlotFlags) == SlotFlags.NONE)
+ return;
+
+ if (args.Equipee != args.EquipTarget)
+ args.Cancel();
+ }
+
+ private void OnBeingUnequipped(Entity ent, ref BeingUnequippedAttemptEvent args)
+ {
+ if (args.Cancelled)
+ return;
+
+ if (args.Unequipee == args.UnEquipTarget)
+ return;
+
+ if (TryComp(ent, out var clothing) && (clothing.Slots & args.SlotFlags) == SlotFlags.NONE)
+ return;
+
+ if (ent.Comp.UnequipRequireConscious && !_actionBlocker.CanConsciouslyPerformAction(args.UnEquipTarget))
+ return;
+ args.Cancel();
+ }
+}
diff --git a/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs b/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs
index 7c793d5eb8..8e89e4b62b 100644
--- a/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs
+++ b/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs
@@ -69,5 +69,10 @@ namespace Content.Shared.Movement.Systems
WalkSpeedModifier *= walk;
SprintSpeedModifier *= sprint;
}
+
+ public void ModifySpeed(float mod)
+ {
+ ModifySpeed(mod, mod);
+ }
}
}
diff --git a/Content.Shared/NPC/Systems/NpcFactionSystem.cs b/Content.Shared/NPC/Systems/NpcFactionSystem.cs
index 355f5bbb3a..98f14afe2a 100644
--- a/Content.Shared/NPC/Systems/NpcFactionSystem.cs
+++ b/Content.Shared/NPC/Systems/NpcFactionSystem.cs
@@ -100,6 +100,28 @@ public sealed partial class NpcFactionSystem : EntitySystem
RefreshFactions((ent, ent.Comp));
}
+ ///
+ /// Adds this entity to the particular faction.
+ ///
+ public void AddFactions(Entity ent, HashSet> factions, bool dirty = true)
+ {
+ ent.Comp ??= EnsureComp(ent);
+
+ foreach (var faction in factions)
+ {
+ if (!_proto.HasIndex(faction))
+ {
+ Log.Error($"Unable to find faction {faction}");
+ continue;
+ }
+
+ ent.Comp.Factions.Add(faction);
+ }
+
+ if (dirty)
+ RefreshFactions((ent, ent.Comp));
+ }
+
///
/// Removes this entity from the particular faction.
///
diff --git a/Resources/Locale/en-US/clothing/components/cursed-mask.ftl b/Resources/Locale/en-US/clothing/components/cursed-mask.ftl
new file mode 100644
index 0000000000..c93a6cfb87
--- /dev/null
+++ b/Resources/Locale/en-US/clothing/components/cursed-mask.ftl
@@ -0,0 +1,5 @@
+cursed-mask-examine-Neutral = It depicts an entirely unremarkable visage.
+cursed-mask-examine-Joy = It depicts a face basking in joy.
+cursed-mask-examine-Despair = It depicts a face wraught with despair.
+cursed-mask-examine-Anger = It depicts a furious expression locked in rage.
+cursed-mask-takeover-popup = The mask seizes control over your body!
diff --git a/Resources/Prototypes/Entities/Clothing/Masks/specific.yml b/Resources/Prototypes/Entities/Clothing/Masks/specific.yml
index c3a07fa8e9..64a1adcebd 100644
--- a/Resources/Prototypes/Entities/Clothing/Masks/specific.yml
+++ b/Resources/Prototypes/Entities/Clothing/Masks/specific.yml
@@ -62,3 +62,41 @@
- type: HideLayerClothing
slots:
- Snout
+
+- type: entity
+ parent: ClothingMaskBase
+ id: ClothingMaskGoldenCursed
+ name: golden mask
+ description: Previously used in strange pantomimes, after one of the actors went mad on stage these masks have avoided use. You swear its face contorts when you're not looking.
+ components:
+ - type: Sprite
+ sprite: Clothing/Mask/goldenmask.rsi
+ layers:
+ - state: icon
+ map: [ "mask" ]
+ - type: Clothing
+ sprite: Clothing/Mask/goldenmask.rsi
+ - type: Appearance
+ - type: GenericVisualizer
+ visuals:
+ enum.CursedMaskVisuals.State:
+ mask:
+ Neutral: { state: icon }
+ Despair: { state: icon-despair }
+ Joy: { state: icon-joy }
+ Anger: { state: icon-anger }
+ - type: Tag
+ tags: [] # ignore "WhitelistChameleon" tag
+ - type: SelfEquipOnly
+ - type: CursedMask
+ despairDamageModifier:
+ coefficients:
+ Blunt: 0.6
+ Slash: 0.6
+ Piercing: 0.4
+ - type: HideLayerClothing
+ slots:
+ - Snout
+ - type: IngestionBlocker
+ - type: StaticPrice
+ price: 5000
diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK-vox.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK-vox.png
new file mode 100644
index 0000000000..1b4db3d093
Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK-vox.png differ
diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK.png
new file mode 100644
index 0000000000..d1353c4980
Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK.png differ
diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-anger.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-anger.png
new file mode 100644
index 0000000000..5e002cadcf
Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-anger.png differ
diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-despair.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-despair.png
new file mode 100644
index 0000000000..71bdd72fc6
Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-despair.png differ
diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-joy.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-joy.png
new file mode 100644
index 0000000000..93d0e26ddd
Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-joy.png differ
diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon.png
new file mode 100644
index 0000000000..1da86e3c82
Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon.png differ
diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-left.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-left.png
new file mode 100644
index 0000000000..3ce2895a39
Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-left.png differ
diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-right.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-right.png
new file mode 100644
index 0000000000..ba71330276
Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-right.png differ
diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/meta.json b/Resources/Textures/Clothing/Mask/goldenmask.rsi/meta.json
new file mode 100644
index 0000000000..62072e7107
--- /dev/null
+++ b/Resources/Textures/Clothing/Mask/goldenmask.rsi/meta.json
@@ -0,0 +1,39 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from tgstation at commit https://github.com/vgstation-coders/vgstation13/blob/HEAD/icons/obj/clothing/masks.dmi. Vox and Reptilian edits by EmoGarbage404 (Github)",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "icon"
+ },
+ {
+ "name": "icon-joy"
+ },
+ {
+ "name": "icon-despair"
+ },
+ {
+ "name": "icon-anger"
+ },
+ {
+ "name": "equipped-MASK",
+ "directions": 4
+ },
+ {
+ "name": "equipped-MASK-vox",
+ "directions": 4
+ },
+ {
+ "name": "inhand-left",
+ "directions": 4
+ },
+ {
+ "name": "inhand-right",
+ "directions": 4
+ }
+ ]
+}