remote merge upstream

This commit is contained in:
Dmitry
2025-09-23 01:32:33 +07:00
380 changed files with 21589 additions and 9396 deletions

View File

@@ -0,0 +1,3 @@
// Global usings for Content.Benchmarks
global using Robust.UnitTesting.Pool;

View File

@@ -24,7 +24,7 @@ namespace Content.Client.Administration.UI.BanPanel;
[GenerateTypedNameReferences]
public sealed partial class BanPanel : DefaultWindow
{
public event Action<string?, (IPAddress, int)?, bool, ImmutableTypedHwid?, bool, uint, string, NoteSeverity, string[]?, bool>? BanSubmitted;
public event Action<Ban>? BanSubmitted;
public event Action<string>? PlayerChanged;
private string? PlayerUsername { get; set; }
private (IPAddress, int)? IpAddress { get; set; }
@@ -37,8 +37,8 @@ public sealed partial class BanPanel : DefaultWindow
// This is less efficient than just holding a reference to the root control and enumerating children, but you
// have to know how the controls are nested, which makes the code more complicated.
// Role group name -> the role buttons themselves.
private readonly Dictionary<string, List<Button>> _roleCheckboxes = new();
private readonly ISawmill _banpanelSawmill;
private readonly Dictionary<string, List<(Button, IPrototype)>> _roleCheckboxes = new();
private readonly ISawmill _banPanelSawmill;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
@@ -79,7 +79,7 @@ public sealed partial class BanPanel : DefaultWindow
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
_banpanelSawmill = _logManager.GetSawmill("admin.banpanel");
_banPanelSawmill = _logManager.GetSawmill("admin.banpanel");
PlayerList.OnSelectionChanged += OnPlayerSelectionChanged;
PlayerNameLine.OnFocusExit += _ => OnPlayerNameChanged();
PlayerCheckbox.OnPressed += _ =>
@@ -110,7 +110,7 @@ public sealed partial class BanPanel : DefaultWindow
TypeOption.SelectId(args.Id);
OnTypeChanged();
};
LastConnCheckbox.OnPressed += args =>
LastConnCheckbox.OnPressed += _ =>
{
IpLine.ModulateSelfOverride = null;
HwidLine.ModulateSelfOverride = null;
@@ -164,7 +164,7 @@ public sealed partial class BanPanel : DefaultWindow
var antagRoles = _protoMan.EnumeratePrototypes<AntagPrototype>()
.OrderBy(x => x.ID);
CreateRoleGroup("Antagonist", Color.Red, antagRoles);
CreateRoleGroup(AntagPrototype.GroupName, AntagPrototype.GroupColor, antagRoles);
}
/// <summary>
@@ -236,14 +236,14 @@ public sealed partial class BanPanel : DefaultWindow
{
foreach (var role in _roleCheckboxes[groupName])
{
role.Pressed = args.Pressed;
role.Item1.Pressed = args.Pressed;
}
if (args.Pressed)
{
if (!Enum.TryParse(_cfg.GetCVar(CCVars.DepartmentBanDefaultSeverity), true, out NoteSeverity newSeverity))
{
_banpanelSawmill
_banPanelSawmill
.Warning("Departmental role ban severity could not be parsed from config!");
return;
}
@@ -255,14 +255,14 @@ public sealed partial class BanPanel : DefaultWindow
{
foreach (var button in roleButtons)
{
if (button.Pressed)
if (button.Item1.Pressed)
return;
}
}
if (!Enum.TryParse(_cfg.GetCVar(CCVars.RoleBanDefaultSeverity), true, out NoteSeverity newSeverity))
{
_banpanelSawmill
_banPanelSawmill
.Warning("Role ban severity could not be parsed from config!");
return;
}
@@ -294,7 +294,7 @@ public sealed partial class BanPanel : DefaultWindow
}
/// <summary>
/// Adds a checkbutton specifically for one "role" in a "group"
/// Adds a check button specifically for one "role" in a "group"
/// E.g. it would add the Chief Medical Officer "role" into the "Medical" group.
/// </summary>
private void AddRoleCheckbox(string group, string role, GridContainer roleGroupInnerContainer, Button roleGroupCheckbox)
@@ -302,23 +302,36 @@ public sealed partial class BanPanel : DefaultWindow
var roleCheckboxContainer = new BoxContainer();
var roleCheckButton = new Button
{
Name = $"{role}RoleCheckbox",
Name = role,
Text = role,
ToggleMode = true,
};
roleCheckButton.OnToggled += args =>
{
// Checks the role group checkbox if all the children are pressed
if (args.Pressed && _roleCheckboxes[group].All(e => e.Pressed))
if (args.Pressed && _roleCheckboxes[group].All(e => e.Item1.Pressed))
roleGroupCheckbox.Pressed = args.Pressed;
else
roleGroupCheckbox.Pressed = false;
};
IPrototype rolePrototype;
if (_protoMan.TryIndex<JobPrototype>(role, out var jobPrototype))
rolePrototype = jobPrototype;
else if (_protoMan.TryIndex<AntagPrototype>(role, out var antagPrototype))
rolePrototype = antagPrototype;
else
{
_banPanelSawmill.Error($"Adding a role checkbox for role {role}: role is not a JobPrototype or AntagPrototype.");
return;
}
// This is adding the icon before the role name
// TODO: This should not be using raw strings for prototypes as it means it won't be validated at all.
// I know the ban manager is doing the same thing, but that should not leak into UI code.
if (_protoMan.TryIndex<JobPrototype>(role, out var jobPrototype) && _protoMan.Resolve(jobPrototype.Icon, out var iconProto))
// // I know the ban manager is doing the same thing, but that should not leak into UI code.
if (jobPrototype is not null && _protoMan.TryIndex(jobPrototype.Icon, out var iconProto))
{
var jobIconTexture = new TextureRect
{
@@ -335,7 +348,7 @@ public sealed partial class BanPanel : DefaultWindow
roleGroupInnerContainer.AddChild(roleCheckboxContainer);
_roleCheckboxes.TryAdd(group, []);
_roleCheckboxes[group].Add(roleCheckButton);
_roleCheckboxes[group].Add((roleCheckButton, rolePrototype));
}
public void UpdateBanFlag(bool newFlag)
@@ -488,7 +501,7 @@ public sealed partial class BanPanel : DefaultWindow
newSeverity = serverSeverity;
else
{
_banpanelSawmill
_banPanelSawmill
.Warning("Server ban severity could not be parsed from config!");
}
@@ -501,7 +514,7 @@ public sealed partial class BanPanel : DefaultWindow
}
else
{
_banpanelSawmill
_banPanelSawmill
.Warning("Role ban severity could not be parsed from config!");
}
break;
@@ -546,34 +559,51 @@ public sealed partial class BanPanel : DefaultWindow
private void SubmitButtonOnOnPressed(BaseButton.ButtonEventArgs obj)
{
string[]? roles = null;
ProtoId<JobPrototype>[]? jobs = null;
ProtoId<AntagPrototype>[]? antags = null;
if (TypeOption.SelectedId == (int) Types.Role)
{
var rolesList = new List<string>();
var jobList = new List<ProtoId<JobPrototype>>();
var antagList = new List<ProtoId<AntagPrototype>>();
if (_roleCheckboxes.Count == 0)
throw new DebugAssertException("RoleCheckboxes was empty");
foreach (var button in _roleCheckboxes.Values.SelectMany(departmentButtons => departmentButtons))
{
if (button is { Pressed: true, Text: not null })
if (button.Item1 is { Pressed: true, Name: not null })
{
rolesList.Add(button.Text);
switch (button.Item2)
{
case JobPrototype:
jobList.Add(button.Item2.ID);
break;
case AntagPrototype:
antagList.Add(button.Item2.ID);
break;
}
}
}
if (rolesList.Count == 0)
if (jobList.Count + antagList.Count == 0)
{
Tabs.CurrentTab = (int) TabNumbers.Roles;
return;
}
roles = rolesList.ToArray();
jobs = jobList.ToArray();
antags = antagList.ToArray();
}
if (TypeOption.SelectedId == (int) Types.None)
{
TypeOption.ModulateSelfOverride = Color.Red;
Tabs.CurrentTab = (int) TabNumbers.BasicInfo;
return;
}
@@ -585,6 +615,7 @@ public sealed partial class BanPanel : DefaultWindow
ReasonTextEdit.GrabKeyboardFocus();
ReasonTextEdit.ModulateSelfOverride = Color.Red;
ReasonTextEdit.OnKeyBindDown += ResetTextEditor;
return;
}
@@ -593,6 +624,7 @@ public sealed partial class BanPanel : DefaultWindow
ButtonResetOn = _gameTiming.CurTime.Add(TimeSpan.FromSeconds(3));
SubmitButton.ModulateSelfOverride = Color.Red;
SubmitButton.Text = Loc.GetString("ban-panel-confirm");
return;
}
@@ -601,7 +633,22 @@ public sealed partial class BanPanel : DefaultWindow
var useLastHwid = HwidCheckbox.Pressed && LastConnCheckbox.Pressed && Hwid is null;
var severity = (NoteSeverity) SeverityOption.SelectedId;
var erase = EraseCheckbox.Pressed;
BanSubmitted?.Invoke(player, IpAddress, useLastIp, Hwid, useLastHwid, (uint) (TimeEntered * Multiplier), reason, severity, roles, erase);
var ban = new Ban(
player,
IpAddress,
useLastIp,
Hwid,
useLastHwid,
(uint)(TimeEntered * Multiplier),
reason,
severity,
jobs,
antags,
erase
);
BanSubmitted?.Invoke(ban);
}
protected override void FrameUpdate(FrameEventArgs args)

View File

@@ -14,8 +14,7 @@ public sealed class BanPanelEui : BaseEui
{
BanPanel = new BanPanel();
BanPanel.OnClose += () => SendMessage(new CloseEuiMessage());
BanPanel.BanSubmitted += (player, ip, useLastIp, hwid, useLastHwid, minutes, reason, severity, roles, erase)
=> SendMessage(new BanPanelEuiStateMsg.CreateBanRequest(player, ip, useLastIp, hwid, useLastHwid, minutes, reason, severity, roles, erase));
BanPanel.BanSubmitted += ban => SendMessage(new BanPanelEuiStateMsg.CreateBanRequest(ban));
BanPanel.PlayerChanged += player => SendMessage(new BanPanelEuiStateMsg.GetPlayerInfoRequest(player));
}

View File

@@ -82,7 +82,11 @@ public sealed partial class AdminNotesLine : BoxContainer
if (Note.UnbannedTime is not null)
{
ExtraLabel.Text = Loc.GetString("admin-notes-unbanned", ("admin", Note.UnbannedByName ?? "[error]"), ("date", Note.UnbannedTime));
ExtraLabel.Text = Loc.GetString(
"admin-notes-unbanned",
("admin", Note.UnbannedByName ?? "[error]"),
("date", Note.UnbannedTime.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))
);
ExtraLabel.Visible = true;
}
else if (Note.ExpiryTime is not null)
@@ -139,7 +143,7 @@ public sealed partial class AdminNotesLine : BoxContainer
private string FormatRoleBanMessage()
{
var banMessage = new StringBuilder($"{Loc.GetString("admin-notes-banned-from")} {string.Join(", ", Note.BannedRoles ?? new []{"unknown"})} ");
var banMessage = new StringBuilder($"{Loc.GetString("admin-notes-banned-from")} {string.Join(", ", Note.BannedRoles ?? new[] { "unknown" })} ");
return FormatBanMessageCommon(banMessage);
}

View File

@@ -30,7 +30,10 @@ public sealed partial class ThresholdBoundControl : BoxContainer
public void SetValue(float value)
{
_value = value;
CSpinner.Value = ScaledValue;
if (!CSpinner.HasKeyboardFocus())
{
CSpinner.Value = ScaledValue;
}
}
public void SetEnabled(bool enabled)

View File

@@ -31,7 +31,7 @@ public sealed class DoorSystem : SharedDoorSystem
comp.OpeningAnimation = new Animation
{
Length = TimeSpan.FromSeconds(comp.OpeningAnimationTime),
Length = comp.OpeningAnimationTime,
AnimationTracks =
{
new AnimationTrackSpriteFlick
@@ -47,7 +47,7 @@ public sealed class DoorSystem : SharedDoorSystem
comp.ClosingAnimation = new Animation
{
Length = TimeSpan.FromSeconds(comp.ClosingAnimationTime),
Length = comp.ClosingAnimationTime,
AnimationTracks =
{
new AnimationTrackSpriteFlick
@@ -63,7 +63,7 @@ public sealed class DoorSystem : SharedDoorSystem
comp.EmaggingAnimation = new Animation
{
Length = TimeSpan.FromSeconds(comp.EmaggingAnimationTime),
Length = comp.EmaggingAnimationTime,
AnimationTracks =
{
new AnimationTrackSpriteFlick
@@ -116,14 +116,14 @@ public sealed class DoorSystem : SharedDoorSystem
return;
case DoorState.Opening:
if (entity.Comp.OpeningAnimationTime == 0.0)
if (entity.Comp.OpeningAnimationTime == TimeSpan.Zero)
return;
_animationSystem.Play(entity, (Animation)entity.Comp.OpeningAnimation, DoorComponent.AnimationKey);
return;
case DoorState.Closing:
if (entity.Comp.ClosingAnimationTime == 0.0 || entity.Comp.CurrentlyCrushing.Count != 0)
if (entity.Comp.ClosingAnimationTime == TimeSpan.Zero || entity.Comp.CurrentlyCrushing.Count != 0)
return;
_animationSystem.Play(entity, (Animation)entity.Comp.ClosingAnimation, DoorComponent.AnimationKey);

View File

@@ -0,0 +1,90 @@
using Robust.Client.Graphics;
namespace Content.Client.Graphics;
/// <summary>
/// A cache for <see cref="Overlay"/>s to store per-viewport render resources, such as render targets.
/// </summary>
/// <typeparam name="T">The type of data stored in the cache.</typeparam>
public sealed class OverlayResourceCache<T> : IDisposable where T : class, IDisposable
{
private readonly Dictionary<long, CacheEntry> _cache = new();
/// <summary>
/// Get the data for a specific viewport, creating a new entry if necessary.
/// </summary>
/// <remarks>
/// The cached data may be cleared at any time if <see cref="IClydeViewport.ClearCachedResources"/> gets invoked.
/// </remarks>
/// <param name="viewport">The viewport for which to retrieve cached data.</param>
/// <param name="factory">A delegate used to create the cached data, if necessary.</param>
public T GetForViewport(IClydeViewport viewport, Func<IClydeViewport, T> factory)
{
return GetForViewport(viewport, out _, factory);
}
/// <summary>
/// Get the data for a specific viewport, creating a new entry if necessary.
/// </summary>
/// <remarks>
/// The cached data may be cleared at any time if <see cref="IClydeViewport.ClearCachedResources"/> gets invoked.
/// </remarks>
/// <param name="viewport">The viewport for which to retrieve cached data.</param>
/// <param name="wasCached">True if the data was pulled from cache, false if it was created anew.</param>
/// <param name="factory">A delegate used to create the cached data, if necessary.</param>
public T GetForViewport(IClydeViewport viewport, out bool wasCached, Func<IClydeViewport, T> factory)
{
if (_cache.TryGetValue(viewport.Id, out var entry))
{
wasCached = true;
return entry.Data;
}
wasCached = false;
entry = new CacheEntry
{
Data = factory(viewport),
Viewport = new WeakReference<IClydeViewport>(viewport),
};
_cache.Add(viewport.Id, entry);
viewport.ClearCachedResources += ViewportOnClearCachedResources;
return entry.Data;
}
private void ViewportOnClearCachedResources(ClearCachedViewportResourcesEvent ev)
{
if (!_cache.Remove(ev.ViewportId, out var entry))
{
// I think this could theoretically happen if you manually dispose the cache *after* a leaked viewport got
// GC'd, but before its ClearCachedResources got invoked.
return;
}
entry.Data.Dispose();
if (ev.Viewport != null)
ev.Viewport.ClearCachedResources -= ViewportOnClearCachedResources;
}
public void Dispose()
{
foreach (var entry in _cache)
{
if (entry.Value.Viewport.TryGetTarget(out var viewport))
viewport.ClearCachedResources -= ViewportOnClearCachedResources;
entry.Value.Data.Dispose();
}
_cache.Clear();
}
private struct CacheEntry
{
public T Data;
public WeakReference<IClydeViewport> Viewport;
}
}

View File

@@ -30,6 +30,7 @@ public sealed class AfterLightTargetOverlay : Overlay
return;
var lightOverlay = _overlay.GetOverlay<BeforeLightTargetOverlay>();
var lightRes = lightOverlay.GetCachedForViewport(args.Viewport);
var bounds = args.WorldBounds;
// at 1-1 render scale it's mostly fine but at 4x4 it's way too fkn big
@@ -38,7 +39,7 @@ public sealed class AfterLightTargetOverlay : Overlay
var localMatrix =
viewport.LightRenderTarget.GetWorldToLocalMatrix(viewport.Eye, newScale);
var diff = (lightOverlay.EnlargedLightTarget.Size - viewport.LightRenderTarget.Size);
var diff = (lightRes.EnlargedLightTarget.Size - viewport.LightRenderTarget.Size);
var halfDiff = diff / 2;
// Pixels -> Metres -> Half distance.
@@ -53,7 +54,7 @@ public sealed class AfterLightTargetOverlay : Overlay
viewport.LightRenderTarget.Size.Y + halfDiff.Y);
worldHandle.SetTransform(localMatrix);
worldHandle.DrawTextureRectRegion(lightOverlay.EnlargedLightTarget.Texture, bounds, subRegion: subRegion);
worldHandle.DrawTextureRectRegion(lightRes.EnlargedLightTarget.Texture, bounds, subRegion: subRegion);
}, Color.Transparent);
}
}

View File

@@ -1,4 +1,5 @@
using System.Numerics;
using Content.Client.Graphics;
using Content.Shared.CCVar;
using Content.Shared.Maps;
using Robust.Client.Graphics;
@@ -27,11 +28,7 @@ public sealed class AmbientOcclusionOverlay : Overlay
public override OverlaySpace Space => OverlaySpace.WorldSpaceBelowEntities;
private IRenderTexture? _aoTarget;
private IRenderTexture? _aoBlurBuffer;
// Couldn't figure out a way to avoid this so if you can then please do.
private IRenderTexture? _aoStencilTarget;
private readonly OverlayResourceCache<CachedResources> _resources = new ();
public AmbientOcclusionOverlay()
{
@@ -69,30 +66,32 @@ public sealed class AmbientOcclusionOverlay : Overlay
var turfSystem = _entManager.System<TurfSystem>();
var invMatrix = args.Viewport.GetWorldToLocalMatrix();
if (_aoTarget?.Texture.Size != target.Size)
var res = _resources.GetForViewport(args.Viewport, static _ => new CachedResources());
if (res.AOTarget?.Texture.Size != target.Size)
{
_aoTarget?.Dispose();
_aoTarget = _clyde.CreateRenderTarget(target.Size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "ambient-occlusion-target");
res.AOTarget?.Dispose();
res.AOTarget = _clyde.CreateRenderTarget(target.Size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "ambient-occlusion-target");
}
if (_aoBlurBuffer?.Texture.Size != target.Size)
if (res.AOBlurBuffer?.Texture.Size != target.Size)
{
_aoBlurBuffer?.Dispose();
_aoBlurBuffer = _clyde.CreateRenderTarget(target.Size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "ambient-occlusion-blur-target");
res.AOBlurBuffer?.Dispose();
res.AOBlurBuffer = _clyde.CreateRenderTarget(target.Size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "ambient-occlusion-blur-target");
}
if (_aoStencilTarget?.Texture.Size != target.Size)
if (res.AOStencilTarget?.Texture.Size != target.Size)
{
_aoStencilTarget?.Dispose();
_aoStencilTarget = _clyde.CreateRenderTarget(target.Size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "ambient-occlusion-stencil-target");
res.AOStencilTarget?.Dispose();
res.AOStencilTarget = _clyde.CreateRenderTarget(target.Size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "ambient-occlusion-stencil-target");
}
// Draw the texture data to the texture.
args.WorldHandle.RenderInRenderTarget(_aoTarget,
args.WorldHandle.RenderInRenderTarget(res.AOTarget,
() =>
{
worldHandle.UseShader(_proto.Index(UnshadedShader).Instance());
var invMatrix = _aoTarget.GetWorldToLocalMatrix(viewport.Eye!, scale);
var invMatrix = res.AOTarget.GetWorldToLocalMatrix(viewport.Eye!, scale);
foreach (var entry in query.QueryAabb(mapId, worldBounds))
{
@@ -106,11 +105,11 @@ public sealed class AmbientOcclusionOverlay : Overlay
}
}, Color.Transparent);
_clyde.BlurRenderTarget(viewport, _aoTarget, _aoBlurBuffer, viewport.Eye!, 14f);
_clyde.BlurRenderTarget(viewport, res.AOTarget, res.AOBlurBuffer, viewport.Eye!, 14f);
// Need to do stencilling after blur as it will nuke it.
// Draw stencil for the grid so we don't draw in space.
args.WorldHandle.RenderInRenderTarget(_aoStencilTarget,
args.WorldHandle.RenderInRenderTarget(res.AOStencilTarget,
() =>
{
// Don't want lighting affecting it.
@@ -136,13 +135,36 @@ public sealed class AmbientOcclusionOverlay : Overlay
// Draw the stencil texture to depth buffer.
worldHandle.UseShader(_proto.Index(StencilMaskShader).Instance());
worldHandle.DrawTextureRect(_aoStencilTarget!.Texture, worldBounds);
worldHandle.DrawTextureRect(res.AOStencilTarget!.Texture, worldBounds);
// Draw the Blurred AO texture finally.
worldHandle.UseShader(_proto.Index(StencilEqualDrawShader).Instance());
worldHandle.DrawTextureRect(_aoTarget!.Texture, worldBounds, color);
worldHandle.DrawTextureRect(res.AOTarget!.Texture, worldBounds, color);
args.WorldHandle.SetTransform(Matrix3x2.Identity);
args.WorldHandle.UseShader(null);
}
protected override void DisposeBehavior()
{
_resources.Dispose();
base.DisposeBehavior();
}
private sealed class CachedResources : IDisposable
{
public IRenderTexture? AOTarget;
public IRenderTexture? AOBlurBuffer;
// Couldn't figure out a way to avoid this so if you can then please do.
public IRenderTexture? AOStencilTarget;
public void Dispose()
{
AOTarget?.Dispose();
AOBlurBuffer?.Dispose();
AOStencilTarget?.Dispose();
}
}
}

View File

@@ -1,4 +1,4 @@
using System.Numerics;
using Content.Client.Graphics;
using Robust.Client.Graphics;
using Robust.Shared.Enums;
@@ -13,7 +13,8 @@ public sealed class BeforeLightTargetOverlay : Overlay
[Dependency] private readonly IClyde _clyde = default!;
public IRenderTexture EnlargedLightTarget = default!;
private readonly OverlayResourceCache<CachedResources> _resources = new();
public Box2Rotated EnlargedBounds;
/// <summary>
@@ -36,16 +37,42 @@ public sealed class BeforeLightTargetOverlay : Overlay
var size = args.Viewport.LightRenderTarget.Size + (int) (_skirting * EyeManager.PixelsPerMeter);
EnlargedBounds = args.WorldBounds.Enlarged(_skirting / 2f);
var res = _resources.GetForViewport(args.Viewport, static _ => new CachedResources());
// This just exists to copy the lightrendertarget and write back to it.
if (EnlargedLightTarget?.Size != size)
if (res.EnlargedLightTarget?.Size != size)
{
EnlargedLightTarget = _clyde
res.EnlargedLightTarget = _clyde
.CreateRenderTarget(size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "enlarged-light-copy");
}
args.WorldHandle.RenderInRenderTarget(EnlargedLightTarget,
args.WorldHandle.RenderInRenderTarget(res.EnlargedLightTarget,
() =>
{
}, _clyde.GetClearColor(args.MapUid));
}
internal CachedResources GetCachedForViewport(IClydeViewport viewport)
{
return _resources.GetForViewport(viewport,
static _ => throw new InvalidOperationException(
"Expected BeforeLightTargetOverlay to have created its resources"));
}
protected override void DisposeBehavior()
{
_resources.Dispose();
base.DisposeBehavior();
}
internal sealed class CachedResources : IDisposable
{
public IRenderTexture EnlargedLightTarget = default!;
public void Dispose()
{
EnlargedLightTarget?.Dispose();
}
}
}

View File

@@ -1,3 +1,4 @@
using Content.Client.Graphics;
using Robust.Client.Graphics;
using Robust.Shared.Enums;
@@ -15,7 +16,7 @@ public sealed class LightBlurOverlay : Overlay
public const int ContentZIndex = TileEmissionOverlay.ContentZIndex + 1;
private IRenderTarget? _blurTarget;
private readonly OverlayResourceCache<CachedResources> _resources = new();
public LightBlurOverlay()
{
@@ -29,16 +30,36 @@ public sealed class LightBlurOverlay : Overlay
return;
var beforeOverlay = _overlay.GetOverlay<BeforeLightTargetOverlay>();
var size = beforeOverlay.EnlargedLightTarget.Size;
var beforeLightRes = beforeOverlay.GetCachedForViewport(args.Viewport);
var res = _resources.GetForViewport(args.Viewport, static _ => new CachedResources());
if (_blurTarget?.Size != size)
var size = beforeLightRes.EnlargedLightTarget.Size;
if (res.BlurTarget?.Size != size)
{
_blurTarget = _clyde
res.BlurTarget = _clyde
.CreateRenderTarget(size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "enlarged-light-blur");
}
var target = beforeOverlay.EnlargedLightTarget;
var target = beforeLightRes.EnlargedLightTarget;
// Yeah that's all this does keep walkin.
_clyde.BlurRenderTarget(args.Viewport, target, _blurTarget, args.Viewport.Eye, 14f * 5f);
_clyde.BlurRenderTarget(args.Viewport, target, res.BlurTarget, args.Viewport.Eye, 14f * 5f);
}
protected override void DisposeBehavior()
{
_resources.Dispose();
base.DisposeBehavior();
}
private sealed class CachedResources : IDisposable
{
public IRenderTarget? BlurTarget;
public void Dispose()
{
BlurTarget?.Dispose();
}
}
}

View File

@@ -51,8 +51,9 @@ public sealed class RoofOverlay : Overlay
var worldHandle = args.WorldHandle;
var lightoverlay = _overlay.GetOverlay<BeforeLightTargetOverlay>();
var lightRes = lightoverlay.GetCachedForViewport(args.Viewport);
var bounds = lightoverlay.EnlargedBounds;
var target = lightoverlay.EnlargedLightTarget;
var target = lightRes.EnlargedLightTarget;
_grids.Clear();
_mapManager.FindGridsIntersecting(args.MapId, bounds, ref _grids, approx: true, includeMap: true);

View File

@@ -1,4 +1,5 @@
using System.Numerics;
using Content.Client.Graphics;
using Content.Shared.Light.Components;
using Robust.Client.Graphics;
using Robust.Shared.Enums;
@@ -24,8 +25,7 @@ public sealed class SunShadowOverlay : Overlay
private readonly HashSet<Entity<SunShadowCastComponent>> _shadows = new();
private IRenderTexture? _blurTarget;
private IRenderTexture? _target;
private readonly OverlayResourceCache<CachedResources> _resources = new();
public SunShadowOverlay()
{
@@ -55,16 +55,18 @@ public sealed class SunShadowOverlay : Overlay
var worldBounds = args.WorldBounds;
var targetSize = viewport.LightRenderTarget.Size;
if (_target?.Size != targetSize)
var res = _resources.GetForViewport(args.Viewport, static _ => new CachedResources());
if (res.Target?.Size != targetSize)
{
_target = _clyde
res.Target = _clyde
.CreateRenderTarget(targetSize,
new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb),
name: "sun-shadow-target");
if (_blurTarget?.Size != targetSize)
if (res.BlurTarget?.Size != targetSize)
{
_blurTarget = _clyde
res.BlurTarget = _clyde
.CreateRenderTarget(targetSize, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "sun-shadow-blur");
}
}
@@ -93,11 +95,11 @@ public sealed class SunShadowOverlay : Overlay
_shadows.Clear();
// Draw shadow polys to stencil
args.WorldHandle.RenderInRenderTarget(_target,
args.WorldHandle.RenderInRenderTarget(res.Target,
() =>
{
var invMatrix =
_target.GetWorldToLocalMatrix(eye, scale);
res.Target.GetWorldToLocalMatrix(eye, scale);
var indices = new Vector2[PhysicsConstants.MaxPolygonVertices * 2];
// Go through shadows in range.
@@ -142,7 +144,7 @@ public sealed class SunShadowOverlay : Overlay
Color.Transparent);
// Slightly blur it just to avoid aliasing issues on the later viewport-wide blur.
_clyde.BlurRenderTarget(viewport, _target, _blurTarget!, eye, 1f);
_clyde.BlurRenderTarget(viewport, res.Target, res.BlurTarget!, eye, 1f);
// Draw stencil (see roofoverlay).
args.WorldHandle.RenderInRenderTarget(viewport.LightRenderTarget,
@@ -155,8 +157,27 @@ public sealed class SunShadowOverlay : Overlay
var maskShader = _protoManager.Index(MixShader).Instance();
worldHandle.UseShader(maskShader);
worldHandle.DrawTextureRect(_target.Texture, worldBounds, Color.Black.WithAlpha(alpha));
worldHandle.DrawTextureRect(res.Target.Texture, worldBounds, Color.Black.WithAlpha(alpha));
}, null);
}
}
protected override void DisposeBehavior()
{
_resources.Dispose();
base.DisposeBehavior();
}
private sealed class CachedResources : IDisposable
{
public IRenderTexture? BlurTarget;
public IRenderTexture? Target;
public void Dispose()
{
BlurTarget?.Dispose();
Target?.Dispose();
}
}
}

View File

@@ -47,7 +47,7 @@ public sealed class TileEmissionOverlay : Overlay
var worldHandle = args.WorldHandle;
var lightoverlay = _overlay.GetOverlay<BeforeLightTargetOverlay>();
var bounds = lightoverlay.EnlargedBounds;
var target = lightoverlay.EnlargedLightTarget;
var target = lightoverlay.GetCachedForViewport(args.Viewport).EnlargedLightTarget;
var viewport = args.Viewport;
_grids.Clear();
_mapManager.FindGridsIntersecting(mapId, bounds, ref _grids, approx: true);

View File

@@ -186,10 +186,10 @@ namespace Content.Client.Lobby
else
{
Lobby!.StartTime.Text = string.Empty;
Lobby!.ReadyButton.Pressed = _gameTicker.AreWeReady;
Lobby!.ReadyButton.Text = Loc.GetString(Lobby!.ReadyButton.Pressed ? "lobby-state-player-status-ready": "lobby-state-player-status-not-ready");
Lobby!.ReadyButton.ToggleMode = true;
Lobby!.ReadyButton.Disabled = false;
Lobby!.ReadyButton.Pressed = _gameTicker.AreWeReady;
Lobby!.ObserveButton.Disabled = true;
}

View File

