Compare commits

...

9 Commits

Author SHA1 Message Date
metalgearsloth
18849be0b4 Version: 262.0.0 2025-06-09 23:56:58 +10:00
Leon Friedrich
c6a1d82bb1 Validate that Toolshed command arguments have parsers (#6014)
* Add Nullable<T> support to ToolshedManager.TryParse

* Check that command arguments are parseable

* release notes

* a

* A is for Array

* Fix test

* Fix indentation
2025-06-09 23:47:29 +10:00
Leon Friedrich
d89e1a43c6 Add PvsResetTest (#6015) 2025-06-09 23:39:06 +10:00
Leon Friedrich
d894ef70ef Misc SpriteSystem fixes (#6001)
* Move GetPrototypeTextures to SpriteSystem

* Fix tests

* Fix #6002

* release notes

---------

Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
2025-06-09 18:59:37 +10:00
DrSmugleaf
c7ea2793ca Fix TransformComponent state handling changing the coordinates of detached entities (#6006)
* Fix TransformComponent state handling changing the coordinates of detached entities

* Make ResetPredictedEntities not handle state for detached entities
2025-06-09 17:44:36 +10:00
Perry Fraser
0c61ff2bee fix: default audio params for PlayStatic (#6011) 2025-06-09 00:28:12 +02:00
Tayrtahn
343a34eac7 Fix warning CS0168 in EntityDeserializer (#6013)
* Fix warning CS0168 in EntityDeserializer

* Actually, why use try-catch just to rethrow anyway?
2025-06-09 00:04:03 +02:00
metalgearsloth
7be41f4890 Add ignoredcomps to IsDefault (#5998) 2025-06-05 23:54:47 +10:00
metalgearsloth
293470a5fe Fix incorrect saved window positions (#5927)
* Fix incorrect saved window positions

As of however many UI PRs ago windows store their last position on the client and it re-opens windows at that position.

The issue is that the code to avoid windows being able to go off-screen was immediately bulldozing this value, at least if the x <= 0. Now we just don't run it until we have a valid measure (probably the frame after) and avoid unnecessarily having an incorrect position applied.

* Explainer
2025-06-05 23:35:28 +10:00
21 changed files with 318 additions and 125 deletions

View File

@@ -1,4 +1,4 @@
<Project>
<!-- This file automatically reset by Tools/version.py -->
<!-- This file automatically reset by Tools/version.py -->

View File

@@ -54,6 +54,25 @@ END TEMPLATE-->
*None yet*
## 262.0.0
### Breaking changes
* Toolshed commands will now validate that each non-generic command argument is parseable (i.e., has a corresponding type parser). This check can be disabled by explicitly marking the argument as unparseable via `CommandArgumentAttribute.Unparseable`.
### New features
* `ToolshedManager.TryParse` now also supports nullable value types.
* Add an ignoredComponents arg to IsDefault.
### Bugfixes
* Fix `SpriteComponent.Layer.Visible` setter not marking a sprite's bounding box as dirty.
* The audio params in the passed SoundSpecifier for PlayStatic(SoundSpecifier, Filter, ...) will now be used as a default like other PlayStatic overrides.
* Fix windows not saving their positions correctly when their x position is <= 0.
* Fix transform state handling overriding PVS detachment.
## 261.2.0
### New features

View File

@@ -21,7 +21,7 @@ public sealed class SpriteTreeSystem : ComponentTreeSystem<SpriteTreeComponent,
protected override Box2 ExtractAabb(in ComponentTreeEntry<SpriteComponent> entry, Vector2 pos, Angle rot)
{
// TODO SPRITE optimize this
// Because the just take the BB of the rotated BB, I'mt pretty sure we do a lot of unnecessary maths.
// Because the just take the BB of the rotated BB, I'm pretty sure we do a lot of unnecessary maths.
return _sprite.CalculateBounds((entry.Uid, entry.Component), pos, rot, default).CalcBoundingBox();
}

View File

@@ -1224,6 +1224,8 @@ namespace Robust.Client.GameObjects
return;
_visible = value;
Owner.Comp.BoundsDirty = true;
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
if (_parent.Owner != EntityUid.Invalid)
Owner.Comp.Sys?.QueueUpdateIsInert(Owner);
@@ -1791,76 +1793,15 @@ namespace Robust.Client.GameObjects
[Obsolete("Use SpriteSystem.GetPrototypeTextures() instead")]
public static IEnumerable<IDirectionalTextureProvider> GetPrototypeTextures(EntityPrototype prototype, IResourceCache resourceCache, out bool noRot)
{
var results = new List<IDirectionalTextureProvider>();
noRot = false;
// TODO when moving to a non-static method in a system, pass in IComponentFactory
if (prototype.TryGetComponent(out IconComponent? icon))
{
var sys = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>();
results.Add(sys.GetIcon(icon));
return results;
}
if (!prototype.Components.TryGetValue("Sprite", out _))
{
results.Add(resourceCache.GetFallback<TextureResource>().Texture);
return results;
}
var entityManager = IoCManager.Resolve<IEntityManager>();
var dummy = entityManager.SpawnEntity(prototype.ID, MapCoordinates.Nullspace);
var spriteComponent = entityManager.EnsureComponent<SpriteComponent>(dummy);
EntitySystem.Get<AppearanceSystem>().OnChangeData(dummy, spriteComponent);
foreach (var layer in spriteComponent.AllLayers)
{
if (!layer.Visible) continue;
if (layer.Texture != null)
{
results.Add(layer.Texture);
continue;
}
if (!layer.RsiState.IsValid) continue;
var rsi = layer.Rsi ?? spriteComponent.BaseRSI;
if (rsi == null ||
!rsi.TryGetState(layer.RsiState, out var state))
continue;
results.Add(state);
}
noRot = spriteComponent.NoRotation;
entityManager.DeleteEntity(dummy);
if (results.Count == 0)
results.Add(resourceCache.GetFallback<TextureResource>().Texture);
return results;
var sys = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>();
return sys.GetPrototypeTextures(prototype, out noRot);
}
[Obsolete("Use SpriteSystem.GetPrototypeIcon() instead")]
public static IRsiStateLike GetPrototypeIcon(EntityPrototype prototype, IResourceCache resourceCache)
{
var sys = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>();
// TODO when moving to a non-static method in a system, pass in IComponentFactory
if (prototype.TryGetComponent(out IconComponent? icon))
return sys.GetIcon(icon);
if (!prototype.Components.ContainsKey("Sprite"))
return sys.GetFallbackState();
var entityManager = IoCManager.Resolve<IEntityManager>();
var dummy = entityManager.SpawnEntity(prototype.ID, MapCoordinates.Nullspace);
var spriteComponent = entityManager.EnsureComponent<SpriteComponent>(dummy);
var result = spriteComponent.Icon ?? sys.GetFallbackState();
entityManager.DeleteEntity(dummy);
return result;
return sys.GetPrototypeIcon(prototype);
}
}
}

View File

@@ -56,10 +56,6 @@ public sealed partial class SpriteSystem
/// </summary>
public IRsiStateLike GetPrototypeIcon(string prototype)
{
// Check if this prototype has been cached before, and if so return the result.
if (_cachedPrototypeIcons.TryGetValue(prototype, out var cachedResult))
return cachedResult;
if (!_proto.TryIndex<EntityPrototype>(prototype, out var entityPrototype))
{
// The specified prototype doesn't exist, return the fallback "error" sprite.
@@ -67,11 +63,7 @@ public sealed partial class SpriteSystem
return GetFallbackState();
}
// Generate the icon and cache it in case it's ever needed again.
var result = GetPrototypeIcon(entityPrototype);
_cachedPrototypeIcons[prototype] = result;
return result;
return GetPrototypeIcon(entityPrototype);
}
/// <summary>
@@ -79,13 +71,19 @@ public sealed partial class SpriteSystem
/// This method does NOT cache the result.
/// </summary>
public IRsiStateLike GetPrototypeIcon(EntityPrototype prototype)
{
// This method may spawn & delete an entity to get an accruate RSI state, hence we cache the results
if (_cachedPrototypeIcons.TryGetValue(prototype.ID, out var cachedResult))
return cachedResult;
return _cachedPrototypeIcons[prototype.ID] = GetPrototypeIconInternal(prototype);
}
private IRsiStateLike GetPrototypeIconInternal(EntityPrototype prototype)
{
// IconComponent takes precedence. If it has a valid icon, return that. Otherwise, continue as normal.
if (prototype.Components.TryGetValue("Icon", out var compData)
&& compData.Component is IconComponent icon)
{
if (prototype.TryGetComponent(out IconComponent? icon, _factory))
return GetIcon(icon);
}
// If the prototype doesn't have a SpriteComponent, then there's nothing we can do but return the fallback.
if (!prototype.Components.ContainsKey("Sprite"))
@@ -102,6 +100,63 @@ public sealed partial class SpriteSystem
return result;
}
public IEnumerable<IDirectionalTextureProvider> GetPrototypeTextures(EntityPrototype proto) =>
GetPrototypeTextures(proto, out _);
public IEnumerable<IDirectionalTextureProvider> GetPrototypeTextures(EntityPrototype proto, out bool noRot)
{
var results = new List<IDirectionalTextureProvider>();
noRot = false;
if (proto.TryGetComponent(out IconComponent? icon, _factory))
{
results.Add(GetIcon(icon));
return results;
}
if (!proto.Components.ContainsKey("Sprite"))
{
results.Add(_resourceCache.GetFallback<TextureResource>().Texture);
return results;
}
var dummy = Spawn(proto.ID, MapCoordinates.Nullspace);
var spriteComponent = EnsureComp<SpriteComponent>(dummy);
// TODO SPRITE is this needed?
// And if it is, shouldn't GetPrototypeIconInternal also use this?
_appearance.OnChangeData(dummy, spriteComponent);
foreach (var layer in spriteComponent.AllLayers)
{
if (!layer.Visible)
continue;
if (layer.Texture != null)
{
results.Add(layer.Texture);
continue;
}
if (!layer.RsiState.IsValid)
continue;
var rsi = layer.Rsi ?? spriteComponent.BaseRSI;
if (rsi == null || !rsi.TryGetState(layer.RsiState, out var state))
continue;
results.Add(state);
}
noRot = spriteComponent.NoRotation;
Del(dummy);
if (results.Count == 0)
results.Add(_resourceCache.GetFallback<TextureResource>().Texture);
return results;
}
[Pure]
public RSI.State GetFallbackState()
{

View File

@@ -34,8 +34,12 @@ namespace Robust.Client.GameObjects
[Dependency] private readonly IPrototypeManager _proto = default!;
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly IComponentFactory _factory = default!;
// Note that any new system dependencies have to be added to RobustUnitTest.BaseSetup()
[Dependency] private readonly SharedTransformSystem _xforms = default!;
[Dependency] private readonly SpriteTreeSystem _tree = default!;
[Dependency] private readonly AppearanceSystem _appearance = default!;
public static readonly ProtoId<ShaderPrototype> UnshadedId = "unshaded";
private readonly Queue<SpriteComponent> _inertUpdateQueue = new();

View File

@@ -631,7 +631,7 @@ namespace Robust.Client.GameStates
if (_sawmill.Level <= LogLevel.Debug)
_sawmill.Debug($" A component was dirtied: {comp.GetType()}");
if (compState != null)
if ((meta.Flags & MetaDataFlags.Detached) == 0 && compState != null)
{
var handleState = new ComponentHandleState(compState, null);
_entities.EventBus.RaiseComponentEvent(entity, comp, ref handleState);

View File

@@ -145,6 +145,11 @@ namespace Robust.Client.UserInterface.CustomControls
protected override void FrameUpdate(FrameEventArgs args)
{
// This is to avoid unnecessarily setting a position where our size isn't yet fully updated.
// This most commonly happens with saved window positions if your window position is <= 0.
if (!IsMeasureValid)
return;
var (spaceX, spaceY) = Parent!.Size;
var maxX = spaceX - ((AllowOffScreen & DirectionFlag.West) == 0 ? Size.X : WindowEdgeSeparation);

View File

@@ -665,7 +665,7 @@ public abstract partial class SharedAudioSystem : EntitySystem
/// <param name="coordinates">The coordinates at which to play the audio.</param>
public (EntityUid Entity, Components.AudioComponent Component)? PlayStatic(SoundSpecifier? sound, Filter playerFilter, EntityCoordinates coordinates, bool recordReplay, AudioParams? audioParams = null)
{
return sound == null ? null : PlayStatic(ResolveSound(sound), playerFilter, coordinates, recordReplay, audioParams);
return sound == null ? null : PlayStatic(ResolveSound(sound), playerFilter, coordinates, recordReplay, audioParams ?? sound.Params);
}
/// <summary>

View File

@@ -547,20 +547,20 @@ public sealed class EntityDeserializer :
_stopwatch.Restart();
foreach (var (entity, data) in Entities)
{
#if EXCEPTION_TOLERANCE
try
{
#endif
CurrentReadingEntity = data;
LoadEntity(entity, _metaQuery.Comp(entity), data.Components, data.MissingComponents);
#if EXCEPTION_TOLERANCE
}
catch (Exception e)
{
#if !EXCEPTION_TOLERANCE
throw;
#else
ToDelete.Add(entity);
_log.Error($"Encountered error while loading entity. Yaml uid: {data.YamlId}. Loaded loaded entity: {EntMan.ToPrettyString(entity)}. Error:\n{e}.");
#endif
}
#endif
}
CurrentReadingEntity = null;

View File

@@ -161,7 +161,7 @@ namespace Robust.Shared.GameObjects
/// <summary>
/// Returns true if the entity's data (apart from transform) is default.
/// </summary>
public bool IsDefault(EntityUid uid)
public bool IsDefault(EntityUid uid, ICollection<string>? ignoredComps = null)
{
if (!MetaQuery.TryGetComponent(uid, out var metadata) || metadata.EntityPrototype == null)
return false;
@@ -195,6 +195,9 @@ namespace Robust.Shared.GameObjects
var compName = _componentFactory.GetComponentName(compType);
if (ignoredComps?.Contains(compName) == true)
continue;
// If the component isn't on the prototype then it's custom.
if (!protoData.TryGetValue(compName, out var protoMapping))
return false;

View File

@@ -1,16 +1,16 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
using Robust.Shared.Containers;
using Robust.Shared.GameStates;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Maths;
using Robust.Shared.Physics;
using Robust.Shared.Utility;
using System;
using System.Linq;
using System.Runtime.CompilerServices;
using Robust.Shared.Map.Components;
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using Robust.Shared.Containers;
namespace Robust.Shared.GameObjects;

View File

@@ -46,8 +46,9 @@ public sealed class PipedArgumentAttribute : Attribute;
[MeansImplicitUse]
public sealed class CommandArgumentAttribute : Attribute
{
public CommandArgumentAttribute(Type? customParser = null)
public CommandArgumentAttribute(Type? customParser = null, bool unparseable = false)
{
Unparseable = unparseable;
if (customParser == null)
return;
@@ -56,6 +57,13 @@ public sealed class CommandArgumentAttribute : Attribute
$"Custom parser {customParser.PrettyName()} does not inherit from {typeof(CustomTypeParser<>).PrettyName()}");
}
/// <summary>
/// Command initialization will validate that all of a command's arguments are parseable (i.e., have a type parser).
/// In some niche situations you might want to have a command that accepts unparseable arguments that must be
/// supplied via a toolshed variable or command block, in which case the check can be disabled via this property.
/// </summary>
public bool Unparseable { get; }
public Type? CustomParser { get; }
}

View File

@@ -11,8 +11,11 @@ internal sealed class TpCommand : ToolshedCommand
{
private SharedTransformSystem? _xform;
// TODO TOOLSHED
// add EntityCoordinates parser
[CommandImplementation("coords")]
public EntityUid TpCoords([PipedArgument] EntityUid teleporter, EntityCoordinates target)
public EntityUid TpCoords([PipedArgument] EntityUid teleporter, [CommandArgument(unparseable:true)] EntityCoordinates target)
{
_xform ??= GetSys<SharedTransformSystem>();
_xform.SetCoordinates(teleporter, target);
@@ -20,7 +23,7 @@ internal sealed class TpCommand : ToolshedCommand
}
[CommandImplementation("coords")]
public IEnumerable<EntityUid> TpCoords([PipedArgument] IEnumerable<EntityUid> teleporters, EntityCoordinates target)
public IEnumerable<EntityUid> TpCoords([PipedArgument] IEnumerable<EntityUid> teleporters, [CommandArgument(unparseable:true)] EntityCoordinates target)
=> teleporters.Select(x => TpCoords(x, target));
[CommandImplementation("to")]

View File

@@ -124,12 +124,12 @@ public abstract partial class ToolshedCommand
{
var hasAnyAttribute = false;
if (param.HasCustomAttribute<CommandArgumentAttribute>())
if (param.GetCustomAttribute<CommandArgumentAttribute>() is {} cmdAttr)
{
if (param.Name == null || !argNames.Add(param.Name))
throw new InvalidCommandImplementation($"Command arguments must have a unique name");
hasAnyAttribute = true;
ValidateArg(param);
ValidateArg(param, cmdAttr);
}
if (param.HasCustomAttribute<PipedArgumentAttribute>())
@@ -232,8 +232,18 @@ public abstract partial class ToolshedCommand
}
}
private void ValidateArg(ParameterInfo arg)
private void ValidateArg(ParameterInfo arg, CommandArgumentAttribute? cmdAttr = null)
{
if (cmdAttr == null || cmdAttr.CustomParser == null && !cmdAttr.Unparseable)
{
// This checks that each argument has a corresponding type parser, as people have sometimes created a command
// without realising that the type is unparseable.
var t = Nullable.GetUnderlyingType(arg.ParameterType) ?? arg.ParameterType;
var ignore = t.IsGenericType || t.IsArray || t.ContainsGenericParameters;
if (!ignore && Toolshed.GetParserForType(t) == null)
throw new InvalidCommandImplementation($"{Name} command argument of type {t.PrettyName()} has no type parser. You either need to add a type parser or explicitly mark the argument as unparseable.");
}
var isParams = arg.HasCustomAttribute<ParamArrayAttribute>();
if (!isParams)
return;

View File

@@ -231,7 +231,10 @@ public sealed partial class ToolshedManager
/// <returns>Success.</returns>
public bool TryParse<T>(ParserContext parserContext, [NotNullWhen(true)] out T? parsed)
{
var res = TryParse(parserContext, typeof(T), out var p);
var t = typeof(T);
t = Nullable.GetUnderlyingType(t) ?? t;
var res = TryParse(parserContext, t, out var p);
if (p is not null)
parsed = (T?) p;
else

View File

@@ -81,7 +81,7 @@ public abstract class SpanLikeTypeParser<T, TElem> : TypeParser<T>
public override CompletionResult? TryAutocomplete(ParserContext parserContext, CommandArgument? arg)
{
return CompletionResult.FromHint(typeof(T).PrettyName());
return CompletionResult.FromHint(GetArgHint(arg));
}
}

View File

@@ -28,6 +28,7 @@ using Robust.Shared.Player;
using Robust.Shared.Reflection;
using Robust.Shared.Threading;
using Robust.Shared.Utility;
using AppearanceSystem = Robust.Client.GameObjects.AppearanceSystem;
using InputSystem = Robust.Server.GameObjects.InputSystem;
using MapSystem = Robust.Server.GameObjects.MapSystem;
using PointLightComponent = Robust.Client.GameObjects.PointLightComponent;
@@ -141,6 +142,7 @@ namespace Robust.UnitTesting
systems.LoadExtraSystemType<RecursiveMoveSystem>();
systems.LoadExtraSystemType<SpriteSystem>();
systems.LoadExtraSystemType<SpriteTreeSystem>();
systems.LoadExtraSystemType<AppearanceSystem>();
systems.LoadExtraSystemType<GridChunkBoundsDebugSystem>();
}
else

View File

@@ -136,35 +136,39 @@ public sealed class PvsPauseTest : RobustIntegrationTest
await RunTicks();
AssertEnt(paused: true, detached: false, clientPaused: true);
// Unpause the entity while out of range
{
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), farAway));
await RunTicks();
AssertEnt(paused: true, detached: true, clientPaused: true);
// Unpause the entity while out of range
{
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), farAway));
await RunTicks();
AssertEnt(paused: true, detached: true, clientPaused: true);
await server.WaitPost(() => metaSys.SetEntityPaused(sEnt, false));
await RunTicks();
AssertEnt(paused: false, detached: true, clientPaused: true);
await server.WaitPost(() => metaSys.SetEntityPaused(sEnt, false));
await RunTicks();
AssertEnt(paused: false, detached: true, clientPaused: true);
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), coords));
await RunTicks();
AssertEnt(paused: false, detached: false, clientPaused: false);
}
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), coords));
await RunTicks();
AssertEnt(paused: false, detached: false, clientPaused: false);
}
// Pause the entity while out of range
{
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), farAway));
await RunTicks();
AssertEnt(paused: false, detached: true, clientPaused: true);
// Pause the entity while out of range
{
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), farAway));
await RunTicks();
AssertEnt(paused: false, detached: true, clientPaused: true);
await server.WaitPost(() => metaSys.SetEntityPaused(sEnt, true));
await RunTicks();
AssertEnt(paused: true, detached: true, clientPaused: true);
await server.WaitPost(() => metaSys.SetEntityPaused(sEnt, true));
await RunTicks();
AssertEnt(paused: true, detached: true, clientPaused: true);
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), coords));
await RunTicks();
AssertEnt(paused: true, detached: false, clientPaused: true);
}
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), coords));
await RunTicks();
AssertEnt(paused: true, detached: false, clientPaused: true);
}
await client.WaitPost(() => netMan.ClientDisconnect(""));
await server.WaitRunTicks(5);
await client.WaitRunTicks(5);
}
}

