Merge remote-tracking branch 'upstream/master' into up/drinks-yml

This commit is contained in:
iaada
2025-07-25 20:44:55 -05:00
50 changed files with 909 additions and 62 deletions

View File

@@ -0,0 +1,20 @@
<Control
xmlns="https://spacestation14.io"
xmlns:viewport="clr-namespace:Content.Client.Viewport"
MouseFilter="Stop">
<PanelContainer StyleClasses="BackgroundDark" Name="AdminCameraWindowRoot" Access="Public">
<BoxContainer Orientation="Vertical" Access="Public">
<!-- Camera -->
<Control VerticalExpand="True" Name="CameraViewBox">
<viewport:ScalingViewport Name="CameraView"
MinSize="100 100"
MouseFilter="Ignore" />
</Control>
<!-- Controller buttons -->
<BoxContainer Orientation="Horizontal" Margin="5 5 5 5">
<Button StyleClasses="OpenRight" Name="FollowButton" HorizontalExpand="True" Access="Public" Text="{Loc 'admin-camera-window-follow'}" />
<Button StyleClasses="OpenLeft" Name="PopControl" HorizontalExpand="True" Access="Public" Text="{Loc 'admin-camera-window-pop-out'}" />
</BoxContainer>
</BoxContainer>
</PanelContainer>
</Control>

View File

@@ -0,0 +1,101 @@
using System.Numerics;
using Content.Client.Eye;
using Content.Shared.Administration;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.Timing;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Timing;
namespace Content.Client.Administration.UI.AdminCamera;
[GenerateTypedNameReferences]
public sealed partial class AdminCameraControl : Control
{
[Dependency] private readonly IEntityManager _entManager = default!;
[Dependency] private readonly IClientGameTiming _timing = default!;
public event Action? OnFollow;
public event Action? OnPopoutControl;
private readonly EyeLerpingSystem _eyeLerpingSystem;
private readonly FixedEye _defaultEye = new();
private AdminCameraEuiState? _nextState;
private const float MinimumZoom = 0.1f;
private const float MaximumZoom = 2.0f;
public EntityUid? CurrentCamera;
public float Zoom = 1.0f;
public bool IsPoppedOut;
public AdminCameraControl()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
_eyeLerpingSystem = _entManager.System<EyeLerpingSystem>();
CameraView.Eye = _defaultEye;
FollowButton.OnPressed += _ => OnFollow?.Invoke();
PopControl.OnPressed += _ => OnPopoutControl?.Invoke();
CameraView.OnResized += OnResized;
}
private new void OnResized()
{
var width = Math.Max(CameraView.PixelWidth, (int)Math.Floor(CameraView.MinWidth));
var height = Math.Max(CameraView.PixelHeight, (int)Math.Floor(CameraView.MinHeight));
CameraView.ViewportSize = new Vector2i(width, height);
}
protected override void MouseWheel(GUIMouseWheelEventArgs args)
{
base.MouseWheel(args);
if (CameraView.Eye == null)
return;
Zoom = Math.Clamp(Zoom - args.Delta.Y * 0.15f * Zoom, MinimumZoom, MaximumZoom);
CameraView.Eye.Zoom = new Vector2(Zoom, Zoom);
args.Handle();
}
public void SetState(AdminCameraEuiState state)
{
_nextState = state;
}
// I know that this is awful, but I copied this from the solution editor anyways.
// This is needed because EUIs update before the gamestate is applied, which means it will fail to get the uid from the net entity.
// The suggestion from the comment in the solution editor saying to use a BUI is not ideal either:
// - We would need to bind the UI to an entity, but with how BUIs currently work we cannot open it in the same tick as we spawn that entity on the server.
// - We want the UI opened by the user session, not by their currently attached entity. Otherwise it would close in cases where admins move from one entity to another, for example when ghosting.
protected override void FrameUpdate(FrameEventArgs args)
{
if (_nextState == null || _timing.LastRealTick < _nextState.Tick) // make sure the last gamestate has been applied
return;
if (!_entManager.TryGetEntity(_nextState.Camera, out var cameraUid))
return;
if (CurrentCamera == null)
{
_eyeLerpingSystem.AddEye(cameraUid.Value);
CurrentCamera = cameraUid;
}
else if (CurrentCamera != cameraUid)
{
_eyeLerpingSystem.RemoveEye(CurrentCamera.Value);
_eyeLerpingSystem.AddEye(cameraUid.Value);
CurrentCamera = cameraUid;
}
if (_entManager.TryGetComponent<EyeComponent>(CurrentCamera, out var eye))
CameraView.Eye = eye.Eye ?? _defaultEye;
}
}