@@ -678,8 +678,10 @@ namespace Content.Client.Lobby.UI
selector.Setup(items, title, 250, description, guides: antag.Guides);
selector.Select(Profile?.AntagPreferences.Contains(antag.ID) == true ? 0 : 1);
var requirements = _entManager.System<SharedRoleSystem>().GetAntagRequirement(antag);
if (!_requirements.CheckRoleRequirements(requirements, (HumanoidCharacterProfile?)_preferencesManager.Preferences?.SelectedCharacter, out var reason))
if (!_requirements.IsAllowed(
antag,
(HumanoidCharacterProfile?)_preferencesManager.Preferences?.SelectedCharacter,
out var reason))
{
selector.LockRequirements(reason);
Profile = Profile?.WithAntagPreference(antag.ID, false);

View File

@@ -1,7 +0,0 @@
using Content.Shared.Nutrition.EntitySystems;
namespace Content.Client.Nutrition.EntitySystems;
public sealed class DrinkSystem : SharedDrinkSystem
{
}

View File

@@ -7,7 +7,11 @@ namespace Content.Client.Overlays;
public sealed partial class StencilOverlay
{
private void DrawRestrictedRange(in OverlayDrawArgs args, RestrictedRangeComponent rangeComp, Matrix3x2 invMatrix)
private void DrawRestrictedRange(
in OverlayDrawArgs args,
CachedResources res,
RestrictedRangeComponent rangeComp,
Matrix3x2 invMatrix)
{
var worldHandle = args.WorldHandle;
var renderScale = args.Viewport.RenderScale.X;
@@ -38,7 +42,7 @@ public sealed partial class StencilOverlay
// Cut out the irrelevant bits via stencil
// This is why we don't just use parallax; we might want specific tiles to get drawn over
// particularly for planet maps or stations.
worldHandle.RenderInRenderTarget(_blep!, () =>
worldHandle.RenderInRenderTarget(res.Blep!, () =>
{
worldHandle.UseShader(_shader);
worldHandle.DrawRect(localAABB, Color.White);
@@ -46,7 +50,7 @@ public sealed partial class StencilOverlay
worldHandle.SetTransform(Matrix3x2.Identity);
worldHandle.UseShader(_protoManager.Index(StencilMask).Instance());
worldHandle.DrawTextureRect(_blep!.Texture, worldBounds);
worldHandle.DrawTextureRect(res.Blep!.Texture, worldBounds);
var curTime = _timing.RealTime;
var sprite = _sprite.GetFrame(new SpriteSpecifier.Texture(new ResPath("/Textures/Parallaxes/noise.png")), curTime);

View File

@@ -11,7 +11,12 @@ public sealed partial class StencilOverlay
{
private List<Entity<MapGridComponent>> _grids = new();
private void DrawWeather(in OverlayDrawArgs args, WeatherPrototype weatherProto, float alpha, Matrix3x2 invMatrix)
private void DrawWeather(
in OverlayDrawArgs args,
CachedResources res,
WeatherPrototype weatherProto,
float alpha,
Matrix3x2 invMatrix)
{
var worldHandle = args.WorldHandle;
var mapId = args.MapId;
@@ -22,7 +27,7 @@ public sealed partial class StencilOverlay
// Cut out the irrelevant bits via stencil
// This is why we don't just use parallax; we might want specific tiles to get drawn over
// particularly for planet maps or stations.
worldHandle.RenderInRenderTarget(_blep!, () =>
worldHandle.RenderInRenderTarget(res.Blep!, () =>
{
var xformQuery = _entManager.GetEntityQuery<TransformComponent>();
_grids.Clear();
@@ -56,7 +61,7 @@ public sealed partial class StencilOverlay
worldHandle.SetTransform(Matrix3x2.Identity);
worldHandle.UseShader(_protoManager.Index(StencilMask).Instance());
worldHandle.DrawTextureRect(_blep!.Texture, worldBounds);
worldHandle.DrawTextureRect(res.Blep!.Texture, worldBounds);
var curTime = _timing.RealTime;
var sprite = _sprite.GetFrame(weatherProto.Sprite, curTime);

View File

@@ -1,4 +1,5 @@
using System.Numerics;
using Content.Client.Graphics;
using Content.Client.Parallax;
using Content.Client.Weather;
using Content.Shared.Salvage;
@@ -34,7 +35,7 @@ public sealed partial class StencilOverlay : Overlay
public override OverlaySpace Space => OverlaySpace.WorldSpaceBelowFOV;
private IRenderTexture? _blep;
private readonly OverlayResourceCache<CachedResources> _resources = new();
private readonly ShaderInstance _shader;
@@ -55,10 +56,12 @@ public sealed partial class StencilOverlay : Overlay
var mapUid = _map.GetMapOrInvalid(args.MapId);
var invMatrix = args.Viewport.GetWorldToLocalMatrix();
if (_blep?.Texture.Size != args.Viewport.Size)
var res = _resources.GetForViewport(args.Viewport, static _ => new CachedResources());
if (res.Blep?.Texture.Size != args.Viewport.Size)
{
_blep?.Dispose();
_blep = _clyde.CreateRenderTarget(args.Viewport.Size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "weather-stencil");
res.Blep?.Dispose();
res.Blep = _clyde.CreateRenderTarget(args.Viewport.Size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "weather-stencil");
}
if (_entManager.TryGetComponent<WeatherComponent>(mapUid, out var comp))
@@ -69,16 +72,33 @@ public sealed partial class StencilOverlay : Overlay
continue;
var alpha = _weather.GetPercent(weather, mapUid);
DrawWeather(args, weatherProto, alpha, invMatrix);
DrawWeather(args, res, weatherProto, alpha, invMatrix);
}
}
if (_entManager.TryGetComponent<RestrictedRangeComponent>(mapUid, out var restrictedRangeComponent))
{
DrawRestrictedRange(args, restrictedRangeComponent, invMatrix);
DrawRestrictedRange(args, res, restrictedRangeComponent, invMatrix);
}
args.WorldHandle.UseShader(null);
args.WorldHandle.SetTransform(Matrix3x2.Identity);
}
protected override void DisposeBehavior()
{
_resources.Dispose();
base.DisposeBehavior();
}
private sealed class CachedResources : IDisposable
{
public IRenderTexture? Blep;
public void Dispose()
{
Blep?.Dispose();
}
}
}

View File

@@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Content.Client.Lobby;
using Content.Shared.CCVar;
using Content.Shared.Players;
using Content.Shared.Players.JobWhitelist;
@@ -26,7 +25,8 @@ public sealed class JobRequirementsManager : ISharedPlaytimeManager
[Dependency] private readonly IPrototypeManager _prototypes = default!;
private readonly Dictionary<string, TimeSpan> _roles = new();
private readonly List<string> _roleBans = new();
private readonly List<string> _jobBans = new();
private readonly List<string> _antagBans = new();
private readonly List<string> _jobWhitelists = new();
private ISawmill _sawmill = default!;
@@ -52,16 +52,19 @@ public sealed class JobRequirementsManager : ISharedPlaytimeManager
// Reset on disconnect, just in case.
_roles.Clear();
_jobWhitelists.Clear();
_roleBans.Clear();
_jobBans.Clear();
_antagBans.Clear();
}
}
private void RxRoleBans(MsgRoleBans message)
{
_sawmill.Debug($"Received roleban info containing {message.Bans.Count} entries.");
_sawmill.Debug($"Received role ban info: {message.JobBans.Count} job ban entries and {message.AntagBans.Count} antag ban entries.");
_roleBans.Clear();
_roleBans.AddRange(message.Bans);
_jobBans.Clear();
_jobBans.AddRange(message.JobBans);
_antagBans.Clear();
_antagBans.AddRange(message.AntagBans);
Updated?.Invoke();
}
@@ -90,33 +93,97 @@ public sealed class JobRequirementsManager : ISharedPlaytimeManager
Updated?.Invoke();
}
public bool IsAllowed(JobPrototype job, HumanoidCharacterProfile? profile, [NotNullWhen(false)] out FormattedMessage? reason)
/// <summary>
/// Check a list of job- and antag prototypes against the current player, for requirements and bans.
/// </summary>
/// <returns>
/// False if any of the prototypes are banned or have unmet requirements.
/// </returns>>
public bool IsAllowed(
List<ProtoId<JobPrototype>>? jobs,
List<ProtoId<AntagPrototype>>? antags,
HumanoidCharacterProfile? profile,
[NotNullWhen(false)] out FormattedMessage? reason)
{
reason = null;
if (_roleBans.Contains($"Job:{job.ID}"))
if (antags is not null)
{
foreach (var proto in antags)
{
if (!IsAllowed(_prototypes.Index(proto), profile, out reason))
return false;
}
}
if (jobs is not null)
{
foreach (var proto in jobs)
{
if (!IsAllowed(_prototypes.Index(proto), profile, out reason))
return false;
}
}
return true;
}
/// <summary>
/// Check the job prototype against the current player, for requirements and bans
/// </summary>
public bool IsAllowed(
JobPrototype job,
HumanoidCharacterProfile? profile,
[NotNullWhen(false)] out FormattedMessage? reason)
{
// Check the player's bans
if (_jobBans.Contains(job.ID))
{
reason = FormattedMessage.FromUnformatted(Loc.GetString("role-ban"));
return false;
}
// Check whitelist requirements
if (!CheckWhitelist(job, out reason))
return false;
var player = _playerManager.LocalSession;
if (player == null)
return true;
// Check other role requirements
var reqs = _entManager.System<SharedRoleSystem>().GetRoleRequirements(job);
if (!CheckRoleRequirements(reqs, profile, out reason))
return false;
return CheckRoleRequirements(job, profile, out reason);
return true;
}
public bool CheckRoleRequirements(JobPrototype job, HumanoidCharacterProfile? profile, [NotNullWhen(false)] out FormattedMessage? reason)
/// <summary>
/// Check the antag prototype against the current player, for requirements and bans
/// </summary>
public bool IsAllowed(
AntagPrototype antag,
HumanoidCharacterProfile? profile,
[NotNullWhen(false)] out FormattedMessage? reason)
{
var reqs = _entManager.System<SharedRoleSystem>().GetJobRequirement(job);
return CheckRoleRequirements(reqs, profile, out reason);
// Check the player's bans
if (_antagBans.Contains(antag.ID))
{
reason = FormattedMessage.FromUnformatted(Loc.GetString("role-ban"));
return false;
}
// Check whitelist requirements
if (!CheckWhitelist(antag, out reason))
return false;
// Check other role requirements
var reqs = _entManager.System<SharedRoleSystem>().GetRoleRequirements(antag);
if (!CheckRoleRequirements(reqs, profile, out reason))
return false;
return true;
}
public bool CheckRoleRequirements(HashSet<JobRequirement>? requirements, HumanoidCharacterProfile? profile, [NotNullWhen(false)] out FormattedMessage? reason)
// This must be private so code paths can't accidentally skip requirement overrides. Call this through IsAllowed()
private bool CheckRoleRequirements(HashSet<JobRequirement>? requirements, HumanoidCharacterProfile? profile, [NotNullWhen(false)] out FormattedMessage? reason)
{
reason = null;
@@ -151,6 +218,15 @@ public sealed class JobRequirementsManager : ISharedPlaytimeManager
return true;
}
public bool CheckWhitelist(AntagPrototype antag, [NotNullWhen(false)] out FormattedMessage? reason)
{
reason = default;
// TODO: Implement antag whitelisting.
return true;
}
public TimeSpan FetchOverallPlaytime()
{
return _roles.TryGetValue("Overall", out var overallPlaytime) ? overallPlaytime : TimeSpan.Zero;

View File

@@ -125,7 +125,7 @@ public sealed class RCDMenuBoundUserInterface : BoundUserInterface
var name = Loc.GetString(proto.SetName);
if (proto.Prototype != null &&
_prototypeManager.Resolve(proto.Prototype, out var entProto))
_prototypeManager.TryIndex(proto.Prototype, out var entProto)) // don't use Resolve because this can be a tile
{
name = entProto.Name;
}
@@ -144,7 +144,7 @@ public sealed class RCDMenuBoundUserInterface : BoundUserInterface
if (proto.Mode is RcdMode.ConstructTile or RcdMode.ConstructObject
&& proto.Prototype != null
&& _prototypeManager.Resolve(proto.Prototype, out var entProto))
&& _prototypeManager.TryIndex(proto.Prototype, out var entProto)) // don't use Resolve because this can be a tile
{
tooltip = Loc.GetString(entProto.Name);
}

View File

@@ -0,0 +1,42 @@
using Content.Shared.Silicons.StationAi;
using Robust.Client.UserInterface;
namespace Content.Client.Silicons.StationAi;
public sealed class StationAiFixerConsoleBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
{
private StationAiFixerConsoleWindow? _window;
protected override void Open()
{
base.Open();
_window = this.CreateWindow<StationAiFixerConsoleWindow>();
_window.SetOwner(Owner);
_window.SendStationAiFixerConsoleMessageAction += SendStationAiFixerConsoleMessage;
_window.OpenConfirmationDialogAction += OpenConfirmationDialog;
}
public override void Update()
{
base.Update();
_window?.UpdateState();
}
private void OpenConfirmationDialog()
{
if (_window == null)
return;
_window.ConfirmationDialog?.Close();
_window.ConfirmationDialog = new StationAiFixerConsoleConfirmationDialog();
_window.ConfirmationDialog.OpenCentered();
_window.ConfirmationDialog.SendStationAiFixerConsoleMessageAction += SendStationAiFixerConsoleMessage;
}
private void SendStationAiFixerConsoleMessage(StationAiFixerConsoleAction action)
{
SendPredictedMessage(new StationAiFixerConsoleMessage(action));
}
}

View File

@@ -0,0 +1,22 @@
<controls:FancyWindow xmlns="https://spacestation14.io"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
Title="{Loc 'station-ai-fixer-console-window-purge-warning-title'}"
Resizable="False">
<BoxContainer Orientation="Vertical" VerticalExpand="True" SetWidth="400">
<RichTextLabel Name="PurgeWarningLabel1" Margin="20 10 20 0"/>
<RichTextLabel Name="PurgeWarningLabel2" Margin="20 10 20 0"/>
<RichTextLabel Name="PurgeWarningLabel3" Margin="20 10 20 10"/>
<BoxContainer HorizontalExpand="True">
<Button Name="CancelPurge"
Text="{Loc 'station-ai-fixer-console-window-cancel-action'}"
SetWidth="150"
Margin="20 10 0 10"/>
<Control HorizontalExpand="True"/>
<Button Name="ContinuePurge"
Text="{Loc 'station-ai-fixer-console-window-continue-action'}"
SetWidth="150"
Margin="0 10 20 10"/>
</BoxContainer>
</BoxContainer>
</controls:FancyWindow>

View File

@@ -0,0 +1,30 @@
using Content.Client.UserInterface.Controls;
using Content.Shared.Silicons.StationAi;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.XAML;
namespace Content.Client.Silicons.StationAi;
[GenerateTypedNameReferences]
public sealed partial class StationAiFixerConsoleConfirmationDialog : FancyWindow
{
public event Action<StationAiFixerConsoleAction>? SendStationAiFixerConsoleMessageAction;
public StationAiFixerConsoleConfirmationDialog()
{
RobustXamlLoader.Load(this);
PurgeWarningLabel1.SetMessage(Loc.GetString($"station-ai-fixer-console-window-purge-warning-1"));
PurgeWarningLabel2.SetMessage(Loc.GetString($"station-ai-fixer-console-window-purge-warning-2"));
PurgeWarningLabel3.SetMessage(Loc.GetString($"station-ai-fixer-console-window-purge-warning-3"));
CancelPurge.OnButtonDown += _ => Close();
ContinuePurge.OnButtonDown += _ => OnSendStationAiFixerConsoleMessage(StationAiFixerConsoleAction.Purge);
}
public void OnSendStationAiFixerConsoleMessage(StationAiFixerConsoleAction action)
{
SendStationAiFixerConsoleMessageAction?.Invoke(action);
Close();
}
}

View File

@@ -0,0 +1,24 @@
using Content.Shared.Silicons.StationAi;
using Robust.Client.GameObjects;
namespace Content.Client.Silicons.StationAi;
public sealed partial class StationAiFixerConsoleSystem : SharedStationAiFixerConsoleSystem
{
[Dependency] private readonly SharedUserInterfaceSystem _userInterface = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<StationAiFixerConsoleComponent, AppearanceChangeEvent>(OnAppearanceChange);
}
private void OnAppearanceChange(Entity<StationAiFixerConsoleComponent> ent, ref AppearanceChangeEvent args)
{
if (_userInterface.TryGetOpenUi(ent.Owner, StationAiFixerConsoleUiKey.Key, out var bui))
{
bui?.Update<StationAiFixerConsoleBoundUserInterfaceState>();
}
}
}

View File

@@ -0,0 +1,172 @@
<controls:FancyWindow xmlns="https://spacestation14.io"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
xmlns:gfx="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
Title="{Loc 'station-ai-fixer-console-window'}"
Resizable="False">
<BoxContainer Orientation="Vertical" VerticalExpand="True">
<BoxContainer VerticalExpand="True" HorizontalExpand="True" Orientation="Horizontal">
<!-- Left side - AI display -->
<BoxContainer VerticalExpand="True" HorizontalExpand="True" Orientation="Vertical" MinWidth="225" Margin="20 15 20 20">
<!-- AI panel -->
<PanelContainer>
<PanelContainer.PanelOverride>
<gfx:StyleBoxFlat BackgroundColor="#1B1B1E" />
</PanelContainer.PanelOverride>
<BoxContainer Orientation="Vertical">
<!-- AI name -->
<Label Name="StationAiNameLabel"
HorizontalAlignment="Center"
Margin="0 5 0 0"
Text="{Loc 'station-ai-fixer-console-window-no-station-ai'}"/>
<!-- AI portrait -->
<AnimatedTextureRect Name="StationAiPortraitTexture" VerticalAlignment="Center" SetSize="128 128" />
</BoxContainer>
</PanelContainer>
<!-- AI status panel-->
<PanelContainer Name="StationAiStatus">
<PanelContainer.PanelOverride>
<gfx:StyleBoxFlat BackgroundColor="#757575" />
</PanelContainer.PanelOverride>
<!-- AI name -->
<Label Name="StationAiStatusLabel"
HorizontalAlignment="Center"
Text="{Loc 'station-ai-fixer-console-window-no-station-ai-status'}"/>
</PanelContainer>
</BoxContainer>
<!-- Central divider -->
<PanelContainer StyleClasses="LowDivider" VerticalExpand="True" Margin="0 0 0 0" SetWidth="2"/>
<!-- Right side - control panel -->
<BoxContainer VerticalExpand="True" HorizontalExpand="True" Orientation="Vertical" MinWidth="225" Margin="10 10 10 10">
<!-- Locked controls -->
<BoxContainer Name="LockScreen"
VerticalExpand="True"
HorizontalExpand="True"
Orientation="Vertical"
ReservesSpace="False">
<controls:StripeBack VerticalExpand="True" HorizontalExpand="True" Margin="0 0 0 5">
<PanelContainer VerticalExpand="True" HorizontalExpand="True">
<BoxContainer VerticalExpand="True" HorizontalExpand="True" Orientation="Vertical">
<Control VerticalExpand="True"/>
<TextureRect VerticalAlignment="Center"
HorizontalAlignment="Center"
SetSize="64 64"
Stretch="KeepAspectCentered"
TexturePath="/Textures/Interface/VerbIcons/lock.svg.192dpi.png">
</TextureRect>
<Label Text="{Loc 'station-ai-fixer-console-window-controls-locked'}"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Margin="0 5 0 0"/>
<Control VerticalExpand="True"/>
</BoxContainer>
</PanelContainer>
</controls:StripeBack>
</BoxContainer>
<!-- Action progress screen -->
<BoxContainer Name="ActionProgressScreen"
VerticalExpand="True"
HorizontalExpand="True"
Orientation="Vertical"
ReservesSpace="False"
Visible="False">
<Control VerticalExpand="True" Margin="0 0 0 0"/>
<Label Name="ActionInProgressLabel" Text="???" HorizontalAlignment="Center"/>
<ProgressBar Name="ActionProgressBar"
MinValue="0"
MaxValue="1"
SetHeight="20"
Margin="5 10 5 10">
</ProgressBar>
<Label Name="ActionProgressEtaLabel" Text="???" HorizontalAlignment="Center"/>
<!-- Cancel button -->
<Button Name="CancelButton" HorizontalExpand="True" Margin="0 20 0 10" SetHeight="40"
Text="{Loc 'station-ai-fixer-console-window-cancel-action'}">
<TextureRect HorizontalAlignment="Left"
VerticalAlignment="Center"
SetSize="24 24"
Stretch="KeepAspectCentered"
TexturePath="/Textures/Interface/Nano/cross.svg.png">
</TextureRect>
</Button>
</BoxContainer>
<!-- Visible controls -->
<BoxContainer Name="MainControls"
VerticalExpand="True"
HorizontalExpand="True"
Orientation="Vertical"
ReservesSpace="False"
Visible="False">
<controls:StripeBack>
<PanelContainer>
<Label Text="{Loc 'Controls'}"
HorizontalExpand="True"
HorizontalAlignment="Center"/>
</PanelContainer>
</controls:StripeBack>
<!-- Eject button -->
<Button Name="EjectButton" HorizontalExpand="True" Margin="0 10 0 0" SetHeight="40"
Text="{Loc 'station-ai-fixer-console-window-station-ai-eject'}">
<TextureRect HorizontalAlignment="Left"
VerticalAlignment="Center"
SetSize="32 32"
Stretch="KeepAspectCentered"
TexturePath="/Textures/Interface/VerbIcons/eject.svg.192dpi.png">
</TextureRect>
</Button>
<!-- Repair button -->
<Button Name="RepairButton" HorizontalExpand="True" Margin="0 10 0 0" SetHeight="40"
Text="{Loc 'station-ai-fixer-console-window-station-ai-repair'}">
<TextureRect HorizontalAlignment="Left"
VerticalAlignment="Center"
SetSize="32 32"
Stretch="KeepAspectCentered"
TexturePath="/Textures/Interface/hammer_scaled.svg.192dpi.png">
</TextureRect>
</Button>
<!-- Purge button -->
<Button Name="PurgeButton" HorizontalExpand="True" Margin="0 10 0 0" SetHeight="40"
Text="{Loc 'station-ai-fixer-console-window-station-ai-purge'}">
<TextureRect HorizontalAlignment="Left"
VerticalAlignment="Center"
SetSize="32 32"
Stretch="KeepAspectCentered"
TexturePath="/Textures/Interface/VerbIcons/delete_transparent.svg.192dpi.png">
</TextureRect>
</Button>
</BoxContainer>
</BoxContainer>
</BoxContainer>
<!-- Footer -->
<BoxContainer Orientation="Vertical">
<PanelContainer StyleClasses="LowDivider" />
<BoxContainer Orientation="Horizontal" Margin="10 2 5 0" VerticalAlignment="Bottom">
<Label Text="{Loc 'station-ai-fixer-console-window-flavor-left'}" StyleClasses="WindowFooterText" />
<Label Text="{Loc 'station-ai-fixer-console-window-flavor-right'}" StyleClasses="WindowFooterText"
HorizontalAlignment="Right" HorizontalExpand="True" Margin="0 0 5 0" />
<TextureRect StyleClasses="NTLogoDark" Stretch="KeepAspectCentered"
VerticalAlignment="Center" HorizontalAlignment="Right" SetSize="19 19"/>
</BoxContainer>
</BoxContainer>
</BoxContainer>
</controls:FancyWindow>

View File

@@ -0,0 +1,198 @@
using Content.Client.UserInterface.Controls;
using Content.Shared.Lock;
using Content.Shared.Silicons.StationAi;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
using System.Numerics;
namespace Content.Client.Silicons.StationAi;
[GenerateTypedNameReferences]
public sealed partial class StationAiFixerConsoleWindow : FancyWindow
{
[Dependency] private readonly IEntityManager _entManager = default!;
[Dependency] private readonly IGameTiming _timing = default!;
private readonly StationAiFixerConsoleSystem _stationAiFixerConsole;
private readonly SharedStationAiSystem _stationAi;
private EntityUid? _owner;
private readonly SpriteSpecifier.Rsi _emptyPortrait = new(new("Mobs/Silicon/station_ai.rsi"), "ai_empty");
private readonly SpriteSpecifier.Rsi _rebootingPortrait = new(new("Mobs/Silicon/station_ai.rsi"), "ai_fuzz");
private SpriteSpecifier? _currentPortrait;
public event Action<StationAiFixerConsoleAction>? SendStationAiFixerConsoleMessageAction;
public event Action? OpenConfirmationDialogAction;
public StationAiFixerConsoleConfirmationDialog? ConfirmationDialog;
private readonly Dictionary<StationAiState, Color> _statusColors = new()
{
[StationAiState.Empty] = Color.FromHex("#464966"),
[StationAiState.Occupied] = Color.FromHex("#3E6C45"),
[StationAiState.Rebooting] = Color.FromHex("#A5762F"),
[StationAiState.Dead] = Color.FromHex("#BB3232"),
};
public StationAiFixerConsoleWindow()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
_stationAiFixerConsole = _entManager.System<StationAiFixerConsoleSystem>();
_stationAi = _entManager.System<StationAiSystem>();
StationAiPortraitTexture.DisplayRect.TextureScale = new Vector2(4f, 4f);
CancelButton.OnButtonDown += _ => OnSendStationAiFixerConsoleMessage(StationAiFixerConsoleAction.Cancel);
EjectButton.OnButtonDown += _ => OnSendStationAiFixerConsoleMessage(StationAiFixerConsoleAction.Eject);
RepairButton.OnButtonDown += _ => OnSendStationAiFixerConsoleMessage(StationAiFixerConsoleAction.Repair);
PurgeButton.OnButtonDown += _ => OnOpenConfirmationDialog();
CancelButton.Label.HorizontalAlignment = HAlignment.Left;
EjectButton.Label.HorizontalAlignment = HAlignment.Left;
RepairButton.Label.HorizontalAlignment = HAlignment.Left;
PurgeButton.Label.HorizontalAlignment = HAlignment.Left;
CancelButton.Label.Margin = new Thickness(40, 0, 0, 0);
EjectButton.Label.Margin = new Thickness(40, 0, 0, 0);
RepairButton.Label.Margin = new Thickness(40, 0, 0, 0);
PurgeButton.Label.Margin = new Thickness(40, 0, 0, 0);
}
public void OnSendStationAiFixerConsoleMessage(StationAiFixerConsoleAction action)
{
SendStationAiFixerConsoleMessageAction?.Invoke(action);
}
public void OnOpenConfirmationDialog()
{
OpenConfirmationDialogAction?.Invoke();
}
public override void Close()
{
base.Close();
ConfirmationDialog?.Close();
}
public void SetOwner(EntityUid owner)
{
_owner = owner;
UpdateState();
}
public void UpdateState()
{
if (!_entManager.TryGetComponent<StationAiFixerConsoleComponent>(_owner, out var stationAiFixerConsole))
return;
var ent = (_owner.Value, stationAiFixerConsole);
var isLocked = _entManager.TryGetComponent<LockComponent>(_owner, out var lockable) && lockable.Locked;
var stationAiHolderInserted = _stationAiFixerConsole.IsStationAiHolderInserted((_owner.Value, stationAiFixerConsole));
var stationAi = stationAiFixerConsole.ActionTarget;
var stationAiState = StationAiState.Empty;
if (_entManager.TryGetComponent<StationAiCustomizationComponent>(stationAi, out var stationAiCustomization))
{
stationAiState = stationAiCustomization.State;
}
// Set subscreen visibility
LockScreen.Visible = isLocked;
MainControls.Visible = !isLocked && !_stationAiFixerConsole.IsActionInProgress(ent);
ActionProgressScreen.Visible = !isLocked && _stationAiFixerConsole.IsActionInProgress(ent);
// Update station AI name
StationAiNameLabel.Text = GetStationAiName(stationAi);
StationAiStatusLabel.Text = Loc.GetString("station-ai-fixer-console-window-no-station-ai-status");
// Update station AI portrait
var portrait = _emptyPortrait;
var statusColor = _statusColors[StationAiState.Empty];
if (stationAiState == StationAiState.Rebooting)
{
portrait = _rebootingPortrait;
StationAiStatusLabel.Text = Loc.GetString("station-ai-fixer-console-window-station-ai-rebooting");
_statusColors.TryGetValue(StationAiState.Rebooting, out statusColor);
}
else if (stationAi != null &&
stationAiCustomization != null &&
_stationAi.TryGetCustomizedAppearanceData((stationAi.Value, stationAiCustomization), out var layerData))
{
StationAiStatusLabel.Text = stationAiState == StationAiState.Occupied ?
Loc.GetString("station-ai-fixer-console-window-station-ai-online") :
Loc.GetString("station-ai-fixer-console-window-station-ai-offline");
if (layerData.TryGetValue(stationAiState.ToString(), out var stateData) && stateData is { RsiPath: not null, State: not null })
{
portrait = new SpriteSpecifier.Rsi(new ResPath(stateData.RsiPath), stateData.State);
}
_statusColors.TryGetValue(stationAiState, out statusColor);
}
if (_currentPortrait == null || !_currentPortrait.Equals(portrait))
{
StationAiPortraitTexture.SetFromSpriteSpecifier(portrait);
_currentPortrait = portrait;
}
StationAiStatus.PanelOverride = new StyleBoxFlat
{
BackgroundColor = statusColor,
};
// Update buttons
EjectButton.Disabled = !stationAiHolderInserted;
RepairButton.Disabled = !stationAiHolderInserted || stationAiState != StationAiState.Dead;
PurgeButton.Disabled = !stationAiHolderInserted || stationAiState == StationAiState.Empty;
// Update progress bar
if (ActionProgressScreen.Visible)
UpdateProgressBar(ent);
}
public void UpdateProgressBar(Entity<StationAiFixerConsoleComponent> ent)
{
ActionInProgressLabel.Text = ent.Comp.ActionType == StationAiFixerConsoleAction.Repair ?
Loc.GetString("station-ai-fixer-console-window-action-progress-repair") :
Loc.GetString("station-ai-fixer-console-window-action-progress-purge");
var fullTimeSpan = ent.Comp.ActionEndTime - ent.Comp.ActionStartTime;
var remainingTimeSpan = ent.Comp.ActionEndTime - _timing.CurTime;
var time = remainingTimeSpan.TotalSeconds > 60 ? remainingTimeSpan.TotalMinutes : remainingTimeSpan.TotalSeconds;
var units = remainingTimeSpan.TotalSeconds > 60 ? Loc.GetString("generic-minutes") : Loc.GetString("generic-seconds");
ActionProgressEtaLabel.Text = Loc.GetString("station-ai-fixer-console-window-action-progress-eta", ("time", (int)time), ("units", units));
ActionProgressBar.Value = 1f - (float)remainingTimeSpan.Divide(fullTimeSpan);
}
private string GetStationAiName(EntityUid? uid)
{
if (_entManager.TryGetComponent<MetaDataComponent>(uid, out var metadata))
{
return metadata.EntityName;
}
return Loc.GetString("station-ai-fixer-console-window-no-station-ai");
}
protected override void FrameUpdate(FrameEventArgs args)
{
if (!ActionProgressScreen.Visible)
return;
if (!_entManager.TryGetComponent<StationAiFixerConsoleComponent>(_owner, out var stationAiFixerConsole))
return;
UpdateProgressBar((_owner.Value, stationAiFixerConsole));
}
}

View File

@@ -1,4 +1,5 @@
using System.Numerics;
using Content.Client.Graphics;
using Content.Shared.Silicons.StationAi;
using Robust.Client.Graphics;
using Robust.Client.Player;
@@ -26,8 +27,7 @@ public sealed class StationAiOverlay : Overlay
private readonly HashSet<Vector2i> _visibleTiles = new();
private IRenderTexture? _staticTexture;
private IRenderTexture? _stencilTexture;
private readonly OverlayResourceCache<CachedResources> _resources = new();
private float _updateRate = 1f / 30f;
private float _accumulator;
@@ -39,12 +39,14 @@ public sealed class StationAiOverlay : Overlay
protected override void Draw(in OverlayDrawArgs args)
{
if (_stencilTexture?.Texture.Size != args.Viewport.Size)
var res = _resources.GetForViewport(args.Viewport, static _ => new CachedResources());
if (res.StencilTexture?.Texture.Size != args.Viewport.Size)
{
_staticTexture?.Dispose();
_stencilTexture?.Dispose();
_stencilTexture = _clyde.CreateRenderTarget(args.Viewport.Size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "station-ai-stencil");
_staticTexture = _clyde.CreateRenderTarget(args.Viewport.Size,
res.StaticTexture?.Dispose();
res.StencilTexture?.Dispose();
res.StencilTexture = _clyde.CreateRenderTarget(args.Viewport.Size, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "station-ai-stencil");
res.StaticTexture = _clyde.CreateRenderTarget(args.Viewport.Size,
new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb),
name: "station-ai-static");
}
@@ -78,7 +80,7 @@ public sealed class StationAiOverlay : Overlay
var matty = Matrix3x2.Multiply(gridMatrix, invMatrix);
// Draw visible tiles to stencil
worldHandle.RenderInRenderTarget(_stencilTexture!, () =>
worldHandle.RenderInRenderTarget(res.StencilTexture!, () =>
{
worldHandle.SetTransform(matty);
@@ -91,7 +93,7 @@ public sealed class StationAiOverlay : Overlay
Color.Transparent);
// Once this is gucci optimise rendering.
worldHandle.RenderInRenderTarget(_staticTexture!,
worldHandle.RenderInRenderTarget(res.StaticTexture!,
() =>
{
worldHandle.SetTransform(invMatrix);
@@ -104,12 +106,12 @@ public sealed class StationAiOverlay : Overlay
// Not on a grid
else
{
worldHandle.RenderInRenderTarget(_stencilTexture!, () =>
worldHandle.RenderInRenderTarget(res.StencilTexture!, () =>
{
},
Color.Transparent);
worldHandle.RenderInRenderTarget(_staticTexture!,
worldHandle.RenderInRenderTarget(res.StaticTexture!,
() =>
{
worldHandle.SetTransform(Matrix3x2.Identity);
@@ -119,14 +121,33 @@ public sealed class StationAiOverlay : Overlay
// Use the lighting as a mask
worldHandle.UseShader(_proto.Index(StencilMaskShader).Instance());
worldHandle.DrawTextureRect(_stencilTexture!.Texture, worldBounds);
worldHandle.DrawTextureRect(res.StencilTexture!.Texture, worldBounds);
// Draw the static
worldHandle.UseShader(_proto.Index(StencilDrawShader).Instance());
worldHandle.DrawTextureRect(_staticTexture!.Texture, worldBounds);
worldHandle.DrawTextureRect(res.StaticTexture!.Texture, worldBounds);
worldHandle.SetTransform(Matrix3x2.Identity);
worldHandle.UseShader(null);
}
protected override void DisposeBehavior()
{
_resources.Dispose();
base.DisposeBehavior();
}
private sealed class CachedResources : IDisposable
{
public IRenderTexture? StaticTexture;
public IRenderTexture? StencilTexture;
public void Dispose()
{
StaticTexture?.Dispose();
StencilTexture?.Dispose();
}
}
}

View File

@@ -81,10 +81,10 @@ public sealed partial class StationAiSystem : SharedStationAiSystem
if (args.Sprite == null)
return;
if (_appearance.TryGetData<PrototypeLayerData>(entity.Owner, StationAiVisualState.Key, out var layerData, args.Component))
_sprite.LayerSetData((entity.Owner, args.Sprite), StationAiVisualState.Key, layerData);
if (_appearance.TryGetData<PrototypeLayerData>(entity.Owner, StationAiVisualLayers.Icon, out var layerData, args.Component))
_sprite.LayerSetData((entity.Owner, args.Sprite), StationAiVisualLayers.Icon, layerData);
_sprite.LayerSetVisible((entity.Owner, args.Sprite), StationAiVisualState.Key, layerData != null);
_sprite.LayerSetVisible((entity.Owner, args.Sprite), StationAiVisualLayers.Icon, layerData != null);
}
public override void Shutdown()

View File

@@ -90,23 +90,25 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles
var spriteSystem = sysManager.GetEntitySystem<SpriteSystem>();
var requirementsManager = IoCManager.Resolve<JobRequirementsManager>();
// TODO: role.Requirements value doesn't work at all as an equality key, this must be fixed
// Grouping roles
var groupedRoles = ghostState.GhostRoles.GroupBy(
role => (role.Name, role.Description, role.Requirements));
role => (
role.Name,
role.Description,
// Check the prototypes for role requirements and bans
requirementsManager.IsAllowed(role.RolePrototypes.Item1, role.RolePrototypes.Item2, null, out var reason),
reason));
// Add a new entry for each role group
foreach (var group in groupedRoles)
{
var reason = group.Key.reason;
var name = group.Key.Name;
var description = group.Key.Description;
var hasAccess = requirementsManager.CheckRoleRequirements(
group.Key.Requirements,
null,
out var reason);
var prototypesAllowed = group.Key.Item3;
// Adding a new role
_window.AddEntry(name, description, hasAccess, reason, group, spriteSystem);
_window.AddEntry(name, description, prototypesAllowed, reason, group, spriteSystem);
}
// Restore the Collapsible box state if it is saved

View File

@@ -1,12 +0,0 @@
using System.IO;
namespace Content.IntegrationTests;
/// <summary>
/// Generic implementation of <see cref="ITestContextLike"/> for usage outside of actual tests.
/// </summary>
public sealed class ExternalTestContext(string name, TextWriter writer) : ITestContextLike
{
public string FullName => name;
public TextWriter Out => writer;
}

View File

@@ -3,3 +3,4 @@
global using NUnit.Framework;
global using System;
global using System.Threading.Tasks;
global using Robust.UnitTesting.Pool;

View File

@@ -1,13 +0,0 @@
using System.IO;
namespace Content.IntegrationTests;
/// <summary>
/// Something that looks like a <see cref="TestContext"/>, for passing to integration tests.
/// </summary>
public interface ITestContextLike
{
string FullName { get; }
TextWriter Out { get; }
}

View File

@@ -1,12 +0,0 @@
using System.IO;
namespace Content.IntegrationTests;
/// <summary>
/// Canonical implementation of <see cref="ITestContextLike"/> for usage in actual NUnit tests.
/// </summary>
public sealed class NUnitTestContextWrap(TestContext context, TextWriter writer) : ITestContextLike
{
public string FullName => context.Test.FullName;
public TextWriter Out => writer;
}

View File

@@ -1,23 +0,0 @@
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
namespace Content.IntegrationTests.Pair;
/// <summary>
/// Simple data class that stored information about a map being used by a test.
/// </summary>
public sealed class TestMapData
{
public EntityUid MapUid { get; set; }
public Entity<MapGridComponent> Grid;
public MapId MapId;
public EntityCoordinates GridCoords { get; set; }
public MapCoordinates MapCoords { get; set; }
public TileRef Tile { get; set; }
// Client-side uids
public EntityUid CMapUid { get; set; }
public EntityUid CGridUid { get; set; }
public EntityCoordinates CGridCoords { get; set; }
}

View File

@@ -1,69 +0,0 @@
#nullable enable
using System.Collections.Generic;
using Content.Shared.CCVar;
using Robust.Shared.Configuration;
using Robust.Shared.Utility;
namespace Content.IntegrationTests.Pair;
public sealed partial class TestPair
{
private readonly Dictionary<string, object> _modifiedClientCvars = new();
private readonly Dictionary<string, object> _modifiedServerCvars = new();
private void OnServerCvarChanged(CVarChangeInfo args)
{
_modifiedServerCvars.TryAdd(args.Name, args.OldValue);
}
private void OnClientCvarChanged(CVarChangeInfo args)
{
_modifiedClientCvars.TryAdd(args.Name, args.OldValue);
}
internal void ClearModifiedCvars()
{
_modifiedClientCvars.Clear();
_modifiedServerCvars.Clear();
}
/// <summary>
/// Reverts any cvars that were modified during a test back to their original values.
/// </summary>
public async Task RevertModifiedCvars()
{
await Server.WaitPost(() =>
{
foreach (var (name, value) in _modifiedServerCvars)
{
if (Server.CfgMan.GetCVar(name).Equals(value))
continue;
Server.Log.Info($"Resetting cvar {name} to {value}");
Server.CfgMan.SetCVar(name, value);
}
// I just love order dependent cvars
if (_modifiedServerCvars.TryGetValue(CCVars.PanicBunkerEnabled.Name, out var panik))
Server.CfgMan.SetCVar(CCVars.PanicBunkerEnabled.Name, panik);
});
await Client.WaitPost(() =>
{
foreach (var (name, value) in _modifiedClientCvars)
{
if (Client.CfgMan.GetCVar(name).Equals(value))
continue;
var flags = Client.CfgMan.GetCVarFlags(name);
if (flags.HasFlag(CVar.REPLICATED) && flags.HasFlag(CVar.SERVER))
continue;
Client.Log.Info($"Resetting cvar {name} to {value}");
Client.CfgMan.SetCVar(name, value);
}
});
ClearModifiedCvars();
}
}

View File

@@ -1,172 +1,19 @@
#nullable enable
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Server.Preferences.Managers;
using Content.Shared.Preferences;
using Content.Shared.Roles;
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Prototypes;
using Robust.UnitTesting;
namespace Content.IntegrationTests.Pair;
// Contains misc helper functions to make writing tests easier.
public sealed partial class TestPair
{
/// <summary>
/// Creates a map, a grid, and a tile, and gives back references to them.
/// </summary>
[MemberNotNull(nameof(TestMap))]
public async Task<TestMapData> CreateTestMap(bool initialized = true, string tile = "Plating")
{
var mapData = new TestMapData();
TestMap = mapData;
await Server.WaitIdleAsync();
var tileDefinitionManager = Server.ResolveDependency<ITileDefinitionManager>();
TestMap = mapData;
await Server.WaitPost(() =>
{
mapData.MapUid = Server.System<SharedMapSystem>().CreateMap(out mapData.MapId, runMapInit: initialized);
mapData.Grid = Server.MapMan.CreateGridEntity(mapData.MapId);
mapData.GridCoords = new EntityCoordinates(mapData.Grid, 0, 0);
var plating = tileDefinitionManager[tile];
var platingTile = new Tile(plating.TileId);
Server.System<SharedMapSystem>().SetTile(mapData.Grid.Owner, mapData.Grid.Comp, mapData.GridCoords, platingTile);
mapData.MapCoords = new MapCoordinates(0, 0, mapData.MapId);
mapData.Tile = Server.System<SharedMapSystem>().GetAllTiles(mapData.Grid.Owner, mapData.Grid.Comp).First();
});
TestMap = mapData;
if (!Settings.Connected)
return mapData;
await RunTicksSync(10);
mapData.CMapUid = ToClientUid(mapData.MapUid);
mapData.CGridUid = ToClientUid(mapData.Grid);
mapData.CGridCoords = new EntityCoordinates(mapData.CGridUid, 0, 0);
TestMap = mapData;
return mapData;
}
/// <summary>
/// Convert a client-side uid into a server-side uid
/// </summary>
public EntityUid ToServerUid(EntityUid uid) => ConvertUid(uid, Client, Server);
/// <summary>
/// Convert a server-side uid into a client-side uid
/// </summary>
public EntityUid ToClientUid(EntityUid uid) => ConvertUid(uid, Server, Client);
private static EntityUid ConvertUid(
EntityUid uid,
RobustIntegrationTest.IntegrationInstance source,
RobustIntegrationTest.IntegrationInstance destination)
{
if (!uid.IsValid())
return EntityUid.Invalid;
if (!source.EntMan.TryGetComponent<MetaDataComponent>(uid, out var meta))
{
Assert.Fail($"Failed to resolve MetaData while converting the EntityUid for entity {uid}");
return EntityUid.Invalid;
}
if (!destination.EntMan.TryGetEntity(meta.NetEntity, out var otherUid))
{
Assert.Fail($"Failed to resolve net ID while converting the EntityUid entity {source.EntMan.ToPrettyString(uid)}");
return EntityUid.Invalid;
}
return otherUid.Value;
}
/// <summary>
/// Execute a command on the server and wait some number of ticks.
/// </summary>
public async Task WaitCommand(string cmd, int numTicks = 10)
{
await Server.ExecuteCommand(cmd);
await RunTicksSync(numTicks);
}
/// <summary>
/// Execute a command on the client and wait some number of ticks.
/// </summary>
public async Task WaitClientCommand(string cmd, int numTicks = 10)
{
await Client.ExecuteCommand(cmd);
await RunTicksSync(numTicks);
}
/// <summary>
/// Retrieve all entity prototypes that have some component.
/// </summary>
public List<(EntityPrototype, T)> GetPrototypesWithComponent<T>(
HashSet<string>? ignored = null,
bool ignoreAbstract = true,
bool ignoreTestPrototypes = true)
where T : IComponent, new()
{
if (!Server.ResolveDependency<IComponentFactory>().TryGetRegistration<T>(out var reg)
&& !Client.ResolveDependency<IComponentFactory>().TryGetRegistration<T>(out reg))
{
Assert.Fail($"Unknown component: {typeof(T).Name}");
return new();
}
var id = reg.Name;
var list = new List<(EntityPrototype, T)>();
foreach (var proto in Server.ProtoMan.EnumeratePrototypes<EntityPrototype>())
{
if (ignored != null && ignored.Contains(proto.ID))
continue;
if (ignoreAbstract && proto.Abstract)
continue;
if (ignoreTestPrototypes && IsTestPrototype(proto))
continue;
if (proto.Components.TryGetComponent(id, out var cmp))
list.Add((proto, (T)cmp));
}
return list;
}
/// <summary>
/// Retrieve all entity prototypes that have some component.
/// </summary>
public List<EntityPrototype> GetPrototypesWithComponent(Type type,
HashSet<string>? ignored = null,
bool ignoreAbstract = true,
bool ignoreTestPrototypes = true)
{
var id = Server.ResolveDependency<IComponentFactory>().GetComponentName(type);
var list = new List<EntityPrototype>();
foreach (var proto in Server.ProtoMan.EnumeratePrototypes<EntityPrototype>())
{
if (ignored != null && ignored.Contains(proto.ID))
continue;
if (ignoreAbstract && proto.Abstract)
continue;
if (ignoreTestPrototypes && IsTestPrototype(proto))
continue;
if (proto.Components.ContainsKey(id))
list.Add((proto));
}
return list;
}
public Task<TestMapData> CreateTestMap(bool initialized = true)
=> CreateTestMap(initialized, "Plating");
/// <summary>
/// Set a user's antag preferences. Modified preferences are automatically reset at the end of the test.

View File

@@ -1,64 +0,0 @@
#nullable enable
using System.Collections.Generic;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
using Robust.UnitTesting;
namespace Content.IntegrationTests.Pair;
// This partial class contains helper methods to deal with yaml prototypes.
public sealed partial class TestPair
{
private Dictionary<Type, HashSet<string>> _loadedPrototypes = new();
private HashSet<string> _loadedEntityPrototypes = new();
public async Task LoadPrototypes(List<string> prototypes)
{
await LoadPrototypes(Server, prototypes);
await LoadPrototypes(Client, prototypes);
}
private async Task LoadPrototypes(RobustIntegrationTest.IntegrationInstance instance, List<string> prototypes)
{
var changed = new Dictionary<Type, HashSet<string>>();
foreach (var file in prototypes)
{
instance.ProtoMan.LoadString(file, changed: changed);
}
await instance.WaitPost(() => instance.ProtoMan.ReloadPrototypes(changed));
foreach (var (kind, ids) in changed)
{
_loadedPrototypes.GetOrNew(kind).UnionWith(ids);
}
if (_loadedPrototypes.TryGetValue(typeof(EntityPrototype), out var entIds))
_loadedEntityPrototypes.UnionWith(entIds);
}
public bool IsTestPrototype(EntityPrototype proto)
{
return _loadedEntityPrototypes.Contains(proto.ID);
}
public bool IsTestEntityPrototype(string id)
{
return _loadedEntityPrototypes.Contains(id);
}
public bool IsTestPrototype<TPrototype>(string id) where TPrototype : IPrototype
{
return IsTestPrototype(typeof(TPrototype), id);
}
public bool IsTestPrototype<TPrototype>(TPrototype proto) where TPrototype : IPrototype
{
return IsTestPrototype(typeof(TPrototype), proto.ID);
}
public bool IsTestPrototype(Type kind, string id)
{
return _loadedPrototypes.TryGetValue(kind, out var ids) && ids.Contains(id);
}
}

View File

@@ -8,84 +8,17 @@ using Content.Shared.GameTicking;
using Content.Shared.Mind;
using Content.Shared.Mind.Components;
using Content.Shared.Preferences;
using Robust.Client;
using Robust.Server.Player;
using Robust.Shared.Exceptions;
using Robust.Shared.GameObjects;
using Robust.Shared.Network;
using Robust.Shared.Utility;
using Robust.Shared.Player;
namespace Content.IntegrationTests.Pair;
// This partial class contains logic related to recycling & disposing test pairs.
public sealed partial class TestPair : IAsyncDisposable
public sealed partial class TestPair
{
public PairState State { get; private set; } = PairState.Ready;
private async Task OnDirtyDispose()
protected override async Task Cleanup()
{
var usageTime = Watch.Elapsed;
Watch.Restart();
await _testOut.WriteLineAsync($"{nameof(DisposeAsync)}: Test gave back pair {Id} in {usageTime.TotalMilliseconds} ms");
Kill();
var disposeTime = Watch.Elapsed;
await _testOut.WriteLineAsync($"{nameof(DisposeAsync)}: Disposed pair {Id} in {disposeTime.TotalMilliseconds} ms");
// Test pairs should only dirty dispose if they are failing. If they are not failing, this probably happened
// because someone forgot to clean-return the pair.
Assert.Warn("Test was dirty-disposed.");
}
private async Task OnCleanDispose()
{
await Server.WaitIdleAsync();
await Client.WaitIdleAsync();
await base.Cleanup();
await ResetModifiedPreferences();
await Server.RemoveAllDummySessions();
if (TestMap != null)
{
await Server.WaitPost(() => Server.EntMan.DeleteEntity(TestMap.MapUid));
TestMap = null;
}
await RevertModifiedCvars();
var usageTime = Watch.Elapsed;
Watch.Restart();
await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Test borrowed pair {Id} for {usageTime.TotalMilliseconds} ms");
// Let any last minute failures the test cause happen.
await ReallyBeIdle();
if (!Settings.Destructive)
{
if (Client.IsAlive == false)
{
throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the client in pair {Id}:", Client.UnhandledException);
}
if (Server.IsAlive == false)
{
throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the server in pair {Id}:", Server.UnhandledException);
}
}
if (Settings.MustNotBeReused)
{
Kill();
await ReallyBeIdle();
await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Clean disposed in {Watch.Elapsed.TotalMilliseconds} ms");
return;
}
var sRuntimeLog = Server.ResolveDependency<IRuntimeLog>();
if (sRuntimeLog.ExceptionCount > 0)
throw new Exception($"{nameof(CleanReturnAsync)}: Server logged exceptions");
var cRuntimeLog = Client.ResolveDependency<IRuntimeLog>();
if (cRuntimeLog.ExceptionCount > 0)
throw new Exception($"{nameof(CleanReturnAsync)}: Client logged exceptions");
var returnTime = Watch.Elapsed;
await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: PoolManager took {returnTime.TotalMilliseconds} ms to put pair {Id} back into the pool");
State = PairState.Ready;
}
private async Task ResetModifiedPreferences()
@@ -95,61 +28,14 @@ public sealed partial class TestPair : IAsyncDisposable
{
await Server.WaitPost(() => prefMan.SetProfile(user, 0, new HumanoidCharacterProfile()).Wait());
}
_modifiedProfiles.Clear();
}
public async ValueTask CleanReturnAsync()
protected override async Task Recycle(PairSettings next, TextWriter testOut)
{
if (State != PairState.InUse)
throw new Exception($"{nameof(CleanReturnAsync)}: Unexpected state. Pair: {Id}. State: {State}.");
await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Return of pair {Id} started");
State = PairState.CleanDisposed;
await OnCleanDispose();
DebugTools.Assert(State is PairState.Dead or PairState.Ready);
PoolManager.NoCheckReturn(this);
ClearContext();
}
public async ValueTask DisposeAsync()
{
switch (State)
{
case PairState.Dead:
case PairState.Ready:
break;
case PairState.InUse:
await _testOut.WriteLineAsync($"{nameof(DisposeAsync)}: Dirty return of pair {Id} started");
await OnDirtyDispose();
PoolManager.NoCheckReturn(this);
ClearContext();
break;
default:
throw new Exception($"{nameof(DisposeAsync)}: Unexpected state. Pair: {Id}. State: {State}.");
}
}
public async Task CleanPooledPair(PoolSettings settings, TextWriter testOut)
{
Settings = default!;
Watch.Restart();
await testOut.WriteLineAsync($"Recycling...");
var gameTicker = Server.System<GameTicker>();
var cNetMgr = Client.ResolveDependency<IClientNetManager>();
await RunTicksSync(1);
// Disconnect the client if they are connected.
if (cNetMgr.IsConnected)
{
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Disconnecting client.");
await Client.WaitPost(() => cNetMgr.ClientDisconnect("Test pooling cleanup disconnect"));
await RunTicksSync(1);
}
Assert.That(cNetMgr.IsConnected, Is.False);
// Move to pre-round lobby. Required to toggle dummy ticker on and off
var gameTicker = Server.System<GameTicker>();
if (gameTicker.RunLevel != GameRunLevel.PreRoundLobby)
{
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Restarting round.");
@@ -162,8 +48,7 @@ public sealed partial class TestPair : IAsyncDisposable
//Apply Cvars
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Setting CVar ");
await PoolManager.SetupCVars(Client, settings);
await PoolManager.SetupCVars(Server, settings);
await ApplySettings(next);
await RunTicksSync(1);
// Restart server.
@@ -171,52 +56,30 @@ public sealed partial class TestPair : IAsyncDisposable
await Server.WaitPost(() => Server.EntMan.FlushEntities());
await Server.WaitPost(() => gameTicker.RestartRound());
await RunTicksSync(1);
// Connect client
if (settings.ShouldBeConnected)
{
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Connecting client");
Client.SetConnectTarget(Server);
await Client.WaitPost(() => cNetMgr.ClientConnect(null!, 0, null!));
}
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Idling");
await ReallyBeIdle();
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Done recycling");
}
public void ValidateSettings(PoolSettings settings)
public override void ValidateSettings(PairSettings s)
{
base.ValidateSettings(s);
var settings = (PoolSettings) s;
var cfg = Server.CfgMan;
Assert.That(cfg.GetCVar(CCVars.AdminLogsEnabled), Is.EqualTo(settings.AdminLogsEnabled));
Assert.That(cfg.GetCVar(CCVars.GameLobbyEnabled), Is.EqualTo(settings.InLobby));
Assert.That(cfg.GetCVar(CCVars.GameDummyTicker), Is.EqualTo(settings.UseDummyTicker));
Assert.That(cfg.GetCVar(CCVars.GameDummyTicker), Is.EqualTo(settings.DummyTicker));
var entMan = Server.ResolveDependency<EntityManager>();
var ticker = entMan.System<GameTicker>();
Assert.That(ticker.DummyTicker, Is.EqualTo(settings.UseDummyTicker));
var ticker = Server.System<GameTicker>();
Assert.That(ticker.DummyTicker, Is.EqualTo(settings.DummyTicker));
var expectPreRound = settings.InLobby | settings.DummyTicker;
var expectedLevel = expectPreRound ? GameRunLevel.PreRoundLobby : GameRunLevel.InRound;
Assert.That(ticker.RunLevel, Is.EqualTo(expectedLevel));
var baseClient = Client.ResolveDependency<IBaseClient>();
var netMan = Client.ResolveDependency<INetManager>();
Assert.That(netMan.IsConnected, Is.Not.EqualTo(!settings.ShouldBeConnected));
if (!settings.ShouldBeConnected)
if (ticker.DummyTicker || !settings.Connected)
return;
Assert.That(baseClient.RunLevel, Is.EqualTo(ClientRunLevel.InGame));
var cPlayer = Client.ResolveDependency<Robust.Client.Player.IPlayerManager>();
var sPlayer = Server.ResolveDependency<IPlayerManager>();
Assert.That(sPlayer.Sessions.Count(), Is.EqualTo(1));
var sPlayer = Server.ResolveDependency<ISharedPlayerManager>();
var session = sPlayer.Sessions.Single();
Assert.That(cPlayer.LocalSession?.UserId, Is.EqualTo(session.UserId));
if (ticker.DummyTicker)
return;
var status = ticker.PlayerGameStatuses[session.UserId];
var expected = settings.InLobby
? PlayerGameStatus.NotReadyToPlay
@@ -231,11 +94,11 @@ public sealed partial class TestPair : IAsyncDisposable
}
Assert.That(session.AttachedEntity, Is.Not.Null);
Assert.That(entMan.EntityExists(session.AttachedEntity));
Assert.That(entMan.HasComponent<MindContainerComponent>(session.AttachedEntity));
var mindCont = entMan.GetComponent<MindContainerComponent>(session.AttachedEntity!.Value);
Assert.That(Server.EntMan.EntityExists(session.AttachedEntity));
Assert.That(Server.EntMan.HasComponent<MindContainerComponent>(session.AttachedEntity));
var mindCont = Server.EntMan.GetComponent<MindContainerComponent>(session.AttachedEntity!.Value);
Assert.That(mindCont.Mind, Is.Not.Null);
Assert.That(entMan.TryGetComponent(mindCont.Mind, out MindComponent? mind));
Assert.That(Server.EntMan.TryGetComponent(mindCont.Mind, out MindComponent? mind));
Assert.That(mind!.VisitingEntity, Is.Null);
Assert.That(mind.OwnedEntity, Is.EqualTo(session.AttachedEntity!.Value));
Assert.That(mind.UserId, Is.EqualTo(session.UserId));

View File

@@ -1,77 +0,0 @@
#nullable enable
namespace Content.IntegrationTests.Pair;
// This partial class contains methods for running the server/client pairs for some number of ticks
public sealed partial class TestPair
{
/// <summary>
/// Runs the server-client pair in sync
/// </summary>
/// <param name="ticks">How many ticks to run them for</param>
public async Task RunTicksSync(int ticks)
{
for (var i = 0; i < ticks; i++)
{
await Server.WaitRunTicks(1);
await Client.WaitRunTicks(1);
}
}
/// <summary>
/// Convert a time interval to some number of ticks.
/// </summary>
public int SecondsToTicks(float seconds)
{
return (int) Math.Ceiling(seconds / Server.Timing.TickPeriod.TotalSeconds);
}
/// <summary>
/// Run the server & client in sync for some amount of time
/// </summary>
public async Task RunSeconds(float seconds)
{
await RunTicksSync(SecondsToTicks(seconds));
}
/// <summary>
/// Runs the server-client pair in sync, but also ensures they are both idle each tick.
/// </summary>
/// <param name="runTicks">How many ticks to run</param>
public async Task ReallyBeIdle(int runTicks = 25)
{
for (var i = 0; i < runTicks; i++)
{
await Client.WaitRunTicks(1);
await Server.WaitRunTicks(1);
for (var idleCycles = 0; idleCycles < 4; idleCycles++)
{
await Client.WaitIdleAsync();
await Server.WaitIdleAsync();
}
}
}
/// <summary>
/// Run the server/clients until the ticks are synchronized.
/// By default the client will be one tick ahead of the server.
/// </summary>
public async Task SyncTicks(int targetDelta = 1)
{
var sTick = (int)Server.Timing.CurTick.Value;
var cTick = (int)Client.Timing.CurTick.Value;
var delta = cTick - sTick;
if (delta == targetDelta)
return;
if (delta > targetDelta)
await Server.WaitRunTicks(delta - targetDelta);
else
await Client.WaitRunTicks(targetDelta - delta);
sTick = (int)Server.Timing.CurTick.Value;
cTick = (int)Client.Timing.CurTick.Value;
delta = cTick - sTick;
Assert.That(delta, Is.EqualTo(targetDelta));
}
}

View File

@@ -1,16 +1,17 @@
#nullable enable
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Content.Client.IoC;
using Content.Client.Parallax.Managers;
using Content.IntegrationTests.Tests.Destructible;
using Content.IntegrationTests.Tests.DeviceNetwork;
using Content.Server.GameTicking;
using Content.Shared.CCVar;
using Content.Shared.Players;
using Robust.Shared.Configuration;
using Robust.Shared.ContentPack;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Random;
using Robust.Shared.Timing;
using Robust.UnitTesting;
namespace Content.IntegrationTests.Pair;
@@ -18,156 +19,99 @@ namespace Content.IntegrationTests.Pair;
/// <summary>
/// This object wraps a pooled server+client pair.
/// </summary>
public sealed partial class TestPair
public sealed partial class TestPair : RobustIntegrationTest.TestPair
{
public readonly int Id;
private bool _initialized;
private TextWriter _testOut = default!;
public readonly Stopwatch Watch = new();
public readonly List<string> TestHistory = new();
public PoolSettings Settings = default!;
public TestMapData? TestMap;
private List<NetUserId> _modifiedProfiles = new();
private int _nextServerSeed;
private int _nextClientSeed;
public int ServerSeed;
public int ClientSeed;
public RobustIntegrationTest.ServerIntegrationInstance Server { get; private set; } = default!;
public RobustIntegrationTest.ClientIntegrationInstance Client { get; private set; } = default!;
public void Deconstruct(
out RobustIntegrationTest.ServerIntegrationInstance server,
out RobustIntegrationTest.ClientIntegrationInstance client)
{
server = Server;
client = Client;
}
public ICommonSession? Player => Server.PlayerMan.SessionsDict.GetValueOrDefault(Client.User!.Value);
public ContentPlayerData? PlayerData => Player?.Data.ContentData();
public PoolTestLogHandler ServerLogHandler { get; private set; } = default!;
public PoolTestLogHandler ClientLogHandler { get; private set; } = default!;
public TestPair(int id)
protected override async Task Initialize()
{
Id = id;
}
public async Task Initialize(PoolSettings settings, TextWriter testOut, List<string> testPrototypes)
{
if (_initialized)
throw new InvalidOperationException("Already initialized");
_initialized = true;
Settings = settings;
(Client, ClientLogHandler) = await PoolManager.GenerateClient(settings, testOut);
(Server, ServerLogHandler) = await PoolManager.GenerateServer(settings, testOut);
ActivateContext(testOut);
Client.CfgMan.OnCVarValueChanged += OnClientCvarChanged;
Server.CfgMan.OnCVarValueChanged += OnServerCvarChanged;
if (!settings.NoLoadTestPrototypes)
await LoadPrototypes(testPrototypes!);
if (!settings.UseDummyTicker)
var settings = (PoolSettings)Settings;
if (!settings.DummyTicker)
{
var gameTicker = Server.ResolveDependency<IEntityManager>().System<GameTicker>();
var gameTicker = Server.System<GameTicker>();
await Server.WaitPost(() => gameTicker.RestartRound());
}
}
// Always initially connect clients to generate an initial random set of preferences/profiles.
// This is to try and prevent issues where if the first test that connects the client is consistently some test
// that uses a fixed seed, it would effectively prevent it from beingrandomized.
public override async Task RevertModifiedCvars()
{
// I just love order dependent cvars
// I.e., cvars that when changed automatically cause others to also change.
var modified = ModifiedServerCvars.TryGetValue(CCVars.PanicBunkerEnabled.Name, out var panik);
Client.SetConnectTarget(Server);
await Client.WaitIdleAsync();
var netMgr = Client.ResolveDependency<IClientNetManager>();
await Client.WaitPost(() => netMgr.ClientConnect(null!, 0, null!));
await ReallyBeIdle(10);
await Client.WaitRunTicks(1);
await base.RevertModifiedCvars();
if (!settings.ShouldBeConnected)
if (!modified)
return;
await Server.WaitPost(() => Server.CfgMan.SetCVar(CCVars.PanicBunkerEnabled.Name, panik!));
ClearModifiedCvars();
}
protected override async Task ApplySettings(IIntegrationInstance instance, PairSettings n)
{
var next = (PoolSettings)n;
await base.ApplySettings(instance, next);
var cfg = instance.CfgMan;
await instance.WaitPost(() =>
{
await Client.WaitPost(() => netMgr.ClientDisconnect("Initial disconnect"));
await ReallyBeIdle(10);
}
if (cfg.IsCVarRegistered(CCVars.GameDummyTicker.Name))
cfg.SetCVar(CCVars.GameDummyTicker, next.DummyTicker);
var cRand = Client.ResolveDependency<IRobustRandom>();
var sRand = Server.ResolveDependency<IRobustRandom>();
_nextClientSeed = cRand.Next();
_nextServerSeed = sRand.Next();
if (cfg.IsCVarRegistered(CCVars.GameLobbyEnabled.Name))
cfg.SetCVar(CCVars.GameLobbyEnabled, next.InLobby);
if (cfg.IsCVarRegistered(CCVars.GameMap.Name))
cfg.SetCVar(CCVars.GameMap, next.Map);
if (cfg.IsCVarRegistered(CCVars.AdminLogsEnabled.Name))
cfg.SetCVar(CCVars.AdminLogsEnabled, next.AdminLogsEnabled);
});
}
public void Kill()
protected override RobustIntegrationTest.ClientIntegrationOptions ClientOptions()
{
State = PairState.Dead;
ServerLogHandler.ShuttingDown = true;
ClientLogHandler.ShuttingDown = true;
Server.Dispose();
Client.Dispose();
}
var opts = base.ClientOptions();
private void ClearContext()
{
_testOut = default!;
ServerLogHandler.ClearContext();
ClientLogHandler.ClearContext();
}
public void ActivateContext(TextWriter testOut)
{
_testOut = testOut;
ServerLogHandler.ActivateContext(testOut);
ClientLogHandler.ActivateContext(testOut);
}
public void Use()
{
if (State != PairState.Ready)
throw new InvalidOperationException($"Pair is not ready to use. State: {State}");
State = PairState.InUse;
}
public enum PairState : byte
{
Ready = 0,
InUse = 1,
CleanDisposed = 2,
Dead = 3,
}
public void SetupSeed()
{
var sRand = Server.ResolveDependency<IRobustRandom>();
if (Settings.ServerSeed is { } severSeed)
opts.LoadTestAssembly = false;
opts.ContentStart = true;
opts.FailureLogLevel = LogLevel.Warning;
opts.Options = new()
{
ServerSeed = severSeed;
sRand.SetSeed(ServerSeed);
}
else
{
ServerSeed = _nextServerSeed;
sRand.SetSeed(ServerSeed);
_nextServerSeed = sRand.Next();
}
LoadConfigAndUserData = false,
};
var cRand = Client.ResolveDependency<IRobustRandom>();
if (Settings.ClientSeed is { } clientSeed)
opts.BeforeStart += () =>
{
ClientSeed = clientSeed;
cRand.SetSeed(ClientSeed);
}
else
IoCManager.Resolve<IModLoader>().SetModuleBaseCallbacks(new ClientModuleTestingCallbacks
{
ClientBeforeIoC = () => IoCManager.Register<IParallaxManager, DummyParallaxManager>(true)
});
};
return opts;
}
protected override RobustIntegrationTest.ServerIntegrationOptions ServerOptions()
{
var opts = base.ServerOptions();
opts.LoadTestAssembly = false;
opts.ContentStart = true;
opts.Options = new()
{
ClientSeed = _nextClientSeed;
cRand.SetSeed(ClientSeed);
_nextClientSeed = cRand.Next();
}
LoadConfigAndUserData = false,
};
opts.BeforeStart += () =>
{
// Server-only systems (i.e., systems that subscribe to events with server-only components)
// There's probably a better way to do this.
var entSysMan = IoCManager.Resolve<IEntitySystemManager>();
entSysMan.LoadExtraSystemType<DeviceNetworkTestSystem>();
entSysMan.LoadExtraSystemType<TestDestructibleListenerSystem>();
};
return opts;
}
}

View File

@@ -1,15 +1,14 @@
#nullable enable
using Content.Shared.CCVar;
using Robust.Shared;
using Robust.Shared.Configuration;
using Robust.UnitTesting;
namespace Content.IntegrationTests;
// Partial class containing cvar logic
// Partial class containing test cvars
// This could probably be merged into the main file, but I'm keeping it separate to reduce
// conflicts for forks.
public static partial class PoolManager
{
private static readonly (string cvar, string value)[] TestCvars =
public static readonly (string cvar, string value)[] TestCvars =
{
// @formatter:off
(CCVars.DatabaseSynchronous.Name, "true"),
@@ -17,9 +16,7 @@ public static partial class PoolManager
(CCVars.HolidaysEnabled.Name, "false"),
(CCVars.GameMap.Name, TestMap),
(CCVars.AdminLogsQueueSendDelay.Name, "0"),
(CVars.NetPVS.Name, "false"),
(CCVars.NPCMaxUpdates.Name, "999999"),
(CVars.ThreadParallelCount.Name, "1"),
(CCVars.GameRoleTimers.Name, "false"),
(CCVars.GameRoleLoadoutTimers.Name, "false"),
(CCVars.GameRoleWhitelist.Name, "false"),
@@ -30,49 +27,13 @@ public static partial class PoolManager
(CCVars.ProcgenPreload.Name, "false"),
(CCVars.WorldgenEnabled.Name, "false"),
(CCVars.GatewayGeneratorEnabled.Name, "false"),
(CVars.ReplayClientRecordingEnabled.Name, "false"),
(CVars.ReplayServerRecordingEnabled.Name, "false"),
(CCVars.GameDummyTicker.Name, "true"),
(CCVars.GameLobbyEnabled.Name, "false"),
(CCVars.ConfigPresetDevelopment.Name, "false"),
(CCVars.AdminLogsEnabled.Name, "false"),
(CCVars.AutosaveEnabled.Name, "false"),
(CVars.NetBufferSize.Name, "0"),
(CCVars.InteractionRateLimitCount.Name, "9999999"),
(CCVars.InteractionRateLimitPeriod.Name, "0.1"),
(CCVars.MovementMobPushing.Name, "false"),
};
public static async Task SetupCVars(RobustIntegrationTest.IntegrationInstance instance, PoolSettings settings)
{
var cfg = instance.ResolveDependency<IConfigurationManager>();
await instance.WaitPost(() =>
{
if (cfg.IsCVarRegistered(CCVars.GameDummyTicker.Name))
cfg.SetCVar(CCVars.GameDummyTicker, settings.UseDummyTicker);
if (cfg.IsCVarRegistered(CCVars.GameLobbyEnabled.Name))
cfg.SetCVar(CCVars.GameLobbyEnabled, settings.InLobby);
if (cfg.IsCVarRegistered(CVars.NetInterp.Name))
cfg.SetCVar(CVars.NetInterp, settings.DisableInterpolate);
if (cfg.IsCVarRegistered(CCVars.GameMap.Name))
cfg.SetCVar(CCVars.GameMap, settings.Map);
if (cfg.IsCVarRegistered(CCVars.AdminLogsEnabled.Name))
cfg.SetCVar(CCVars.AdminLogsEnabled, settings.AdminLogsEnabled);
if (cfg.IsCVarRegistered(CVars.NetInterp.Name))
cfg.SetCVar(CVars.NetInterp, !settings.DisableInterpolate);
});
}
private static void SetDefaultCVars(RobustIntegrationTest.IntegrationOptions options)
{
foreach (var (cvar, value) in TestCvars)
{
options.CVarOverrides[cvar] = value;
}
}
}

View File

@@ -1,35 +0,0 @@
#nullable enable
using System.Collections.Generic;
using System.Reflection;
using Robust.Shared.Utility;
namespace Content.IntegrationTests;
// Partial class for handling the discovering and storing test prototypes.
public static partial class PoolManager
{
private static List<string> _testPrototypes = new();
private const BindingFlags Flags = BindingFlags.Static
| BindingFlags.NonPublic
| BindingFlags.Public
| BindingFlags.DeclaredOnly;
private static void DiscoverTestPrototypes(Assembly assembly)
{
foreach (var type in assembly.GetTypes())
{
foreach (var field in type.GetFields(Flags))
{
if (!field.HasCustomAttribute<TestPrototypesAttribute>())
continue;
var val = field.GetValue(null);
if (val is not string str)
throw new Exception($"TestPrototypeAttribute is only valid on non-null string fields");
_testPrototypes.Add(str);
}
}
}
}

View File

@@ -1,373 +1,17 @@
#nullable enable
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using Content.Client.IoC;
using Content.Client.Parallax.Managers;
using Content.IntegrationTests.Pair;
using Content.IntegrationTests.Tests;
using Content.IntegrationTests.Tests.Destructible;
using Content.IntegrationTests.Tests.DeviceNetwork;
using Content.IntegrationTests.Tests.Interaction.Click;
using Robust.Client;
using Robust.Server;
using Robust.Shared.Configuration;
using Robust.Shared.ContentPack;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
using Content.Shared.CCVar;
using Robust.UnitTesting;
namespace Content.IntegrationTests;
/// <summary>
/// Making clients, and servers is slow, this manages a pool of them so tests can reuse them.
/// </summary>
// The static class exist to avoid breaking changes
public static partial class PoolManager
{
public static readonly ContentPoolManager Instance = new();
public const string TestMap = "Empty";
private static int _pairId;
private static readonly object PairLock = new();
private static bool _initialized;
// Pair, IsBorrowed
private static readonly Dictionary<TestPair, bool> Pairs = new();
private static bool _dead;
private static Exception? _poolFailureReason;
private static HashSet<Assembly> _contentAssemblies = default!;
public static async Task<(RobustIntegrationTest.ServerIntegrationInstance, PoolTestLogHandler)> GenerateServer(
PoolSettings poolSettings,
TextWriter testOut)
{
var options = new RobustIntegrationTest.ServerIntegrationOptions
{
ContentStart = true,
Options = new ServerOptions()
{
LoadConfigAndUserData = false,
LoadContentResources = !poolSettings.NoLoadContent,
},
ContentAssemblies = _contentAssemblies.ToArray()
};
var logHandler = new PoolTestLogHandler("SERVER");
logHandler.ActivateContext(testOut);
options.OverrideLogHandler = () => logHandler;
options.BeforeStart += () =>
{
// Server-only systems (i.e., systems that subscribe to events with server-only components)
var entSysMan = IoCManager.Resolve<IEntitySystemManager>();
entSysMan.LoadExtraSystemType<DeviceNetworkTestSystem>();
entSysMan.LoadExtraSystemType<TestDestructibleListenerSystem>();
IoCManager.Resolve<ILogManager>().GetSawmill("loc").Level = LogLevel.Error;
IoCManager.Resolve<IConfigurationManager>()
.OnValueChanged(RTCVars.FailureLogLevel, value => logHandler.FailureLevel = value, true);
};
SetDefaultCVars(options);
var server = new RobustIntegrationTest.ServerIntegrationInstance(options);
await server.WaitIdleAsync();
await SetupCVars(server, poolSettings);
return (server, logHandler);
}
/// <summary>
/// This shuts down the pool, and disposes all the server/client pairs.
/// This is a one time operation to be used when the testing program is exiting.
/// </summary>
public static void Shutdown()
{
List<TestPair> localPairs;
lock (PairLock)
{
if (_dead)
return;
_dead = true;
localPairs = Pairs.Keys.ToList();
}
foreach (var pair in localPairs)
{
pair.Kill();
}
_initialized = false;
}
public static string DeathReport()
{
lock (PairLock)
{
var builder = new StringBuilder();
var pairs = Pairs.Keys.OrderBy(pair => pair.Id);
foreach (var pair in pairs)
{
var borrowed = Pairs[pair];
builder.AppendLine($"Pair {pair.Id}, Tests Run: {pair.TestHistory.Count}, Borrowed: {borrowed}");
for (var i = 0; i < pair.TestHistory.Count; i++)
{
builder.AppendLine($"#{i}: {pair.TestHistory[i]}");
}
}
return builder.ToString();
}
}
public static async Task<(RobustIntegrationTest.ClientIntegrationInstance, PoolTestLogHandler)> GenerateClient(
PoolSettings poolSettings,
TextWriter testOut)
{
var options = new RobustIntegrationTest.ClientIntegrationOptions
{
FailureLogLevel = LogLevel.Warning,
ContentStart = true,
ContentAssemblies = new[]
{
typeof(Shared.Entry.EntryPoint).Assembly,
typeof(Client.Entry.EntryPoint).Assembly,
typeof(PoolManager).Assembly,
}
};
if (poolSettings.NoLoadContent)
{
Assert.Warn("NoLoadContent does not work on the client, ignoring");
}
options.Options = new GameControllerOptions()
{
LoadConfigAndUserData = false,
// LoadContentResources = !poolSettings.NoLoadContent
};
var logHandler = new PoolTestLogHandler("CLIENT");
logHandler.ActivateContext(testOut);
options.OverrideLogHandler = () => logHandler;
options.BeforeStart += () =>
{
IoCManager.Resolve<IModLoader>().SetModuleBaseCallbacks(new ClientModuleTestingCallbacks
{
ClientBeforeIoC = () =>
{
// do not register extra systems or components here -- they will get cleared when the client is
// disconnected. just use reflection.
IoCManager.Register<IParallaxManager, DummyParallaxManager>(true);
IoCManager.Resolve<ILogManager>().GetSawmill("loc").Level = LogLevel.Error;
IoCManager.Resolve<IConfigurationManager>()
.OnValueChanged(RTCVars.FailureLogLevel, value => logHandler.FailureLevel = value, true);
}
});
};
SetDefaultCVars(options);
var client = new RobustIntegrationTest.ClientIntegrationInstance(options);
await client.WaitIdleAsync();
await SetupCVars(client, poolSettings);
return (client, logHandler);
}
/// <summary>
/// Gets a <see cref="Pair.TestPair"/>, which can be used to get access to a server, and client <see cref="Pair.TestPair"/>
/// </summary>
/// <param name="poolSettings">See <see cref="PoolSettings"/></param>
/// <returns></returns>
public static async Task<TestPair> GetServerClient(
PoolSettings? poolSettings = null,
ITestContextLike? testContext = null)
{
return await GetServerClientPair(
poolSettings ?? new PoolSettings(),
testContext ?? new NUnitTestContextWrap(TestContext.CurrentContext, TestContext.Out));
}
private static string GetDefaultTestName(ITestContextLike testContext)
{
return testContext.FullName.Replace("Content.IntegrationTests.Tests.", "");
}
private static async Task<TestPair> GetServerClientPair(
PoolSettings poolSettings,
ITestContextLike testContext)
{
if (!_initialized)
throw new InvalidOperationException($"Pool manager has not been initialized");
// Trust issues with the AsyncLocal that backs this.
var testOut = testContext.Out;
DieIfPoolFailure();
var currentTestName = poolSettings.TestName ?? GetDefaultTestName(testContext);
var poolRetrieveTimeWatch = new Stopwatch();
await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Called by test {currentTestName}");
TestPair? pair = null;
try
{
poolRetrieveTimeWatch.Start();
if (poolSettings.MustBeNew)
{
await testOut.WriteLineAsync(
$"{nameof(GetServerClientPair)}: Creating pair, because settings of pool settings");
pair = await CreateServerClientPair(poolSettings, testOut);
}
else
{
await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Looking in pool for a suitable pair");
pair = GrabOptimalPair(poolSettings);
if (pair != null)
{
pair.ActivateContext(testOut);
await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Suitable pair found");
var canSkip = pair.Settings.CanFastRecycle(poolSettings);
if (canSkip)
{
await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Cleanup not needed, Skipping cleanup of pair");
await SetupCVars(pair.Client, poolSettings);
await SetupCVars(pair.Server, poolSettings);
await pair.RunTicksSync(1);
}
else
{
await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Cleaning existing pair");
await pair.CleanPooledPair(poolSettings, testOut);
}
await pair.RunTicksSync(5);
await pair.SyncTicks(targetDelta: 1);
}
else
{
await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Creating a new pair, no suitable pair found in pool");
pair = await CreateServerClientPair(poolSettings, testOut);
}
}
}
finally
{
if (pair != null && pair.TestHistory.Count > 0)
{
await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Pair {pair.Id} Test History Start");
for (var i = 0; i < pair.TestHistory.Count; i++)
{
await testOut.WriteLineAsync($"- Pair {pair.Id} Test #{i}: {pair.TestHistory[i]}");
}
await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Pair {pair.Id} Test History End");
}
}
pair.ValidateSettings(poolSettings);
var poolRetrieveTime = poolRetrieveTimeWatch.Elapsed;
await testOut.WriteLineAsync(
$"{nameof(GetServerClientPair)}: Retrieving pair {pair.Id} from pool took {poolRetrieveTime.TotalMilliseconds} ms");
pair.ClearModifiedCvars();
pair.Settings = poolSettings;
pair.TestHistory.Add(currentTestName);
pair.SetupSeed();
await testOut.WriteLineAsync(
$"{nameof(GetServerClientPair)}: Returning pair {pair.Id} with client/server seeds: {pair.ClientSeed}/{pair.ServerSeed}");
pair.Watch.Restart();
return pair;
}
private static TestPair? GrabOptimalPair(PoolSettings poolSettings)
{
lock (PairLock)
{
TestPair? fallback = null;
foreach (var pair in Pairs.Keys)
{
if (Pairs[pair])
continue;
if (!pair.Settings.CanFastRecycle(poolSettings))
{
fallback = pair;
continue;
}
pair.Use();
Pairs[pair] = true;
return pair;
}
if (fallback != null)
{
fallback.Use();
Pairs[fallback!] = true;
}
return fallback;
}
}
/// <summary>
/// Used by TestPair after checking the server/client pair, Don't use this.
/// </summary>
public static void NoCheckReturn(TestPair pair)
{
lock (PairLock)
{
if (pair.State == TestPair.PairState.Dead)
Pairs.Remove(pair);
else if (pair.State == TestPair.PairState.Ready)
Pairs[pair] = false;
else
throw new InvalidOperationException($"Attempted to return a pair in an invalid state. Pair: {pair.Id}. State: {pair.State}.");
}
}
private static void DieIfPoolFailure()
{
if (_poolFailureReason != null)
{
// If the _poolFailureReason is not null, we can assume at least one test failed.
// So we say inconclusive so we don't add more failed tests to search through.
Assert.Inconclusive(@$"
In a different test, the pool manager had an exception when trying to create a server/client pair.
Instead of risking that the pool manager will fail at creating a server/client pairs for every single test,
we are just going to end this here to save a lot of time. This is the exception that started this:\n {_poolFailureReason}");
}
if (_dead)
{
// If Pairs is null, we ran out of time, we can't assume a test failed.
// So we are going to tell it all future tests are a failure.
Assert.Fail("The pool was shut down");
}
}
private static async Task<TestPair> CreateServerClientPair(PoolSettings poolSettings, TextWriter testOut)
{
try
{
var id = Interlocked.Increment(ref _pairId);
var pair = new TestPair(id);
await pair.Initialize(poolSettings, testOut, _testPrototypes);
pair.Use();
await pair.RunTicksSync(5);
await pair.SyncTicks(targetDelta: 1);
return pair;
}
catch (Exception ex)
{
_poolFailureReason = ex;
throw;
}
}
/// <summary>
/// Runs a server, or a client until a condition is true
@@ -423,29 +67,42 @@ we are just going to end this here to save a lot of time. This is the exception
Assert.That(passed);
}
/// <summary>
/// Initialize the pool manager.
/// </summary>
/// <param name="extraAssemblies">Assemblies to search for to discover extra prototypes and systems.</param>
public static void Startup(params Assembly[] extraAssemblies)
public static async Task<TestPair> GetServerClient(
PoolSettings? settings = null,
ITestContextLike? testContext = null)
{
if (_initialized)
throw new InvalidOperationException("Already initialized");
return await Instance.GetPair(settings, testContext);
}
_initialized = true;
_contentAssemblies =
[
typeof(Shared.Entry.EntryPoint).Assembly,
typeof(Server.Entry.EntryPoint).Assembly,
typeof(PoolManager).Assembly
];
_contentAssemblies.UnionWith(extraAssemblies);
public static void Startup(params Assembly[] extra)
=> Instance.Startup(extra);
_testPrototypes.Clear();
DiscoverTestPrototypes(typeof(PoolManager).Assembly);
foreach (var assembly in extraAssemblies)
{
DiscoverTestPrototypes(assembly);
}
public static void Shutdown() => Instance.Shutdown();
public static string DeathReport() => Instance.DeathReport();
}
/// <summary>
/// Making clients, and servers is slow, this manages a pool of them so tests can reuse them.
/// </summary>
public sealed class ContentPoolManager : PoolManager<TestPair>
{
public override PairSettings DefaultSettings => new PoolSettings();
protected override string GetDefaultTestName(ITestContextLike testContext)
{
return testContext.FullName.Replace("Content.IntegrationTests.Tests.", "");
}
public override void Startup(params Assembly[] extraAssemblies)
{
DefaultCvars.AddRange(PoolManager.TestCvars);
var shared = extraAssemblies
.Append(typeof(Shared.Entry.EntryPoint).Assembly)
.Append(typeof(PoolManager).Assembly)
.ToArray();
Startup([typeof(Client.Entry.EntryPoint).Assembly],
[typeof(Server.Entry.EntryPoint).Assembly],
shared);
}
}

View File

@@ -1,43 +1,31 @@
#nullable enable
namespace Content.IntegrationTests;
using Robust.Shared.Random;
namespace Content.IntegrationTests;
/// <summary>
/// Settings for the pooled server, and client pair.
/// Some options are for changing the pair, and others are
/// so the pool can properly clean up what you borrowed.
/// </summary>
public sealed class PoolSettings
/// <inheritdoc/>
public sealed class PoolSettings : PairSettings
{
/// <summary>
/// Set to true if the test will ruin the server/client pair.
/// </summary>
public bool Destructive { get; init; }
public override bool Connected
{
get => _connected || InLobby;
init => _connected = value;
}
/// <summary>
/// Set to true if the given server/client pair should be created fresh.
/// </summary>
public bool Fresh { get; init; }
private readonly bool _dummyTicker = true;
private readonly bool _connected;
/// <summary>
/// Set to true if the given server should be using a dummy ticker. Ignored if <see cref="InLobby"/> is true.
/// </summary>
public bool DummyTicker { get; init; } = true;
public bool DummyTicker
{
get => _dummyTicker && !InLobby;
init => _dummyTicker = value;
}
/// <summary>
/// If true, this enables the creation of admin logs during the test.
/// </summary>
public bool AdminLogsEnabled { get; init; }
/// <summary>
/// Set to true if the given server/client pair should be connected from each other.
/// Defaults to disconnected as it makes dirty recycling slightly faster.
/// If <see cref="InLobby"/> is true, this option is ignored.
/// </summary>
public bool Connected { get; init; }
/// <summary>
/// Set to true if the given server/client pair should be in the lobby.
/// If the pair is not in the lobby at the end of the test, this test must be marked as dirty.
@@ -53,81 +41,22 @@ public sealed class PoolSettings
/// </summary>
public bool NoLoadContent { get; init; }
/// <summary>
/// This will return a server-client pair that has not loaded test prototypes.
/// Try avoiding this whenever possible, as this will always create & destroy a new pair.
/// Use <see cref="Pair.TestPair.IsTestPrototype(Robust.Shared.Prototypes.EntityPrototype)"/> if you need to exclude test prototypees.
/// </summary>
public bool NoLoadTestPrototypes { get; init; }
/// <summary>
/// Set this to true to disable the NetInterp CVar on the given server/client pair
/// </summary>
public bool DisableInterpolate { get; init; }
/// <summary>
/// Set this to true to always clean up the server/client pair before giving it to another borrower
/// </summary>
public bool Dirty { get; init; }
/// <summary>
/// Set this to the path of a map to have the given server/client pair load the map.
/// </summary>
public string Map { get; init; } = PoolManager.TestMap;
/// <summary>
/// Overrides the test name detection, and uses this in the test history instead
/// </summary>
public string? TestName { get; set; }
/// <summary>
/// If set, this will be used to call <see cref="IRobustRandom.SetSeed"/>
/// </summary>
public int? ServerSeed { get; set; }
/// <summary>
/// If set, this will be used to call <see cref="IRobustRandom.SetSeed"/>
/// </summary>
public int? ClientSeed { get; set; }
#region Inferred Properties
/// <summary>
/// If the returned pair must not be reused
/// </summary>
public bool MustNotBeReused => Destructive || NoLoadContent || NoLoadTestPrototypes;
/// <summary>
/// If the given pair must be brand new
/// </summary>
public bool MustBeNew => Fresh || NoLoadContent || NoLoadTestPrototypes;
public bool UseDummyTicker => !InLobby && DummyTicker;
public bool ShouldBeConnected => InLobby || Connected;
#endregion
/// <summary>
/// Tries to guess if we can skip recycling the server/client pair.
/// </summary>
/// <param name="nextSettings">The next set of settings the old pair will be set to</param>
/// <returns>If we can skip cleaning it up</returns>
public bool CanFastRecycle(PoolSettings nextSettings)
public override bool CanFastRecycle(PairSettings nextSettings)
{
if (MustNotBeReused)
throw new InvalidOperationException("Attempting to recycle a non-reusable test.");
if (!base.CanFastRecycle(nextSettings))
return false;
if (nextSettings.MustBeNew)
throw new InvalidOperationException("Attempting to recycle a test while requesting a fresh test.");
if (Dirty)
if (nextSettings is not PoolSettings next)
return false;
// Check that certain settings match.
return !ShouldBeConnected == !nextSettings.ShouldBeConnected
&& UseDummyTicker == nextSettings.UseDummyTicker
&& Map == nextSettings.Map
&& InLobby == nextSettings.InLobby;
return DummyTicker == next.DummyTicker
&& Map == next.Map
&& InLobby == next.InLobby;
}
}

View File

@@ -1,79 +0,0 @@
using System.IO;
using Robust.Shared.Log;
using Robust.Shared.Timing;
using Serilog.Events;
namespace Content.IntegrationTests;
#nullable enable
/// <summary>
/// Log handler intended for pooled integration tests.
/// </summary>
/// <remarks>
/// <para>
/// This class logs to two places: an NUnit <see cref="TestContext"/>
/// (so it nicely gets attributed to a test in your IDE),
/// and an in-memory ring buffer for diagnostic purposes.
/// If test pooling breaks, the ring buffer can be used to see what the broken instance has gone through.
/// </para>
/// <para>
/// The active test context can be swapped out so pooled instances can correctly have their logs attributed.
/// </para>
/// </remarks>
public sealed class PoolTestLogHandler : ILogHandler
{
private readonly string? _prefix;
private RStopwatch _stopwatch;
public TextWriter? ActiveContext { get; private set; }
public LogLevel? FailureLevel { get; set; }
public PoolTestLogHandler(string? prefix)
{
_prefix = prefix != null ? $"{prefix}: " : "";
}
public bool ShuttingDown;
public void Log(string sawmillName, LogEvent message)
{
var level = message.Level.ToRobust();
if (ShuttingDown && (FailureLevel == null || level < FailureLevel))
return;
if (ActiveContext is not { } testContext)
{
// If this gets hit it means something is logging to this instance while it's "between" tests.
// This is a bug in either the game or the testing system, and must always be investigated.
throw new InvalidOperationException("Log to pool test log handler without active test context");
}
var name = LogMessage.LogLevelToName(level);
var seconds = _stopwatch.Elapsed.TotalSeconds;
var rendered = message.RenderMessage();
var line = $"{_prefix}{seconds:F3}s [{name}] {sawmillName}: {rendered}";
testContext.WriteLine(line);
if (FailureLevel == null || level < FailureLevel)
return;
testContext.Flush();
Assert.Fail($"{line} Exception: {message.Exception}");
}
public void ClearContext()
{
ActiveContext = null;
}
public void ActivateContext(TextWriter context)
{
_stopwatch.Restart();
ActiveContext = context;
}
}

View File

@@ -1,12 +0,0 @@
using JetBrains.Annotations;
namespace Content.IntegrationTests;
/// <summary>
/// Attribute that indicates that a string contains yaml prototype data that should be loaded by integration tests.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
[MeansImplicitUse]
public sealed class TestPrototypesAttribute : Attribute
{
}

View File

@@ -54,7 +54,7 @@ namespace Content.IntegrationTests.Tests.Access
system.ClearDenyTags(reader);
// test one list
system.AddAccess(reader, "A");
system.TryAddAccess(reader, "A");
Assert.Multiple(() =>
{
Assert.That(system.AreAccessTagsAllowed(new List<ProtoId<AccessLevelPrototype>> { "A" }, reader), Is.True);
@@ -62,10 +62,10 @@ namespace Content.IntegrationTests.Tests.Access
Assert.That(system.AreAccessTagsAllowed(new List<ProtoId<AccessLevelPrototype>> { "A", "B" }, reader), Is.True);
Assert.That(system.AreAccessTagsAllowed(Array.Empty<ProtoId<AccessLevelPrototype>>(), reader), Is.False);
});
system.ClearAccesses(reader);
system.TryClearAccesses(reader);
// test one list - two items
system.AddAccess(reader, new HashSet<ProtoId<AccessLevelPrototype>> { "A", "B" });
system.TryAddAccess(reader, new HashSet<ProtoId<AccessLevelPrototype>> { "A", "B" });
Assert.Multiple(() =>
{
Assert.That(system.AreAccessTagsAllowed(new List<ProtoId<AccessLevelPrototype>> { "A" }, reader), Is.False);
@@ -73,14 +73,14 @@ namespace Content.IntegrationTests.Tests.Access
Assert.That(system.AreAccessTagsAllowed(new List<ProtoId<AccessLevelPrototype>> { "A", "B" }, reader), Is.True);
Assert.That(system.AreAccessTagsAllowed(Array.Empty<ProtoId<AccessLevelPrototype>>(), reader), Is.False);
});
system.ClearAccesses(reader);
system.TryClearAccesses(reader);
// test two list
var accesses = new List<HashSet<ProtoId<AccessLevelPrototype>>>() {
new HashSet<ProtoId<AccessLevelPrototype>> () { "A" },
new HashSet<ProtoId<AccessLevelPrototype>> () { "B", "C" }
};
system.AddAccesses(reader, accesses);
system.TryAddAccesses(reader, accesses);
Assert.Multiple(() =>
{
Assert.That(system.AreAccessTagsAllowed(new List<ProtoId<AccessLevelPrototype>> { "A" }, reader), Is.True);
@@ -90,10 +90,10 @@ namespace Content.IntegrationTests.Tests.Access
Assert.That(system.AreAccessTagsAllowed(new List<ProtoId<AccessLevelPrototype>> { "C", "B", "A" }, reader), Is.True);
Assert.That(system.AreAccessTagsAllowed(Array.Empty<ProtoId<AccessLevelPrototype>>(), reader), Is.False);
});
system.ClearAccesses(reader);
system.TryClearAccesses(reader);
// test deny list
system.AddAccess(reader, new HashSet<ProtoId<AccessLevelPrototype>> { "A" });
system.TryAddAccess(reader, new HashSet<ProtoId<AccessLevelPrototype>> { "A" });
system.AddDenyTag(reader, "B");
Assert.Multiple(() =>
{
@@ -102,7 +102,7 @@ namespace Content.IntegrationTests.Tests.Access
Assert.That(system.AreAccessTagsAllowed(new List<ProtoId<AccessLevelPrototype>> { "A", "B" }, reader), Is.False);
Assert.That(system.AreAccessTagsAllowed(Array.Empty<ProtoId<AccessLevelPrototype>>(), reader), Is.False);
});
system.ClearAccesses(reader);
system.TryClearAccesses(reader);
system.ClearDenyTags(reader);
});
await pair.CleanReturnAsync();

View File

@@ -0,0 +1,135 @@
using Content.IntegrationTests.Tests.Movement;
using Content.Shared.Chasm;
using Content.Shared.Projectiles;
using Content.Shared.Weapons.Misc;
using Content.Shared.Weapons.Ranged.Components;
using Robust.Shared.Maths;
using Robust.Shared.Physics.Components;
using Robust.Shared.Prototypes;
namespace Content.IntegrationTests.Tests.Chasm;
/// <summary>
/// A test for chasms, which delete entities when a player walks over them.
/// </summary>
[TestOf(typeof(ChasmComponent))]
public sealed class ChasmTest : MovementTest
{
private readonly EntProtoId _chasmProto = "FloorChasmEntity";
private readonly EntProtoId _catWalkProto = "Catwalk";
private readonly EntProtoId _grapplingGunProto = "WeaponGrapplingGun";
/// <summary>
/// Test that a player falls into the chasm when walking over it.
/// </summary>
[Test]
public async Task ChasmFallTest()
{
// Spawn a chasm.
await SpawnTarget(_chasmProto);
Assert.That(Delta(), Is.GreaterThan(0.5), "Player did not spawn left of the chasm.");
// Attempt (and fail) to walk past the chasm.
// If you are modifying the default value of ChasmFallingComponent.DeletionTime this time might need to be adjusted.
await Move(DirectionFlag.East, 0.5f);
// We should be falling right now.
Assert.That(TryComp<ChasmFallingComponent>(Player, out var falling), "Player is not falling after walking over a chasm.");
var fallTime = (float)falling.DeletionTime.TotalSeconds;
// Wait until we get deleted.
await Pair.RunSeconds(fallTime);
// Check that the player was deleted.
AssertDeleted(Player);
}
/// <summary>
/// Test that a catwalk placed over a chasm will protect a player from falling.
/// </summary>
[Test]
public async Task ChasmCatwalkTest()
{
// Spawn a chasm.
await SpawnTarget(_chasmProto);
Assert.That(Delta(), Is.GreaterThan(0.5), "Player did not spawn left of the chasm.");
// Spawn a catwalk over the chasm.
var catwalk = await Spawn(_catWalkProto);
// Attempt to walk past the chasm.
await Move(DirectionFlag.East, 1f);
// We should be on the other side.
Assert.That(Delta(), Is.LessThan(-0.5), "Player was unable to walk over a chasm with a catwalk.");
// Check that the player is not deleted.
AssertExists(Player);
// Make sure the player is not falling right now.
Assert.That(HasComp<ChasmFallingComponent>(Player), Is.False, "Player has ChasmFallingComponent after walking over a catwalk.");
// Delete the catwalk.
await Delete(catwalk);
// Attempt (and fail) to walk past the chasm.
await Move(DirectionFlag.West, 1f);
// Wait until we get deleted.
await Pair.RunSeconds(5f);
// Check that the player was deleted
AssertDeleted(Player);
}
/// <summary>
/// Tests that a player is able to cross a chasm by using a grappling gun.
/// </summary>
[Test]
public async Task ChasmGrappleTest()
{
// Spawn a chasm.
await SpawnTarget(_chasmProto);
Assert.That(Delta(), Is.GreaterThan(0.5), "Player did not spawn left of the chasm.");
// Give the player a grappling gun.
var grapplingGun = await PlaceInHands(_grapplingGunProto);
await Pair.RunSeconds(2f); // guns have a cooldown when picking them up
// Shoot at the wall to the right.
Assert.That(WallRight, Is.Not.Null, "No wall to shoot at!");
await AttemptShoot(WallRight);
await Pair.RunSeconds(2f);
// Check that the grappling hook is embedded into the wall.
Assert.That(TryComp<GrapplingGunComponent>(grapplingGun, out var grapplingGunComp), "Grappling gun did not have GrapplingGunComponent.");
Assert.That(grapplingGunComp.Projectile, Is.Not.Null, "Grappling gun projectile does not exist.");
Assert.That(SEntMan.TryGetComponent<EmbeddableProjectileComponent>(grapplingGunComp.Projectile, out var embeddable), "Grappling hook was not embeddable.");
Assert.That(embeddable.EmbeddedIntoUid, Is.EqualTo(ToServer(WallRight)), "Grappling hook was not embedded into the wall.");
// Check that the player is hooked.
var grapplingSystem = SEntMan.System<SharedGrapplingGunSystem>();
Assert.That(grapplingSystem.IsEntityHooked(SPlayer), "Player is not hooked to the wall.");
Assert.That(HasComp<JointRelayTargetComponent>(Player), "Player does not have the JointRelayTargetComponent after using a grappling gun.");
// Attempt to walk past the chasm.
await Move(DirectionFlag.East, 1f);
// We should be on the other side.
Assert.That(Delta(), Is.LessThan(-0.5), "Player was unable to walk over a chasm with a grappling gun.");
// Check that the player is not deleted.
AssertExists(Player);
// Make sure the player is not falling right now.
Assert.That(HasComp<ChasmFallingComponent>(Player), Is.False, "Player has ChasmFallingComponent after moving over a chasm with a grappling gun.");
// Drop the grappling gun.
await Drop();
// Check that the player no longer hooked.
Assert.That(grapplingSystem.IsEntityHooked(SPlayer), Is.False, "Player still hooked after dropping the grappling gun.");
Assert.That(HasComp<JointRelayTargetComponent>(Player), Is.False, "Player still has the JointRelayTargetComponent after dropping the grappling gun.");
}
}

View File

@@ -10,6 +10,7 @@ using Content.Server.Construction.Components;
using Content.Server.Gravity;
using Content.Server.Power.Components;
using Content.Shared.Atmos;
using Content.Shared.CombatMode;
using Content.Shared.Construction.Prototypes;
using Content.Shared.Gravity;
using Content.Shared.Item;
@@ -85,7 +86,7 @@ public abstract partial class InteractionTest
}
/// <summary>
/// Spawn an entity entity and set it as the target.
/// Spawn an entity at the target coordinates and set it as the target.
/// </summary>
[MemberNotNull(nameof(Target), nameof(STarget), nameof(CTarget))]
#pragma warning disable CS8774 // Member must have a non-null value when exiting.
@@ -103,6 +104,22 @@ public abstract partial class InteractionTest
}
#pragma warning restore CS8774 // Member must have a non-null value when exiting.
/// <summary>
/// Spawn an entity entity at the target coordinates without setting it as the target.
/// </summary>
protected async Task<NetEntity> Spawn(string prototype)
{
var entity = NetEntity.Invalid;
await Server.WaitPost(() =>
{
entity = SEntMan.GetNetEntity(SEntMan.SpawnAtPosition(prototype, SEntMan.GetCoordinates(TargetCoords)));
});
await RunTicks(5);
AssertPrototype(prototype, entity);
return entity;
}
/// <summary>
/// Spawn an entity in preparation for deconstruction
/// </summary>
@@ -386,6 +403,119 @@ public abstract partial class InteractionTest
#endregion
# region Combat
/// <summary>
/// Returns if the player is currently in combat mode.
/// </summary>
protected bool IsInCombatMode()
{
if (!SEntMan.TryGetComponent(SPlayer, out CombatModeComponent? combat))
{
Assert.Fail($"Entity {SEntMan.ToPrettyString(SPlayer)} does not have a CombatModeComponent");
return false;
}
return combat.IsInCombatMode;
}
/// <summary>
/// Set the combat mode for the player.
/// </summary>
protected async Task SetCombatMode(bool enabled)
{
if (!SEntMan.TryGetComponent(SPlayer, out CombatModeComponent? combat))
{
Assert.Fail($"Entity {SEntMan.ToPrettyString(SPlayer)} does not have a CombatModeComponent");
return;
}
await Server.WaitPost(() => SCombatMode.SetInCombatMode(SPlayer, enabled, combat));
await RunTicks(1);
Assert.That(combat.IsInCombatMode, Is.EqualTo(enabled), $"Player could not set combate mode to {enabled}");
}
/// <summary>
/// Make the player shoot with their currently held gun.
/// The player needs to be able to enter combat mode for this.
/// This does not pass a target entity into the GunSystem, meaning that targets that
/// need to be aimed at directly won't be hit.
/// </summary>
/// <remarks>
/// Guns have a cooldown when picking them up.
/// So make sure to wait a little after spawning a gun in the player's hand or this will fail.
/// </remarks>
/// <param name="target">The target coordinates to shoot at. Defaults to the current <see cref="TargetCoords"/>.</param>
/// <param name="assert">If true this method will assert that the gun was successfully fired.</param>
protected async Task AttemptShoot(NetCoordinates? target = null, bool assert = true)
{
var actualTarget = SEntMan.GetCoordinates(target ?? TargetCoords);
if (!SEntMan.TryGetComponent(SPlayer, out CombatModeComponent? combat))
{
Assert.Fail($"Entity {SEntMan.ToPrettyString(SPlayer)} does not have a CombatModeComponent");
return;
}
// Enter combat mode before shooting.
var wasInCombatMode = IsInCombatMode();
await SetCombatMode(true);
Assert.That(SGun.TryGetGun(SPlayer, out var gunUid, out var gunComp), "Player was not holding a gun!");
await Server.WaitAssertion(() =>
{
var success = SGun.AttemptShoot(SPlayer, gunUid, gunComp!, actualTarget);
if (assert)
Assert.That(success, "Gun failed to shoot.");
});
await RunTicks(1);
// If the player was not in combat mode before then disable it again.
await SetCombatMode(wasInCombatMode);
}
/// <summary>
/// Make the player shoot with their currently held gun.
/// The player needs to be able to enter combat mode for this.
/// </summary>
/// <remarks>
/// Guns have a cooldown when picking them up.
/// So make sure to wait a little after spawning a gun in the player's hand or this will fail.
/// </remarks>
/// <param name="target">The target entity to shoot at. Defaults to the current <see cref="Target"/> entity.</param>
/// <param name="assert">If true this method will assert that the gun was successfully fired.</param>
protected async Task AttemptShoot(NetEntity? target = null, bool assert = true)
{
var actualTarget = target ?? Target;
Assert.That(actualTarget, Is.Not.Null, "No target to shoot at!");
if (!SEntMan.TryGetComponent(SPlayer, out CombatModeComponent? combat))
{
Assert.Fail($"Entity {SEntMan.ToPrettyString(SPlayer)} does not have a CombatModeComponent");
return;
}
// Enter combat mode before shooting.
var wasInCombatMode = IsInCombatMode();
await SetCombatMode(true);
Assert.That(SGun.TryGetGun(SPlayer, out var gunUid, out var gunComp), "Player was not holding a gun!");
await Server.WaitAssertion(() =>
{
var success = SGun.AttemptShoot(SPlayer, gunUid, gunComp!, Position(actualTarget!.Value), ToServer(actualTarget));
if (assert)
Assert.That(success, "Gun failed to shoot.");
});
await RunTicks(1);
// If the player was not in combat mode before then disable it again.
await SetCombatMode(wasInCombatMode);
}
#endregion
/// <summary>
/// Wait for any currently active DoAfters to finish.
/// </summary>
@@ -746,6 +876,18 @@ public abstract partial class InteractionTest
return SEntMan.GetComponent<T>(ToServer(target!.Value));
}
/// <summary>
/// Convenience method to check if the target has a component on the server.
/// </summary>
protected bool HasComp<T>(NetEntity? target = null) where T : IComponent
{
target ??= Target;
if (target == null)
Assert.Fail("No target specified");
return SEntMan.HasComponent<T>(ToServer(target));
}
/// <inheritdoc cref="Comp{T}"/>
protected bool TryComp<T>(NetEntity? target, [NotNullWhen(true)] out T? comp) where T : IComponent
{
@@ -1013,7 +1155,7 @@ public abstract partial class InteractionTest
}
Assert.That(control.GetType().IsAssignableTo(typeof(TControl)));
return (TControl) control;
return (TControl)control;
}
/// <summary>
@@ -1177,8 +1319,8 @@ public abstract partial class InteractionTest
{
var atmosSystem = SEntMan.System<AtmosphereSystem>();
var moles = new float[Atmospherics.AdjustedNumberOfGases];
moles[(int) Gas.Oxygen] = 21.824779f;
moles[(int) Gas.Nitrogen] = 82.10312f;
moles[(int)Gas.Oxygen] = 21.824779f;
moles[(int)Gas.Nitrogen] = 82.10312f;
atmosSystem.SetMapAtmosphere(target, false, new GasMixture(moles, Atmospherics.T20C));
});
}