View File

@@ -0,0 +1,134 @@
using System.Linq;
using System.Numerics;
using System.Threading.Tasks;
using NUnit.Framework;
using Robust.Shared;
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Network;
namespace Robust.UnitTesting.Server.GameStates;
public sealed class PvsResetTest : RobustIntegrationTest
{
/// <summary>
/// Check that the client doesn't reset dirty detached entities. They should remain in nullspace.
/// </summary>
[Test]
public async Task ResetTest()
{
var server = StartServer();
var client = StartClient();
await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync());
var sEntMan = server.EntMan;
var confMan = server.CfgMan;
var sPlayerMan = server.PlayerMan;
var xforms = sEntMan.System<SharedTransformSystem>();
var cEntMan = client.EntMan;
var cPlayerMan = client.PlayerMan;
var netMan = client.ResolveDependency<IClientNetManager>();
Assert.DoesNotThrow(() => client.SetConnectTarget(server));
client.Post(() => netMan.ClientConnect(null!, 0, null!));
server.Post(() => confMan.SetCVar(CVars.NetPVS, true));
async Task RunTicks()
{
for (int i = 0; i < 10; i++)
{
await server.WaitRunTicks(1);
await client.WaitRunTicks(1);
}
}
await RunTicks();
// Set up map and spawn player
EntityUid sMap = default;
EntityUid playerUid = default;
EntityUid sEnt = default;
EntityCoordinates coords = default;
await server.WaitPost(() =>
{
sMap = server.System<SharedMapSystem>().CreateMap();
coords = new(sMap, default);
playerUid = sEntMan.SpawnEntity(null, coords);
sEnt = sEntMan.SpawnEntity(null, coords);
// Attach player.
var session = sPlayerMan.Sessions.First();
server.PlayerMan.SetAttachedEntity(session, playerUid);
sPlayerMan.JoinGame(session);
});
await RunTicks();
var farAway = new EntityCoordinates(sMap, new Vector2(100, 100));
var netEnt = sEntMan.GetNetEntity(sEnt);
var player = sEntMan.GetNetEntity(playerUid);
Assert.That(player, Is.Not.EqualTo(NetEntity.Invalid));
Assert.That(netEnt, Is.Not.EqualTo(NetEntity.Invalid));
// Check player got properly attached, and has received the other entity.
Assert.That(cEntMan.TryGetEntity(netEnt, out var uid));
Assert.That(cEntMan.TryGetEntity(player, out var cPlayerUid));
var cEnt = uid!.Value;
Assert.That(cPlayerMan.LocalEntity, Is.EqualTo(cPlayerUid));
var cMap = cEntMan.GetEntity(sEntMan.GetNetEntity(sMap));
void AssertDetached(bool detached)
{
var cXform = client.Transform(cEnt);
var sXform = server.Transform(sEnt);
var meta = client.MetaData(cEnt);
Assert.That(sXform.MapUid, Is.EqualTo(sMap));
Assert.That(sXform.ParentUid, Is.EqualTo(sMap));
if (detached)
{
Assert.That(meta.Flags.HasFlag(MetaDataFlags.Detached));
Assert.That(cXform.MapUid, Is.Null);
Assert.That(cXform.ParentUid, Is.EqualTo(EntityUid.Invalid));
}
else
{
Assert.That(!meta.Flags.HasFlag(MetaDataFlags.Detached));
Assert.That(cXform.MapUid, Is.EqualTo(cMap));
Assert.That(cXform.ParentUid, Is.EqualTo(cMap));
}
}
// Entity is initially in view
AssertDetached(false);
// Move the player out of the entity's PVS range
await server.WaitPost(() => xforms.SetCoordinates(playerUid, farAway));
await RunTicks();
// Client should now have detached the entity, moving it into nullspace
AssertDetached(true);
// Marking the entity as dirty due to client-side prediction should have effect
await client.WaitPost(() => client.EntMan.Dirty(cEnt, client.Transform(cEnt)));
await RunTicks();
AssertDetached(true);
// Move the player back into range
await server.WaitPost( () => xforms.SetCoordinates(playerUid, coords));
await RunTicks();
AssertDetached(false);
// Marking the entity as dirty due to client-side prediction should have no real effect
await client.WaitPost(() => client.EntMan.Dirty(cEnt, client.Transform(cEnt)));
await RunTicks();
AssertDetached(false);
await client.WaitPost(() => netMan.ClientDisconnect(""));
await server.WaitRunTicks(5);
await client.WaitRunTicks(5);
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;
using Robust.Shared.IoC;
using Robust.Shared.Reflection;
using Robust.Shared.Toolshed;
using Robust.Shared.Toolshed.TypeParsers;
@@ -43,6 +44,7 @@ public sealed class ToolshedValidationTest : ToolshedTest
foreach (var type in types)
{
var instance = (ToolshedCommand)Activator.CreateInstance(type)!;
Server.Resolve<IDependencyCollection>().InjectDependencies(instance, oneOff: true);
Assert.Throws<InvalidCommandImplementation>(instance.Init, $"{type.PrettyName()} did not throw a {nameof(InvalidCommandImplementation)} exception");
}
});