View File

@@ -0,0 +1,117 @@
using System.Numerics;
using Content.Client.Eui;
using Content.Shared.Administration;
using Content.Shared.Eui;
using JetBrains.Annotations;
using Robust.Client.UserInterface.Controls;
namespace Content.Client.Administration.UI.AdminCamera;
/// <summary>
/// Admin Eui for opening a viewport window to observe entities.
/// Use the "Open Camera" admin verb or the "camera" command to open.
/// </summary>
[UsedImplicitly]
public sealed partial class AdminCameraEui : BaseEui
{
private readonly AdminCameraWindow _window;
private readonly AdminCameraControl _control;
// If not null the camera is in "popped out" mode and is in an external window.
private OSWindow? _OSWindow;
// The last location the window was located at in game.
// Is used for getting knowing where to "pop in" external windows.
private Vector2 _lastLocation;
public AdminCameraEui()
{
_window = new AdminCameraWindow();
_control = new AdminCameraControl();
_window.Contents.AddChild(_control);
_control.OnFollow += () => SendMessage(new AdminCameraFollowMessage());
_window.OnClose += () =>
{
if (!_control.IsPoppedOut)
SendMessage(new CloseEuiMessage());
};
_control.OnPopoutControl += () =>
{
if (_control.IsPoppedOut)
PopIn();
else
PopOut();
};
}
// Pop the window out into an external OS window
private void PopOut()
{
_lastLocation = _window.Position;
// TODO: When there is a way to have a minimum window size, enforce something!
_OSWindow = new OSWindow
{
SetSize = _window.Size,
Title = _window.Title ?? Loc.GetString("admin-camera-window-title-placeholder"),
};
_OSWindow.Show();
if (_OSWindow.Root == null)
return;
_control.Orphan();
_OSWindow.Root.AddChild(_control);
_OSWindow.Closed += () =>
{
if (_control.IsPoppedOut)
SendMessage(new CloseEuiMessage());
};
_control.IsPoppedOut = true;
_control.PopControl.Text = Loc.GetString("admin-camera-window-pop-in");
_window.Close();
}
// Pop the window back into the in game window.
private void PopIn()
{
_control.Orphan();
_window.Contents.AddChild(_control);
_window.Open(_lastLocation);
_control.IsPoppedOut = false;
_control.PopControl.Text = Loc.GetString("admin-camera-window-pop-out");
_OSWindow?.Close();
_OSWindow = null;
}
public override void Opened()
{
base.Opened();
_window.OpenCentered();
}
public override void Closed()
{
base.Closed();
_window.Close();
}
public override void HandleState(EuiStateBase baseState)
{
if (baseState is not AdminCameraEuiState state)
return;
_window.SetState(state);
_control.SetState(state);
}
}

View File

@@ -0,0 +1,6 @@
<DefaultWindow xmlns="https://spacestation14.io"
Title="{Loc admin-camera-window-title-placeholder}"
SetSize="425 550"
MinSize="200 225"
Name="Window">
</DefaultWindow>

View File

@@ -0,0 +1,23 @@
using Content.Shared.Administration;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
namespace Content.Client.Administration.UI.AdminCamera;
[GenerateTypedNameReferences]
public sealed partial class AdminCameraWindow : DefaultWindow
{
public AdminCameraWindow()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
ContentsContainer.Margin = new Thickness(5, 0, 5, 0);
}
public void SetState(AdminCameraEuiState state)
{
Title = Loc.GetString("admin-camera-window-title", ("name", state.Name));
}
}

View File

@@ -31,6 +31,7 @@
<Button Name="FreezeAndMuteToggleButton" Text="{Loc player-panel-freeze-and-mute}" Disabled="True"/>
<Button Name="BanButton" Text="{Loc player-panel-ban}" Disabled="True"/>
<controls:ConfirmButton Name="RejuvenateButton" Text="{Loc player-panel-rejuvenate}" Disabled="True"/>
<Button Name="CameraButton" Text="{Loc player-panel-camera}" Disabled="True"/>
</GridContainer>
</BoxContainer>
</BoxContainer>