View File

@@ -7,12 +7,16 @@ using Content.IntegrationTests.Pair;
using Content.Server.Hands.Systems;
using Content.Server.Stack;
using Content.Server.Tools;
using Content.Shared.CombatMode;
using Content.Shared.DoAfter;
using Content.Shared.Hands.Components;
using Content.Shared.Interaction;
using Content.Shared.Item.ItemToggle;
using Content.Shared.Mind;
using Content.Shared.Players;
using Content.Shared.Weapons.Ranged.Systems;
using Robust.Client.Input;
using Robust.Client.State;
using Robust.Client.UserInterface;
using Robust.Shared.GameObjects;
using Robust.Shared.Log;
@@ -21,8 +25,6 @@ using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
using Robust.UnitTesting;
using Content.Shared.Item.ItemToggle;
using Robust.Client.State;
namespace Content.IntegrationTests.Tests.Interaction;
@@ -107,6 +109,8 @@ public abstract partial class InteractionTest
protected SharedMapSystem MapSystem = default!;
protected ISawmill SLogger = default!;
protected SharedUserInterfaceSystem SUiSys = default!;
protected SharedCombatModeSystem SCombatMode = default!;
protected SharedGunSystem SGun = default!;
// CLIENT dependencies
protected IEntityManager CEntMan = default!;
@@ -124,7 +128,7 @@ public abstract partial class InteractionTest
protected HandsComponent Hands = default!;
protected DoAfterComponent DoAfters = default!;
public float TickPeriod => (float) STiming.TickPeriod.TotalSeconds;
public float TickPeriod => (float)STiming.TickPeriod.TotalSeconds;
// Simple mob that has one hand and can perform misc interactions.
[TestPrototypes]
@@ -149,6 +153,7 @@ public abstract partial class InteractionTest
tags:
- CanPilot
- type: UserInterface
- type: CombatMode
";
[SetUp]
@@ -163,6 +168,7 @@ public abstract partial class InteractionTest
ProtoMan = Server.ResolveDependency<IPrototypeManager>();
Factory = Server.ResolveDependency<IComponentFactory>();
STiming = Server.ResolveDependency<IGameTiming>();
SLogger = Server.ResolveDependency<ILogManager>().RootSawmill;
HandSys = SEntMan.System<HandsSystem>();
InteractSys = SEntMan.System<SharedInteractionSystem>();
ToolSys = SEntMan.System<ToolSystem>();
@@ -173,20 +179,21 @@ public abstract partial class InteractionTest
SConstruction = SEntMan.System<Server.Construction.ConstructionSystem>();
STestSystem = SEntMan.System<InteractionTestSystem>();
Stack = SEntMan.System<StackSystem>();
SLogger = Server.ResolveDependency<ILogManager>().RootSawmill;
SUiSys = Client.System<SharedUserInterfaceSystem>();
SUiSys = SEntMan.System<SharedUserInterfaceSystem>();
SCombatMode = SEntMan.System<SharedCombatModeSystem>();
SGun = SEntMan.System<SharedGunSystem>();
// client dependencies
CEntMan = Client.ResolveDependency<IEntityManager>();
UiMan = Client.ResolveDependency<IUserInterfaceManager>();
CTiming = Client.ResolveDependency<IGameTiming>();
InputManager = Client.ResolveDependency<IInputManager>();
CLogger = Client.ResolveDependency<ILogManager>().RootSawmill;
InputSystem = CEntMan.System<Robust.Client.GameObjects.InputSystem>();
CTestSystem = CEntMan.System<InteractionTestSystem>();
CConSys = CEntMan.System<ConstructionSystem>();
ExamineSys = CEntMan.System<ExamineSystem>();
CLogger = Client.ResolveDependency<ILogManager>().RootSawmill;
CUiSys = Client.System<SharedUserInterfaceSystem>();
CUiSys = CEntMan.System<SharedUserInterfaceSystem>();
// Setup map.
await Pair.CreateTestMap();

