diff --git a/Content.Server/_Arcane/AggressionInhibitor/Systems/AggressionInhibitorSystem.cs b/Content.Server/_Arcane/AggressionInhibitor/Systems/AggressionInhibitorSystem.cs new file mode 100644 index 00000000000..d03477a4b32 --- /dev/null +++ b/Content.Server/_Arcane/AggressionInhibitor/Systems/AggressionInhibitorSystem.cs @@ -0,0 +1,297 @@ +using Content.Shared._Arcane.AggressionInhibitor.Components; +using Robust.Shared.Containers; +using Content.Shared.CombatMode; +using Content.Shared.Popups; +using Content.Shared.Interaction; +using Content.Shared.Access.Components; +using Content.Server.Access.Systems; +using Robust.Shared.Audio.Systems; +using Content.Server.Administration; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.Inventory; +using Robust.Server.Player; +using Robust.Shared.Timing; + +namespace Content.Server._Arcane.AggressionInhibitor.Systems; + +public sealed partial class AggressionInhibitorSystem : EntitySystem +{ + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private InventorySystem _inventorySystem = default!; + [Dependency] private SharedTransformSystem _transformSystem = default!; + [Dependency] private SharedPopupSystem _popup = default!; + [Dependency] private IdCardSystem _idCard = default!; + [Dependency] private SharedAudioSystem _audio = default!; + [Dependency] private SharedHandsSystem _handsSystem = default!; + [Dependency] private QuickDialogSystem _quickDialog = default!; + [Dependency] private SharedCombatModeSystem _combatMode = default!; + [Dependency] private SharedContainerSystem _containerSystem = default!; + [Dependency] private IPlayerManager _playerManager = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnInteractUsing); + SubscribeLocalEvent(OnOpenDialogReceived); + SubscribeLocalEvent(OnToggleLockReceived); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + var now = _timing.CurTime; + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var comp)) + { + if (now < comp.NextUpdate || !comp.IsActive || comp.WearingEntity == null) + continue; + + if (!RemoveInhibitor(uid, comp)) + continue; + + Dirty(uid, comp); + } + } + + private void OnInteractUsing(EntityUid uid, AggressionInhibitorComponent comp, InteractUsingEvent args) + { + var user = args.User; + + if (!TryComp(uid, out var xform)) + return; + + var parent = xform.ParentUid; + + if (!parent.IsValid() && HasComp(parent)) + { + PlaybackDenySound(uid, comp); + + args.Handled = true; + return; + } + + if (!_idCard.TryFindIdCard(args.Used, out var idCard) || + !TryComp(idCard.Owner, out var accessComp)) + return; + + if (comp.IsLocked) + { + if (GetHasUnlockAccess(comp, accessComp.Tags)) + { + if (!RemoveInhibitor(uid, comp)) + return; + + args.Handled = true; + return; + } + else + PlaybackDenySound(uid, comp); + } + else + { + if (GetHasLockAccess(comp, accessComp.Tags)) + { + if (!ActivateInhibitor(uid, parent, comp, user)) + return; + + args.Handled = true; + return; + } + else + PlaybackDenySound(uid, comp); + } + } + + public void OpenDialog(EntityUid uid, AggressionInhibitorComponent comp, EntityUid user) + { + EntityUid? parent = _containerSystem.TryGetContainingContainer((uid, null, null), out var container) + ? container.Owner + : null; + + var targetEntity = parent ?? uid; + if (!_transformSystem.InRange(user, targetEntity, 2f)) + return; + + if (!_handsSystem.TryGetActiveItem(user, out var heldItem) || + !_idCard.TryFindIdCard(heldItem.Value, out var idCard) || + !TryComp(idCard.Owner, out var accessComp)) + return; + + if (comp.IsLocked) + return; + + if (!GetHasLockAccess(comp, accessComp.Tags)) + { + PlaybackDenySound(uid, comp); + return; + } + + if (!_playerManager.TryGetSessionByEntity(user, out var session)) + return; + + _quickDialog.OpenDialog(session, Loc.GetString("stabikor-dialog-title"), Loc.GetString("stabikor-dialog-field") + "\n", (string input) => + { + if (!EntityManager.EntityExists(uid) || comp.IsLocked) + return; + + if (string.IsNullOrEmpty(input)) + { + comp.Duration = 60f; + comp.NextUpdate = _timing.CurTime + TimeSpan.FromSeconds(comp.Duration); + + Dirty(uid, comp); + _popup.PopupEntity(Loc.GetString("stabikor-duration-set-cancel-fallback", ("time", 1)), uid, user); + return; + } + + if (!int.TryParse(input, out var durationMinutes) || durationMinutes < 1 || durationMinutes > 900) + { + _popup.PopupEntity(Loc.GetString("stabikor-dialog-invalid-range"), user, user, PopupType.SmallCaution); + PlaybackDenySound(uid, comp); + return; + } + + comp.Duration = durationMinutes * 60f; + comp.NextUpdate = _timing.CurTime + TimeSpan.FromMinutes(durationMinutes); + + _popup.PopupEntity(Loc.GetString("stabikor-duration-set-success", ("time", durationMinutes)), uid, user); + PlaybackUnlockSound(uid, comp); + + Dirty(uid, comp); + }); + } + + public void ToggleLock(EntityUid uid, AggressionInhibitorComponent comp, EntityUid user) + { + EntityUid? parent = _containerSystem.TryGetContainingContainer((uid, null, null), out var container) + ? container.Owner + : null; + + var targetEntity = parent ?? uid; + if (!_transformSystem.InRange(user, targetEntity, 2f)) + return; + + if (!_handsSystem.TryGetActiveItem(user, out var heldItem) || + !_idCard.TryFindIdCard(heldItem.Value, out var idCard) || + !TryComp(idCard.Owner, out var accessComp)) + return; + + if (comp.IsLocked) + { + if (GetHasUnlockAccess(comp, accessComp.Tags)) + { + if (!RemoveInhibitor(uid, comp)) + return; + } + else + PlaybackDenySound(uid, comp); + } + else + { + if (GetHasLockAccess(comp, accessComp.Tags)) + { + if (!ActivateInhibitor(uid, parent ?? uid, comp, user)) + return; + } + else + PlaybackDenySound(uid, comp); + } + } + + private bool ActivateInhibitor(EntityUid uid, EntityUid wearerUid, AggressionInhibitorComponent comp, EntityUid user) + { + if (comp.IsActive) + return false; + + if (_inventorySystem.TryGetContainingSlot(uid, out var slotDef)) + { + if ((slotDef.SlotFlags & SlotFlags.POCKET) != 0) + return false; + + if (_inventorySystem.TryGetSlotEntity(wearerUid, slotDef.Name, out var slotItem) && slotItem == uid) + { + comp.NextUpdate = _timing.CurTime + TimeSpan.FromSeconds(comp.Duration); + comp.IsLocked = true; + comp.IsActive = true; + comp.WearingEntity = wearerUid; + _combatMode.SetInCombatMode(wearerUid, false); + + Dirty(uid, comp); + + PlaybackLockSound(uid, comp); + + _popup.PopupEntity(Loc.GetString("stabikor-activated-success", ("item", uid), ("user", Name(wearerUid))), uid); + + return true; + } + } + PlaybackDenySound(uid, comp); + + _popup.PopupEntity(Loc.GetString("stabikor-not-equipped"), uid, user); + return false; + } + + private bool RemoveInhibitor(EntityUid uid, AggressionInhibitorComponent comp) + { + if (comp.WearingEntity is not { Valid: true } user) + return false; + + if (_containerSystem.TryGetContainingContainer(uid, out var container)) + { + if (!_containerSystem.TryRemoveFromContainer(uid, force: true)) + return false; + + _transformSystem.SetCoordinates(uid, _transformSystem.GetMoverCoordinates(user)); + } + + comp.NextUpdate = TimeSpan.MaxValue; + comp.IsLocked = false; + comp.IsActive = false; + comp.WearingEntity = null; + + Dirty(uid, comp); + + PlaybackUnlockSound(uid, comp); + + _popup.PopupEntity(Loc.GetString("stabikor-moment-shutdown", ("item", uid)), uid); + + return true; + } + + private static bool GetHasLockAccess(AggressionInhibitorComponent comp, HashSet> cardAccess) + { + return comp.LockAccess.Exists(proto => cardAccess.Contains(proto.Id)); + } + + private static bool GetHasUnlockAccess(AggressionInhibitorComponent comp, HashSet> cardAccess) + { + return comp.UnlockAccess.Exists(proto => cardAccess.Contains(proto.Id)); + } + + private void PlaybackDenySound(EntityUid uid, AggressionInhibitorComponent comp) + { + _audio.PlayPvs(comp.DenySound, uid); + } + + private void PlaybackUnlockSound(EntityUid uid, AggressionInhibitorComponent comp) + { + _audio.PlayPvs(comp.UnlockSound, uid); + } + + private void PlaybackLockSound(EntityUid uid, AggressionInhibitorComponent comp) + { + _audio.PlayPvs(comp.LockSound, uid); + } + + private void OnOpenDialogReceived(EntityUid uid, AggressionInhibitorComponent comp, OpenDialogEvent args) + { + OpenDialog(uid, comp, args.User); + } + + private void OnToggleLockReceived(EntityUid uid, AggressionInhibitorComponent comp, ToggleLockEvent args) + { + ToggleLock(uid, comp, args.User); + } +} diff --git a/Content.Server/_Arcane/CuttableItem/Systems/CuttableItemSystem.cs b/Content.Server/_Arcane/CuttableItem/Systems/CuttableItemSystem.cs new file mode 100644 index 00000000000..28401f06eff --- /dev/null +++ b/Content.Server/_Arcane/CuttableItem/Systems/CuttableItemSystem.cs @@ -0,0 +1,75 @@ +using Content.Server.Radio.EntitySystems; +using Content.Shared.Radio; +using Content.Shared._Arcane.CuttableItem.Components; +using Robust.Shared.Prototypes; +using Content.Shared.CuttableItem; +using Content.Shared.Popups; +using Content.Shared.Inventory; + +namespace Content.Server._Arcane.CuttableItem.Systems; + +public sealed partial class CuttableItemSystem : EntitySystem +{ + [Dependency] private RadioSystem _radio = default!; + [Dependency] private IPrototypeManager _prototypeManager = default!; + [Dependency] private SharedPopupSystem _popup = default!; + [Dependency] private InventorySystem _inventorySystem = default!; + [Dependency] private SharedTransformSystem _transformSystem = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnItemCut); + SubscribeLocalEvent(OnCutCompleted); + } + + private void OnCutCompleted(EntityUid uid, CuttableItemComponent comp, CuttableDoAfterEvent args) + { + if (args.Cancelled || args.Handled) + return; + + args.Handled = true; + + var victim = Transform(uid).ParentUid; + if (!victim.IsValid()) + return; + + if (!_inventorySystem.TryGetSlots(victim, out var slotDefinitions)) + return; + + foreach (var slotDef in slotDefinitions) + { + if (!_inventorySystem.TryGetSlotEntity(victim, slotDef.Name, out var slotEntity) || slotEntity != uid) + continue; + + var target = args.User; + + if (!_inventorySystem.TryUnequip(target, victim, slotDef.Name, force: true)) + continue; + + _transformSystem.AttachToGridOrMap(uid); + + var victimCoords = Transform(victim).Coordinates; + _transformSystem.SetCoordinates(uid, victimCoords); + + _popup.PopupEntity(Loc.GetString("cuttable-item-broken-moment-popup", ("item", uid)), uid); + + var ev = new CuttableCutEvent(target); + RaiseLocalEvent(uid, ev); + } + } + + private void OnItemCut(EntityUid uid, CuttableItemComponent comp, CuttableCutEvent args) + { + if (!_prototypeManager.TryIndex(comp.RadioChannel, out var channel)) + return; + + var userName = Name(args.User); + var userItem = Name(uid); + + var message = Loc.GetString(comp.AlertMessage, ("user", userName), ("item", userItem)); + + _radio.SendRadioMessage(uid, message, channel, uid); + } +} diff --git a/Content.Shared/_Arcane/AggressionInhibitor/Components/AggressionInhibitorComponent.cs b/Content.Shared/_Arcane/AggressionInhibitor/Components/AggressionInhibitorComponent.cs new file mode 100644 index 00000000000..4414d5127b2 --- /dev/null +++ b/Content.Shared/_Arcane/AggressionInhibitor/Components/AggressionInhibitorComponent.cs @@ -0,0 +1,109 @@ +using Content.Shared.Access; +using Robust.Shared.Prototypes; +using Robust.Shared.Audio; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Shared._Arcane.AggressionInhibitor.Components; + +/// +/// A component that punishes creatures with a bad tone with electric shocks +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause] +public sealed partial class AggressionInhibitorComponent : Component +{ + /// + /// Stores the UID of the player who is wearing the object + /// + [ViewVariables, AutoNetworkedField] + public EntityUid? WearingEntity; + + [ViewVariables] + public TimeSpan LastVerbClickTime = TimeSpan.Zero; + + /// + /// When to go to the next step of the schedule. + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField] + public TimeSpan NextUpdate; + + /// + /// The time in seconds for which the object is blocked + /// + [AutoNetworkedField] + public float Duration = 60f; + + /// + /// Is the blocking process currently active + /// + [AutoNetworkedField] + public bool IsActive = false; + + /// + /// Electric shock damage after punishment + /// + [DataField] + public int Damage = 10; + + /// + /// The time of the knockout after the punishment (in seconds). + /// + [DataField] + public float TimeStun = 10.0f; + + + /// + /// Object status: blocked or not + /// + [AutoNetworkedField] + public bool IsLocked = false; + + /// + /// Who can CLOSE the object + /// + [DataField(required: true), AutoNetworkedField] + public List> LockAccess = new(); + + /// + /// Who can OPEN the object + /// + [DataField(required: true), AutoNetworkedField] + public List> UnlockAccess = new(); + + /// + /// Sound of successful blocking + /// + [DataField, AutoNetworkedField] + public SoundSpecifier LockSound = new SoundPathSpecifier("/Audio/Effects/beep1.ogg"); + + /// + /// The sound of a successful unlock + /// + [DataField, AutoNetworkedField] + public SoundSpecifier UnlockSound = new SoundPathSpecifier("/Audio/Effects/beep1.ogg"); + + /// + /// The sound of an error / denial of access + /// + [DataField, AutoNetworkedField] + public SoundSpecifier DenySound = new SoundPathSpecifier("/Audio/Effects/beep_landmine.ogg"); + +} + +/// +/// A local event for requesting the opening of the time dialog +/// +public sealed class OpenDialogEvent(EntityUid target, EntityUid user) : EntityEventArgs +{ + public EntityUid Target { get; } = target; + public EntityUid User { get; } = user; +} + +/// +/// A local event for requesting a lock change (ToggleLock) +/// +public sealed class ToggleLockEvent(EntityUid target, EntityUid user) : EntityEventArgs +{ + public EntityUid Target { get; } = target; + public EntityUid User { get; } = user; +} diff --git a/Content.Shared/_Arcane/AggressionInhibitor/Systems/SharedAggressionInhibitorSystem.cs b/Content.Shared/_Arcane/AggressionInhibitor/Systems/SharedAggressionInhibitorSystem.cs new file mode 100644 index 00000000000..556492e73e1 --- /dev/null +++ b/Content.Shared/_Arcane/AggressionInhibitor/Systems/SharedAggressionInhibitorSystem.cs @@ -0,0 +1,184 @@ +using Content.Shared._Arcane.AggressionInhibitor.Components; +using Robust.Shared.Containers; +using Content.Shared.Popups; +using Content.Shared.Examine; +using Content.Shared.Inventory.Events; +using Content.Shared.Verbs; +using Robust.Shared.Utility; +using Robust.Shared.Timing; +using Robust.Shared.Network; +using Content.Shared.CombatMode; +using Content.Shared.Inventory; +using Content.Shared.Electrocution; + +namespace Content.Shared._Arcane.AggressionInhibitor.Systems; + +public sealed partial class SharedAggressionInhibitorSystem : EntitySystem +{ + private static SpriteSpecifier.Texture _settingsIcon = new(new ResPath("/Textures/Interface/VerbIcons/settings.svg.192dpi.png")); + [Dependency] private SharedPopupSystem _popup = default!; + [Dependency] private SharedContainerSystem _containerSystem = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private INetManager _netManager = default!; + [Dependency] private InventorySystem _inventorySystem = default!; + [Dependency] private SharedElectrocutionSystem _electrocution = default!; + [Dependency] private SharedCombatModeSystem _combatMode = default!; + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnToggleCombatAction, before: [typeof(SharedCombatModeSystem)]); + SubscribeLocalEvent(OnExamine); + SubscribeLocalEvent(OnGotUnequipped); + SubscribeLocalEvent(OnBeingUnequippedAttempt); + SubscribeLocalEvent>(OnGetActivationVerbs); + } + + private void OnToggleCombatAction(ToggleCombatActionEvent args) + { + if (args.Handled) + return; + + var user = args.Performer; + + if (TryComp(user, out var mobState)) + { + if (mobState.CurrentState == Mobs.MobState.Critical || + mobState.CurrentState == Mobs.MobState.Dead) + return; + } + + EntityUid inhibitorItem = default; + AggressionInhibitorComponent? inhibitorComp = null; + + var slotEnumerator = _inventorySystem.GetSlotEnumerator(user); + while (slotEnumerator.MoveNext(out var containerSlot)) + { + if (containerSlot.ContainedEntity is not { } slotItem) + continue; + + if (_inventorySystem.TryGetContainingSlot(slotItem, out var slotDef)) + { + if (slotDef.SlotFlags.HasFlag(SlotFlags.POCKET)) + continue; + } + + if (TryComp(slotItem, out var comp) && comp.IsActive) + { + inhibitorItem = slotItem; + inhibitorComp = comp; + break; + } + } + + if (inhibitorComp == null) + return; + + if (!_combatMode.IsInCombatMode(user)) + { + _combatMode.SetInCombatMode(user, false); + args.Handled = true; + + if (_netManager.IsServer) + { + _electrocution.TryDoElectrocution(user, inhibitorItem, inhibitorComp.Damage, TimeSpan.FromSeconds(inhibitorComp.TimeStun), refresh: false, ignoreInsulation: true); + + _popup.PopupEntity(Loc.GetString("stabikor-disarm-shock-popup"), user, user, PopupType.LargeCaution); + } + } + } + + private void OnBeingUnequippedAttempt(EntityUid uid, AggressionInhibitorComponent comp, BeingUnequippedAttemptEvent args) + { + if (comp.IsLocked || comp.IsActive) + { + _popup.PopupPredicted(Loc.GetString("stabikor-unequip-blocked-active"), uid, args.Unequipee, PopupType.SmallCaution); + args.Cancel(); + } + } + + private void OnGotUnequipped(EntityUid uid, AggressionInhibitorComponent comp, ref GotUnequippedEvent args) + { + if (comp.IsActive && _netManager.IsServer) + { + comp.NextUpdate = default; + Dirty(uid, comp); + } + } + + private void OnExamine(EntityUid uid, AggressionInhibitorComponent comp, ExaminedEvent args) + { + var state = comp.IsLocked ? "stabikor-examine-locked" : "stabikor-examine-unlocked"; + args.PushMarkup(Loc.GetString("stabikor-examine-status-main", ("mode", Loc.GetString(state)))); + + var durationTotalMinutes = (int) (comp.Duration / 60f); + var durationHours = durationTotalMinutes / 60; + var durationMinutes = durationTotalMinutes % 60; + + args.PushMarkup(Loc.GetString("stabikor-examine-duration-info", + ("hours", durationHours), + ("minutes", durationMinutes))); + + var remaining = comp.NextUpdate - _timing.CurTime; + + if (comp.IsLocked && remaining.Ticks > 0) + { + var remainingHours = (int) remaining.TotalHours; + var remainingMinutes = remaining.Minutes; + var remainingSeconds = remaining.Seconds; + + args.PushMarkup(Loc.GetString("stabikor-examine-timer-remaining", + ("hours", remainingHours), + ("minutes", remainingMinutes), + ("seconds", remainingSeconds))); + } + } + + private void OnGetActivationVerbs(EntityUid uid, AggressionInhibitorComponent comp, GetVerbsEvent args) + { + var isInContainer = _containerSystem.TryGetContainingContainer((uid, null, null), out var container) + && container.Owner.IsValid(); + + if (!isInContainer && (!args.CanAccess || !args.CanInteract)) + return; + + if (!comp.IsLocked) + { + args.Verbs.Add(new ActivationVerb() + { + Text = Loc.GetString("stabikor-verb-set-duration"), + Icon = _settingsIcon, + Act = () => FlipOpenDialog(uid, args.User, comp) + }); + } + + var verbText = comp.IsLocked ? "stabikor-verb-unlock" : "stabikor-verb-lock"; + + args.Verbs.Add(new ActivationVerb() + { + Text = Loc.GetString(verbText), + Icon = _settingsIcon, + Act = () => FlipToggleLock(uid, args.User, comp) + }); + } + + private void FlipOpenDialog(EntityUid uid, EntityUid user, AggressionInhibitorComponent comp) + { + if (_timing.CurTime < comp.LastVerbClickTime + TimeSpan.FromSeconds(0.4)) + return; + + comp.LastVerbClickTime = _timing.CurTime; + + RaiseLocalEvent(uid, new OpenDialogEvent(uid, user)); + } + + private void FlipToggleLock(EntityUid uid, EntityUid user, AggressionInhibitorComponent comp) + { + if (_timing.CurTime < comp.LastVerbClickTime + TimeSpan.FromSeconds(0.4)) + return; + + comp.LastVerbClickTime = _timing.CurTime; + + RaiseLocalEvent(uid, new ToggleLockEvent(uid, user)); + } +} diff --git a/Content.Shared/_Arcane/CuttableItem/Components/CuttableItemComponent.cs b/Content.Shared/_Arcane/CuttableItem/Components/CuttableItemComponent.cs new file mode 100644 index 00000000000..7692f3d43b6 --- /dev/null +++ b/Content.Shared/_Arcane/CuttableItem/Components/CuttableItemComponent.cs @@ -0,0 +1,44 @@ +using Robust.Shared.GameStates; +using Content.Shared.Radio; +using Content.Shared.Tools; +using Robust.Shared.Prototypes; + +namespace Content.Shared._Arcane.CuttableItem.Components; + +/// +/// This component allows you to get rid of an object using tools, but notifies others about it via a radio channel. +/// +[RegisterComponent] +[NetworkedComponent] +[AutoGenerateComponentState] +public sealed partial class CuttableItemComponent : Component +{ + /// + /// The quality of the tools needed to cut the object. + /// + [DataField(required: true), AutoNetworkedField] + public List> ToolQualities = new(); + + /// + /// The time in seconds required to cut through the object. + /// + [DataField, AutoNetworkedField] + public float Delay = 45.0f; + + /// + /// The ID of the prototype radio channel for sending notifications when an attempt is made to cut through. + /// + [DataField, AutoNetworkedField] + public ProtoId RadioChannel = "Security"; + + /// + /// The localization key for the message being sent to the communication channel. + /// + [DataField, AutoNetworkedField] + public LocId AlertMessage = "cuttable-item-alert-activated"; +} + +public sealed class CuttableCutEvent(EntityUid user) : HandledEntityEventArgs +{ + public EntityUid User { get; } = user; +} diff --git a/Content.Shared/_Arcane/CuttableItem/CuttableDoAfterEvent.cs b/Content.Shared/_Arcane/CuttableItem/CuttableDoAfterEvent.cs new file mode 100644 index 00000000000..1b509a6e7b9 --- /dev/null +++ b/Content.Shared/_Arcane/CuttableItem/CuttableDoAfterEvent.cs @@ -0,0 +1,7 @@ +using Content.Shared.DoAfter; +using Robust.Shared.Serialization; + +namespace Content.Shared.CuttableItem; + +[Serializable, NetSerializable] +public sealed partial class CuttableDoAfterEvent : SimpleDoAfterEvent { } diff --git a/Content.Shared/_Arcane/CuttableItem/Systems/SharedCuttableItemSystem.cs b/Content.Shared/_Arcane/CuttableItem/Systems/SharedCuttableItemSystem.cs new file mode 100644 index 00000000000..8b40930c35e --- /dev/null +++ b/Content.Shared/_Arcane/CuttableItem/Systems/SharedCuttableItemSystem.cs @@ -0,0 +1,80 @@ +using Content.Shared._Arcane.CuttableItem.Components; +using Content.Shared.Interaction; +using Content.Shared.DoAfter; +using Content.Shared.CuttableItem; +using Content.Shared.Popups; +using Content.Shared.Tools.Systems; +using Robust.Shared.Network; +using Content.Shared.Examine; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Content.Shared._Arcane.CuttableItem.Systems; + +public sealed partial class SharedCuttableItemSystem : EntitySystem +{ + [Dependency] private SharedDoAfterSystem _doAfter = default!; + [Dependency] private SharedToolSystem _toolSystem = default!; + [Dependency] private SharedPopupSystem _popup = default!; + [Dependency] private INetManager _netManager = default!; + [Dependency] private IPrototypeManager _prototypeManager = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnInteractUsing); + SubscribeLocalEvent(OnExamined); + } + + private void OnInteractUsing(EntityUid uid, CuttableItemComponent comp, InteractUsingEvent args) + { + if (args.Handled || _netManager.IsClient) + return; + + var toolFound = false; + for (var i = 0; i < comp.ToolQualities.Count; i++) + { + if (_toolSystem.HasQuality(args.Used, comp.ToolQualities[i])) + { + toolFound = true; + break; + } + } + + if (!toolFound) + return; + + var doAfterArgs = new DoAfterArgs(EntityManager, args.User, TimeSpan.FromSeconds(comp.Delay), new CuttableDoAfterEvent(), uid, target: uid, used: args.Used) + { + BreakOnMove = true, + BreakOnDamage = true, + NeedHand = true + }; + + if (_doAfter.TryStartDoAfter(doAfterArgs)) + { + args.Handled = true; + _popup.PopupEntity(Loc.GetString("cuttable-item-attempt-broken-popup", ("item", uid), ("user", Name(args.User))), uid); + } + } + + private void OnExamined(EntityUid uid, CuttableItemComponent comp, ref ExaminedEvent args) + { + if (comp.ToolQualities.Count == 0) + return; + + var message = new FormattedMessage(); + message.AddMarkupOrThrow(Loc.GetString("cuttable-item-examine-header") + "\n"); + + foreach (var qualityId in comp.ToolQualities) + { + if (_prototypeManager.TryIndex(qualityId, out var qualityProto)) + { + var qualityName = Loc.GetString(qualityProto.Name); + message.AddMarkupOrThrow($" - [color=yellow]{qualityName}[/color]\n"); + } + } + args.PushMessage(message); + } +} diff --git a/Resources/Locale/en-US/_Arcane/aggression-Inhibitor/aggression_inhibitor_component.ftl b/Resources/Locale/en-US/_Arcane/aggression-Inhibitor/aggression_inhibitor_component.ftl new file mode 100644 index 00000000000..9aac8b4287b --- /dev/null +++ b/Resources/Locale/en-US/_Arcane/aggression-Inhibitor/aggression_inhibitor_component.ftl @@ -0,0 +1,79 @@ +stabikor-activate-verb = Activate +stabikor-activated-success = The { $item } snaps shut! +stabikor-not-equipped = Object not detected. +stabikor-moment-shutdown = The { $item } unfastens and falls to the ground! +stabikor-disarm-shock-popup = You are severely shocked for attempting aggression! Combat mode disabled. + +stabikor-examine-status-main = The device mode indicator lights up: [color=yellow]{$mode}[/color]. + +stabikor-examine-duration-info = Operation time is set to{ $hours -> + [0] { "" } + [one] {" "}[color=cyan]{$hours}[/color] hour + *[other] {" "}[color=cyan]{$hours}[/color] hours +}{ $hours -> + [0] { "" } + *[other] { $minutes -> + [0] { "" } + *[other] {" and"} + } +}{ $minutes -> + [0] { "" } + [one] {" "}[color=cyan]{$minutes}[/color] minute + *[other] {" "}[color=cyan]{$minutes}[/color] minutes +}. + +stabikor-examine-timer-remaining = + Time remaining until shutdown:{ $hours -> + [0] { "" } + *[other] {" "}[color=orange]{$hours}[/color] { $hours -> + [one] hour + *[other] hours + } + }{ $hours -> + [0] { "" } + *[other] { $minutes -> + [0] { "" } + *[other] {" and"} + } + }{ $minutes -> + [0] { "" } + *[other] {" "}[color=orange]{$minutes}[/color] { $minutes -> + [one] minute + *[other] minutes + } + }{ $hours -> + [0] { $minutes -> + [0] { "" } + *[other] { $seconds -> + [0] { "" } + *[other] {" and"} + } + } + *[other] { $seconds -> + [0] { "" } + *[other] {" and"} + } + }{ $seconds -> + [0] { "" } + *[other] {" "}[color=orange]{$seconds}[/color] { $seconds -> + [one] second + *[other] seconds + } + }. + +stabikor-examine-locked = locked +stabikor-examine-unlocked = standby + +stabikor-verb-set-duration = Set Timer +stabikor-verb-lock = Activate Lock +stabikor-verb-unlock = Release Lock + +stabikor-dialog-title = Timer Configuration +stabikor-dialog-field = + Enter the device operation time + (in minutes, from 1 to 900): +stabikor-dialog-invalid-range = Invalid time range! Enter a number from 1 to 900. +stabikor-duration-set-success = Operation time successfully set to {$time} min. +stabikor-duration-set-cancel-fallback = Input canceled, set to default time: {$time} min. + +stabikor-unequip-blocked-active = The device cannot be removed until the timer expires! diff --git a/Resources/Locale/en-US/_Arcane/cuttable/cuttable_Item_component.ftl b/Resources/Locale/en-US/_Arcane/cuttable/cuttable_Item_component.ftl new file mode 100644 index 00000000000..bf44ade3af2 --- /dev/null +++ b/Resources/Locale/en-US/_Arcane/cuttable/cuttable_Item_component.ftl @@ -0,0 +1,5 @@ +cuttable-item-broken-moment-popup = The { $item } has been cut off! +cuttable-item-attempt-broken-popup = { $user } is attempting to cut the { $item }! +cuttable-item-examine-header = It looks like you can break free using a tool with quality: + +cuttable-item-alert-activated = Unauthorized removal of { $item } detected. Perpetrator: { $user } diff --git a/Resources/Locale/en-US/_Arcane/entities/clothing/other/aggression_inhibitor.ftl b/Resources/Locale/en-US/_Arcane/entities/clothing/other/aggression_inhibitor.ftl new file mode 100644 index 00000000000..40b4962185c --- /dev/null +++ b/Resources/Locale/en-US/_Arcane/entities/clothing/other/aggression_inhibitor.ftl @@ -0,0 +1,2 @@ +ent-ClothingAggressionInhibitor = aggression inhibitor + .desc = Stabilizes the behavior of assistants using electrical current! An ID card reader can be seen on the casing. diff --git a/Resources/Locale/ru-RU/_Arcane/aggression-Inhibitor/aggression_inhibitor_component.ftl b/Resources/Locale/ru-RU/_Arcane/aggression-Inhibitor/aggression_inhibitor_component.ftl new file mode 100644 index 00000000000..fb2d35af282 --- /dev/null +++ b/Resources/Locale/ru-RU/_Arcane/aggression-Inhibitor/aggression_inhibitor_component.ftl @@ -0,0 +1,84 @@ +stabikor-activate-verb = Активировать +stabikor-activated-success = { $item } защелкивается! +stabikor-not-equipped = Объект не обнаружен. +stabikor-moment-shutdown = { $item } расстегивается и падает на землю! +stabikor-disarm-shock-popup = Вас сильно бьет током за попытку агрессии! Боевой режим отключен. + +stabikor-examine-status-main = На устройстве горит индикатор режима: [color=yellow]{$mode}[/color]. + +stabikor-examine-duration-info = Время работы настроено на{ $hours -> + [0] { "" } + [one] {" "}[color=cyan]{$hours}[/color] час + [few] {" "}[color=cyan]{$hours}[/color] часа + *[other] {" "}[color=cyan]{$hours}[/color] часов +}{ $hours -> + [0] { "" } + *[other] { $minutes -> + [0] { "" } + *[other] {" и"} + } +}{ $minutes -> + [0] { "" } + [one] {" "}[color=cyan]{$minutes}[/color] минуту + [few] {" "}[color=cyan]{$minutes}[/color] минуты + *[other] {" "}[color=cyan]{$minutes}[/color] минут +}. + +stabikor-examine-timer-remaining = + До отключения осталось:{ $hours -> + [0] { "" } + *[other] {" "}[color=orange]{$hours}[/color] { $hours -> + [one] час + [few] часа + *[other] часов + } + }{ $hours -> + [0] { "" } + *[other] { $minutes -> + [0] { "" } + *[other] {" и"} + } + }{ $minutes -> + [0] { "" } + *[other] {" "}[color=orange]{$minutes}[/color] { $minutes -> + [one] минута + [few] минуты + *[other] минут + } + }{ $hours -> + [0] { $minutes -> + [0] { "" } + *[other] { $seconds -> + [0] { "" } + *[other] {" и"} + } + } + *[other] { $seconds -> + [0] { "" } + *[other] {" и"} + } + }{ $seconds -> + [0] { "" } + *[other] {" "}[color=orange]{$seconds}[/color] { $seconds -> + [one] секунда + [few] секунды + *[other] секунд + } + }. + +stabikor-examine-locked = заблокирован +stabikor-examine-unlocked = ожидание + +stabikor-verb-set-duration = Настроить таймер +stabikor-verb-lock = Активировать блокировку +stabikor-verb-unlock = Снять блокировку + +stabikor-dialog-title = Конфигурация таймера +stabikor-dialog-field = + Введите время работы устройства + (в минутах, от 1 до 900): +stabikor-dialog-invalid-range = Неверный диапазон времени! Введите число от 1 до 900. +stabikor-duration-set-success = Время работы успешно настроено на {$time} мин. +stabikor-duration-set-cancel-fallback = Ввод отменён, установлено время по умолчанию: {$time} мин. + +stabikor-unequip-blocked-active = Устройство невозможно снять до окончания таймера! diff --git a/Resources/Locale/ru-RU/_Arcane/cuttableItem/cuttable_Item_component.ftl b/Resources/Locale/ru-RU/_Arcane/cuttableItem/cuttable_Item_component.ftl new file mode 100644 index 00000000000..48cee7e695f --- /dev/null +++ b/Resources/Locale/ru-RU/_Arcane/cuttableItem/cuttable_Item_component.ftl @@ -0,0 +1,5 @@ +cuttable-item-broken-moment-popup = { $item } снялся! +cuttable-item-attempt-broken-popup = { $user } пытается снять { $item }! +cuttable-item-examine-header = Кажется, можно освободиться, используя инструмент с качеством: + +cuttable-item-alert-activated = Зафиксировано несанкционированное снятие { $item }. Нарушитель: { $user } diff --git a/Resources/Locale/ru-RU/_Arcane/entities/clothing/other/aggression_inhibitor.ftl b/Resources/Locale/ru-RU/_Arcane/entities/clothing/other/aggression_inhibitor.ftl new file mode 100644 index 00000000000..e174ca6b914 --- /dev/null +++ b/Resources/Locale/ru-RU/_Arcane/entities/clothing/other/aggression_inhibitor.ftl @@ -0,0 +1,2 @@ +ent-ClothingAggressionInhibitor = ингибитор агрессии + .desc = Стабилизирует поведение ассистентов с помощью силы тока! На корпусе виднеется считыватель ID-карт. diff --git a/Resources/Prototypes/Catalog/Cargo/cargo_vending.yml b/Resources/Prototypes/Catalog/Cargo/cargo_vending.yml index c484db5f47a..a1d03566128 100644 --- a/Resources/Prototypes/Catalog/Cargo/cargo_vending.yml +++ b/Resources/Prototypes/Catalog/Cargo/cargo_vending.yml @@ -33,7 +33,7 @@ sprite: Objects/Specific/Service/vending_machine_restock.rsi state: base product: CrateVendingMachineRestockClothesFilled - cost: 5300 + cost: 6000 # Arcane-Edit 5300 > 6000 category: cargoproduct-category-name-service group: market diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/security.yml b/Resources/Prototypes/Catalog/Fills/Lockers/security.yml index 44d1ce172bc..d6a1d8ba81c 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/security.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/security.yml @@ -43,6 +43,10 @@ - id: BookSpaceLaw - id: ClothingBackpackElectropack amount: 2 + # Arcane-Start + - id: ClothingAggressionInhibitor + amount: 2 + # Arcane-End - id: RemoteSignaller amount: 2 #- id: NetworkConfigurator # Goobstation? diff --git a/Resources/Prototypes/Recipes/Lathes/Packs/medical.yml b/Resources/Prototypes/Recipes/Lathes/Packs/medical.yml index cd2a0d2a5cd..24c35f017c3 100644 --- a/Resources/Prototypes/Recipes/Lathes/Packs/medical.yml +++ b/Resources/Prototypes/Recipes/Lathes/Packs/medical.yml @@ -52,6 +52,7 @@ - Drill - Saw - Hemostat + - SawElectric # Arcane - type: latheRecipePack id: MedicalStatic @@ -65,6 +66,11 @@ - OffsetCane - OffsetCaneWood - JetInjector + # Arcane-Start + - Bloodpack + - ClothingNeckStethoscope + - MedicalBeamGunSyndicate + # Arcane-End - type: latheRecipePack id: RollerBedsStatic @@ -79,6 +85,7 @@ - ClothingHandsGlovesLatex - ClothingHandsGlovesNitrile - ClothingMaskSterile + - ClothingEyesNightVisionMedicalGoggles # Arcane # These are all empty - type: latheRecipePack @@ -102,6 +109,14 @@ - ChemMasterMachineCircuitboard - CondenserMachineCircuitBoard - HotplateMachineCircuitboard + # Arcane-Start + - MedicalScannerMachineCircuitboard + - MedicalRecordsComputerCircuitboard + - CrewMonitoringComputerCircuitboard + - MedicalBiofabMachineBoard + - DiagnoserMachineCircuitboard + - VaccinatorMachineCircuitboard + # Arcane-End ## Dynamic diff --git a/Resources/Prototypes/Recipes/Lathes/Packs/security.yml b/Resources/Prototypes/Recipes/Lathes/Packs/security.yml index 5f3e6baf227..8932541fee6 100644 --- a/Resources/Prototypes/Recipes/Lathes/Packs/security.yml +++ b/Resources/Prototypes/Recipes/Lathes/Packs/security.yml @@ -60,6 +60,11 @@ - SpeedLoaderMagnum - SpeedLoaderMagnumEmpty # - ShrapnelPayload + # Arcane-Start + - MagazineShotgun + - MagazineShotgunEmpty + - MagazineShotgunSlug + # Arcane-End - type: latheRecipePack id: SecurityWeaponsStatic @@ -83,6 +88,7 @@ - SmokeGrenade # Orion-End - ClothingBackpackElectropack + - ClothingAggressionInhibitor # Arcane - HoloprojectorSecurity - HoloprojectorSecurityAdvanced - AntiMindControlDevice diff --git a/Resources/Prototypes/Recipes/Lathes/security.yml b/Resources/Prototypes/Recipes/Lathes/security.yml index 229d4c0d9d9..782bdc31ff3 100644 --- a/Resources/Prototypes/Recipes/Lathes/security.yml +++ b/Resources/Prototypes/Recipes/Lathes/security.yml @@ -62,6 +62,19 @@ Plastic: 250 Cloth: 500 +# Arcane-Start +- type: latheRecipe + id: ClothingAggressionInhibitor + result: ClothingAggressionInhibitor + categories: + - Clothing + completetime: 4 + materials: + Steel: 500 + Plastic: 250 + Cloth: 500 +# Arcane-End + - type: latheRecipe id: ClothingEyesHudSecurity result: ClothingEyesHudSecurity diff --git a/Resources/Prototypes/_Arcane/Entities/Clothing/Other/inhibitor.yml b/Resources/Prototypes/_Arcane/Entities/Clothing/Other/inhibitor.yml new file mode 100644 index 00000000000..2ed239e3e82 --- /dev/null +++ b/Resources/Prototypes/_Arcane/Entities/Clothing/Other/inhibitor.yml @@ -0,0 +1,34 @@ +- type: entity + parent: Clothing + id: ClothingAggressionInhibitor + name: The aggression inhibitor + description: Stabilizes the behavior of assistants with the help of amperage! An ID card reader is visible on the case. + components: + - type: Item + size: Small + sprite: _Arcane/Clothing/Neck/Misc/petcollar.rsi + - type: Clothing + quickEquip: true + equipDelay: 2.5 + slots: + - neck + - gloves + sprite: _Arcane/Clothing/Neck/Misc/petcollar.rsi + - type: Sprite + state: icon + sprite: _Arcane/Clothing/Neck/Misc/petcollar.rsi + scale: 0.75,0.75 + - type: AggressionInhibitor + damage: 5 + timeStun: 5.0 + lockAccess: + - Security + unlockAccess: + - Armory + - HeadOfSecurity + - Captain + - CentralCommand + - type: CuttableItem + toolQualities: + - Sawing + delay: 45.0 diff --git a/Resources/Prototypes/_Orion/Research/Nodes/cyborg.yml b/Resources/Prototypes/_Orion/Research/Nodes/cyborg.yml index 34336a78be1..1aaddc810de 100644 --- a/Resources/Prototypes/_Orion/Research/Nodes/cyborg.yml +++ b/Resources/Prototypes/_Orion/Research/Nodes/cyborg.yml @@ -133,6 +133,7 @@ - AdvancedSurgery recipeUnlocks: - BorgModuleAdvancedSurgery + - BorgModuleAdvancedTopical # Arcane position: -5,0 - type: technology @@ -157,6 +158,10 @@ - BorgModuleTool - BorgModuleCable - BorgModuleFireExtinguisher + # Arcane-Start + - BorgModuleInflatable + - BorgModuleArtistry + # Arcane-End position: -5,-1 - type: technology diff --git a/Resources/Prototypes/_Orion/Research/Nodes/engineering.yml b/Resources/Prototypes/_Orion/Research/Nodes/engineering.yml index 73f431081eb..a8493403501 100644 --- a/Resources/Prototypes/_Orion/Research/Nodes/engineering.yml +++ b/Resources/Prototypes/_Orion/Research/Nodes/engineering.yml @@ -28,6 +28,7 @@ - TimerTrigger - VoiceSignaller - VoiceTrigger + - VoiceSensor # Arcane - ProximitySignaller position: 12,4 @@ -194,6 +195,7 @@ - CableStack - CableMVStack - CableHVStack + - CableDetStack1 # Arcane - ClothingHeadHatWelding - Welder - WelderMini @@ -212,6 +214,16 @@ - LightTube - ClothingMaskWeldingGas - SignallerAdvanced + # Arcane-Start + - EmptyDetonator + - TowelColorWhite + - MaterialDurathread + - FauxTileDarkAstroGrass + - FauxTileLightAstroGrass + - FauxTileDesertAstroSand + - FauxTileAstroIronsand + - FauxTileAstroIronsandBorderless + # Arcane-End - FauxTileAstroGrass - FauxTileMowedAstroGrass - FauxTileJungleAstroGrass diff --git a/Resources/Prototypes/_Orion/Research/Nodes/medical.yml b/Resources/Prototypes/_Orion/Research/Nodes/medical.yml index 710aed9760d..41960ef5293 100644 --- a/Resources/Prototypes/_Orion/Research/Nodes/medical.yml +++ b/Resources/Prototypes/_Orion/Research/Nodes/medical.yml @@ -18,6 +18,10 @@ - ScanningReagentHaloperidol - ScanningReagentCryostylane recipeUnlocks: + # Arcane-Start + - JetInjector + - ChemistryEmptyVialSmall + # Arcane-End - LargeBeaker - Beaker - Bloodpack @@ -137,6 +141,7 @@ requiredExperiments: - ScanningReagentHaloperidol recipeUnlocks: + - AdvancedJetInjector # Arcane - HandheldCrewMonitor - SyringeGun - DefibrillatorCompact diff --git a/Resources/Prototypes/_Orion/Research/Nodes/security.yml b/Resources/Prototypes/_Orion/Research/Nodes/security.yml index 99e6afbb367..3249adae54f 100644 --- a/Resources/Prototypes/_Orion/Research/Nodes/security.yml +++ b/Resources/Prototypes/_Orion/Research/Nodes/security.yml @@ -79,6 +79,7 @@ - Zipties - FlashlightSeclite - ClothingBackpackElectropack + - ClothingAggressionInhibitor # Arcane - ForensicPad - TargetClown - TargetHuman diff --git a/Resources/Prototypes/_Orion/Research/Nodes/service.yml b/Resources/Prototypes/_Orion/Research/Nodes/service.yml index bfdd949a511..463a6bea5fc 100644 --- a/Resources/Prototypes/_Orion/Research/Nodes/service.yml +++ b/Resources/Prototypes/_Orion/Research/Nodes/service.yml @@ -12,6 +12,11 @@ - type: General amount: 500 recipeUnlocks: + # Arcane-Start + - UtilityKnife + - BoxFolderClipboardEmpty + - BoxFolderPlasticClipboardEmpty + # Arcane-End - HandLabeler - ParcelWrap - CutterMachineCircuitboard @@ -148,6 +153,7 @@ - type: General amount: 500 recipeUnlocks: + - SmartFridgeCircuitboard # Arcane - ElectricGrillMachineCircuitboard - MicrowaveMachineCircuitboard - FoodBowlBig diff --git a/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/equipped-HAND.png b/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/equipped-HAND.png new file mode 100644 index 00000000000..0632814ca44 Binary files /dev/null and b/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/equipped-HAND.png differ diff --git a/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/equipped-NECK.png b/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/equipped-NECK.png new file mode 100644 index 00000000000..82472c994e0 Binary files /dev/null and b/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/equipped-NECK.png differ diff --git a/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/icon.png b/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/icon.png new file mode 100644 index 00000000000..da484051caf Binary files /dev/null and b/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/icon.png differ diff --git a/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/meta.json b/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/meta.json new file mode 100644 index 00000000000..b4294846589 --- /dev/null +++ b/Resources/Textures/_Arcane/Clothing/Neck/Misc/petcollar.rsi/meta.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Created by Kat_L (Discord) for Space Station 14 server Arcane", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "icon" + }, + { + "name": "equipped-NECK", + "directions": 4 + } + , + { + "name": "equipped-HAND", + "directions": 4 + } + ] +}