View File

@@ -18,6 +18,7 @@ public sealed partial class PlayerPanel : FancyWindow
public event Action<NetUserId?>? OnOpenBans;
public event Action<NetUserId?>? OnAhelp;
public event Action<string?>? OnKick;
public event Action<string?>? OnCamera;
public event Action<NetUserId?>? OnOpenBanPanel;
public event Action<NetUserId?, bool>? OnWhitelistToggle;
public event Action? OnFollow;
@@ -33,26 +34,27 @@ public sealed partial class PlayerPanel : FancyWindow
public PlayerPanel(IClientAdminManager adminManager)
{
RobustXamlLoader.Load(this);
_adminManager = adminManager;
RobustXamlLoader.Load(this);
_adminManager = adminManager;
UsernameCopyButton.OnPressed += _ => OnUsernameCopy?.Invoke(TargetUsername ?? "");
BanButton.OnPressed += _ => OnOpenBanPanel?.Invoke(TargetPlayer);
KickButton.OnPressed += _ => OnKick?.Invoke(TargetUsername);
NotesButton.OnPressed += _ => OnOpenNotes?.Invoke(TargetPlayer);
ShowBansButton.OnPressed += _ => OnOpenBans?.Invoke(TargetPlayer);
AhelpButton.OnPressed += _ => OnAhelp?.Invoke(TargetPlayer);
WhitelistToggle.OnPressed += _ =>
{
OnWhitelistToggle?.Invoke(TargetPlayer, _isWhitelisted);
SetWhitelisted(!_isWhitelisted);
};
FollowButton.OnPressed += _ => OnFollow?.Invoke();
FreezeButton.OnPressed += _ => OnFreeze?.Invoke();
FreezeAndMuteToggleButton.OnPressed += _ => OnFreezeAndMuteToggle?.Invoke();
LogsButton.OnPressed += _ => OnLogs?.Invoke();
DeleteButton.OnPressed += _ => OnDelete?.Invoke();
RejuvenateButton.OnPressed += _ => OnRejuvenate?.Invoke();
UsernameCopyButton.OnPressed += _ => OnUsernameCopy?.Invoke(TargetUsername ?? "");
BanButton.OnPressed += _ => OnOpenBanPanel?.Invoke(TargetPlayer);
KickButton.OnPressed += _ => OnKick?.Invoke(TargetUsername);
CameraButton.OnPressed += _ => OnCamera?.Invoke(TargetUsername);
NotesButton.OnPressed += _ => OnOpenNotes?.Invoke(TargetPlayer);
ShowBansButton.OnPressed += _ => OnOpenBans?.Invoke(TargetPlayer);
AhelpButton.OnPressed += _ => OnAhelp?.Invoke(TargetPlayer);
WhitelistToggle.OnPressed += _ =>
{
OnWhitelistToggle?.Invoke(TargetPlayer, _isWhitelisted);
SetWhitelisted(!_isWhitelisted);
};
FollowButton.OnPressed += _ => OnFollow?.Invoke();
FreezeButton.OnPressed += _ => OnFreeze?.Invoke();
FreezeAndMuteToggleButton.OnPressed += _ => OnFreezeAndMuteToggle?.Invoke();
LogsButton.OnPressed += _ => OnLogs?.Invoke();
DeleteButton.OnPressed += _ => OnDelete?.Invoke();
RejuvenateButton.OnPressed += _ => OnRejuvenate?.Invoke();
}
public void SetUsername(string player)
@@ -122,6 +124,7 @@ public sealed partial class PlayerPanel : FancyWindow
{
BanButton.Disabled = !_adminManager.CanCommand("banpanel");
KickButton.Disabled = !_adminManager.CanCommand("kick");
CameraButton.Disabled = !_adminManager.CanCommand("camera");
NotesButton.Disabled = !_adminManager.CanCommand("adminnotes");
ShowBansButton.Disabled = !_adminManager.CanCommand("banlist");
WhitelistToggle.Disabled =

View File

@@ -15,7 +15,7 @@ public sealed class PlayerPanelEui : BaseEui
[Dependency] private readonly IClientAdminManager _admin = default!;
[Dependency] private readonly IClipboardManager _clipboard = default!;
private PlayerPanel PlayerPanel { get; }
private PlayerPanel PlayerPanel { get; }
public PlayerPanelEui()
{
@@ -25,6 +25,7 @@ public sealed class PlayerPanelEui : BaseEui
PlayerPanel.OnOpenNotes += id => _console.ExecuteCommand($"adminnotes \"{id}\"");
// Kick command does not support GUIDs
PlayerPanel.OnKick += username => _console.ExecuteCommand($"kick \"{username}\"");
PlayerPanel.OnCamera += username => _console.ExecuteCommand($"camera \"{username}\"");
PlayerPanel.OnOpenBanPanel += id => _console.ExecuteCommand($"banpanel \"{id}\"");
PlayerPanel.OnOpenBans += id => _console.ExecuteCommand($"banlist \"{id}\"");
PlayerPanel.OnAhelp += id => _console.ExecuteCommand($"openahelp \"{id}\"");
@@ -37,7 +38,7 @@ public sealed class PlayerPanelEui : BaseEui
PlayerPanel.OnFreeze += () => SendMessage(new PlayerPanelFreezeMessage());
PlayerPanel.OnLogs += () => SendMessage(new PlayerPanelLogsMessage());
PlayerPanel.OnRejuvenate += () => SendMessage(new PlayerPanelRejuvenationMessage());
PlayerPanel.OnDelete+= () => SendMessage(new PlayerPanelDeleteMessage());
PlayerPanel.OnDelete += () => SendMessage(new PlayerPanelDeleteMessage());
PlayerPanel.OnFollow += () => SendMessage(new PlayerPanelFollowMessage());
PlayerPanel.OnClose += () => SendMessage(new CloseEuiMessage());

View File

@@ -0,0 +1,58 @@
using Content.Server.Administration.UI;
using Content.Server.EUI;
using Content.Shared.Administration;
using Robust.Server.Player;
using Robust.Shared.Console;
namespace Content.Server.Administration.Commands;
[AdminCommand(AdminFlags.Admin)]
public sealed class CameraCommand : LocalizedCommands
{
[Dependency] private readonly EuiManager _eui = default!;
[Dependency] private readonly IEntityManager _entManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
public override string Command => "camera";
public override void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (shell.Player is not { } user)
{
shell.WriteError(Loc.GetString("shell-cannot-run-command-from-server"));
return;
}
if (args.Length != 1)
{
shell.WriteError(Loc.GetString("shell-wrong-arguments-number"));
return;
}
if (!NetEntity.TryParse(args[0], out var targetNetId) || !_entManager.TryGetEntity(targetNetId, out var targetUid))
{
if (!_playerManager.TryGetSessionByUsername(args[0], out var player)
|| player.AttachedEntity == null)
{
shell.WriteError(Loc.GetString("cmd-camera-wrong-argument"));
return;
}
targetUid = player.AttachedEntity.Value;
}
var ui = new AdminCameraEui(targetUid.Value);
_eui.OpenEui(ui, user);
}
public override CompletionResult GetCompletion(IConsoleShell shell, string[] args)
{
if (args.Length == 1)
{
return CompletionResult.FromHintOptions(
CompletionHelper.SessionNames(players: _playerManager),
Loc.GetString("cmd-camera-hint"));
}
return CompletionResult.Empty;
}
}

View File

@@ -388,6 +388,22 @@ namespace Content.Server.Administration.Systems
Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Interface/Actions/actions_borg.rsi"), "state-laws"),
});
}
// open camera
args.Verbs.Add(new Verb()
{
Priority = 10,
Text = Loc.GetString("admin-verbs-camera"),
Message = Loc.GetString("admin-verbs-camera-description"),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/vv.svg.192dpi.png")),
Category = VerbCategory.Admin,
Act = () =>
{
var ui = new AdminCameraEui(args.Target);
_euiManager.OpenEui(ui, player);
},
Impact = LogImpact.Low
});
}
}