View File

@@ -145,7 +145,7 @@ public sealed class MaterialArbitrageTest
Dictionary<string, double> priceCache = new();
Dictionary<string, (Dictionary<string, int> Ents, Dictionary<string, int> Mats)> spawnedOnDestroy = new();
Dictionary<string, (Dictionary<string, float> Ents, Dictionary<string, float> Mats)> spawnedOnDestroy = new();
// cache the compositions of entities
// If the entity is refineable (i.e. glass shared can be turned into glass, we take the greater of the two compositions.
@@ -217,8 +217,8 @@ public sealed class MaterialArbitrageTest
var comp = (DestructibleComponent) destructible.Component;
var spawnedEnts = new Dictionary<string, int>();
var spawnedMats = new Dictionary<string, int>();
var spawnedEnts = new Dictionary<string, float>();
var spawnedMats = new Dictionary<string, float>();
// This test just blindly assumes that ALL spawn entity behaviors get triggered. In reality, some entities
// might only trigger a subset. If that starts being a problem, this test either needs fixing or needs to
@@ -233,14 +233,14 @@ public sealed class MaterialArbitrageTest
foreach (var (key, value) in spawn.Spawn)
{
spawnedEnts[key] = spawnedEnts.GetValueOrDefault(key) + value.Max;
spawnedEnts[key] = spawnedEnts.GetValueOrDefault(key) + (float)(value.Min + value.Max) / 2;
if (!compositions.TryGetValue(key, out var composition))
continue;
foreach (var (matId, amount) in composition)
{
spawnedMats[matId] = value.Max * amount + spawnedMats.GetValueOrDefault(matId);
spawnedMats[matId] = (float)(value.Min + value.Max) / 2 * amount + spawnedMats.GetValueOrDefault(matId);
}
}
}
@@ -451,7 +451,7 @@ public sealed class MaterialArbitrageTest
await server.WaitPost(() => mapSystem.DeleteMap(testMap.MapId));
await pair.CleanReturnAsync();
async Task<double> GetSpawnedPrice(Dictionary<string, int> ents)
async Task<double> GetSpawnedPrice(Dictionary<string, float> ents)
{
double price = 0;
foreach (var (id, num) in ents)

View File

@@ -24,6 +24,15 @@ public abstract class MovementTest : InteractionTest
/// </summary>
protected virtual bool AddWalls => true;
/// <summary>
/// The wall entity on the left side.
/// </summary>
protected NetEntity? WallLeft;
/// <summary>
/// The wall entity on the right side.
/// </summary>
protected NetEntity? WallRight;
[SetUp]
public override async Task Setup()
{
@@ -38,8 +47,11 @@ public abstract class MovementTest : InteractionTest
if (AddWalls)
{
await SpawnEntity("WallSolid", pCoords.Offset(new Vector2(-Tiles, 0)));
await SpawnEntity("WallSolid", pCoords.Offset(new Vector2(Tiles, 0)));
var sWallLeft = await SpawnEntity("WallSolid", pCoords.Offset(new Vector2(-Tiles, 0)));
var sWallRight = await SpawnEntity("WallSolid", pCoords.Offset(new Vector2(Tiles, 0)));
WallLeft = SEntMan.GetNetEntity(sWallLeft);
WallRight = SEntMan.GetNetEntity(sWallRight);
}
await AddGravity();

View File

@@ -20,6 +20,7 @@ using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Maths;
using Robust.Shared.Timing;
using Robust.UnitTesting.Pool;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;

View File

@@ -9,6 +9,7 @@ using Content.IntegrationTests;
using Content.MapRenderer.Painters;
using Content.Server.Maps;
using Robust.Shared.Prototypes;
using Robust.UnitTesting.Pool;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Webp;

View File

@@ -229,7 +229,7 @@ public sealed class AccessOverriderSystem : SharedAccessOverriderSystem
_adminLogger.Add(LogType.Action, LogImpact.High,
$"{ToPrettyString(player):player} has modified {ToPrettyString(accessReaderEnt.Value):entity} with the following allowed access level holders: [{string.Join(", ", addedTags.Union(removedTags))}] [{string.Join(", ", newAccessList)}]");
_accessReader.SetAccesses(accessReaderEnt.Value, newAccessList);
_accessReader.TrySetAccesses(accessReaderEnt.Value, newAccessList);
var ev = new OnAccessOverriderAccessUpdatedEvent(player);
RaiseLocalEvent(component.TargetAccessReaderId, ref ev);

View File

@@ -7,9 +7,7 @@ using Content.Server.EUI;
using Content.Shared.Administration;
using Content.Shared.Database;
using Content.Shared.Eui;
using Content.Shared.Roles;
using Robust.Shared.Network;
using Robust.Shared.Prototypes;
namespace Content.Server.Administration;
@@ -21,7 +19,6 @@ public sealed class BanPanelEui : BaseEui
[Dependency] private readonly IPlayerLocator _playerLocator = default!;
[Dependency] private readonly IChatManager _chat = default!;
[Dependency] private readonly IAdminManager _admins = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
private readonly ISawmill _sawmill;
@@ -52,7 +49,7 @@ public sealed class BanPanelEui : BaseEui
switch (msg)
{
case BanPanelEuiStateMsg.CreateBanRequest r:
BanPlayer(r.Player, r.IpAddress, r.UseLastIp, r.Hwid, r.UseLastHwid, r.Minutes, r.Severity, r.Reason, r.Roles, r.Erase);
BanPlayer(r.Ban);
break;
case BanPanelEuiStateMsg.GetPlayerInfoRequest r:
ChangePlayer(r.PlayerUsername);
@@ -60,29 +57,26 @@ public sealed class BanPanelEui : BaseEui
}
}
private async void BanPlayer(string? target, string? ipAddressString, bool useLastIp, ImmutableTypedHwid? hwid, bool useLastHwid, uint minutes, NoteSeverity severity, string reason, IReadOnlyCollection<string>? roles, bool erase)
private async void BanPlayer(Ban ban)
{
if (!_admins.HasAdminFlag(Player, AdminFlags.Ban))
{
_sawmill.Warning($"{Player.Name} ({Player.UserId}) tried to create a ban with no ban flag");
return;
}
if (target == null && string.IsNullOrWhiteSpace(ipAddressString) && hwid == null)
if (ban.Target == null && string.IsNullOrWhiteSpace(ban.IpAddress) && ban.Hwid == null)
{
_chat.DispatchServerMessage(Player, Loc.GetString("ban-panel-no-data"));
return;
}
(IPAddress, int)? addressRange = null;
if (ipAddressString is not null)
if (ban.IpAddress is not null)
{
var hid = "0";
var split = ipAddressString.Split('/', 2);
ipAddressString = split[0];
if (split.Length > 1)
hid = split[1];
if (!IPAddress.TryParse(ipAddressString, out var ipAddress) || !uint.TryParse(hid, out var hidInt) || hidInt > Ipv6_CIDR || hidInt > Ipv4_CIDR && ipAddress.AddressFamily == AddressFamily.InterNetwork)
if (!IPAddress.TryParse(ban.IpAddress, out var ipAddress) || !uint.TryParse(ban.IpAddressHid, out var hidInt) || hidInt > Ipv6_CIDR || hidInt > Ipv4_CIDR && ipAddress.AddressFamily == AddressFamily.InterNetwork)
{
_chat.DispatchServerMessage(Player, Loc.GetString("ban-panel-invalid-ip"));
return;
@@ -94,12 +88,12 @@ public sealed class BanPanelEui : BaseEui
addressRange = (ipAddress, (int) hidInt);
}
var targetUid = target is not null ? PlayerId : null;
addressRange = useLastIp && LastAddress is not null ? (LastAddress, LastAddress.AddressFamily == AddressFamily.InterNetworkV6 ? Ipv6_CIDR : Ipv4_CIDR) : addressRange;
var targetHWid = useLastHwid ? LastHwid : hwid;
if (target != null && target != PlayerName || Guid.TryParse(target, out var parsed) && parsed != PlayerId)
var targetUid = ban.Target is not null ? PlayerId : null;
addressRange = ban.UseLastIp && LastAddress is not null ? (LastAddress, LastAddress.AddressFamily == AddressFamily.InterNetworkV6 ? Ipv6_CIDR : Ipv4_CIDR) : addressRange;
var targetHWid = ban.UseLastHwid ? LastHwid : ban.Hwid;
if (ban.Target != null && ban.Target != PlayerName || Guid.TryParse(ban.Target, out var parsed) && parsed != PlayerId)
{
var located = await _playerLocator.LookupIdByNameOrIdAsync(target);
var located = await _playerLocator.LookupIdByNameOrIdAsync(ban.Target);
if (located == null)
{
_chat.DispatchServerMessage(Player, Loc.GetString("cmd-ban-player"));
@@ -107,7 +101,7 @@ public sealed class BanPanelEui : BaseEui
}
targetUid = located.UserId;
var targetAddress = located.LastAddress;
if (useLastIp && targetAddress != null)
if (ban.UseLastIp && targetAddress != null)
{
if (targetAddress.IsIPv4MappedToIPv6)
targetAddress = targetAddress.MapToIPv4();
@@ -116,30 +110,50 @@ public sealed class BanPanelEui : BaseEui
var hid = targetAddress.AddressFamily == AddressFamily.InterNetworkV6 ? Ipv6_CIDR : Ipv4_CIDR;
addressRange = (targetAddress, hid);
}
targetHWid = useLastHwid ? located.LastHWId : hwid;
targetHWid = ban.UseLastHwid ? located.LastHWId : ban.Hwid;
}
if (roles?.Count > 0)
if (ban.BannedJobs?.Length > 0 || ban.BannedAntags?.Length > 0)
{
var now = DateTimeOffset.UtcNow;
foreach (var role in roles)
foreach (var role in ban.BannedJobs ?? [])
{
if (_prototypeManager.HasIndex<JobPrototype>(role))
{
_banManager.CreateRoleBan(targetUid, target, Player.UserId, addressRange, targetHWid, role, minutes, severity, reason, now);
}
else
{
_sawmill.Warning($"{Player.Name} ({Player.UserId}) tried to issue a job ban with an invalid job: {role}");
}
_banManager.CreateRoleBan(
targetUid,
ban.Target,
Player.UserId,
addressRange,
targetHWid,
role,
ban.BanDurationMinutes,
ban.Severity,
ban.Reason,
now
);
}
foreach (var role in ban.BannedAntags ?? [])
{
_banManager.CreateRoleBan(
targetUid,
ban.Target,
Player.UserId,
addressRange,
targetHWid,
role,
ban.BanDurationMinutes,
ban.Severity,
ban.Reason,
now
);
}
Close();
return;
}
if (erase &&
targetUid != null)
if (ban.Erase && targetUid is not null)
{
try
{
@@ -152,7 +166,16 @@ public sealed class BanPanelEui : BaseEui
}
}
_banManager.CreateServerBan(targetUid, target, Player.UserId, addressRange, targetHWid, minutes, severity, reason);
_banManager.CreateServerBan(
targetUid,
ban.Target,
Player.UserId,
addressRange,
targetHWid,
ban.BanDurationMinutes,
ban.Severity,
ban.Reason
);
Close();
}

View File

@@ -1,37 +0,0 @@
using Content.Server.GameTicking;
using Content.Shared.Administration;
using Content.Shared.GameTicking;
using Robust.Shared.Console;
namespace Content.Server.Administration.Commands
{
[AdminCommand(AdminFlags.Round)]
public sealed class ReadyAll : IConsoleCommand
{
[Dependency] private readonly IEntityManager _e = default!;
public string Command => "readyall";
public string Description => "Readies up all players in the lobby, except for observers.";
public string Help => $"{Command} | ̣{Command} <ready>";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
var ready = true;
if (args.Length > 0)
{
ready = bool.Parse(args[0]);
}
var gameTicker = _e.System<GameTicker>();
if (gameTicker.RunLevel != GameRunLevel.PreRoundLobby)
{
shell.WriteLine("This command can only be ran while in the lobby!");
return;
}
gameTicker.ToggleReadyAll(ready);
}
}
}

View File

@@ -0,0 +1,32 @@
using Content.Server.GameTicking;
using Content.Shared.Administration;
using Robust.Shared.Console;
namespace Content.Server.Administration.Commands;
[AdminCommand(AdminFlags.Round)]
public sealed class ReadyAllCommand : LocalizedEntityCommands
{
[Dependency] private readonly GameTicker _gameTicker = default!;
public override string Command => "readyall";
public override void Execute(IConsoleShell shell, string argStr, string[] args)
{
var ready = true;
if (_gameTicker.RunLevel != GameRunLevel.PreRoundLobby)
{
shell.WriteError(Loc.GetString("shell-can-only-run-from-pre-round-lobby"));
return;
}
if (args.Length > 0 && !bool.TryParse(args[0], out ready))
{
shell.WriteError(Loc.GetString("shell-argument-must-be-boolean"));
return;
}
_gameTicker.ToggleReadyAll(ready);
}
}

View File

@@ -29,9 +29,10 @@ public sealed class RoleBanCommand : IConsoleCommand
public async void Execute(IConsoleShell shell, string argStr, string[] args)
{
string target;
string job;
string role;
string reason;
uint minutes;
if (!Enum.TryParse(_cfg.GetCVar(CCVars.RoleBanDefaultSeverity), out NoteSeverity severity))
{
_sawmill ??= _log.GetSawmill("admin.role_ban");
@@ -43,30 +44,33 @@ public sealed class RoleBanCommand : IConsoleCommand
{
case 3:
target = args[0];
job = args[1];
role = args[1];
reason = args[2];
minutes = 0;
break;
case 4:
target = args[0];
job = args[1];
role = args[1];
reason = args[2];
if (!uint.TryParse(args[3], out minutes))
{
shell.WriteError(Loc.GetString("cmd-roleban-minutes-parse", ("time", args[3]), ("help", Help)));
return;
}
break;
case 5:
target = args[0];
job = args[1];
role = args[1];
reason = args[2];
if (!uint.TryParse(args[3], out minutes))
{
shell.WriteError(Loc.GetString("cmd-roleban-minutes-parse", ("time", args[3]), ("help", Help)));
return;
}
@@ -80,26 +84,27 @@ public sealed class RoleBanCommand : IConsoleCommand
default:
shell.WriteError(Loc.GetString("cmd-roleban-arg-count"));
shell.WriteLine(Help);
return;
}
if (!_proto.HasIndex<JobPrototype>(job))
{
shell.WriteError(Loc.GetString("cmd-roleban-job-parse", ("job", job)));
return;
return;
}
var located = await _locator.LookupIdByNameOrIdAsync(target);
if (located == null)
{
shell.WriteError(Loc.GetString("cmd-roleban-name-parse"));
return;
}
var targetUid = located.UserId;
var targetHWid = located.LastHWId;
_bans.CreateRoleBan(targetUid, located.Username, shell.Player?.UserId, null, targetHWid, job, minutes, severity, reason, DateTimeOffset.UtcNow);
if (_proto.HasIndex<JobPrototype>(role))
_bans.CreateRoleBan<JobPrototype>(targetUid, located.Username, shell.Player?.UserId, null, targetHWid, role, minutes, severity, reason, DateTimeOffset.UtcNow);
else if (_proto.HasIndex<AntagPrototype>(role))
_bans.CreateRoleBan<AntagPrototype>(targetUid, located.Username, shell.Player?.UserId, null, targetHWid, role, minutes, severity, reason, DateTimeOffset.UtcNow);
else
shell.WriteError(Loc.GetString("cmd-roleban-job-parse", ("job", role)));
}
public CompletionResult GetCompletion(IConsoleShell shell, string[] args)

View File

@@ -26,24 +26,25 @@ namespace Content.Server.Administration.Managers;
public sealed partial class BanManager : IBanManager, IPostInjectInit
{
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IChatManager _chat = default!;
[Dependency] private readonly IServerDbManager _db = default!;
[Dependency] private readonly ServerDbEntryManager _entryManager = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly ILocalizationManager _localizationManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly INetManager _netManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IEntitySystemManager _systems = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly ILocalizationManager _localizationManager = default!;
[Dependency] private readonly ServerDbEntryManager _entryManager = default!;
[Dependency] private readonly IChatManager _chat = default!;
[Dependency] private readonly INetManager _netManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly ITaskManager _taskManager = default!;
[Dependency] private readonly UserDbDataManager _userDbData = default!;
private ISawmill _sawmill = default!;
public const string SawmillId = "admin.bans";
public const string JobPrefix = "Job:";
public const string PrefixAntag = "Antag:";
public const string PrefixJob = "Job:";
private readonly Dictionary<ICommonSession, List<ServerRoleBanDef>> _cachedRoleBans = new();
// Cached ban exemption flags are used to handle
@@ -91,30 +92,6 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit
_cachedBanExemptions.Remove(player);
}
private async Task<bool> AddRoleBan(ServerRoleBanDef banDef)
{
banDef = await _db.AddServerRoleBanAsync(banDef);
if (banDef.UserId != null
&& _playerManager.TryGetSessionById(banDef.UserId, out var player)
&& _cachedRoleBans.TryGetValue(player, out var cachedBans))
{
cachedBans.Add(banDef);
}
return true;
}
public HashSet<string>? GetRoleBans(NetUserId playerUserId)
{
if (!_playerManager.TryGetSessionById(playerUserId, out var session))
return null;
return _cachedRoleBans.TryGetValue(session, out var roleBans)
? roleBans.Select(banDef => banDef.Role).ToHashSet()
: null;
}
public void Restart()
{
// Clear out players that have disconnected.
@@ -232,23 +209,54 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit
#endregion
#region Job Bans
#region Role Bans
// If you are trying to remove timeOfBan, please don't. It's there because the note system groups role bans by time, reason and banning admin.
// Removing it will clutter the note list. Please also make sure that department bans are applied to roles with the same DateTimeOffset.
public async void CreateRoleBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableTypedHwid? hwid, string role, uint? minutes, NoteSeverity severity, string reason, DateTimeOffset timeOfBan)
public async void CreateRoleBan<T>(
NetUserId? target,
string? targetUsername,
NetUserId? banningAdmin,
(IPAddress, int)? addressRange,
ImmutableTypedHwid? hwid,
ProtoId<T> role,
uint? minutes,
NoteSeverity severity,
string reason,
DateTimeOffset timeOfBan
) where T : class, IPrototype
{
if (!_prototypeManager.TryIndex(role, out JobPrototype? _))
string encodedRole;
// TODO: Note that it's possible to clash IDs here between a job and an antag. The refactor that introduced
// this check has consciously avoided refactoring Job and Antag prototype.
// Refactor Job- and Antag- Prototype to introduce a common RolePrototype, which will fix this possible clash.
//TODO remove this check as part of the above refactor
if (_prototypeManager.HasIndex<JobPrototype>(role) && _prototypeManager.HasIndex<AntagPrototype>(role))
{
throw new ArgumentException($"Invalid role '{role}'", nameof(role));
_sawmill.Error($"Creating role ban for {role}: cannot create role ban, role is both JobPrototype and AntagPrototype.");
return;
}
role = string.Concat(JobPrefix, role);
DateTimeOffset? expires = null;
if (minutes > 0)
// Don't trust the input: make sure the job or antag actually exists.
if (_prototypeManager.HasIndex<JobPrototype>(role))
encodedRole = PrefixJob + role;
else if (_prototypeManager.HasIndex<AntagPrototype>(role))
encodedRole = PrefixAntag + role;
else
{
expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes.Value);
_sawmill.Error($"Creating role ban for {role}: cannot create role ban, role is not a JobPrototype or an AntagPrototype.");
return;
}
DateTimeOffset? expires = null;
if (minutes > 0)
expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes.Value);
_systems.TryGetEntitySystem(out GameTicker? ticker);
int? roundId = ticker == null || ticker.RoundId == 0 ? null : ticker.RoundId;
var playtime = target == null ? TimeSpan.Zero : (await _db.GetPlayTimes(target.Value)).Find(p => p.Tracker == PlayTimeTrackingShared.TrackerOverall)?.TimeSpent ?? TimeSpan.Zero;
@@ -266,21 +274,34 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit
severity,
banningAdmin,
null,
role);
encodedRole);
if (!await AddRoleBan(banDef))
{
_chat.SendAdminAlert(Loc.GetString("cmd-roleban-existing", ("target", targetUsername ?? "null"), ("role", role)));
return;
}
var length = expires == null ? Loc.GetString("cmd-roleban-inf") : Loc.GetString("cmd-roleban-until", ("expires", expires));
_chat.SendAdminAlert(Loc.GetString("cmd-roleban-success", ("target", targetUsername ?? "null"), ("role", role), ("reason", reason), ("length", length)));
if (target != null && _playerManager.TryGetSessionById(target.Value, out var session))
{
if (target is not null && _playerManager.TryGetSessionById(target.Value, out var session))
SendRoleBans(session);
}
private async Task<bool> AddRoleBan(ServerRoleBanDef banDef)
{
banDef = await _db.AddServerRoleBanAsync(banDef);
if (banDef.UserId != null
&& _playerManager.TryGetSessionById(banDef.UserId, out var player)
&& _cachedRoleBans.TryGetValue(player, out var cachedBans))
{
cachedBans.Add(banDef);
}
return true;
}
public async Task<string> PardonRoleBan(int banId, NetUserId? unbanningAdmin, DateTimeOffset unbanTime)
@@ -319,32 +340,109 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit
}
public HashSet<ProtoId<JobPrototype>>? GetJobBans(NetUserId playerUserId)
{
return GetRoleBans<JobPrototype>(playerUserId, PrefixJob);
}
public HashSet<ProtoId<AntagPrototype>>? GetAntagBans(NetUserId playerUserId)
{
return GetRoleBans<AntagPrototype>(playerUserId, PrefixAntag);
}
private HashSet<ProtoId<T>>? GetRoleBans<T>(NetUserId playerUserId, string prefix) where T : class, IPrototype
{
if (!_playerManager.TryGetSessionById(playerUserId, out var session))
return null;
if (!_cachedRoleBans.TryGetValue(session, out var roleBans))
return GetRoleBans<T>(session, prefix);
}
private HashSet<ProtoId<T>>? GetRoleBans<T>(ICommonSession playerSession, string prefix) where T : class, IPrototype
{
if (!_cachedRoleBans.TryGetValue(playerSession, out var roleBans))
return null;
return roleBans
.Where(ban => ban.Role.StartsWith(JobPrefix, StringComparison.Ordinal))
.Select(ban => new ProtoId<JobPrototype>(ban.Role[JobPrefix.Length..]))
.Where(ban => ban.Role.StartsWith(prefix, StringComparison.Ordinal))
.Select(ban => new ProtoId<T>(ban.Role[prefix.Length..]))
.ToHashSet();
}
#endregion
public HashSet<string>? GetRoleBans(NetUserId playerUserId)
{
if (!_playerManager.TryGetSessionById(playerUserId, out var session))
return null;
return _cachedRoleBans.TryGetValue(session, out var roleBans)
? roleBans.Select(banDef => banDef.Role).ToHashSet()
: null;
}
public bool IsRoleBanned(ICommonSession player, List<ProtoId<JobPrototype>> jobs)
{
return IsRoleBanned(player, jobs, PrefixJob);
}
public bool IsRoleBanned(ICommonSession player, List<ProtoId<AntagPrototype>> antags)
{
return IsRoleBanned(player, antags, PrefixAntag);
}
private bool IsRoleBanned<T>(ICommonSession player, List<ProtoId<T>> roles, string prefix) where T : class, IPrototype
{
var bans = GetRoleBans(player.UserId);
if (bans is null || bans.Count == 0)
return false;
// ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator
foreach (var role in roles)
{
if (bans.Contains(prefix + role))
return true;
}
return false;
}
public void SendRoleBans(ICommonSession pSession)
{
var roleBans = _cachedRoleBans.GetValueOrDefault(pSession) ?? new List<ServerRoleBanDef>();
var jobBans = GetRoleBans<JobPrototype>(pSession, PrefixJob);
var jobBansList = new List<string>(jobBans?.Count ?? 0);
if (jobBans is not null)
{
// ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator
foreach (var encodedId in jobBans)
{
jobBansList.Add(encodedId.ToString().Replace(PrefixJob, ""));
}
}
var antagBans = GetRoleBans<AntagPrototype>(pSession, PrefixAntag);
var antagBansList = new List<string>(antagBans?.Count ?? 0);
if (antagBans is not null)
{
// ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator
foreach (var encodedId in antagBans)
{
antagBansList.Add(encodedId.ToString().Replace(PrefixAntag, ""));
}
}
var bans = new MsgRoleBans()
{
Bans = roleBans.Select(o => o.Role).ToList()
JobBans = jobBansList,
AntagBans = antagBansList,
};
_sawmill.Debug($"Sent rolebans to {pSession.Name}");
_sawmill.Debug($"Sent role bans to {pSession.Name}");
_netManager.ServerSendMessage(bans, pSession.Channel);
}
#endregion
public void PostInject()
{
_sawmill = _logManager.GetSawmill(SawmillId);

View File

@@ -1,4 +1,3 @@
using System.Collections.Immutable;
using System.Net;
using System.Threading.Tasks;
using Content.Shared.Database;
@@ -25,19 +24,63 @@ public interface IBanManager
/// <param name="severity">Severity of the resulting ban note</param>
/// <param name="reason">Reason for the ban</param>
public void CreateServerBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableTypedHwid? hwid, uint? minutes, NoteSeverity severity, string reason);
/// <summary>
/// Gets a list of prefixed prototype IDs with the player's role bans.
/// </summary>
public HashSet<string>? GetRoleBans(NetUserId playerUserId);
/// <summary>
/// Checks if the player is currently banned from any of the listed roles.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="antags">A list of valid antag prototype IDs.</param>
/// <returns>Returns True if an active role ban is found for this player for any of the listed roles.</returns>
public bool IsRoleBanned(ICommonSession player, List<ProtoId<AntagPrototype>> antags);
/// <summary>
/// Checks if the player is currently banned from any of the listed roles.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="jobs">A list of valid job prototype IDs.</param>
/// <returns>Returns True if an active role ban is found for this player for any of the listed roles.</returns>
public bool IsRoleBanned(ICommonSession player, List<ProtoId<JobPrototype>> jobs);
/// <summary>
/// Gets a list of prototype IDs with the player's job bans.
/// </summary>
public HashSet<ProtoId<JobPrototype>>? GetJobBans(NetUserId playerUserId);
/// <summary>
/// Gets a list of prototype IDs with the player's antag bans.
/// </summary>
public HashSet<ProtoId<AntagPrototype>>? GetAntagBans(NetUserId playerUserId);
/// <summary>
/// Creates a job ban for the specified target, username or GUID
/// </summary>
/// <param name="target">Target user, username or GUID, null for none</param>
/// <param name="role">Role to be banned from</param>
/// <param name="targetUsername">The username of the target, if known</param>
/// <param name="banningAdmin">The responsible admin for the ban</param>
/// <param name="addressRange">The range of IPs that are to be banned, if known</param>
/// <param name="hwid">The HWID to be banned, if known</param>
/// <param name="role">The role ID to be banned from. Either an AntagPrototype or a JobPrototype</param>
/// <param name="minutes">Number of minutes to ban for. 0 and null mean permanent</param>
/// <param name="severity">Severity of the resulting ban note</param>
/// <param name="reason">Reason for the ban</param>
/// <param name="minutes">Number of minutes to ban for. 0 and null mean permanent</param>
/// <param name="timeOfBan">Time when the ban was applied, used for grouping role bans</param>
public void CreateRoleBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableTypedHwid? hwid, string role, uint? minutes, NoteSeverity severity, string reason, DateTimeOffset timeOfBan);
public void CreateRoleBan<T>(
NetUserId? target,
string? targetUsername,
NetUserId? banningAdmin,
(IPAddress, int)? addressRange,
ImmutableTypedHwid? hwid,
ProtoId<T> role,
uint? minutes,
NoteSeverity severity,
string reason,
DateTimeOffset timeOfBan
) where T : class, IPrototype;
/// <summary>
/// Pardons a role ban for the specified target, username or GUID

