]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Added Kill Tome (Death Note). (#39011)
authorxsainteer <156868231+xsainteer@users.noreply.github.com>
Fri, 25 Jul 2025 22:00:58 +0000 (04:00 +0600)
committerGitHub <noreply@github.com>
Fri, 25 Jul 2025 22:00:58 +0000 (00:00 +0200)
Co-authored-by: ScarKy0 <scarky0@onet.eu>
Content.Shared/KillTome/KillTomeComponent.cs [new file with mode: 0644]
Content.Shared/KillTome/KillTomeSystem.cs [new file with mode: 0644]
Content.Shared/KillTome/KillTomeTargetComponent.cs [new file with mode: 0644]
Content.Shared/Paper/PaperSystem.cs
Resources/Locale/en-US/killtome.ftl [new file with mode: 0644]
Resources/Prototypes/Entities/Objects/Misc/killtome.yml [new file with mode: 0644]
Resources/Textures/Objects/Misc/killtome.rsi/icon.png [new file with mode: 0644]
Resources/Textures/Objects/Misc/killtome.rsi/meta.json [new file with mode: 0644]

diff --git a/Content.Shared/KillTome/KillTomeComponent.cs b/Content.Shared/KillTome/KillTomeComponent.cs
new file mode 100644 (file)
index 0000000..266ff1a
--- /dev/null
@@ -0,0 +1,38 @@
+using Content.Shared.Damage;
+using Content.Shared.Damage.Prototypes;
+using Content.Shared.FixedPoint;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.KillTome;
+
+/// <summary>
+/// Paper with that component is KillTome.
+/// </summary>
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class KillTomeComponent : Component
+{
+    /// <summary>
+    /// if delay is not specified, it will use this default value
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public TimeSpan DefaultKillDelay = TimeSpan.FromSeconds(40);
+
+    /// <summary>
+    /// Damage specifier that will be used to kill the target.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public DamageSpecifier Damage = new()
+    {
+        DamageDict = new Dictionary<string, FixedPoint2>
+        {
+            { "Blunt", 200 }
+        }
+    };
+
+    /// <summary>
+    /// to keep a track of already killed people so they won't be killed again
+    /// </summary>
+    [DataField]
+    public HashSet<EntityUid> KilledEntities = [];
+}
diff --git a/Content.Shared/KillTome/KillTomeSystem.cs b/Content.Shared/KillTome/KillTomeSystem.cs
new file mode 100644 (file)
index 0000000..bd49c48
--- /dev/null
@@ -0,0 +1,187 @@
+using System.Diagnostics.CodeAnalysis;
+using Content.Shared.Administration.Logs;
+using Content.Shared.Damage;
+using Content.Shared.Database;
+using Content.Shared.Humanoid;
+using Content.Shared.Mobs;
+using Content.Shared.Mobs.Components;
+using Content.Shared.NameModifier.EntitySystems;
+using Content.Shared.Paper;
+using Content.Shared.Popups;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.KillTome;
+
+/// <summary>
+/// This handles KillTome functionality.
+/// </summary>
+
+///     Kill Tome Rules:
+// 1. The humanoid whose name is written in this note shall die.
+// 2. If the name is shared by multiple humanoids, a random humanoid with that name will die.
+// 3. Each name shall be written on a new line.
+// 4. Names must be written in the format: "Name, Delay (in seconds)" (e.g., John Doe, 40).
+// 5. A humanoid can be killed by the same Kill Tome only once.
+public sealed class KillTomeSystem : EntitySystem
+{
+    [Dependency] private readonly IGameTiming _gameTiming = default!;
+    [Dependency] private readonly SharedPopupSystem _popupSystem = default!;
+    [Dependency] private readonly DamageableSystem _damageSystem = default!;
+    [Dependency] private readonly ISharedAdminLogManager _adminLogs = default!;
+    [Dependency] private readonly NameModifierSystem _nameModifierSystem = default!;
+
+    /// <inheritdoc/>
+    public override void Initialize()
+    {
+        SubscribeLocalEvent<KillTomeComponent, PaperAfterWriteEvent>(OnPaperAfterWriteInteract);
+    }
+
+    public override void Update(float frameTime)
+    {
+        // Getting all the entities that are targeted by Kill Tome and checking if their kill time has passed.
+        // If it has, we kill them and remove the KillTomeTargetComponent.
+        var query = EntityQueryEnumerator<KillTomeTargetComponent>();
+
+        while (query.MoveNext(out var uid, out var targetComp))
+        {
+            if (_gameTiming.CurTime < targetComp.KillTime)
+                continue;
+
+            // The component doesn't get removed fast enough and the update loop will run through it a few more times.
+            // This check is here to ensure it will not spam popups or kill you several times over.
+            if (targetComp.Dead)
+                continue;
+
+            Kill(uid, targetComp);
+
+            _popupSystem.PopupPredicted(Loc.GetString("killtome-death"),
+                Loc.GetString("killtome-death-others", ("target", uid)),
+                uid,
+                uid,
+                PopupType.LargeCaution);
+
+            targetComp.Dead = true;
+
+            RemCompDeferred<KillTomeTargetComponent>(uid);
+        }
+    }
+
+    private void OnPaperAfterWriteInteract(Entity<KillTomeComponent> ent, ref PaperAfterWriteEvent args)
+    {
+        // if the entity is not a paper, we don't do anything
+        if (!TryComp<PaperComponent>(ent.Owner, out var paper))
+            return;
+
+        var content = paper.Content;
+
+        var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+        var showPopup = false;
+
+        foreach (var line in lines)
+        {
+            if (string.IsNullOrEmpty(line))
+                continue;
+
+            var parts = line.Split(',', 2, StringSplitOptions.RemoveEmptyEntries);
+
+            var name = parts[0].Trim();
+
+            var delay = ent.Comp.DefaultKillDelay;
+
+            if (parts.Length == 2 && Parse.TryInt32(parts[1].Trim(), out var parsedDelay) && parsedDelay > 0)
+                delay = TimeSpan.FromSeconds(parsedDelay);
+
+            if (!CheckIfEligible(name, ent.Comp, out var uid))
+            {
+                continue;
+            }
+
+            // Compiler will complain if we don't check for null here.
+            if (uid is not { } realUid)
+                continue;
+
+            showPopup = true;
+
+            EnsureComp<KillTomeTargetComponent>(realUid, out var targetComp);
+
+            targetComp.KillTime = _gameTiming.CurTime + delay;
+            targetComp.Damage = ent.Comp.Damage;
+
+            Dirty(realUid, targetComp);
+
+            ent.Comp.KilledEntities.Add(realUid);
+
+            Dirty(ent);
+
+            _adminLogs.Add(LogType.Chat,
+                LogImpact.High,
+                $"{ToPrettyString(args.Actor)} has written {ToPrettyString(uid)}'s name in Kill Tome.");
+        }
+
+        // If we have written at least one eligible name, we show the popup (So the player knows death note worked).
+        if (showPopup)
+            _popupSystem.PopupEntity(Loc.GetString("killtome-kill-success"), ent.Owner, args.Actor, PopupType.Large);
+    }
+
+    // A person to be killed by KillTome must:
+    // 1. be with the name
+    // 2. have HumanoidAppearanceComponent (so it targets only humanoids, obv)
+    // 3. not be already dead
+    // 4. not be already killed by Kill Tome
+
+    // If all these conditions are met, we return true and the entityUid of the person to kill.
+    private bool CheckIfEligible(string name, KillTomeComponent comp, [NotNullWhen(true)] out EntityUid? entityUid)
+    {
+        if (!TryFindEntityByName(name, out var uid) ||
+            !TryComp<MobStateComponent>(uid, out var mob))
+        {
+            entityUid = null;
+            return false;
+        }
+
+        if (uid is not { } realUid)
+        {
+            entityUid = null;
+            return false;
+        }
+
+        if (comp.KilledEntities.Contains(realUid))
+        {
+            entityUid = null;
+            return false;
+        }
+
+        if (mob.CurrentState == MobState.Dead)
+        {
+            entityUid = null;
+            return false;
+        }
+
+        entityUid = uid;
+        return true;
+    }
+
+    private bool TryFindEntityByName(string name, [NotNullWhen(true)] out EntityUid? entityUid)
+    {
+        var query = EntityQueryEnumerator<HumanoidAppearanceComponent>();
+
+        while (query.MoveNext(out var uid, out _))
+        {
+            if (!_nameModifierSystem.GetBaseName(uid).Equals(name, StringComparison.OrdinalIgnoreCase))
+                continue;
+
+            entityUid = uid;
+            return true;
+        }
+
+        entityUid = null;
+        return false;
+    }
+
+    private void Kill(EntityUid uid, KillTomeTargetComponent comp)
+    {
+        _damageSystem.TryChangeDamage(uid, comp.Damage, true);
+    }
+}
diff --git a/Content.Shared/KillTome/KillTomeTargetComponent.cs b/Content.Shared/KillTome/KillTomeTargetComponent.cs
new file mode 100644 (file)
index 0000000..14a573b
--- /dev/null
@@ -0,0 +1,41 @@
+using Content.Shared.Damage;
+using Content.Shared.FixedPoint;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.KillTome;
+
+/// <summary>
+/// Entity with this component is a Kill Tome target.
+/// </summary>
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause]
+public sealed partial class KillTomeTargetComponent : Component
+{
+    ///<summary>
+    /// Damage that will be dealt to the target.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public DamageSpecifier Damage = new()
+    {
+        DamageDict = new Dictionary<string, FixedPoint2>
+        {
+            { "Blunt", 200 }
+        }
+    };
+
+    /// <summary>
+    /// The time when the target is killed.
+    /// </summary>
+    [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField]
+    [AutoPausedField]
+    public TimeSpan KillTime = TimeSpan.Zero;
+
+    /// <summary>
+    /// Indicates this target has been killed by the killtome.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public bool Dead;
+
+    // Disallows cheat clients from seeing who is about to die to the killtome.
+    public override bool SendOnlyToOwner => true;
+}
index 75496d93b4083fd50c0fe67565c6a82cd5d6f9c4..6a181e4ae916dc842ac43581e46be9a5e3c23c9a 100644 (file)
@@ -187,6 +187,7 @@ public sealed class PaperSystem : EntitySystem
     {
         var ev = new PaperWriteAttemptEvent(entity.Owner);
         RaiseLocalEvent(args.Actor, ref ev);
+
         if (ev.Cancelled)
             return;
 
@@ -211,6 +212,9 @@ public sealed class PaperSystem : EntitySystem
 
         entity.Comp.Mode = PaperAction.Read;
         UpdateUserInterface(entity);
+
+        var writeAfterEv = new PaperAfterWriteEvent(args.Actor);
+        RaiseLocalEvent(entity.Owner, ref writeAfterEv);
     }
 
     private void OnRandomPaperContentMapInit(Entity<RandomPaperContentComponent> ent, ref MapInitEvent args)