View File

@@ -0,0 +1,97 @@
using Content.Server.Administration.Managers;
using Content.Server.EUI;
using Content.Shared.Administration;
using Content.Shared.Eui;
using Content.Shared.Follower;
using Content.Shared.Coordinates;
using Robust.Server.GameStates;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
using JetBrains.Annotations;
namespace Content.Server.Administration.UI;
/// <summary>
/// Admin Eui for opening a viewport window to observe entities.
/// Use the "Open Camera" admin verb or the "camera" command to open.
/// </summary>
[UsedImplicitly]
public sealed partial class AdminCameraEui : BaseEui
{
[Dependency] private readonly IAdminManager _admin = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
[Dependency] private readonly IGameTiming _timing = default!;
private readonly FollowerSystem _follower = default!;
private readonly PvsOverrideSystem _pvs = default!;
private readonly SharedViewSubscriberSystem _viewSubscriber = default!;
private static readonly EntProtoId CameraProtoId = "AdminCamera";
private readonly EntityUid _target;
private EntityUid? _camera;
public AdminCameraEui(EntityUid target)
{
IoCManager.InjectDependencies(this);
_follower = _entityManager.System<FollowerSystem>();
_pvs = _entityManager.System<PvsOverrideSystem>();
_viewSubscriber = _entityManager.System<SharedViewSubscriberSystem>();
_target = target;
}
public override void Opened()
{
base.Opened();
_camera = CreateCamera(_target, Player);
StateDirty();
}
public override void Closed()
{
base.Closed();
_entityManager.DeleteEntity(_camera);
}
public override void HandleMessage(EuiMessageBase msg)
{
base.HandleMessage(msg);
switch (msg)
{
case AdminCameraFollowMessage:
if (!_admin.HasAdminFlag(Player, AdminFlags.Admin) || Player.AttachedEntity == null)
return;
_follower.StartFollowingEntity(Player.AttachedEntity.Value, _target);
break;
default:
break;
}
}
public override EuiStateBase GetNewState()
{
var name = _entityManager.GetComponent<MetaDataComponent>(_target).EntityName;
var netEnt = _entityManager.GetNetEntity(_camera);
return new AdminCameraEuiState(netEnt, name, _timing.CurTick);
}
private EntityUid CreateCamera(EntityUid target, ICommonSession observer)
{
// Spawn a camera entity attached to the target.
var coords = target.ToCoordinates();
var camera = _entityManager.SpawnAttachedTo(CameraProtoId, coords);
// Allow the user to see the entities near the camera.
// This also force sends the camera entity to the user, overriding the visibility flags.
// (The camera entity has its visibility flags set to VisibilityFlags.Admin so that cheat clients can't see it)
_viewSubscriber.AddViewSubscriber(camera, observer);
return camera;
}
}

