diff --git a/Content.Shared/KillTome/KillTomeComponent.cs b/Content.Shared/KillTome/KillTomeComponent.cs new file mode 100644 index 0000000000..266ff1a8f8 --- /dev/null +++ b/Content.Shared/KillTome/KillTomeComponent.cs @@ -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; + +/// +/// Paper with that component is KillTome. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class KillTomeComponent : Component +{ + /// + /// if delay is not specified, it will use this default value + /// + [DataField, AutoNetworkedField] + public TimeSpan DefaultKillDelay = TimeSpan.FromSeconds(40); + + /// + /// Damage specifier that will be used to kill the target. + /// + [DataField, AutoNetworkedField] + public DamageSpecifier Damage = new() + { + DamageDict = new Dictionary + { + { "Blunt", 200 } + } + }; + + /// + /// to keep a track of already killed people so they won't be killed again + /// + [DataField] + public HashSet KilledEntities = []; +} diff --git a/Content.Shared/KillTome/KillTomeSystem.cs b/Content.Shared/KillTome/KillTomeSystem.cs new file mode 100644 index 0000000000..bd49c483d9 --- /dev/null +++ b/Content.Shared/KillTome/KillTomeSystem.cs @@ -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; + +/// +/// This handles KillTome functionality. +/// + +/// 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!; + + /// + public override void Initialize() + { + SubscribeLocalEvent(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(); + + 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(uid); + } + } + + private void OnPaperAfterWriteInteract(Entity ent, ref PaperAfterWriteEvent args) + { + // if the entity is not a paper, we don't do anything + if (!TryComp(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(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(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(); + + 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 index 0000000000..14a573b75f --- /dev/null +++ b/Content.Shared/KillTome/KillTomeTargetComponent.cs @@ -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; + +/// +/// Entity with this component is a Kill Tome target. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause] +public sealed partial class KillTomeTargetComponent : Component +{ + /// + /// Damage that will be dealt to the target. + /// + [DataField, AutoNetworkedField] + public DamageSpecifier Damage = new() + { + DamageDict = new Dictionary + { + { "Blunt", 200 } + } + }; + + /// + /// The time when the target is killed. + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField] + [AutoPausedField] + public TimeSpan KillTime = TimeSpan.Zero; + + /// + /// Indicates this target has been killed by the killtome. + /// + [DataField, AutoNetworkedField] + public bool Dead; + + // Disallows cheat clients from seeing who is about to die to the killtome. + public override bool SendOnlyToOwner => true; +} diff --git a/Content.Shared/Paper/PaperSystem.cs b/Content.Shared/Paper/PaperSystem.cs index 75496d93b4..6a181e4ae9 100644 --- a/Content.Shared/Paper/PaperSystem.cs +++ b/Content.Shared/Paper/PaperSystem.cs @@ -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 ent, ref MapInitEvent args) @@ -319,6 +323,14 @@ public record struct PaperWriteEvent(EntityUid User, EntityUid Paper); /// /// Cancellable event for attempting to write on a piece of paper. /// -/// The paper that the writing will take place on. +/// The paper that the writing will take place on. [ByRefEvent] public record struct PaperWriteAttemptEvent(EntityUid Paper, string? FailReason = null, bool Cancelled = false); + +/// +/// Event raised on paper after it was written on by someone. +/// +/// Entity that wrote something on the paper. +[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 index 0000000000..2a45db41ff --- /dev/null +++ b/Resources/Locale/en-US/killtome.ftl @@ -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 index 0000000000..41339a9281 --- /dev/null +++ b/Resources/Prototypes/Entities/Objects/Misc/killtome.yml @@ -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 index 0000000000..b3f5427ebe 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 index 0000000000..b418b2056f --- /dev/null +++ b/Resources/Textures/Objects/Misc/killtome.rsi/meta.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "alexmactep", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "icon" + } + ] +}