View File

@@ -11,10 +11,12 @@ using Content.Server.Nutrition.EntitySystems;
using Content.Server.Pointing.Components;
using Content.Server.Polymorph.Systems;
using Content.Server.Popups;
using Content.Server.Roles;
using Content.Server.Speech.Components;
using Content.Server.Storage.EntitySystems;
using Content.Server.Tabletop;
using Content.Server.Tabletop.Components;
using Content.Shared.Actions;
using Content.Shared.Administration;
using Content.Shared.Administration.Components;
using Content.Shared.Atmos.Components;
@@ -36,7 +38,10 @@ using Content.Shared.Mobs.Systems;
using Content.Shared.Movement.Components;
using Content.Shared.Movement.Systems;
using Content.Shared.Nutrition.Components;
using Content.Shared.Polymorph;
using Content.Shared.Popups;
using Content.Shared.Silicons.Laws;
using Content.Shared.Silicons.Laws.Components;
using Content.Shared.Slippery;
using Content.Shared.Storage.Components;
using Content.Shared.Tabletop.Components;
@@ -47,6 +52,7 @@ using Robust.Shared.Physics;
using Robust.Shared.Physics.Components;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Utility;
using Timer = Robust.Shared.Timing.Timer;
@@ -55,6 +61,10 @@ namespace Content.Server.Administration.Systems;
public sealed partial class AdminVerbSystem
{
private readonly ProtoId<PolymorphPrototype> LizardSmite = "AdminLizardSmite";
private readonly ProtoId<PolymorphPrototype> VulpkaninSmite = "AdminVulpSmite";
[Dependency] private readonly SharedActionsSystem _actions = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly BloodstreamSystem _bloodstreamSystem = default!;
[Dependency] private readonly BodySystem _bodySystem = default!;
@@ -72,6 +82,7 @@ public sealed partial class AdminVerbSystem
[Dependency] private readonly MobThresholdSystem _mobThresholdSystem = default!;
[Dependency] private readonly PopupSystem _popupSystem = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly RoleSystem _role = default!;
[Dependency] private readonly TabletopSystem _tabletopSystem = default!;
[Dependency] private readonly VomitSystem _vomitSystem = default!;
[Dependency] private readonly WeldableSystem _weldableSystem = default!;
@@ -80,6 +91,12 @@ public sealed partial class AdminVerbSystem
[Dependency] private readonly SuperBonkSystem _superBonkSystem = default!;
[Dependency] private readonly SlipperySystem _slipperySystem = default!;
private readonly EntProtoId _actionViewLawsProtoId = "ActionViewLaws";
private readonly ProtoId<SiliconLawsetPrototype> _crewsimovLawset = "Crewsimov";
private readonly EntProtoId _siliconMindRole = "MindRoleSiliconBrain";
private const string SiliconLawBoundUserInterface = "SiliconLawBoundUserInterface";
// All smite verbs have names so invokeverb works.
private void AddSmiteVerbs(GetVerbsEvent<Verb> args)
{
@@ -100,7 +117,7 @@ public sealed partial class AdminVerbSystem
{
Text = explodeName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/VerbIcons/smite.svg.192dpi.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/smite.svg.192dpi.png")),
Act = () =>
{
var coords = _transformSystem.GetMapCoordinates(args.Target);
@@ -121,7 +138,7 @@ public sealed partial class AdminVerbSystem
{
Text = chessName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Objects/Fun/Tabletop/chessboard.rsi"), "chessboard"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Objects/Fun/Tabletop/chessboard.rsi"), "chessboard"),
Act = () =>
{
_sharedGodmodeSystem.EnableGodmode(args.Target); // So they don't suffocate.
@@ -150,7 +167,7 @@ public sealed partial class AdminVerbSystem
{
Text = flamesName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/Alerts/Fire/fire.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/Alerts/Fire/fire.png")),
Act = () =>
{
// Fuck you. Burn Forever.
@@ -173,7 +190,7 @@ public sealed partial class AdminVerbSystem
{
Text = monkeyName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Mobs/Animals/monkey.rsi"), "monkey"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Mobs/Animals/monkey.rsi"), "monkey"),
Act = () =>
{
_polymorphSystem.PolymorphEntity(args.Target, "AdminMonkeySmite");
@@ -188,7 +205,7 @@ public sealed partial class AdminVerbSystem
{
Text = disposalBinName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Structures/Piping/disposal.rsi"), "disposal"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Structures/Piping/disposal.rsi"), "disposal"),
Act = () =>
{
_polymorphSystem.PolymorphEntity(args.Target, "AdminDisposalsSmite");
@@ -206,20 +223,21 @@ public sealed partial class AdminVerbSystem
{
Text = hardElectrocuteName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Clothing/Hands/Gloves/Color/yellow.rsi"), "icon"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Clothing/Hands/Gloves/Color/yellow.rsi"), "icon"),
Act = () =>
{
int damageToDeal;
if (!_mobThresholdSystem.TryGetThresholdForState(args.Target, MobState.Critical, out var criticalThreshold)) {
if (!_mobThresholdSystem.TryGetThresholdForState(args.Target, MobState.Critical, out var criticalThreshold))
{
// We can't crit them so try killing them.
if (!_mobThresholdSystem.TryGetThresholdForState(args.Target, MobState.Dead,
out var deadThreshold))
return;// whelp.
damageToDeal = deadThreshold.Value.Int() - (int) damageable.TotalDamage;
damageToDeal = deadThreshold.Value.Int() - (int)damageable.TotalDamage;
}
else
{
damageToDeal = criticalThreshold.Value.Int() - (int) damageable.TotalDamage;
damageToDeal = criticalThreshold.Value.Int() - (int)damageable.TotalDamage;
}
if (damageToDeal <= 0)
@@ -252,7 +270,7 @@ public sealed partial class AdminVerbSystem
{
Text = creamPieName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Objects/Consumable/Food/Baked/pie.rsi"), "plain-slice"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Objects/Consumable/Food/Baked/pie.rsi"), "plain-slice"),
Act = () =>
{
_creamPieSystem.SetCreamPied(args.Target, creamPied, true);
@@ -270,7 +288,7 @@ public sealed partial class AdminVerbSystem
{
Text = bloodRemovalName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Fluids/tomato_splat.rsi"), "puddle-1"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Fluids/tomato_splat.rsi"), "puddle-1"),
Act = () =>
{
_bloodstreamSystem.SpillAllSolutions((args.Target, bloodstream));
@@ -323,7 +341,7 @@ public sealed partial class AdminVerbSystem
{
Text = handsRemovalName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/AdminActions/remove-hands.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/AdminActions/remove-hands.png")),
Act = () =>
{
var baseXform = Transform(args.Target);
@@ -346,7 +364,7 @@ public sealed partial class AdminVerbSystem
{
Text = handRemovalName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/AdminActions/remove-hand.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/AdminActions/remove-hand.png")),
Act = () =>
{
var baseXform = Transform(args.Target);
@@ -370,7 +388,7 @@ public sealed partial class AdminVerbSystem
{
Text = stomachRemovalName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Mobs/Species/Human/organs.rsi"), "stomach"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Mobs/Species/Human/organs.rsi"), "stomach"),
Act = () =>
{
foreach (var entity in _bodySystem.GetBodyOrganEntityComps<StomachComponent>((args.Target, body)))
@@ -391,7 +409,7 @@ public sealed partial class AdminVerbSystem
{
Text = lungRemovalName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Mobs/Species/Human/organs.rsi"), "lung-r"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Mobs/Species/Human/organs.rsi"), "lung-r"),
Act = () =>
{
foreach (var entity in _bodySystem.GetBodyOrganEntityComps<LungComponent>((args.Target, body)))
@@ -415,7 +433,7 @@ public sealed partial class AdminVerbSystem
{
Text = pinballName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Objects/Fun/Balls/basketball.rsi"), "icon"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Objects/Fun/Balls/basketball.rsi"), "icon"),
Act = () =>
{
var xform = Transform(args.Target);
@@ -450,7 +468,7 @@ public sealed partial class AdminVerbSystem
{
Text = yeetName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/VerbIcons/eject.svg.192dpi.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/eject.svg.192dpi.png")),
Act = () =>
{
var xform = Transform(args.Target);
@@ -482,7 +500,7 @@ public sealed partial class AdminVerbSystem
{
Text = breadName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Objects/Consumable/Food/Baked/bread.rsi"), "plain"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Objects/Consumable/Food/Baked/bread.rsi"), "plain"),
Act = () =>
{
_polymorphSystem.PolymorphEntity(args.Target, "AdminBreadSmite");
@@ -497,7 +515,7 @@ public sealed partial class AdminVerbSystem
{
Text = mouseName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Mobs/Animals/mouse.rsi"), "icon-0"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Mobs/Animals/mouse.rsi"), "icon-0"),
Act = () =>
{
_polymorphSystem.PolymorphEntity(args.Target, "AdminMouseSmite");
@@ -514,7 +532,7 @@ public sealed partial class AdminVerbSystem
{
Text = ghostKickName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/gavel.svg.192dpi.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/gavel.svg.192dpi.png")),
Act = () =>
{
_ghostKickManager.DoDisconnect(actorComponent.PlayerSession.Channel, "Smitten.");
@@ -533,7 +551,7 @@ public sealed partial class AdminVerbSystem
{
Text = nyanifyName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Clothing/Head/Hats/catears.rsi"), "icon"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Clothing/Head/Hats/catears.rsi"), "icon"),
Act = () =>
{
var ears = Spawn("ClothingHeadHatCatEars", Transform(args.Target).Coordinates);
@@ -551,7 +569,7 @@ public sealed partial class AdminVerbSystem
{
Text = killSignName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Objects/Misc/killsign.rsi"), "icon"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Objects/Misc/killsign.rsi"), "icon"),
Act = () =>
{
EnsureComp<KillSignComponent>(args.Target);
@@ -567,7 +585,7 @@ public sealed partial class AdminVerbSystem
Text = cluwneName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Clothing/Mask/cluwne.rsi"), "icon"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Clothing/Mask/cluwne.rsi"), "icon"),
Act = () =>
{
@@ -583,7 +601,7 @@ public sealed partial class AdminVerbSystem
{
Text = maidenName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Clothing/Uniforms/Jumpskirt/janimaid.rsi"), "icon"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Clothing/Uniforms/Jumpskirt/janimaid.rsi"), "icon"),
Act = () =>
{
_outfit.SetOutfit(args.Target, "JanitorMaidGear", (_, clothing) =>
@@ -604,7 +622,7 @@ public sealed partial class AdminVerbSystem
{
Text = angerPointingArrowsName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Interface/Misc/pointing.rsi"), "pointing"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Interface/Misc/pointing.rsi"), "pointing"),
Act = () =>
{
EnsureComp<PointingArrowAngeringComponent>(args.Target);
@@ -619,7 +637,7 @@ public sealed partial class AdminVerbSystem
{
Text = dustName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Objects/Materials/materials.rsi"), "ash"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Objects/Materials/materials.rsi"), "ash"),
Act = () =>
{
QueueDel(args.Target);
@@ -636,7 +654,7 @@ public sealed partial class AdminVerbSystem
{
Text = youtubeVideoSimulationName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/Misc/buffering_smite_icon.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/Misc/buffering_smite_icon.png")),
Act = () =>
{
EnsureComp<BufferingComponent>(args.Target);
@@ -651,7 +669,7 @@ public sealed partial class AdminVerbSystem
{
Text = instrumentationName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Objects/Fun/Instruments/h_synthesizer.rsi"), "supersynth"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Objects/Fun/Instruments/h_synthesizer.rsi"), "supersynth"),
Act = () =>
{
_polymorphSystem.PolymorphEntity(args.Target, "AdminInstrumentSmite");
@@ -689,22 +707,37 @@ public sealed partial class AdminVerbSystem
{
Text = reptilianName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Objects/Fun/Plushies/lizard.rsi"), "icon"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Objects/Fun/Plushies/lizard.rsi"), "icon"),
Act = () =>
{
_polymorphSystem.PolymorphEntity(args.Target, "AdminLizardSmite");
_polymorphSystem.PolymorphEntity(args.Target, LizardSmite);
},
Impact = LogImpact.Extreme,
Message = string.Join(": ", reptilianName, Loc.GetString("admin-smite-reptilian-species-swap-description"))
};
args.Verbs.Add(reptilian);
var vulpName = Loc.GetString("admin-smite-vulpkanin-species-swap-name").ToLowerInvariant();
Verb vulp = new()
{
Text = vulpName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Objects/Fun/Balls/tennisball.rsi"), "icon"),
Act = () =>
{
_polymorphSystem.PolymorphEntity(args.Target, VulpkaninSmite);
},
Impact = LogImpact.Extreme,
Message = string.Join(": ", vulpName, Loc.GetString("admin-smite-vulpkanin-species-swap-description"))
};
args.Verbs.Add(vulp);
var lockerName = Loc.GetString("admin-smite-locker-stuff-name").ToLowerInvariant();
Verb locker = new()
{
Text = lockerName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new ("/Textures/Structures/Storage/closet.rsi"), "generic"),
Icon = new SpriteSpecifier.Rsi(new("/Textures/Structures/Storage/closet.rsi"), "generic"),
Act = () =>
{
var xform = Transform(args.Target);
@@ -727,7 +760,7 @@ public sealed partial class AdminVerbSystem
{
Text = headstandName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/VerbIcons/refresh.svg.192dpi.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/refresh.svg.192dpi.png")),
Act = () =>
{
EnsureComp<HeadstandComponent>(args.Target);
@@ -742,7 +775,7 @@ public sealed partial class AdminVerbSystem
{
Text = zoomInName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/AdminActions/zoom.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/AdminActions/zoom.png")),
Act = () =>
{
var eye = EnsureComp<ContentEyeComponent>(args.Target);
@@ -758,7 +791,7 @@ public sealed partial class AdminVerbSystem
{
Text = flipEyeName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/AdminActions/flip.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/AdminActions/flip.png")),
Act = () =>
{
var eye = EnsureComp<ContentEyeComponent>(args.Target);
@@ -774,7 +807,7 @@ public sealed partial class AdminVerbSystem
{
Text = runWalkSwapName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/AdminActions/run-walk-swap.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/AdminActions/run-walk-swap.png")),
Act = () =>
{
var movementSpeed = EnsureComp<MovementSpeedModifierComponent>(args.Target);
@@ -795,7 +828,7 @@ public sealed partial class AdminVerbSystem
{
Text = backwardsAccentName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/AdminActions/help-backwards.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/AdminActions/help-backwards.png")),
Act = () =>
{
EnsureComp<BackwardsAccentComponent>(args.Target);
@@ -810,7 +843,7 @@ public sealed partial class AdminVerbSystem
{
Text = disarmProneName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/Actions/disarm.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/Actions/disarm.png")),
Act = () =>
{
EnsureComp<DisarmProneComponent>(args.Target);
@@ -825,7 +858,7 @@ public sealed partial class AdminVerbSystem
{
Text = superSpeedName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/AdminActions/super_speed.png")),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/AdminActions/super_speed.png")),
Act = () =>
{
var movementSpeed = EnsureComp<MovementSpeedModifierComponent>(args.Target);
@@ -942,5 +975,36 @@ public sealed partial class AdminVerbSystem
Message = string.Join(": ", crawlerName, Loc.GetString("admin-smite-crawler-description"))
};
args.Verbs.Add(crawler);
var siliconName = Loc.GetString("admin-smite-silicon-laws-bound-name").ToLowerInvariant();
Verb silicon = new()
{
Text = siliconName,
Category = VerbCategory.Smite,
Icon = new SpriteSpecifier.Rsi(new("Interface/Actions/actions_borg.rsi"), "state-laws"),
Act = () =>
{
var userInterfaceComp = EnsureComp<UserInterfaceComponent>(args.Target);
_uiSystem.SetUi((args.Target, userInterfaceComp), SiliconLawsUiKey.Key, new InterfaceData(SiliconLawBoundUserInterface));
if (!HasComp<SiliconLawBoundComponent>(args.Target))
{
EnsureComp<SiliconLawBoundComponent>(args.Target);
_actions.AddAction(args.Target, _actionViewLawsProtoId);
}
EnsureComp<SiliconLawProviderComponent>(args.Target);
_siliconLawSystem.SetLaws(_siliconLawSystem.GetLawset(_crewsimovLawset).Laws, args.Target);
if (_mindSystem.TryGetMind(args.Target, out var mindId, out _))
_role.MindAddRole(mindId, _siliconMindRole);
_popupSystem.PopupEntity(Loc.GetString("admin-smite-silicon-laws-bound-self"), args.Target,
args.Target, PopupType.LargeCaution);
},
Impact = LogImpact.Extreme,
Message = string.Join(": ", siliconName, Loc.GetString("admin-smite-silicon-laws-bound-description"))
};
args.Verbs.Add(silicon);
}
}

View File

@@ -2,16 +2,17 @@ using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Server.Antag.Components;
using Content.Server.GameTicking.Rules.Components;
using Content.Server.Objectives;
using Content.Shared.Antag;
using Content.Shared.Chat;
using Content.Shared.GameTicking.Components;
using Content.Shared.Mind;
using Content.Shared.Preferences;
using Content.Shared.Roles;
using JetBrains.Annotations;
using Robust.Shared.Audio;
using Robust.Shared.Enums;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
namespace Content.Server.Antag;
@@ -161,33 +162,35 @@ public sealed partial class AntagSelectionSystem
}
/// <summary>
/// Checks if a given session has the primary antag preferences for a given definition
/// Checks if a given session has enabled the antag preferences for a given definition,
/// and if it is blocked by any requirements or bans.
/// </summary>
public bool HasPrimaryAntagPreference(ICommonSession? session, AntagSelectionDefinition def)
/// <returns>Returns true if at least one role from the provided list passes every condition</returns>>
public bool ValidAntagPreference(ICommonSession? session, List<ProtoId<AntagPrototype>> roles)
{
if (session == null)
return true;
if (def.PrefRoles.Count == 0)
if (roles.Count == 0)
return false;
var pref = (HumanoidCharacterProfile) _pref.GetPreferences(session.UserId).SelectedCharacter;
return pref.AntagPreferences.Any(p => def.PrefRoles.Contains(p));
}
/// <summary>
/// Checks if a given session has the fallback antag preferences for a given definition
/// </summary>
public bool HasFallbackAntagPreference(ICommonSession? session, AntagSelectionDefinition def)
{
if (session == null)
return true;
var valid = false;
if (def.FallbackRoles.Count == 0)
return false;
// Check each individual antag role
foreach (var role in roles)
{
var list = new List<ProtoId<AntagPrototype>>{role};
var pref = (HumanoidCharacterProfile) _pref.GetPreferences(session.UserId).SelectedCharacter;
return pref.AntagPreferences.Any(p => def.FallbackRoles.Contains(p));
if (pref.AntagPreferences.Contains(role)
&& !_ban.IsRoleBanned(session, list)
&& _playTime.IsAllowed(session, list))
valid = true;
}
return valid;
}
/// <summary>

View File

@@ -1,4 +1,5 @@
using System.Linq;
using Content.Server.Administration.Managers;
using Content.Server.Antag.Components;
using Content.Server.Chat.Managers;
using Content.Server.GameTicking;
@@ -8,11 +9,11 @@ using Content.Server.Ghost.Roles;
using Content.Server.Ghost.Roles.Components;
using Content.Server.Mind;
using Content.Server.Objectives;
using Content.Server.Players.PlayTimeTracking;
using Content.Server.Preferences.Managers;
using Content.Server.Roles;
using Content.Server.Roles.Jobs;
using Content.Server.Shuttles.Components;
using Content.Server.Station.Events;
using Content.Shared.Administration.Logs;
using Content.Shared.Antag;
using Content.Shared.Clothing;
@@ -40,12 +41,14 @@ namespace Content.Server.Antag;
public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelectionComponent>
{
[Dependency] private readonly AudioSystem _audio = default!;
[Dependency] private readonly IBanManager _ban = default!;
[Dependency] private readonly IChatManager _chat = default!;
[Dependency] private readonly GhostRoleSystem _ghostRole = default!;
[Dependency] private readonly JobSystem _jobs = default!;
[Dependency] private readonly LoadoutSystem _loadout = default!;
[Dependency] private readonly MindSystem _mind = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly PlayTimeTrackingSystem _playTime = default!;
[Dependency] private readonly IServerPreferencesManager _pref = default!;
[Dependency] private readonly RoleSystem _role = default!;
[Dependency] private readonly TransformSystem _transform = default!;
@@ -344,7 +347,7 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
{
_adminLogger.Add(LogType.AntagSelection, $"Start trying to make {session} become the antagonist: {ToPrettyString(ent)}");
if (checkPref && !HasPrimaryAntagPreference(session, def))
if (checkPref && !ValidAntagPreference(session, def.PrefRoles))
return false;
if (!IsSessionValid(ent, session, def) || !IsEntityValid(session?.AttachedEntity, def))
@@ -497,11 +500,12 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
if (ent.Comp.PreSelectedSessions.TryGetValue(def, out var preSelected) && preSelected.Contains(session))
continue;
if (HasPrimaryAntagPreference(session, def))
// Add player to the appropriate antag pool
if (ValidAntagPreference(session, def.PrefRoles))
{
preferredList.Add(session);
}
else if (HasFallbackAntagPreference(session, def))
else if (ValidAntagPreference(session, def.FallbackRoles))
{
fallbackList.Add(session);
}

View File

@@ -28,7 +28,6 @@ namespace Content.Server.Database
public abstract class ServerDbBase
{
private readonly ISawmill _opsLog;
public event Action<DatabaseNotification>? OnNotificationReceived;
/// <param name="opsLog">Sawmill to trace log database operations to.</param>
@@ -1394,7 +1393,7 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}
ban.LastEditedAt,
ban.ExpirationTime,
ban.Hidden,
new [] { ban.RoleId.Replace(BanManager.JobPrefix, null) },
new [] { ban.RoleId.Replace(BanManager.PrefixJob, null).Replace(BanManager.PrefixAntag, null) },
MakePlayerRecord(unbanningAdmin),
ban.Unban?.UnbanTime);
}
@@ -1694,7 +1693,7 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}
NormalizeDatabaseTime(firstBan.LastEditedAt),
NormalizeDatabaseTime(firstBan.ExpirationTime),
firstBan.Hidden,
banGroup.Select(ban => ban.RoleId.Replace(BanManager.JobPrefix, null)).ToArray(),
banGroup.Select(ban => ban.RoleId.Replace(BanManager.PrefixJob, null).Replace(BanManager.PrefixAntag, null)).ToArray(),
MakePlayerRecord(unbanningAdmin),
NormalizeDatabaseTime(firstBan.Unban?.UnbanTime)));
}

View File

@@ -48,7 +48,7 @@ public sealed class DoorElectronicsSystem : EntitySystem
DoorElectronicsUpdateConfigurationMessage args)
{
var accessReader = EnsureComp<AccessReaderComponent>(uid);
_accessReader.SetAccesses((uid, accessReader), args.AccessList);
_accessReader.TrySetAccesses((uid, accessReader), args.AccessList);
}
private void OnAccessReaderChanged(

View File

@@ -18,7 +18,6 @@ using Content.Server.Speech.Components;
using Content.Server.Spreader;
using Content.Server.Temperature.Components;
using Content.Server.Temperature.Systems;
using Content.Server.Traits.Assorted;
using Content.Server.Zombies;
using Content.Shared.Atmos;
using Content.Shared.Atmos.Components;
@@ -33,6 +32,7 @@ using Content.Shared.Maps;
using Content.Shared.Mind.Components;
using Content.Shared.Popups;
using Content.Shared.Random;
using Content.Shared.Traits.Assorted;
using Content.Shared.Zombies;
using Robust.Server.GameObjects;
using Robust.Shared.Audio;

View File

@@ -275,7 +275,7 @@ public sealed partial class ExplosionSystem
radius = Math.Min(radius, MaxIterations / 4);
EntityUid? referenceGrid = null;
float mass = 0;
var mass = float.MinValue;
// First attempt to find a grid that is relatively close to the explosion's center. Instead of looking in a
// diameter x diameter sized box, use a smaller box with radius sized sides:
@@ -285,7 +285,7 @@ public sealed partial class ExplosionSystem
_mapManager.FindGridsIntersecting(epicenter.MapId, box, ref _grids);
foreach (var grid in _grids)
{
if (TryComp(grid.Owner, out PhysicsComponent? physics) && physics.Mass > mass)
if (TryComp(grid.Owner, out PhysicsComponent? physics) && physics.FixturesMass > mass)
{
mass = physics.Mass;
referenceGrid = grid.Owner;
@@ -315,7 +315,7 @@ public sealed partial class ExplosionSystem
{
if (TryComp(grid.Owner, out PhysicsComponent? physics) && physics.Mass > mass)
{
mass = physics.Mass;
mass = physics.FixturesMass;
referenceGrid = grid.Owner;
}
}

View File

@@ -1,32 +1,41 @@
using Content.Shared.Administration;
using Robust.Shared.Console;
namespace Content.Server.GameTicking.Commands
namespace Content.Server.GameTicking.Commands;
[AnyCommand]
public sealed class ToggleReadyCommand : LocalizedEntityCommands
{
[AnyCommand]
sealed class ToggleReadyCommand : IConsoleCommand
[Dependency] private readonly GameTicker _gameTicker = default!;
public override string Command => "toggleready";
public override void Execute(IConsoleShell shell, string argStr, string[] args)
{
[Dependency] private readonly IEntityManager _e = default!;
public string Command => "toggleready";
public string Description => "";
public string Help => "";
public void Execute(IConsoleShell shell, string argStr, string[] args)
if (args.Length != 1)
{
var player = shell.Player;
if (args.Length != 1)
{
shell.WriteError(Loc.GetString("shell-wrong-arguments-number"));
return;
}
if (player == null)
{
return;
}
var ticker = _e.System<GameTicker>();
ticker.ToggleReady(player, bool.Parse(args[0]));
shell.WriteError(Loc.GetString("shell-need-exactly-one-argument"));
return;
}
if (shell.Player is not { } player)
{
shell.WriteError(Loc.GetString("shell-only-players-can-run-this-command"));
return;
}
if (_gameTicker.RunLevel != GameRunLevel.PreRoundLobby)
{
shell.WriteError(Loc.GetString("shell-can-only-run-from-pre-round-lobby"));
return;
}
if (!bool.TryParse(args[0], out var ready))
{
shell.WriteError(Loc.GetString("shell-argument-must-be-boolean"));
return;
}
_gameTicker.ToggleReady(player, ready);
}
}

View File

@@ -1,13 +0,0 @@
using Content.Shared.Roles;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
namespace Content.Server.GameTicking.Events;
[ByRefEvent]
public struct IsJobAllowedEvent(ICommonSession player, ProtoId<JobPrototype> jobId, bool cancelled = false)
{
public readonly ICommonSession Player = player;
public readonly ProtoId<JobPrototype> JobId = jobId;
public bool Cancelled = cancelled;
}

View File

@@ -0,0 +1,24 @@
using Content.Shared.Roles;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
namespace Content.Server.GameTicking.Events;
/// <summary>
/// Event raised to check if a player is allowed/able to assume a role.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="jobs">Optional list of job prototype IDs</param>
/// <param name="antags">Optional list of antag prototype IDs</param>
[ByRefEvent]
public struct IsRoleAllowedEvent(
ICommonSession player,
List<ProtoId<JobPrototype>>? jobs,
List<ProtoId<AntagPrototype>>? antags,
bool cancelled = false)
{
public readonly ICommonSession Player = player;
public readonly List<ProtoId<JobPrototype>>? Jobs = jobs;
public readonly List<ProtoId<AntagPrototype>>? Antags = antags;
public bool Cancelled = cancelled;
}

View File

@@ -173,7 +173,6 @@ namespace Content.Server.GameTicking
return;
}
var status = ready ? PlayerGameStatus.ReadyToPlay : PlayerGameStatus.NotReadyToPlay;
_playerGameStatuses[player.UserId] = ready ? PlayerGameStatus.ReadyToPlay : PlayerGameStatus.NotReadyToPlay;
RaiseNetworkEvent(GetStatusMsg(player), player.Channel);
// update server info to reflect new ready count

View File

@@ -141,12 +141,13 @@ namespace Content.Server.GameTicking
var character = GetPlayerProfile(player);
var jobBans = _banManager.GetJobBans(player.UserId);
if (jobBans == null || jobId != null && jobBans.Contains(jobId))
if (jobBans == null || jobId != null && jobBans.Contains(jobId)) //TODO: use IsRoleBanned directly?
return;
if (jobId != null)
{
var ev = new IsJobAllowedEvent(player, new ProtoId<JobPrototype>(jobId));
var jobs = new List<ProtoId<JobPrototype>> {jobId};
var ev = new IsRoleAllowedEvent(player, jobs, null);
RaiseLocalEvent(ref ev);
if (ev.Cancelled)
return;

View File

@@ -60,7 +60,7 @@ namespace Content.Server.GameTicking
jObject["panic_bunker"] = _cfg.GetCVar(CCVars.PanicBunkerEnabled);
jObject["run_level"] = (int) _runLevel;
if (preset != null)
jObject["preset"] = Loc.GetString(preset.ModeTitle);
jObject["preset"] = (Decoy == null) ? Loc.GetString(preset.ModeTitle) : Loc.GetString(Decoy.ModeTitle);
if (_runLevel >= GameRunLevel.InRound)
{
jObject["round_start_time"] = _roundStartDateTime.ToString("o");

View File

@@ -15,12 +15,6 @@ public sealed partial class GhostRoleComponent : Component
[DataField("rules")] private string _roleRules = "ghost-role-component-default-rules";
// Actually make use of / enforce this requirement?
// Why is this even here.
// Move to ghost role prototype & respect CCvars.GameRoleTimerOverride
[DataField("requirements")]
public HashSet<JobRequirement>? Requirements;
/// <summary>
/// Whether the <see cref="MakeSentientCommand"/> should run on the mob.
/// </summary>

View File

@@ -1,6 +1,8 @@
using System.Linq;
using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers;
using Content.Server.EUI;
using Content.Server.GameTicking.Events;
using Content.Server.Ghost.Roles.Components;
using Content.Server.Ghost.Roles.Events;
using Content.Shared.Ghost.Roles.Raffles;
@@ -32,13 +34,16 @@ using Content.Server.Popups;
using Content.Shared.Verbs;
using Robust.Shared.Collections;
using Content.Shared.Ghost.Roles.Components;
using Content.Shared.Roles.Components;
namespace Content.Server.Ghost.Roles;
[UsedImplicitly]
public sealed class GhostRoleSystem : EntitySystem
{
[Dependency] private readonly IBanManager _ban = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IEntityManager _ent = default!;
[Dependency] private readonly EuiManager _euiManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
@@ -459,6 +464,23 @@ public sealed class GhostRoleSystem : EntitySystem
if (!_ghostRoles.TryGetValue(identifier, out var roleEnt))
return;
TryPrototypes(roleEnt, out var antags, out var jobs);
// Check role bans
if (_ban.IsRoleBanned(player, antags) || _ban.IsRoleBanned(player, jobs))
{
Log.Warning($"Server rejected ghost role request '{roleEnt.Comp.RoleName}' for '{player.Name}' - client missed ban?");
return;
}
// Check role requirements
if (!IsRoleAllowed(player, jobs, antags))
{
Log.Warning($"Server rejected ghost role request '{roleEnt.Comp.RoleName}' for '{player.Name}' - client missed requirement check?");
return;
}
// Decide to do a raffle or not
if (roleEnt.Comp.RaffleConfig is not null)
{
JoinRaffle(player, identifier);
@@ -469,6 +491,78 @@ public sealed class GhostRoleSystem : EntitySystem
}
}
/// <summary>
/// Collect all role prototypes on the Ghostrole.
/// </summary>
/// <returns>
/// Returns true if at least on role prototype could be found.
/// </returns>
private bool TryPrototypes(
Entity<GhostRoleComponent> roleEnt,
out List<ProtoId<AntagPrototype>> antags,
out List<ProtoId<JobPrototype>> jobs)
{
antags = [];
jobs = [];
// If there is a mind already, check its mind roles.
// Not sure if this can ever actually happen.
if (TryComp<MindContainerComponent>(roleEnt, out var mindCont)
&& TryComp<MindComponent>(mindCont.Mind, out var mind))
{
foreach (var role in mind.MindRoleContainer.ContainedEntities)
{
if(!TryComp<MindRoleComponent>(role, out var comp))
continue;
if (comp.JobPrototype is not null)
jobs.Add(comp.JobPrototype.Value);
else if (comp.AntagPrototype is not null)
antags.Add(comp.AntagPrototype.Value);
}
return antags.Count > 0 || jobs.Count > 0;
}
if (roleEnt.Comp.JobProto is not null)
jobs.Add(roleEnt.Comp.JobProto.Value);
// If there is no mind, check the mindRole prototypes
foreach (var proto in roleEnt.Comp.MindRoles)
{
if (!_prototype.TryIndex(proto, out var indexed)
|| !indexed.TryGetComponent<MindRoleComponent>(out var comp, _ent.ComponentFactory))
continue;
var roleComp = (MindRoleComponent)comp;
if (roleComp.JobPrototype is not null)
jobs.Add(roleComp.JobPrototype.Value);
else if (roleComp.AntagPrototype is not null)
antags.Add(roleComp.AntagPrototype.Value);
else
Log.Debug($"Mind role '{proto}' of '{roleEnt.Comp.RoleName}' has neither a job or antag prototype specified");
}
return antags.Count > 0 || jobs.Count > 0;
}
/// <summary>
/// Checks if the player passes the requirements for the supplied roles.
/// Returns false if any role fails the check.
/// </summary>
private bool IsRoleAllowed(
ICommonSession player,
List<ProtoId<JobPrototype>>? jobIds,
List<ProtoId<AntagPrototype>>? antagIds)
{
var ev = new IsRoleAllowedEvent(player, jobIds, antagIds);
RaiseLocalEvent(ref ev);
return !ev.Cancelled;
}
/// <summary>
/// Attempts having the player take over the ghost role with the corresponding ID. Does not start a raffle.
/// </summary>
@@ -571,13 +665,15 @@ public sealed class GhostRoleSystem : EntitySystem
? _timing.CurTime.Add(raffle.Countdown)
: TimeSpan.MinValue;
TryPrototypes((uid, role), out var antags, out var jobs);
roles.Add(new GhostRoleInfo
{
Identifier = id,
Name = role.RoleName,
Description = role.RoleDescription,
Rules = role.RoleRules,
Requirements = role.Requirements,
RolePrototypes = (jobs, antags),
Kind = kind,
RafflePlayerCount = rafflePlayerCount,
RaffleEndTime = raffleEndTime

View File

@@ -8,6 +8,8 @@ using Content.Shared.Chat.TypingIndicator;
using Content.Shared.Holopad;
using Content.Shared.IdentityManagement;
using Content.Shared.Labels.Components;
using Content.Shared.Mobs;
using Content.Shared.Mobs.Systems;
using Content.Shared.Power;
using Content.Shared.Silicons.StationAi;
using Content.Shared.Speech;
@@ -38,6 +40,7 @@ public sealed class HolopadSystem : SharedHolopadSystem
[Dependency] private readonly PopupSystem _popupSystem = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly PvsOverrideSystem _pvs = default!;
[Dependency] private readonly MobStateSystem _mobState = default!;
private float _updateTimer = 1.0f;
private const float UpdateTime = 1.0f;
@@ -77,6 +80,8 @@ public sealed class HolopadSystem : SharedHolopadSystem
SubscribeLocalEvent<HolopadComponent, EntRemovedFromContainerMessage>(OnAiRemove);
SubscribeLocalEvent<HolopadComponent, EntParentChangedMessage>(OnParentChanged);
SubscribeLocalEvent<HolopadComponent, PowerChangedEvent>(OnPowerChanged);
SubscribeLocalEvent<HolopadUserComponent, MobStateChangedEvent>(OnMobStateChanged);
}
#region: Holopad UI bound user interface messages
@@ -226,7 +231,7 @@ public sealed class HolopadSystem : SharedHolopadSystem
if (!_stationAiSystem.TryGetHeld((receiver, receiverStationAiCore), out var insertedAi))
continue;
if (_userInterfaceSystem.TryOpenUi(receiverUid, HolopadUiKey.AiRequestWindow, insertedAi))
if (_userInterfaceSystem.TryOpenUi(receiverUid, HolopadUiKey.AiRequestWindow, insertedAi.Value))
LinkHolopadToUser(entity, args.Actor);
}
@@ -446,6 +451,17 @@ public sealed class HolopadSystem : SharedHolopadSystem
UpdateHolopadControlLockoutStartTime(entity);
}
private void OnMobStateChanged(Entity<HolopadUserComponent> ent, ref MobStateChangedEvent args)
{
if (!HasComp<StationAiHeldComponent>(ent))
return;
foreach (var holopad in ent.Comp.LinkedHolopads)
{
ShutDownHolopad(holopad);
}
}
#endregion
public override void Update(float frameTime)
@@ -605,25 +621,23 @@ public sealed class HolopadSystem : SharedHolopadSystem
if (entity.Comp.Hologram != null)
DeleteHologram(entity.Comp.Hologram.Value, entity);
if (entity.Comp.User != null)
// Check if the associated holopad user is an AI
if (HasComp<StationAiHeldComponent>(entity.Comp.User) &&
_stationAiSystem.TryGetCore(entity.Comp.User.Value, out var stationAiCore))
{
// Check if the associated holopad user is an AI
if (TryComp<StationAiHeldComponent>(entity.Comp.User, out var stationAiHeld) &&
_stationAiSystem.TryGetCore(entity.Comp.User.Value, out var stationAiCore))
// Return the AI eye to free roaming
_stationAiSystem.SwitchRemoteEntityMode(stationAiCore, true);
// If the AI core is still broadcasting, end its calls
if (TryComp<TelephoneComponent>(stationAiCore, out var stationAiCoreTelephone) &&
_telephoneSystem.IsTelephoneEngaged((stationAiCore.Owner, stationAiCoreTelephone)))
{
// Return the AI eye to free roaming
_stationAiSystem.SwitchRemoteEntityMode(stationAiCore, true);
// If the AI core is still broadcasting, end its calls
if (entity.Owner != stationAiCore.Owner &&
TryComp<TelephoneComponent>(stationAiCore, out var stationAiCoreTelephone) &&
_telephoneSystem.IsTelephoneEngaged((stationAiCore.Owner, stationAiCoreTelephone)))
{
_telephoneSystem.EndTelephoneCalls((stationAiCore.Owner, stationAiCoreTelephone));
}
_telephoneSystem.EndTelephoneCalls((stationAiCore.Owner, stationAiCoreTelephone));
}
UnlinkHolopadFromUser(entity, entity.Comp.User.Value);
}
else
{
UnlinkHolopadFromUser(entity, entity.Comp.User);
}
Dirty(entity);

View File

@@ -6,7 +6,5 @@ namespace Content.Server.Nutrition.Components;
/// This component prevents NPC mobs like mice or cows from wanting to drink something that shouldn't be drank from.
/// Including but not limited to: puddles
/// </summary>
[RegisterComponent, Access(typeof(DrinkSystem))]
public sealed partial class BadDrinkComponent : Component
{
}
[RegisterComponent]
public sealed partial class BadDrinkComponent : Component;

View File

@@ -1,63 +0,0 @@
using Content.Shared.Chemistry.Components;
using Content.Shared.Chemistry.Components.SolutionManager;
using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Nutrition.Components;
using Content.Shared.Nutrition.EntitySystems;
namespace Content.Server.Nutrition.EntitySystems;
public sealed class DrinkSystem : SharedDrinkSystem
{
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly SharedSolutionContainerSystem _solutionContainer = default!;
public override void Initialize()
{
base.Initialize();
// TODO add InteractNoHandEvent for entities like mice.
SubscribeLocalEvent<DrinkComponent, SolutionContainerChangedEvent>(OnSolutionChange);
SubscribeLocalEvent<DrinkComponent, ComponentInit>(OnDrinkInit);
// run before inventory so for bucket it always tries to drink before equipping (when empty)
// run after openable so its always open -> drink
}
private void OnDrinkInit(Entity<DrinkComponent> entity, ref ComponentInit args)
{
if (TryComp<DrainableSolutionComponent>(entity, out var existingDrainable))
{
// Beakers have Drink component but they should use the existing Drainable
entity.Comp.Solution = existingDrainable.Solution;
}
else
{
_solutionContainer.EnsureSolution(entity.Owner, entity.Comp.Solution, out _);
}
UpdateAppearance(entity, entity.Comp);
if (TryComp(entity, out RefillableSolutionComponent? refillComp))
refillComp.Solution = entity.Comp.Solution;
if (TryComp(entity, out DrainableSolutionComponent? drainComp))
drainComp.Solution = entity.Comp.Solution;
}
private void OnSolutionChange(Entity<DrinkComponent> entity, ref SolutionContainerChangedEvent args)
{
UpdateAppearance(entity, entity.Comp);
}
public void UpdateAppearance(EntityUid uid, DrinkComponent component)
{
if (!TryComp<AppearanceComponent>(uid, out var appearance) ||
!HasComp<SolutionContainerManagerComponent>(uid))
{
return;
}
var drainAvailable = DrinkVolume(uid, component);
_appearance.SetData(uid, FoodVisuals.Visual, drainAvailable.Float(), appearance);
}
}

View File

@@ -58,6 +58,9 @@ public sealed class JobWhitelistManager : IPostInjectInit
SendJobWhitelist(session);
}
/// <summary>
/// Returns false if role whitelist is required but the player does not have it.
/// </summary>
public bool IsAllowed(ICommonSession session, ProtoId<JobPrototype> job)
{
if (!_config.GetCVar(CCVars.GameRoleWhitelist))

View File

@@ -23,7 +23,7 @@ public sealed class JobWhitelistSystem : EntitySystem
{
SubscribeLocalEvent<PrototypesReloadedEventArgs>(OnPrototypesReloaded);
SubscribeLocalEvent<StationJobsGetCandidatesEvent>(OnStationJobsGetCandidates);
SubscribeLocalEvent<IsJobAllowedEvent>(OnIsJobAllowed);
SubscribeLocalEvent<IsRoleAllowedEvent>(OnIsRoleAllowed);
SubscribeLocalEvent<GetDisallowedJobsEvent>(OnGetDisallowedJobs);
CacheJobs();
@@ -51,11 +51,18 @@ public sealed class JobWhitelistSystem : EntitySystem
}
}
private void OnIsJobAllowed(ref IsJobAllowedEvent ev)
private void OnIsRoleAllowed(ref IsRoleAllowedEvent ev)
{
if (!_manager.IsAllowed(ev.Player, ev.JobId))
ev.Cancelled = true;
if (ev.Jobs is null)
return;
foreach (var proto in ev.Jobs)
{
if (!_manager.IsAllowed(ev.Player, proto))
ev.Cancelled = true;
}
}
//TODO: Antagonist role whitelists?
private void OnGetDisallowedJobs(ref GetDisallowedJobsEvent ev)
{

View File

@@ -54,7 +54,7 @@ public sealed class PlayTimeTrackingSystem : EntitySystem
SubscribeLocalEvent<MobStateChangedEvent>(OnMobStateChanged);
SubscribeLocalEvent<PlayerJoinedLobbyEvent>(OnPlayerJoinedLobby);
SubscribeLocalEvent<StationJobsGetCandidatesEvent>(OnStationJobsGetCandidates);
SubscribeLocalEvent<IsJobAllowedEvent>(OnIsJobAllowed);
SubscribeLocalEvent<IsRoleAllowedEvent>(OnIsRoleAllowed);
SubscribeLocalEvent<GetDisallowedJobsEvent>(OnGetDisallowedJobs);
_adminManager.OnPermsChanged += AdminPermsChanged;
}
@@ -86,6 +86,9 @@ public sealed class PlayTimeTrackingSystem : EntitySystem
trackers.UnionWith(GetTimedRoles(player));
}
/// <summary>
/// Returns true if the player has an attached mob and it is alive (even if in critical).
/// </summary>
private bool IsPlayerAlive(ICommonSession session)
{
var attached = session.AttachedEntity;
@@ -176,9 +179,9 @@ public sealed class PlayTimeTrackingSystem : EntitySystem
RemoveDisallowedJobs(ev.Player, ev.Jobs);
}
private void OnIsJobAllowed(ref IsJobAllowedEvent ev)
private void OnIsRoleAllowed(ref IsRoleAllowedEvent ev)
{
if (!IsAllowed(ev.Player, ev.JobId))
if (!IsAllowed(ev.Player, ev.Jobs) || !IsAllowed(ev.Player, ev.Antags))
ev.Cancelled = true;
}
@@ -187,10 +190,55 @@ public sealed class PlayTimeTrackingSystem : EntitySystem
ev.Jobs.UnionWith(GetDisallowedJobs(ev.Player));
}
public bool IsAllowed(ICommonSession player, string role)
/// <summary>
/// Checks if the player meets role requirements.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="jobs">A list of role prototype IDs</param>
/// <returns>Returns true if all requirements were met or there were no requirements.</returns>
public bool IsAllowed(ICommonSession player, List<ProtoId<JobPrototype>>? jobs)
{
if (!_prototypes.TryIndex<JobPrototype>(role, out var job) ||
!_cfg.GetCVar(CCVars.GameRoleTimers))
if (jobs is null)
return true;
foreach (var job in jobs)
{
if (!IsAllowed(player, job))
return false;
}
return true;
}
/// <summary>
/// Checks if the player meets role requirements.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="antags">A list of role prototype IDs</param>
/// <returns>Returns true if all requirements were met or there were no requirements.</returns>
public bool IsAllowed(ICommonSession player, List<ProtoId<AntagPrototype>>? antags)
{
if (antags is null)
return true;
foreach (var antag in antags)
{
if (!IsAllowed(player, antag))
return false;
}
return true;
}
/// <summary>
/// Checks if the player meets role requirements.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="job">A list of role prototype IDs</param>
/// <returns>Returns true if all requirements were met or there were no requirements.</returns>
public bool IsAllowed(ICommonSession player, ProtoId<JobPrototype> job)
{
if (!_cfg.GetCVar(CCVars.GameRoleTimers))
return true;
if (!_tracking.TryGetTrackerTimes(player, out var playTimes))
@@ -199,7 +247,43 @@ public sealed class PlayTimeTrackingSystem : EntitySystem
playTimes = new Dictionary<string, TimeSpan>();
}
return JobRequirements.TryRequirementsMet(job, playTimes, out _, EntityManager, _prototypes, (HumanoidCharacterProfile?) _preferencesManager.GetPreferences(player.UserId).SelectedCharacter);
var requirements = _roles.GetRoleRequirements(job);
return JobRequirements.TryRequirementsMet(
requirements,
playTimes,
out _,
EntityManager,
_prototypes,
(HumanoidCharacterProfile?)
_preferencesManager.GetPreferences(player.UserId).SelectedCharacter);
}
/// <summary>
/// Checks if the player meets role requirements.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="antag">A list of role prototype IDs</param>
/// <returns>Returns true if all requirements were met or there were no requirements.</returns>
public bool IsAllowed(ICommonSession player, ProtoId<AntagPrototype> antag)
{
if (!_cfg.GetCVar(CCVars.GameRoleTimers))
return true;
if (!_tracking.TryGetTrackerTimes(player, out var playTimes))
{
Log.Error($"Unable to check playtimes {Environment.StackTrace}");
playTimes = new Dictionary<string, TimeSpan>();
}
var requirements = _roles.GetRoleRequirements(antag);
return JobRequirements.TryRequirementsMet(
requirements,
playTimes,
out _,
EntityManager,
_prototypes,
(HumanoidCharacterProfile?)
_preferencesManager.GetPreferences(player.UserId).SelectedCharacter);
}
public HashSet<ProtoId<JobPrototype>> GetDisallowedJobs(ICommonSession player)

View File

@@ -261,7 +261,7 @@ public sealed partial class PolymorphSystem : EntitySystem
if (configuration.TransferHumanoidAppearance)
{
_humanoid.CloneAppearance(child, uid);
_humanoid.CloneAppearance(uid, child);
}
if (_mindSystem.TryGetMind(uid, out var mindId, out var mind))

View File

@@ -56,7 +56,7 @@ public sealed partial class ShuttleConsoleSystem : SharedShuttleConsoleSystem
SubscribeLocalEvent<ShuttleConsoleComponent, ComponentShutdown>(OnConsoleShutdown);
SubscribeLocalEvent<ShuttleConsoleComponent, PowerChangedEvent>(OnConsolePowerChange);
SubscribeLocalEvent<ShuttleConsoleComponent, AnchorStateChangedEvent>(OnConsoleAnchorChange);
SubscribeLocalEvent<ShuttleConsoleComponent, ActivatableUIOpenAttemptEvent>(OnConsoleUIOpenAttempt);
SubscribeLocalEvent<ShuttleConsoleComponent, AfterActivatableUIOpenEvent>(OnConsoleUIOpenAttempt);
Subs.BuiEvents<ShuttleConsoleComponent>(ShuttleConsoleUiKey.Key, subs =>
{
subs.Event<ShuttleConsoleFTLBeaconMessage>(OnBeaconFTLMessage);
@@ -150,10 +150,9 @@ public sealed partial class ShuttleConsoleSystem : SharedShuttleConsoleSystem
}
private void OnConsoleUIOpenAttempt(EntityUid uid, ShuttleConsoleComponent component,
ActivatableUIOpenAttemptEvent args)
AfterActivatableUIOpenEvent args)
{
if (!TryPilot(args.User, uid))
args.Cancel();
TryPilot(args.User, uid);
}
private void OnConsoleAnchorChange(EntityUid uid, ShuttleConsoleComponent component,

View File

@@ -0,0 +1,64 @@
using Content.Shared.Silicons.StationAi;
using Content.Server.EUI;
using Content.Server.Ghost;
using Content.Server.Mind;
using Robust.Shared.Audio.Systems;
using Robust.Server.Player;
using Content.Shared.Popups;
namespace Content.Server.Silicons.StationAi;
public sealed partial class StationAiFixerConsoleSystem : SharedStationAiFixerConsoleSystem
{
[Dependency] private readonly EuiManager _eui = default!;
[Dependency] private readonly IPlayerManager _player = default!;
[Dependency] private readonly MindSystem _mind = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
protected override void FinalizeAction(Entity<StationAiFixerConsoleComponent> ent)
{
if (IsActionInProgress(ent) && ent.Comp.ActionTarget != null)
{
switch (ent.Comp.ActionType)
{
case StationAiFixerConsoleAction.Repair:
// Send message to disembodied player that they are being revived
if (_mind.TryGetMind(ent.Comp.ActionTarget.Value, out _, out var mind) &&
mind.IsVisitingEntity &&
_player.TryGetSessionById(mind.UserId, out var session))
{
_eui.OpenEui(new ReturnToBodyEui(mind, _mind, _player), session);
_popup.PopupEntity(Loc.GetString("station-ai-fixer-console-repair-finished"), ent);
}
else
{
_popup.PopupEntity(Loc.GetString("station-ai-fixer-console-repair-successful"), ent);
}
// TODO: make predicted once a user is not required
if (ent.Comp.RepairFinishedSound != null)
{
_audio.PlayPvs(ent.Comp.RepairFinishedSound, ent);
}
break;
case StationAiFixerConsoleAction.Purge:
_popup.PopupEntity(Loc.GetString("station-ai-fixer-console-purge-successful"), ent);
// TODO: make predicted once a user is not required
if (ent.Comp.PurgeFinishedSound != null)
{
_audio.PlayPvs(ent.Comp.PurgeFinishedSound, ent);
}
break;
}
}
base.FinalizeAction(ent);
}
}

View File

@@ -1,10 +1,34 @@
using Content.Server.Chat.Systems;
using Content.Server.Construction;
using Content.Server.Destructible;
using Content.Server.Ghost;
using Content.Server.Mind;
using Content.Server.Power.Components;
using Content.Server.Power.EntitySystems;
using Content.Server.Roles;
using Content.Server.Spawners.Components;
using Content.Server.Spawners.EntitySystems;
using Content.Server.Station.Systems;
using Content.Shared.Alert;
using Content.Shared.Chat.Prototypes;
using Content.Shared.Containers.ItemSlots;
using Content.Shared.Damage;
using Content.Shared.Destructible;
using Content.Shared.DeviceNetwork.Components;
using Content.Shared.DoAfter;
using Content.Shared.Mobs;
using Content.Shared.Mobs.Systems;
using Content.Shared.Popups;
using Content.Shared.Power.Components;
using Content.Shared.Rejuvenate;
using Content.Shared.Roles;
using Content.Shared.Silicons.StationAi;
using Content.Shared.Speech.Components;
using Content.Shared.StationAi;
using Content.Shared.Turrets;
using Content.Shared.Weapons.Ranged.Events;
using Robust.Server.Containers;
using Robust.Shared.Containers;
using Robust.Shared.Map.Components;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
@@ -16,19 +40,300 @@ public sealed class StationAiSystem : SharedStationAiSystem
{
[Dependency] private readonly EntityLookupSystem _lookup = default!;
[Dependency] private readonly SharedTransformSystem _xforms = default!;
[Dependency] private readonly ContainerSystem _container = default!;
[Dependency] private readonly MindSystem _mind = default!;
[Dependency] private readonly RoleSystem _roles = default!;
[Dependency] private readonly ItemSlotsSystem _slots = default!;
[Dependency] private readonly GhostSystem _ghost = default!;
[Dependency] private readonly AlertsSystem _alerts = default!;
[Dependency] private readonly DestructibleSystem _destructible = default!;
[Dependency] private readonly BatterySystem _battery = default!;
[Dependency] private readonly DamageableSystem _damageable = default!;
[Dependency] private readonly SharedPopupSystem _popups = default!;
[Dependency] private readonly StationSystem _station = default!;
[Dependency] private readonly StationJobsSystem _stationJobs = default!;
[Dependency] private readonly IPrototypeManager _proto = default!;
[Dependency] private readonly MobStateSystem _mobState = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
private readonly HashSet<Entity<StationAiCoreComponent>> _stationAiCores = new();
private readonly ProtoId<ChatNotificationPrototype> _turretIsAttackingChatNotificationPrototype = "TurretIsAttacking";
private readonly ProtoId<ChatNotificationPrototype> _aiWireSnippedChatNotificationPrototype = "AiWireSnipped";
private readonly ProtoId<ChatNotificationPrototype> _aiLosingPowerChatNotificationPrototype = "AiLosingPower";
private readonly ProtoId<ChatNotificationPrototype> _aiCriticalPowerChatNotificationPrototype = "AiCriticalPower";
private readonly ProtoId<JobPrototype> _stationAiJob = "StationAi";
private readonly EntProtoId _stationAiBrain = "StationAiBrain";
private readonly ProtoId<AlertPrototype> _batteryAlert = "BorgBattery";
private readonly ProtoId<AlertPrototype> _damageAlert = "BorgHealth";
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<StationAiCoreComponent, AfterConstructionChangeEntityEvent>(AfterConstructionChangeEntity);
SubscribeLocalEvent<StationAiCoreComponent, ContainerSpawnEvent>(OnContainerSpawn);
SubscribeLocalEvent<StationAiCoreComponent, ApcPowerReceiverBatteryChangedEvent>(OnApcBatteryChanged);
SubscribeLocalEvent<StationAiCoreComponent, ChargeChangedEvent>(OnChargeChanged);
SubscribeLocalEvent<StationAiCoreComponent, DamageChangedEvent>(OnDamageChanged);
SubscribeLocalEvent<StationAiCoreComponent, DestructionEventArgs>(OnDestruction);
SubscribeLocalEvent<StationAiCoreComponent, DoAfterAttemptEvent<IntellicardDoAfterEvent>>(OnDoAfterAttempt);
SubscribeLocalEvent<StationAiCoreComponent, RejuvenateEvent>(OnRejuvenate);
SubscribeLocalEvent<ExpandICChatRecipientsEvent>(OnExpandICChatRecipients);
SubscribeLocalEvent<StationAiTurretComponent, AmmoShotEvent>(OnAmmoShot);
}
private void AfterConstructionChangeEntity(Entity<StationAiCoreComponent> ent, ref AfterConstructionChangeEntityEvent args)
{
if (!_container.TryGetContainer(ent, StationAiCoreComponent.BrainContainer, out var container) ||
container.Count == 0)
{
return;
}
var brain = container.ContainedEntities[0];
if (_mind.TryGetMind(brain, out var mindId, out var mind))
{
// Found an existing mind to transfer into the AI core
var aiBrain = Spawn(_stationAiBrain, Transform(ent.Owner).Coordinates);
_roles.MindAddJobRole(mindId, mind, false, _stationAiJob);
_mind.TransferTo(mindId, aiBrain);
if (!TryComp<StationAiHolderComponent>(ent, out var targetHolder) ||
!_slots.TryInsert(ent, targetHolder.Slot, aiBrain, null))
{
QueueDel(aiBrain);
}
}
// TODO: We should consider keeping the borg brain inside the AI core.
// When the core is destroyed, the station AI can be transferred into the brain,
// then dropped on the ground. The deceased AI can then be revived later,
// instead of being lost forever.
QueueDel(brain);
}
private void OnContainerSpawn(Entity<StationAiCoreComponent> ent, ref ContainerSpawnEvent args)
{
// Ensure that players that recently joined the round will spawn
// into an AI core that has a full battery and full integrity.
if (TryComp<BatteryComponent>(ent, out var battery))
{
_battery.SetCharge(ent, battery.MaxCharge);
}
if (TryComp<DamageableComponent>(ent, out var damageable))
{
_damageable.SetAllDamage(ent, damageable, 0);
}
}
protected override void OnAiInsert(Entity<StationAiCoreComponent> ent, ref EntInsertedIntoContainerMessage args)
{
base.OnAiInsert(ent, ref args);
UpdateBatteryAlert(ent);
UpdateCoreIntegrityAlert(ent);
UpdateDamagedAccent(ent);
}
protected override void OnAiRemove(Entity<StationAiCoreComponent> ent, ref EntRemovedFromContainerMessage args)
{
base.OnAiRemove(ent, ref args);
_alerts.ClearAlert(args.Entity, _batteryAlert);
_alerts.ClearAlert(args.Entity, _damageAlert);
if (TryComp<DamagedSiliconAccentComponent>(args.Entity, out var accent))
{
accent.OverrideChargeLevel = null;
accent.OverrideTotalDamage = null;
accent.DamageAtMaxCorruption = null;
}
}
protected override void OnMobStateChanged(Entity<StationAiCustomizationComponent> ent, ref MobStateChangedEvent args)
{
if (args.NewMobState != MobState.Alive)
{
SetStationAiState(ent, StationAiState.Dead);
return;
}
var state = StationAiState.Rebooting;
if (_mind.TryGetMind(ent, out var _, out var mind) && !mind.IsVisitingEntity)
{
state = StationAiState.Occupied;
}
if (TryGetCore(ent, out var aiCore) && aiCore.Comp != null)
{
var aiCoreEnt = (aiCore.Owner, aiCore.Comp);
if (SetupEye(aiCoreEnt))
AttachEye(aiCoreEnt);
}
SetStationAiState(ent, state);
}
private void OnDestruction(Entity<StationAiCoreComponent> ent, ref DestructionEventArgs args)
{
var station = _station.GetOwningStation(ent);
if (station == null)
return;
if (!HasComp<ContainerSpawnPointComponent>(ent))
return;
// If the destroyed core could act as a player spawn point,
// reduce the number of available AI jobs by one
_stationJobs.TryAdjustJobSlot(station.Value, _stationAiJob, -1, false, true);
}
private void OnApcBatteryChanged(Entity<StationAiCoreComponent> ent, ref ApcPowerReceiverBatteryChangedEvent args)
{
if (!args.Enabled)
return;
if (!TryGetHeld((ent.Owner, ent.Comp), out var held))
return;
var ev = new ChatNotificationEvent(_aiLosingPowerChatNotificationPrototype, ent);
RaiseLocalEvent(held.Value, ref ev);
}
private void OnChargeChanged(Entity<StationAiCoreComponent> entity, ref ChargeChangedEvent args)
{
UpdateBatteryAlert(entity);
UpdateDamagedAccent(entity);
}
private void OnDamageChanged(Entity<StationAiCoreComponent> entity, ref DamageChangedEvent args)
{
UpdateCoreIntegrityAlert(entity);
UpdateDamagedAccent(entity);
}
private void UpdateDamagedAccent(Entity<StationAiCoreComponent> ent)
{
if (!TryGetHeld((ent.Owner, ent.Comp), out var held))
return;
if (!TryComp<DamagedSiliconAccentComponent>(held, out var accent))
return;
if (TryComp<BatteryComponent>(ent, out var battery))
accent.OverrideChargeLevel = battery.CurrentCharge / battery.MaxCharge;
if (TryComp<DamageableComponent>(ent, out var damageable))
accent.OverrideTotalDamage = damageable.TotalDamage;
if (TryComp<DestructibleComponent>(ent, out var destructible))
accent.DamageAtMaxCorruption = _destructible.DestroyedAt(ent, destructible);
Dirty(held.Value, accent);
}
private void UpdateBatteryAlert(Entity<StationAiCoreComponent> ent)
{
if (!TryComp<BatteryComponent>(ent, out var battery))
return;
if (!TryGetHeld((ent.Owner, ent.Comp), out var held))
return;
if (!_proto.TryIndex(_batteryAlert, out var proto))
return;
var chargePercent = battery.CurrentCharge / battery.MaxCharge;
var chargeLevel = Math.Round(chargePercent * proto.MaxSeverity);
_alerts.ShowAlert(held.Value, _batteryAlert, (short)Math.Clamp(chargeLevel, 0, proto.MaxSeverity));
if (TryComp<ApcPowerReceiverBatteryComponent>(ent, out var apcBattery) &&
apcBattery.Enabled &&
chargePercent < 0.2)
{
var ev = new ChatNotificationEvent(_aiCriticalPowerChatNotificationPrototype, ent);
RaiseLocalEvent(held.Value, ref ev);
}
}
private void UpdateCoreIntegrityAlert(Entity<StationAiCoreComponent> ent)
{
if (!TryComp<DamageableComponent>(ent, out var damageable))
return;
if (!TryComp<DestructibleComponent>(ent, out var destructible))
return;
if (!TryGetHeld((ent.Owner, ent.Comp), out var held))
return;
if (!_proto.TryIndex(_damageAlert, out var proto))
return;
var damagePercent = damageable.TotalDamage / _destructible.DestroyedAt(ent, destructible);
var damageLevel = Math.Round(damagePercent.Float() * proto.MaxSeverity);
_alerts.ShowAlert(held.Value, _damageAlert, (short)Math.Clamp(damageLevel, 0, proto.MaxSeverity));
}
private void OnDoAfterAttempt(Entity<StationAiCoreComponent> ent, ref DoAfterAttemptEvent<IntellicardDoAfterEvent> args)
{
if (TryGetHeld((ent.Owner, ent.Comp), out _))
return;
// Prevent AIs from being uploaded into an unpowered or broken AI core.
if (TryComp<ApcPowerReceiverComponent>(ent, out var apcPower) && !apcPower.Powered)
{
_popups.PopupEntity(Loc.GetString("station-ai-has-no-power-for-upload"), ent, args.Event.User);
args.Cancel();
}
else if (TryComp<DestructibleComponent>(ent, out var destructible) && destructible.IsBroken)
{
_popups.PopupEntity(Loc.GetString("station-ai-is-too-damaged-for-upload"), ent, args.Event.User);
args.Cancel();
}
}
public override void KillHeldAi(Entity<StationAiCoreComponent> ent)
{
base.KillHeldAi(ent);
if (TryGetHeld((ent.Owner, ent.Comp), out var held) &&
_mind.TryGetMind(held.Value, out var mindId, out var mind))
{
_ghost.OnGhostAttempt(mindId, canReturnGlobal: true, mind: mind);
RemComp<StationAiOverlayComponent>(held.Value);
}
ClearEye(ent);
}
private void OnRejuvenate(Entity<StationAiCoreComponent> ent, ref RejuvenateEvent args)
{
if (TryGetHeld((ent.Owner, ent.Comp), out var held))
{
_mobState.ChangeMobState(held.Value, MobState.Alive);
EnsureComp<StationAiOverlayComponent>(held.Value);
}
if (TryComp<StationAiHolderComponent>(ent, out var holder))
{
_appearance.SetData(ent, StationAiVisuals.Broken, false);
UpdateAppearance((ent, holder));
}
}
private void OnExpandICChatRecipients(ExpandICChatRecipientsEvent ev)
{
var xformQuery = GetEntityQuery<TransformComponent>();
@@ -147,7 +452,7 @@ public sealed class StationAiSystem : SharedStationAiSystem
if (!TryGetHeld((stationAiCore, stationAiCore.Comp), out var insertedAi))
continue;
hashSet.Add(insertedAi);
hashSet.Add(insertedAi.Value);
}
return hashSet;

View File

@@ -1,8 +1,7 @@
using Content.Server.GameTicking;
using Content.Server.GameTicking;
using Content.Server.Spawners.Components;
using Content.Server.Station.Systems;
using Content.Shared.Preferences;
using Content.Shared.Roles;
using Robust.Server.Containers;
using Robust.Shared.Containers;
using Robust.Shared.Prototypes;
@@ -87,6 +86,9 @@ public sealed class ContainerSpawnPointSystem : EntitySystem
if (!_container.Insert(args.SpawnResult.Value, container, containerXform: xform))
continue;
var ev = new ContainerSpawnEvent(args.SpawnResult.Value);
RaiseLocalEvent(uid, ref ev);
return;
}
@@ -94,3 +96,9 @@ public sealed class ContainerSpawnPointSystem : EntitySystem
args.SpawnResult = null;
}
}
/// <summary>
/// Raised on a container when a player is spawned into it.
/// </summary>
[ByRefEvent]
public record struct ContainerSpawnEvent(EntityUid Player);

View File

@@ -371,7 +371,7 @@ public sealed partial class StationJobsSystem
if (weight is not null && job.Weight != weight.Value)
continue;
if (!(roleBans == null || !roleBans.Contains(jobId)))
if (!(roleBans == null || !roleBans.Contains(jobId))) //TODO: Replace with IsRoleBanned
continue;
availableJobs ??= new List<string>(profile.JobPriorities.Count);

View File

@@ -1,24 +0,0 @@
using System.Numerics;
namespace Content.Server.Traits.Assorted;
/// <summary>
/// This is used for the narcolepsy trait.
/// </summary>
[RegisterComponent, Access(typeof(NarcolepsySystem))]
public sealed partial class NarcolepsyComponent : Component
{
/// <summary>
/// The random time between incidents, (min, max).
/// </summary>
[DataField("timeBetweenIncidents", required: true)]
public Vector2 TimeBetweenIncidents { get; private set; }
/// <summary>
/// The duration of incidents, (min, max).
/// </summary>
[DataField("durationOfIncident", required: true)]
public Vector2 DurationOfIncident { get; private set; }
public float NextIncidentTime;
}

View File

@@ -1,59 +0,0 @@
using Content.Shared.Bed.Sleep;
using Content.Shared.StatusEffectNew;
using Robust.Shared.Random;
namespace Content.Server.Traits.Assorted;
/// <summary>
/// This handles narcolepsy, causing the affected to fall asleep uncontrollably at a random interval.
/// </summary>
public sealed class NarcolepsySystem : EntitySystem
{
[Dependency] private readonly StatusEffectsSystem _statusEffects = default!;
[Dependency] private readonly IRobustRandom _random = default!;
/// <inheritdoc/>
public override void Initialize()
{
SubscribeLocalEvent<NarcolepsyComponent, ComponentStartup>(SetupNarcolepsy);
}
private void SetupNarcolepsy(EntityUid uid, NarcolepsyComponent component, ComponentStartup args)
{
component.NextIncidentTime =
_random.NextFloat(component.TimeBetweenIncidents.X, component.TimeBetweenIncidents.Y);
}
public void AdjustNarcolepsyTimer(EntityUid uid, int TimerReset, NarcolepsyComponent? narcolepsy = null)
{
if (!Resolve(uid, ref narcolepsy, false))
return;
narcolepsy.NextIncidentTime = TimerReset;
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var query = EntityQueryEnumerator<NarcolepsyComponent>();
while (query.MoveNext(out var uid, out var narcolepsy))
{
narcolepsy.NextIncidentTime -= frameTime;
if (narcolepsy.NextIncidentTime >= 0)
continue;
// Set the new time.
narcolepsy.NextIncidentTime +=
_random.NextFloat(narcolepsy.TimeBetweenIncidents.X, narcolepsy.TimeBetweenIncidents.Y);
var duration = _random.NextFloat(narcolepsy.DurationOfIncident.X, narcolepsy.DurationOfIncident.Y);
// Make sure the sleep time doesn't cut into the time to next incident.
narcolepsy.NextIncidentTime += duration;
_statusEffects.TryAddStatusEffectDuration(uid, SleepingSystem.StatusEffectForcedSleeping, TimeSpan.FromSeconds(duration));
}
}
}

View File

@@ -1,7 +1,9 @@
using Content.Server.Administration.Managers;
using Content.Server.Atmos.Components;
using Content.Server.Body.Components;
using Content.Server.Chat;
using Content.Server.Chat.Managers;
using Content.Server.Ghost;
using Content.Server.Ghost.Roles.Components;
using Content.Server.Humanoid;
using Content.Server.IdentityManagement;
@@ -14,6 +16,7 @@ using Content.Server.StationEvents.Components;
using Content.Server.Speech.Components;
using Content.Server.Temperature.Components;
using Content.Shared.Body.Components;
using Content.Shared.Chat;
using Content.Shared.CombatMode;
using Content.Shared.CombatMode.Pacification;
using Content.Shared.Damage;
@@ -41,6 +44,7 @@ using Content.Shared.Tag;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Content.Shared.NPC.Prototypes;
using Content.Shared.Roles;
namespace Content.Server.Zombies;
@@ -53,23 +57,27 @@ namespace Content.Server.Zombies;
public sealed partial class ZombieSystem
{
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly IBanManager _ban = default!;
[Dependency] private readonly IChatManager _chatMan = default!;
[Dependency] private readonly SharedCombatModeSystem _combat = default!;
[Dependency] private readonly NpcFactionSystem _faction = default!;
[Dependency] private readonly GhostSystem _ghost = default!;
[Dependency] private readonly SharedHandsSystem _hands = default!;
[Dependency] private readonly HumanoidAppearanceSystem _humanoidAppearance = default!;
[Dependency] private readonly IdentitySystem _identity = default!;
[Dependency] private readonly ServerInventorySystem _inventory = default!;
[Dependency] private readonly MindSystem _mind = default!;
[Dependency] private readonly MovementSpeedModifierSystem _movementSpeedModifier = default!;
[Dependency] private readonly NameModifierSystem _nameMod = default!;
[Dependency] private readonly NPCSystem _npc = default!;
[Dependency] private readonly TagSystem _tag = default!;
[Dependency] private readonly NameModifierSystem _nameMod = default!;
[Dependency] private readonly ISharedPlayerManager _player = default!;
private static readonly ProtoId<TagPrototype> InvalidForGlobalSpawnSpellTag = "InvalidForGlobalSpawnSpell";
private static readonly ProtoId<TagPrototype> CannotSuicideTag = "CannotSuicide";
private static readonly ProtoId<NpcFactionPrototype> ZombieFaction = "Zombie";
private static readonly string MindRoleZombie = "MindRoleZombie";
private static readonly List<ProtoId<AntagPrototype>> BannableZombiePrototypes = ["Zombie"];
/// <summary>
/// Handles an entity turning into a zombie when they die or go into crit
@@ -104,6 +112,24 @@ public sealed partial class ZombieSystem
if (!Resolve(target, ref mobState, logMissing: false))
return;
// Detach role-banned players before zombification
if (TryComp<ActorComponent>(target, out var actor) && _ban.IsRoleBanned(actor.PlayerSession, BannableZombiePrototypes))
{
var sess = actor.PlayerSession;
var message = Loc.GetString("zombie-roleban-ghosted");
if (_mind.TryGetMind(sess, out var playerMindEnt, out var playerMind))
{
// Detach
_ghost.SpawnGhost((playerMindEnt, playerMind), target);
// Notify
_chatMan.DispatchServerMessage(sess, message);
}
else
Log.Error($"Mind for session '{sess}' could not be found");
}
//you're a real zombie now, son.
var zombiecomp = AddComp<ZombieComponent>(target);
@@ -246,7 +272,7 @@ public sealed partial class ZombieSystem
if (hasMind && mind != null && _player.TryGetSessionById(mind.UserId, out var session))
{
//Zombie role for player manifest
_role.MindAddRole(mindId, "MindRoleZombie", mind: null, silent: true);
_role.MindAddRole(mindId, MindRoleZombie, mind: null, silent: true);
//Greeting message for new bebe zombers
_chatMan.DispatchServerMessage(session, Loc.GetString("zombie-infection-greeting"));
@@ -267,6 +293,7 @@ public sealed partial class ZombieSystem
ghostRole.RoleName = Loc.GetString("zombie-generic");
ghostRole.RoleDescription = Loc.GetString("zombie-role-desc");
ghostRole.RoleRules = Loc.GetString("zombie-role-rules");
ghostRole.MindRoles.Add(MindRoleZombie);
}
if (TryComp<HandsComponent>(target, out var handsComp))

View File

@@ -34,6 +34,15 @@ public sealed partial class AccessReaderComponent : Component
[DataField("access")]
public List<HashSet<ProtoId<AccessLevelPrototype>>> AccessLists = new();
/// <summary>
/// An unmodified copy of the original list of the access groups that grant access to this reader.
/// </summary>
/// <remarks>
/// If null, the access lists of this entity have not been modified yet.
/// </remarks>
[DataField]
public List<HashSet<ProtoId<AccessLevelPrototype>>>? AccessListsOriginal = null;
/// <summary>
/// A list of <see cref="StationRecordKey"/>s that grant access. Only a single matching key is required to gain access.
/// </summary>
@@ -76,6 +85,16 @@ public sealed partial class AccessReaderComponent : Component
/// </summary>
[DataField]
public bool BreakOnAccessBreaker = true;
/// <summary>
/// The examination text associated with this component.
/// </summary>
/// <remarks>
/// The text can be supplied with the 'access' variable to populate it
/// with a comma separated list of the access levels contained in <see cref="AccessLists"/>.
/// </remarks>
[DataField]
public LocId ExaminationText = "access-reader-examination";
}
[DataDefinition, Serializable, NetSerializable]
@@ -96,19 +115,36 @@ public sealed class AccessReaderComponentState : ComponentState
public bool Enabled;
public HashSet<ProtoId<AccessLevelPrototype>> DenyTags;
public List<HashSet<ProtoId<AccessLevelPrototype>>> AccessLists;
public List<HashSet<ProtoId<AccessLevelPrototype>>>? AccessListsOriginal;
public List<(NetEntity, uint)> AccessKeys;
public Queue<AccessRecord> AccessLog;
public int AccessLogLimit;
public AccessReaderComponentState(bool enabled, HashSet<ProtoId<AccessLevelPrototype>> denyTags, List<HashSet<ProtoId<AccessLevelPrototype>>> accessLists, List<(NetEntity, uint)> accessKeys, Queue<AccessRecord> accessLog, int accessLogLimit)
public AccessReaderComponentState(
bool enabled,
HashSet<ProtoId<AccessLevelPrototype>> denyTags,
List<HashSet<ProtoId<AccessLevelPrototype>>> accessLists,
List<HashSet<ProtoId<AccessLevelPrototype>>>? accessListsOriginal,
List<(NetEntity, uint)> accessKeys,
Queue<AccessRecord> accessLog,
int accessLogLimit)
{
Enabled = enabled;
DenyTags = denyTags;
AccessLists = accessLists;
AccessListsOriginal = accessListsOriginal;
AccessKeys = accessKeys;
AccessLog = accessLog;
AccessLogLimit = accessLogLimit;
}
}
/// <summary>
/// Raised after the settings on the access reader are changed.
/// </summary>
public sealed class AccessReaderConfigurationChangedEvent : EntityEventArgs;
/// <summary>
/// Raised before the settings on the access reader are changed. Can be cancelled.
/// </summary>
public sealed class AccessReaderConfigurationAttemptEvent : CancellableEntityEventArgs;

View File

@@ -0,0 +1,16 @@
using Content.Shared.Inventory;
using Robust.Shared.GameStates;
namespace Content.Shared.Access.Components;
/// <summary>
/// This component allows you to see whether an access reader's settings have been modified.
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class ShowAccessReaderSettingsComponent : Component, IClothingSlots
{
/// <summary>
/// Determines from which equipment slots this entity can provide its benefits.
/// </summary>
public SlotFlags Slots { get; set; } = ~SlotFlags.POCKET;
}

View File

@@ -1,12 +1,15 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using Content.Shared.Access.Components;
using Content.Shared.DeviceLinking.Events;
using Content.Shared.Emag.Systems;
using Content.Shared.Examine;
using Content.Shared.GameTicking;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.IdentityManagement;
using Content.Shared.Inventory;
using Content.Shared.Localizations;
using Content.Shared.NameIdentifier;
using Content.Shared.PDA;
using Content.Shared.StationRecords;
@@ -37,17 +40,74 @@ public sealed class AccessReaderSystem : EntitySystem
{
base.Initialize();
SubscribeLocalEvent<AccessReaderComponent, ExaminedEvent>(OnExamined);
SubscribeLocalEvent<AccessReaderComponent, GotEmaggedEvent>(OnEmagged);
SubscribeLocalEvent<AccessReaderComponent, LinkAttemptEvent>(OnLinkAttempt);
SubscribeLocalEvent<AccessReaderComponent, AccessReaderConfigurationAttemptEvent>(OnConfigurationAttempt);
SubscribeLocalEvent<AccessReaderComponent, ComponentGetState>(OnGetState);
SubscribeLocalEvent<AccessReaderComponent, ComponentHandleState>(OnHandleState);
}
private void OnExamined(Entity<AccessReaderComponent> ent, ref ExaminedEvent args)
{
if (!GetMainAccessReader(ent, out var mainAccessReader))
return;
mainAccessReader.Value.Comp.AccessListsOriginal ??= new(mainAccessReader.Value.Comp.AccessLists);
var accessHasBeenModified = mainAccessReader.Value.Comp.AccessLists.Count != mainAccessReader.Value.Comp.AccessListsOriginal.Count;
if (!accessHasBeenModified)
{
foreach (var accessSubgroup in mainAccessReader.Value.Comp.AccessLists)
{
if (!mainAccessReader.Value.Comp.AccessListsOriginal.Any(y => y.SetEquals(accessSubgroup)))
{
accessHasBeenModified = true;
break;
}
}
}
var canSeeAccessModification = accessHasBeenModified &&
(HasComp<ShowAccessReaderSettingsComponent>(ent) ||
_inventorySystem.TryGetInventoryEntity<ShowAccessReaderSettingsComponent>(args.Examiner, out _));
if (canSeeAccessModification)
{
var localizedCurrentNames = GetLocalizedAccessNames(mainAccessReader.Value.Comp.AccessLists);
var accessesFormatted = ContentLocalizationManager.FormatListToOr(localizedCurrentNames);
var currentSettingsMessage = localizedCurrentNames.Count > 0
? Loc.GetString("access-reader-access-settings-modified-message", ("access", accessesFormatted))
: Loc.GetString("access-reader-access-settings-removed-message");
args.PushMarkup(currentSettingsMessage);
return;
}
var localizedOriginalNames = GetLocalizedAccessNames(mainAccessReader.Value.Comp.AccessListsOriginal);
// If the string list is empty either there were no access restrictions or the localized names were invalid
if (localizedOriginalNames.Count == 0)
return;
var originalAccessesFormatted = ContentLocalizationManager.FormatListToOr(localizedOriginalNames);
var originalSettingsMessage = Loc.GetString(mainAccessReader.Value.Comp.ExaminationText, ("access", originalAccessesFormatted));
args.PushMarkup(originalSettingsMessage);
}
private void OnGetState(EntityUid uid, AccessReaderComponent component, ref ComponentGetState args)
{
args.State = new AccessReaderComponentState(component.Enabled, component.DenyTags, component.AccessLists,
_recordsSystem.Convert(component.AccessKeys), component.AccessLog, component.AccessLogLimit);
args.State = new AccessReaderComponentState(
component.Enabled,
component.DenyTags,
component.AccessLists,
component.AccessListsOriginal,
_recordsSystem.Convert(component.AccessKeys),
component.AccessLog,
component.AccessLogLimit);
}
private void OnHandleState(EntityUid uid, AccessReaderComponent component, ref ComponentHandleState args)
@@ -66,6 +126,7 @@ public sealed class AccessReaderSystem : EntitySystem
}
component.AccessLists = new(state.AccessLists);
component.AccessListsOriginal = state.AccessListsOriginal == null ? null : new(state.AccessListsOriginal);
component.DenyTags = new(state.DenyTags);
component.AccessLog = new(state.AccessLog);
component.AccessLogLimit = state.AccessLogLimit;
@@ -100,6 +161,13 @@ public sealed class AccessReaderSystem : EntitySystem
Dirty(uid, reader);
}
private void OnConfigurationAttempt(Entity<AccessReaderComponent> ent, ref AccessReaderConfigurationAttemptEvent args)
{
// The first time that the access list of the reader is modified,
// make a copy of the original settings
ent.Comp.AccessListsOriginal ??= new(ent.Comp.AccessLists);
}
/// <summary>
/// Searches the source for access tags
/// then compares it with the all targets accesses to see if it is allowed.
@@ -348,11 +416,23 @@ public sealed class AccessReaderSystem : EntitySystem
#region: AccessLists API
/// <summary>
/// Tries to clear the entity's <see cref="AccessReaderComponent.AccessLists"/>.
/// </summary>
/// <param name="ent">The access reader entity which is having its access permissions cleared.</param>
public void TryClearAccesses(Entity<AccessReaderComponent> ent)
{
if (CanConfigureAccessReader(ent))
{
ClearAccesses(ent);
}
}
/// <summary>
/// Clears the entity's <see cref="AccessReaderComponent.AccessLists"/>.
/// </summary>
/// <param name="ent">The access reader entity which is having its access permissions cleared.</param>
public void ClearAccesses(Entity<AccessReaderComponent> ent)
private void ClearAccesses(Entity<AccessReaderComponent> ent)
{
ent.Comp.AccessLists.Clear();
@@ -360,32 +440,65 @@ public sealed class AccessReaderSystem : EntitySystem
RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent());
}
/// <summary>
/// Tries to replace the access permissions in an entity's <see cref="AccessReaderComponent.AccessLists"/> with a supplied list.
/// </summary>
/// <param name="ent">The access reader entity which is having its list of access permissions replaced.</param>
/// <param name="accesses">The list of access permissions replacing the original one.</param>
public void TrySetAccesses(Entity<AccessReaderComponent> ent, List<HashSet<ProtoId<AccessLevelPrototype>>> accesses)
{
if (CanConfigureAccessReader(ent))
{
SetAccesses(ent, accesses);
}
}
/// <summary>
/// Replaces the access permissions in an entity's <see cref="AccessReaderComponent.AccessLists"/> with a supplied list.
/// </summary>
/// <param name="ent">The access reader entity which is having its list of access permissions replaced.</param>
/// <param name="accesses">The list of access permissions replacing the original one.</param>
public void SetAccesses(Entity<AccessReaderComponent> ent, List<HashSet<ProtoId<AccessLevelPrototype>>> accesses)
private void SetAccesses(Entity<AccessReaderComponent> ent, List<HashSet<ProtoId<AccessLevelPrototype>>> accesses)
{
ent.Comp.AccessLists.Clear();
AddAccesses(ent, accesses);
}
/// <inheritdoc cref = "TrySetAccesses"/>
public void TrySetAccesses(Entity<AccessReaderComponent> ent, List<ProtoId<AccessLevelPrototype>> accesses)
{
if (CanConfigureAccessReader(ent))
{
SetAccesses(ent, accesses);
}
}
/// <inheritdoc cref = "SetAccesses"/>
public void SetAccesses(Entity<AccessReaderComponent> ent, List<ProtoId<AccessLevelPrototype>> accesses)
private void SetAccesses(Entity<AccessReaderComponent> ent, List<ProtoId<AccessLevelPrototype>> accesses)
{
ent.Comp.AccessLists.Clear();
AddAccesses(ent, accesses);
}
/// <summary>
/// Tries to add a collection of access permissions to an access reader entity's <see cref="AccessReaderComponent.AccessLists"/>
/// </summary>
/// <param name="ent">The access reader entity to which the new access permissions are being added.</param>
/// <param name="accesses">The list of access permissions being added.</param>
public void TryAddAccesses(Entity<AccessReaderComponent> ent, List<HashSet<ProtoId<AccessLevelPrototype>>> accesses)
{
if (CanConfigureAccessReader(ent))
{
AddAccesses(ent, accesses);
}
}
/// <summary>
/// Adds a collection of access permissions to an access reader entity's <see cref="AccessReaderComponent.AccessLists"/>
/// </summary>
/// <param name="ent">The access reader entity to which the new access permissions are being added.</param>
/// <param name="accesses">The list of access permissions being added.</param>
public void AddAccesses(Entity<AccessReaderComponent> ent, List<HashSet<ProtoId<AccessLevelPrototype>>> accesses)
private void AddAccesses(Entity<AccessReaderComponent> ent, List<HashSet<ProtoId<AccessLevelPrototype>>> accesses)
{
foreach (var access in accesses)
{
@@ -396,8 +509,17 @@ public sealed class AccessReaderSystem : EntitySystem
RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent());
}
/// <inheritdoc cref = "TryAddAccesses"/>
public void TryAddAccesses(Entity<AccessReaderComponent> ent, List<ProtoId<AccessLevelPrototype>> accesses)
{
if (CanConfigureAccessReader(ent))
{
AddAccesses(ent, accesses);
}
}
/// <inheritdoc cref = "AddAccesses"/>
public void AddAccesses(Entity<AccessReaderComponent> ent, List<ProtoId<AccessLevelPrototype>> accesses)
private void AddAccesses(Entity<AccessReaderComponent> ent, List<ProtoId<AccessLevelPrototype>> accesses)
{
foreach (var access in accesses)
{
@@ -408,13 +530,27 @@ public sealed class AccessReaderSystem : EntitySystem
RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent());
}
/// <summary>
/// Tries to add an access permission to an access reader entity's <see cref="AccessReaderComponent.AccessLists"/>
/// </summary>
/// <param name="ent">The access reader entity to which the access permission is being added.</param>
/// <param name="access">The access permission being added.</param>
/// <param name="dirty">If true, the component will be marked as changed afterward.</param>
public void TryAddAccess(Entity<AccessReaderComponent> ent, HashSet<ProtoId<AccessLevelPrototype>> access)
{
if (CanConfigureAccessReader(ent))
{
AddAccess(ent, access);
}
}
/// <summary>
/// Adds an access permission to an access reader entity's <see cref="AccessReaderComponent.AccessLists"/>
/// </summary>
/// <param name="ent">The access reader entity to which the access permission is being added.</param>
/// <param name="access">The access permission being added.</param>
/// <param name="dirty">If true, the component will be marked as changed afterward.</param>
public void AddAccess(Entity<AccessReaderComponent> ent, HashSet<ProtoId<AccessLevelPrototype>> access, bool dirty = true)
private void AddAccess(Entity<AccessReaderComponent> ent, HashSet<ProtoId<AccessLevelPrototype>> access, bool dirty = true)
{
ent.Comp.AccessLists.Add(access);
@@ -425,18 +561,40 @@ public sealed class AccessReaderSystem : EntitySystem
RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent());
}
/// <inheritdoc cref = "TryAddAccess"/>
public void TryAddAccess(Entity<AccessReaderComponent> ent, ProtoId<AccessLevelPrototype> access)
{
if (CanConfigureAccessReader(ent))
{
AddAccess(ent, access);
}
}
/// <inheritdoc cref = "AddAccess"/>
public void AddAccess(Entity<AccessReaderComponent> ent, ProtoId<AccessLevelPrototype> access, bool dirty = true)
private void AddAccess(Entity<AccessReaderComponent> ent, ProtoId<AccessLevelPrototype> access, bool dirty = true)
{
AddAccess(ent, new HashSet<ProtoId<AccessLevelPrototype>>() { access }, dirty);
}
/// <summary>
/// Tries to remove a collection of access permissions from an access reader entity's <see cref="AccessReaderComponent.AccessLists"/>
/// </summary>
/// <param name="ent">The access reader entity from which the access permissions are being removed.</param>
/// <param name="accesses">The list of access permissions being removed.</param>
public void TryRemoveAccesses(Entity<AccessReaderComponent> ent, List<HashSet<ProtoId<AccessLevelPrototype>>> accesses)
{
if (CanConfigureAccessReader(ent))
{
RemoveAccesses(ent, accesses);
}
}
/// <summary>
/// Removes a collection of access permissions from an access reader entity's <see cref="AccessReaderComponent.AccessLists"/>
/// </summary>
/// <param name="ent">The access reader entity from which the access permissions are being removed.</param>
/// <param name="accesses">The list of access permissions being removed.</param>
public void RemoveAccesses(Entity<AccessReaderComponent> ent, List<HashSet<ProtoId<AccessLevelPrototype>>> accesses)
private void RemoveAccesses(Entity<AccessReaderComponent> ent, List<HashSet<ProtoId<AccessLevelPrototype>>> accesses)
{
foreach (var access in accesses)
{
@@ -447,8 +605,17 @@ public sealed class AccessReaderSystem : EntitySystem
RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent());
}
/// <inheritdoc cref = "TryRemoveAccesses"/>
public void TryRemoveAccesses(Entity<AccessReaderComponent> ent, List<ProtoId<AccessLevelPrototype>> accesses)
{
if (CanConfigureAccessReader(ent))
{
RemoveAccesses(ent, accesses);
}
}
/// <inheritdoc cref = "RemoveAccesses"/>
public void RemoveAccesses(Entity<AccessReaderComponent> ent, List<ProtoId<AccessLevelPrototype>> accesses)
private void RemoveAccesses(Entity<AccessReaderComponent> ent, List<ProtoId<AccessLevelPrototype>> accesses)
{
foreach (var access in accesses)
{
@@ -459,13 +626,27 @@ public sealed class AccessReaderSystem : EntitySystem
RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent());
}
/// <summary>
/// Tries to removes an access permission from an access reader entity's <see cref="AccessReaderComponent.AccessLists"/>
/// </summary>
/// <param name="ent">The access reader entity from which the access permission is being removed.</param>
/// <param name="access">The access permission being removed.</param>
/// <param name="dirty">If true, the component will be marked as changed afterward.</param>
public void TryRemoveAccess(Entity<AccessReaderComponent> ent, HashSet<ProtoId<AccessLevelPrototype>> access)
{
if (CanConfigureAccessReader(ent))
{
RemoveAccess(ent, access);
}
}
/// <summary>
/// Removes an access permission from an access reader entity's <see cref="AccessReaderComponent.AccessLists"/>
/// </summary>
/// <param name="ent">The access reader entity from which the access permission is being removed.</param>
/// <param name="access">The access permission being removed.</param>
/// <param name="dirty">If true, the component will be marked as changed afterward.</param>
public void RemoveAccess(Entity<AccessReaderComponent> ent, HashSet<ProtoId<AccessLevelPrototype>> access, bool dirty = true)
private void RemoveAccess(Entity<AccessReaderComponent> ent, HashSet<ProtoId<AccessLevelPrototype>> access, bool dirty = true)
{
for (int i = ent.Comp.AccessLists.Count - 1; i >= 0; i--)
{
@@ -482,12 +663,29 @@ public sealed class AccessReaderSystem : EntitySystem
RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent());
}
/// <inheritdoc cref = "TryRemoveAccess"/>
public void TryRemoveAccess(Entity<AccessReaderComponent> ent, ProtoId<AccessLevelPrototype> access)
{
if (CanConfigureAccessReader(ent))
{
RemoveAccess(ent, new HashSet<ProtoId<AccessLevelPrototype>>() { access });
}
}
/// <inheritdoc cref = "RemoveAccess"/>
public void RemoveAccess(Entity<AccessReaderComponent> ent, ProtoId<AccessLevelPrototype> access, bool dirty = true)
private void RemoveAccess(Entity<AccessReaderComponent> ent, ProtoId<AccessLevelPrototype> access, bool dirty = true)
{
RemoveAccess(ent, new HashSet<ProtoId<AccessLevelPrototype>>() { access }, dirty);
}
private bool CanConfigureAccessReader(Entity<AccessReaderComponent> ent)
{
var ev = new AccessReaderConfigurationAttemptEvent();
RaiseLocalEvent(ent, ev);
return !ev.Cancelled;
}
#endregion
#region: AccessKeys API
@@ -727,4 +925,38 @@ public sealed class AccessReaderSystem : EntitySystem
Dirty(ent);
}
private List<string> GetLocalizedAccessNames(List<HashSet<ProtoId<AccessLevelPrototype>>> accessLists)
{
var localizedNames = new List<string>();
string? andSeparator = null;
foreach (var accessHashSet in accessLists)
{
var sb = new StringBuilder();
var accessSubset = accessHashSet.ToList();
// Combine the names of all access levels in the subset into a single string
foreach (var access in accessSubset)
{
var accessName = Loc.GetString("access-reader-unknown-id");
if (_prototype.Resolve(access, out var accessProto) && !string.IsNullOrWhiteSpace(accessProto.Name))
accessName = Loc.GetString(accessProto.Name);
sb.Append(Loc.GetString("access-reader-access-label", ("access", accessName)));
if (accessSubset.IndexOf(access) < (accessSubset.Count - 1))
{
andSeparator ??= " " + Loc.GetString("generic-and") + " ";
sb.Append(andSeparator);
}
}
// Add this string to the list
localizedNames.Add(sb.ToString());
}
return localizedNames;
}
}

View File

@@ -1,6 +1,8 @@
using System.Net;
using Content.Shared.Database;
using Content.Shared.Eui;
using Content.Shared.Roles;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Administration;
@@ -21,32 +23,9 @@ public sealed class BanPanelEuiState : EuiStateBase
public static class BanPanelEuiStateMsg
{
[Serializable, NetSerializable]
public sealed class CreateBanRequest : EuiMessageBase
public sealed class CreateBanRequest(Ban ban) : EuiMessageBase
{
public string? Player { get; set; }
public string? IpAddress { get; set; }
public ImmutableTypedHwid? Hwid { get; set; }
public uint Minutes { get; set; }
public string Reason { get; set; }
public NoteSeverity Severity { get; set; }
public string[]? Roles { get; set; }
public bool UseLastIp { get; set; }
public bool UseLastHwid { get; set; }
public bool Erase { get; set; }
public CreateBanRequest(string? player, (IPAddress, int)? ipAddress, bool useLastIp, ImmutableTypedHwid? hwid, bool useLastHwid, uint minutes, string reason, NoteSeverity severity, string[]? roles, bool erase)
{
Player = player;
IpAddress = ipAddress == null ? null : $"{ipAddress.Value.Item1}/{ipAddress.Value.Item2}";
UseLastIp = useLastIp;
Hwid = hwid;
UseLastHwid = useLastHwid;
Minutes = minutes;
Reason = reason;
Severity = severity;
Roles = roles;
Erase = erase;
}
public Ban Ban { get; } = ban;
}
[Serializable, NetSerializable]
@@ -60,3 +39,50 @@ public static class BanPanelEuiStateMsg
}
}
}
/// <summary>
/// Contains all the data related to a particular ban action created by the BanPanel window.
/// </summary>
[Serializable, NetSerializable]
public sealed record Ban
{
public Ban(
string? target,
(IPAddress, int)? ipAddressTuple,
bool useLastIp,
ImmutableTypedHwid? hwid,
bool useLastHwid,
uint banDurationMinutes,
string reason,
NoteSeverity severity,
ProtoId<JobPrototype>[]? bannedJobs,
ProtoId<AntagPrototype>[]? bannedAntags,
bool erase)
{
Target = target;
IpAddress = ipAddressTuple?.Item1.ToString();
IpAddressHid = ipAddressTuple?.Item2.ToString() ?? "0";
UseLastIp = useLastIp;
Hwid = hwid;
UseLastHwid = useLastHwid;
BanDurationMinutes = banDurationMinutes;
Reason = reason;
Severity = severity;
BannedJobs = bannedJobs;
BannedAntags = bannedAntags;
Erase = erase;
}
public readonly string? Target;
public readonly string? IpAddress;
public readonly string? IpAddressHid;
public readonly bool UseLastIp;
public readonly ImmutableTypedHwid? Hwid;
public readonly bool UseLastHwid;
public readonly uint BanDurationMinutes;
public readonly string Reason;
public readonly NoteSeverity Severity;
public readonly ProtoId<JobPrototype>[]? BannedJobs;
public readonly ProtoId<AntagPrototype>[]? BannedAntags;
public readonly bool Erase;
}

View File

@@ -187,22 +187,22 @@ public sealed partial class DoorComponent : Component
public string EmaggingSpriteState = "sparks";
/// <summary>
/// The sprite state used for the door when it's open.
/// The length of the door's opening animation.
/// </summary>
[DataField]
public float OpeningAnimationTime = 0.8f;
public TimeSpan OpeningAnimationTime = TimeSpan.FromSeconds(0.8);
/// <summary>
/// The sprite state used for the door when it's open.
/// The length of the door's closing animation.
/// </summary>
[DataField]
public float ClosingAnimationTime = 0.8f;
public TimeSpan ClosingAnimationTime = TimeSpan.FromSeconds(0.8);
/// <summary>
/// The sprite state used for the door when it's open.
/// The length of the door's emagging animation.
/// </summary>
[DataField]
public float EmaggingAnimationTime = 1.5f;
public TimeSpan EmaggingAnimationTime = TimeSpan.FromSeconds(1.5);
/// <summary>
/// The animation used when the door opens.
@@ -264,8 +264,8 @@ public sealed partial class DoorComponent : Component
/// <summary>
/// Default time that the door should take to pry open.
/// </summary>
[DataField, ViewVariables(VVAccess.ReadWrite)]
public float PryTime = 1.5f;
[DataField]
public TimeSpan PryTime = TimeSpan.FromSeconds(1.5f);
[DataField]
public bool ChangeAirtight = true;

View File

@@ -12,7 +12,7 @@ public sealed partial class ResetNarcolepsy : EventEntityEffect<ResetNarcolepsy>
/// The # of seconds the effect resets the narcolepsy timer to
/// </summary>
[DataField("TimerReset")]
public int TimerReset = 600;
public TimeSpan TimerReset = TimeSpan.FromSeconds(600);
protected override string? ReagentEffectGuidebookText(IPrototypeManager prototype, IEntitySystemManager entSys)
=> Loc.GetString("reagent-effect-guidebook-reset-narcolepsy", ("chance", Probability));

View File

@@ -1,5 +1,6 @@
using Content.Shared.Eui;
using Content.Shared.Roles;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Ghost.Roles
@@ -12,11 +13,10 @@ namespace Content.Shared.Ghost.Roles
public string Description { get; set; }
public string Rules { get; set; }
// TODO ROLE TIMERS
// Actually make use of / enforce this requirement?
// Why is this even here.
// Move to ghost role prototype & respect CCvars.GameRoleTimerOverride
public HashSet<JobRequirement>? Requirements { get; set; }
/// <summary>
/// A list of all antag and job prototype IDs of the ghost role and its mind role(s).
/// </summary>
public (List<ProtoId<JobPrototype>>?,List<ProtoId<AntagPrototype>>?) RolePrototypes;
/// <inheritdoc cref="GhostRoleKind"/>
public GhostRoleKind Kind { get; set; }

Some files were not shown because too many files have changed in this diff Show More