View File

@@ -1,4 +1,6 @@
using Content.Server.Power.Components;
using Content.Server.Administration.Logs;
using Content.Server.Power.Components;
using Content.Shared.Database;
using Content.Shared.Power;
using Robust.Server.GameObjects;
@@ -19,6 +21,7 @@ namespace Content.Server.Power.EntitySystems;
/// </remarks>
public sealed class BatteryInterfaceSystem : EntitySystem
{
[Dependency] private readonly IAdminLogManager _adminLog = default!;
[Dependency] private readonly UserInterfaceSystem _uiSystem = null!;
public override void Initialize()
@@ -43,12 +46,16 @@ public sealed class BatteryInterfaceSystem : EntitySystem
{
var netBattery = Comp<PowerNetworkBatteryComponent>(ent);
netBattery.CanCharge = args.On;
_adminLog.Add(LogType.Action,$"{ToPrettyString(args.Actor):actor} set input breaker to {args.On} on {ToPrettyString(ent):target}");
}
private void HandleSetOutputBreaker(Entity<BatteryInterfaceComponent> ent, ref BatterySetOutputBreakerMessage args)
{
var netBattery = Comp<PowerNetworkBatteryComponent>(ent);
netBattery.CanDischarge = args.On;
_adminLog.Add(LogType.Action,$"{ToPrettyString(args.Actor):actor} set output breaker to {args.On} on {ToPrettyString(ent):target}");
}
private void HandleSetChargeRate(Entity<BatteryInterfaceComponent> ent, ref BatterySetChargeRateMessage args)

View File

@@ -0,0 +1,27 @@
using Content.Shared.Eui;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
namespace Content.Shared.Administration;
[Serializable, NetSerializable]
public sealed partial class AdminCameraEuiState(NetEntity? camera, string name, GameTick tick) : EuiStateBase
{
/// <summary>
/// The camera entity we will use for the window.
/// </summary>
public readonly NetEntity? Camera = camera;
/// <summary>
/// The name of the observed entity.
/// </summary>
public readonly string Name = name;
/// <summary>
/// The current tick time, needed for cursed reasons.
/// </summary>
public readonly GameTick Tick = tick;
}
[Serializable, NetSerializable]
public sealed partial class AdminCameraFollowMessage : EuiMessageBase;

View File

@@ -6,9 +6,10 @@ namespace Content.Shared.Eye
[FlagsFor(typeof(VisibilityMaskLayer))]
public enum VisibilityFlags : int
{
None = 0,
None = 0,
Normal = 1 << 0,
Ghost = 1 << 1,
Subfloor = 1 << 2,
Ghost = 1 << 1, // Observers and revenants.
Subfloor = 1 << 2, // Pipes, disposal chutes, cables etc. while hidden under tiles. Can be revealed with a t-ray.
Admin = 1 << 3, // Reserved for admins in stealth mode and admin tools.
}
}