@@ -319,6 +323,14 @@ public record struct PaperWriteEvent(EntityUid User, EntityUid Paper);
 /// <summary>
 /// Cancellable event for attempting to write on a piece of paper.
 /// </summary>
-/// <param name="paper">The paper that the writing will take place on.</param>
+/// <param name="Paper">The paper that the writing will take place on.</param>
 [ByRefEvent]
 public record struct PaperWriteAttemptEvent(EntityUid Paper, string? FailReason = null, bool Cancelled = false);
+
+/// <summary>
+/// Event raised on paper after it was written on by someone.
+/// </summary>
+/// <param name="Actor">Entity that wrote something on the paper.</param>
+[ByRefEvent]
+public readonly record struct PaperAfterWriteEvent(EntityUid Actor);
+
diff --git a/Resources/Locale/en-US/killtome.ftl b/Resources/Locale/en-US/killtome.ftl
new file mode 100644 (file)
index 0000000..2a45db4
--- /dev/null
@@ -0,0 +1,11 @@
+killtome-rules =
+    Kill Tome Rules:
+    1. The humanoid whose name is written in this note shall die.
+    2. If the name is shared by multiple humanoids, a random humanoid with that name will die.
+    3. Each name shall be written on a new line.
+    4. Names must be written in the format: "Name, Delay (in seconds)" (e.g., John Doe, 40).
+    5. A humanoid can be killed by the same Kill Tome only once.
+
+killtome-kill-success = The name is written. The countdown begins.
+killtome-death = You feel sudden pain in your chest!
+killtome-death-others = {CAPITALIZE($target)} grabs onto {POSS-ADJ($target)} chest and falls to the ground!
diff --git a/Resources/Prototypes/Entities/Objects/Misc/killtome.yml b/Resources/Prototypes/Entities/Objects/Misc/killtome.yml
new file mode 100644 (file)
index 0000000..41339a9
--- /dev/null
@@ -0,0 +1,24 @@
+- type: entity
+  name: black tome
+  parent: BasePaper
+  id: KillTome
+  suffix: KillTome, Admeme # To stay true to the lore, please never make this accessible outside of divine intervention (admeme).
+  description: A worn black tome. It smells like old paper.
+  components:
+  - type: Sprite
+    sprite: Objects/Misc/killtome.rsi
+    state: icon
+  - type: KillTome
+    defaultKillDelay: 40
+    damage:
+      types:
+        Blunt: 200
+  - type: Paper
+    content: killtome-rules
+  - type: ActivatableUI
+    key: enum.PaperUiKey.Key
+    requiresComplex: false
+  - type: UserInterface
+    interfaces:
+      enum.PaperUiKey.Key:
+        type: PaperBoundUserInterface
diff --git a/Resources/Textures/Objects/Misc/killtome.rsi/icon.png b/Resources/Textures/Objects/Misc/killtome.rsi/icon.png
new file mode 100644 (file)
index 0000000..b3f5427
Binary files /dev/null and b/Resources/Textures/Objects/Misc/killtome.rsi/icon.png differ
diff --git a/Resources/Textures/Objects/Misc/killtome.rsi/meta.json b/Resources/Textures/Objects/Misc/killtome.rsi/meta.json
new file mode 100644 (file)
index 0000000..b418b20
--- /dev/null
@@ -0,0 +1,14 @@
+{
+  "version": 1,
+  "license": "CC-BY-SA-3.0",
+  "copyright": "alexmactep",
+  "size": {
+    "x": 32,
+    "y": 32
+  },
+  "states": [
+      {
+          "name": "icon"
+      }
+  ]
+}