View File

@@ -189,6 +189,9 @@ public sealed class FollowerSystem : EntitySystem
/// <param name="entity">The entity to be followed</param>
public void StartFollowingEntity(EntityUid follower, EntityUid entity)
{
if (follower == entity || TerminatingOrDeleted(entity))
return;
// No recursion for you
var targetXform = Transform(entity);
while (targetXform.ParentUid.IsValid())

View File

@@ -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 = [];
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View 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);

View File

@@ -1285,5 +1285,13 @@ Entries:
id: 156
time: '2025-07-24T15:13:29.0000000+00:00'
url: https://github.com/space-wizards/space-station-14/pull/39025
- author: slarticodefast, beck-thompson
changes:
- message: Added the "Open Camera" admin verb and the "camera" console command which
allow you to observe entities in separate window using a remote camera.
type: Add
id: 157
time: '2025-07-25T16:53:01.0000000+00:00'
url: https://github.com/space-wizards/space-station-14/pull/36969
Name: Admin
Order: 2

View File

@@ -1,32 +1,4 @@
Entries:
- author: Entvari
changes:
- message: Added a new Wizard headset.
type: Add
- message: The Wizard now starts with the Wizard headset.
type: Tweak
id: 8295
time: '2025-04-20T21:38:30.0000000+00:00'
url: https://github.com/space-wizards/space-station-14/pull/35732
- author: aada
changes:
- message: Filter categories added to the circuit imprinter.
type: Add
id: 8296
time: '2025-04-20T22:27:49.0000000+00:00'
url: https://github.com/space-wizards/space-station-14/pull/35713
- author: ScarKy0
changes:
- message: Mail spawning has been reworked. The mail teleporter now has to be manually
emptied to take all the mail out. (You can do it by alt-clicking it or using
a verb)
type: Tweak
- message: The mail teleporter now has an internal capacity of 20. It will not spawn
mail past that until emptied by someone.
type: Add
id: 8297
time: '2025-04-21T09:32:18.0000000+00:00'
url: https://github.com/space-wizards/space-station-14/pull/36774
- author: ScarKy0
changes:
- message: Default cargo split is now 50% instead of 75%. Lockboxes remain unchanged.
@@ -3911,3 +3883,28 @@
id: 8806
time: '2025-07-24T11:24:19.0000000+00:00'
url: https://github.com/space-wizards/space-station-14/pull/39177
- author: SlamBamActionman
changes:
- message: Potassium-water explosion damage scaling has been tweaked to be less
potent.
type: Tweak
id: 8807
time: '2025-07-25T16:04:27.0000000+00:00'
url: https://github.com/space-wizards/space-station-14/pull/37924
- author: SlamBamActionman
changes:
- message: Chemistry foam and smoke reactions now trigger after regular mix reactions.
type: Tweak
- message: Foam, smoke and explosion reactions no longer transfer the reagents'
energies to the container.
type: Tweak
id: 8808
time: '2025-07-25T16:33:44.0000000+00:00'
url: https://github.com/space-wizards/space-station-14/pull/37915
- author: Quantum-cross
changes:
- message: pinpointer will no longer trigger sufferers of OCD
type: Fix
id: 8809
time: '2025-07-25T19:46:42.0000000+00:00'
url: https://github.com/space-wizards/space-station-14/pull/38657

View File

@@ -8,6 +8,8 @@ admin-verbs-teleport-here = Teleport Here
admin-verbs-freeze = Freeze
admin-verbs-freeze-and-mute = Freeze And Mute
admin-verbs-unfreeze = Unfreeze
admin-verbs-camera = Open Camera
admin-verbs-camera-description = Open a camera window that follows the selected entity.
admin-verbs-erase = Erase
admin-verbs-erase-description = Removes the player from the round and crew manifest and deletes their chat messages.
Their items are dropped on the ground.

View File

@@ -0,0 +1,5 @@
cmd-camera-desc = Opens a remote camera window for an entity.
cmd-camera-help = Usage: camera <entityUid or player>
cmd-camera-hint = <entityUid or player>
cmd-camera-wrong-argument = Argument must be a valid netUid or a player name.

View File

@@ -0,0 +1,5 @@
admin-camera-window-title = Observing { $name }
admin-camera-window-title-placeholder = Observing
admin-camera-window-follow = Follow
admin-camera-window-pop-out = Pop out
admin-camera-window-pop-in = Pop in

View File

@@ -22,3 +22,4 @@ player-panel-rejuvenate = Rejuvenate
player-panel-false = False
player-panel-true = True
player-panel-follow = Follow
player-panel-camera = Camera

View File

@@ -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!

View File

@@ -0,0 +1,14 @@
# dummy entity for the admin camera EUI
# this gets parented to the person being observed
- type: entity
id: AdminCamera
categories: [ HideSpawnMenu ]
name: admin camera
description: We are watching you.
components:
- type: Visibility
layer: 8 # Don't network this to anyone so cheat clients can't see it. We are adding a PVS override to the user.
- type: Eye # for the camera itself
drawFov: false
pvsScale: 0.8 # we don't need the full range

View File

@@ -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

View File

@@ -104,6 +104,7 @@
id: PotassiumExplosion
impact: High
priority: 20
conserveEnergy: false
reactants:
Water:
amount: 1
@@ -112,15 +113,16 @@
effects:
- !type:ExplosionReactionEffect
explosionType: Default
maxIntensity: 100
intensityPerUnit: 0.5 # 50+50 reagent for maximum explosion
intensitySlope: 4
intensityPerUnit: 0.25
maxTotalIntensity: 100
intensitySlope: 4
maxIntensity: 7
- type: reaction
id: Smoke
priority: 10
priority: -10
impact: High
conserveEnergy: false
reactants:
Phosphorus:
amount: 1
@@ -137,8 +139,9 @@
- type: reaction
id: Foam
priority: 10
priority: -10
impact: High
conserveEnergy: false
reactants:
Fluorosurfactant:
amount: 1
@@ -154,7 +157,8 @@
- type: reaction
id: IronMetalFoam
impact: High
priority: 10
priority: -10
conserveEnergy: false
reactants:
Iron:
amount: 3
@@ -172,7 +176,8 @@
- type: reaction
id: AluminiumMetalFoam
impact: High
priority: 10
priority: -10
conserveEnergy: false
reactants:
Aluminium:
amount: 3
@@ -191,6 +196,7 @@
id: UraniumEmpExplosion
impact: High
priority: 20
conserveEnergy: false
reactants:
Iron:
amount: 1
@@ -209,6 +215,7 @@
id: Flash
impact: High
priority: 20
conserveEnergy: false
reactants:
Aluminium:
amount: 1

View File

@@ -5,7 +5,7 @@
"y": 32
},
"license": "CC-BY-SA-3.0",
"copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/59f2a4e10e5ba36033c9734ddebfbbdc6157472d, station and syndicate pinpointer resprited by Ubaser, inhands by TiniestShark (github)",
"copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/59f2a4e10e5ba36033c9734ddebfbbdc6157472d, station and syndicate pinpointer resprited by Ubaser, inhands by TiniestShark (github). Modified by @whatston3 and @Quantum-cross (github) to center the sprite so arrow rotation is around the proper axis.",
"states": [
{
"name": "pinonalert",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 B

After

Width:  |  Height:  |  Size: 555 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 B

After

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 B

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 528 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 B

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 B

After

Width:  |  Height:  |  Size: 164 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 523 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 B

After

Width:  |  Height:  |  Size: 194 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 323 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 B

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 B

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

View File

@@ -0,0 +1,14 @@
{
"version": 1,
"license": "CC-BY-SA-3.0",
"copyright": "alexmactep",
"size": {
"x": 32,
"y": 32
},
"states": [
{
"name": "icon"
}
]
}