diff --git a/Content.Client/Construction/ConstructionSystem.cs b/Content.Client/Construction/ConstructionSystem.cs index 4800b76b3a..06726d6caa 100644 --- a/Content.Client/Construction/ConstructionSystem.cs +++ b/Content.Client/Construction/ConstructionSystem.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Content.Shared.Construction; using Content.Shared.Construction.Prototypes; using Content.Shared.Examine; @@ -151,33 +152,45 @@ namespace Content.Client.Construction /// Creates a construction ghost at the given location. /// public void SpawnGhost(ConstructionPrototype prototype, EntityCoordinates loc, Direction dir) + => TrySpawnGhost(prototype, loc, dir, out _); + + /// + /// Creates a construction ghost at the given location. + /// + public bool TrySpawnGhost( + ConstructionPrototype prototype, + EntityCoordinates loc, + Direction dir, + [NotNullWhen(true)] out EntityUid? ghost) { + ghost = null; if (_playerManager.LocalPlayer?.ControlledEntity is not { } user || !user.IsValid()) { - return; + return false; } - if (GhostPresent(loc)) return; + if (GhostPresent(loc)) + return false; // This InRangeUnobstructed should probably be replaced with "is there something blocking us in that tile?" var predicate = GetPredicate(prototype.CanBuildInImpassable, loc.ToMap(EntityManager)); if (!_interactionSystem.InRangeUnobstructed(user, loc, 20f, predicate: predicate)) - return; + return false; foreach (var condition in prototype.Conditions) { if (!condition.Condition(user, loc, dir)) - return; + return false; } - var ghost = EntityManager.SpawnEntity("constructionghost", loc); - var comp = EntityManager.GetComponent(ghost); + ghost = EntityManager.SpawnEntity("constructionghost", loc); + var comp = EntityManager.GetComponent(ghost.Value); comp.Prototype = prototype; comp.GhostId = _nextId++; - EntityManager.GetComponent(ghost).LocalRotation = dir.ToAngle(); + EntityManager.GetComponent(ghost.Value).LocalRotation = dir.ToAngle(); _ghosts.Add(comp.GhostId, comp); - var sprite = EntityManager.GetComponent(ghost); + var sprite = EntityManager.GetComponent(ghost.Value); sprite.Color = new Color(48, 255, 48, 128); for (int i = 0; i < prototype.Layers.Count; i++) @@ -189,7 +202,9 @@ namespace Content.Client.Construction } if (prototype.CanBuildInImpassable) - EnsureComp(ghost).Arc = new(Math.Tau); + EnsureComp(ghost.Value).Arc = new(Math.Tau); + + return true; } /// @@ -205,7 +220,7 @@ namespace Content.Client.Construction return false; } - private void TryStartConstruction(int ghostId) + public void TryStartConstruction(int ghostId) { var ghost = _ghosts[ghostId]; diff --git a/Content.Client/Examine/ExamineSystem.cs b/Content.Client/Examine/ExamineSystem.cs index 3cb464f2fe..39fc1408c4 100644 --- a/Content.Client/Examine/ExamineSystem.cs +++ b/Content.Client/Examine/ExamineSystem.cs @@ -21,7 +21,7 @@ using static Robust.Client.UserInterface.Controls.BoxContainer; namespace Content.Client.Examine { [UsedImplicitly] - internal sealed class ExamineSystem : ExamineSystemShared + public sealed class ExamineSystem : ExamineSystemShared { [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; diff --git a/Content.IntegrationTests/PoolManager.cs b/Content.IntegrationTests/PoolManager.cs index 997238d056..e71369c0c3 100644 --- a/Content.IntegrationTests/PoolManager.cs +++ b/Content.IntegrationTests/PoolManager.cs @@ -14,6 +14,7 @@ using Content.IntegrationTests.Tests.Interaction.Click; using Content.IntegrationTests.Tests.Networking; using Content.Server.GameTicking; using Content.Shared.CCVar; +using Microsoft.Diagnostics.Tracing.Parsers.Kernel; using NUnit.Framework; using Robust.Client; using Robust.Server; @@ -561,6 +562,7 @@ we are just going to end this here to save a lot of time. This is the exception { var mapManager = IoCManager.Resolve(); mapData.MapId = mapManager.CreateMap(); + mapData.MapUid = mapManager.GetMapEntityId(mapData.MapId); mapData.MapGrid = mapManager.CreateGrid(mapData.MapId); mapData.GridCoords = new EntityCoordinates(mapData.MapGrid.Owner, 0, 0); var tileDefinitionManager = IoCManager.Resolve(); @@ -790,6 +792,7 @@ public sealed class PoolSettings /// public sealed class TestMapData { + public EntityUid MapUid { get; set; } public MapId MapId { get; set; } public MapGridComponent MapGrid { get; set; } public EntityCoordinates GridCoords { get; set; } diff --git a/Content.IntegrationTests/Tests/Construction/Interaction/ComputerContruction.cs b/Content.IntegrationTests/Tests/Construction/Interaction/ComputerContruction.cs new file mode 100644 index 0000000000..aade73e701 --- /dev/null +++ b/Content.IntegrationTests/Tests/Construction/Interaction/ComputerContruction.cs @@ -0,0 +1,99 @@ +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using NUnit.Framework; + +namespace Content.IntegrationTests.Tests.Construction.Interaction; + +public sealed class ComputerConstruction : InteractionTest +{ + private const string Computer = "Computer"; + private const string ComputerId = "ComputerId"; + private const string ComputerFrame = "ComputerFrame"; + private const string IdBoard = "IDComputerCircuitboard"; + + [Test] + public async Task ConstructComputer() + { + // Place ghost + await StartConstruction(Computer); + + // Initial interaction (ghost turns into real entity) + await Interact(Steel, 5); + AssertPrototype(ComputerFrame); + + // Perform construction steps + await Interact( + Wrench, + IdBoard, + Screw, + (Cable, 5), + (Glass, 2), + Screw); + + // Construction finished, target entity was replaced with a new one: + AssertPrototype(ComputerId); + } + + [Test] + public async Task DeconstructComputer() + { + // Spawn initial entity + await StartDeconstruction(ComputerId); + + // Initial interaction turns id computer into generic computer + await Interact(Screw); + AssertPrototype(ComputerFrame); + + // Perform deconstruction steps + await Interact( + Pry, + Cut, + Screw, + Pry, + Wrench, + Weld); + + // construction finished, entity no longer exists. + AssertDeleted(); + + // Check expected entities were dropped. + await AssertEntityLookup( + IdBoard, + (Cable, 5), + (Steel, 5), + (Glass, 2)); + } + + [Test] + public async Task ChangeComputer() + { + // Spawn initial entity + await SpawnTarget(ComputerId); + + // Initial interaction turns id computer into generic computer + await Interact(Screw); + AssertPrototype(ComputerFrame); + + // Perform partial deconstruction steps + await Interact( + Pry, + Cut, + Screw, + Pry); + + // Entity should still exist + AssertPrototype(ComputerFrame); + + // Begin re-constructing with a new circuit board + await Interact( + "CargoRequestComputerCircuitboard", + Screw, + (Cable, 5), + (Glass, 2), + Screw); + + // Construction finished, target entity was replaced with a new one: + AssertPrototype("ComputerCargoOrders"); + } +} + diff --git a/Content.IntegrationTests/Tests/Construction/Interaction/CraftingTests.cs b/Content.IntegrationTests/Tests/Construction/Interaction/CraftingTests.cs new file mode 100644 index 0000000000..698f81b122 --- /dev/null +++ b/Content.IntegrationTests/Tests/Construction/Interaction/CraftingTests.cs @@ -0,0 +1,127 @@ +using System.Linq; +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using Content.Shared.Stacks; +using NUnit.Framework; +using Robust.Shared.Containers; + +namespace Content.IntegrationTests.Tests.Construction.Interaction; + +public sealed class CraftingTests : InteractionTest +{ + public const string ShardGlass = "ShardGlass"; + public const string Spear = "Spear"; + + /// + /// Craft a simple instant recipe + /// + [Test] + public async Task CraftRods() + { + await PlaceInHands(Steel); + await CraftItem(Rod); + await FindEntity((Rod, 2)); + } + + /// + /// Craft a simple recipe with a DoAfter + /// + [Test] + public async Task CraftGrenade() + { + await PlaceInHands(Steel, 5); + await CraftItem("ModularGrenadeRecipe"); + await FindEntity("ModularGrenade"); + } + + /// + /// Craft a complex recipe (more than one ingredient). + /// + [Test] + public async Task CraftSpear() + { + // Spawn a full tack of rods in the user's hands. + await PlaceInHands(Rod, 10); + await SpawnEntity((Cable, 10), PlayerCoords); + + // Attempt (and fail) to craft without glass. + await CraftItem(Spear, shouldSucceed: false); + await FindEntity(Spear, shouldSucceed: false); + + // Spawn three shards of glass and finish crafting (only one is needed). + await SpawnTarget(ShardGlass); + await SpawnTarget(ShardGlass); + await SpawnTarget(ShardGlass); + await CraftItem(Spear); + await FindEntity(Spear); + + // Player's hands should be full of the remaining rods, except those dropped during the failed crafting attempt. + // Spear and left over stacks should be on the floor. + await AssertEntityLookup((Rod, 2), (Cable, 8), (ShardGlass, 2), (Spear, 1)); + } + + // The following is wrapped in an if DEBUG. This is because of cursed state handling bugs. Tests don't (de)serialize + // net messages and just copy objects by reference. This means that the server will directly modify cached server + // states on the client's end. Crude fix at the moment is to used modified state handling while in debug mode + // Otherwise, this test cannot work. +#if DEBUG + /// + /// Cancel crafting a complex recipe. + /// + [Test] + public async Task CancelCraft() + { + var rods = await SpawnEntity((Rod, 10), TargetCoords); + var wires = await SpawnEntity((Cable, 10), TargetCoords); + var shard = await SpawnEntity(ShardGlass, TargetCoords); + + var rodStack = SEntMan.GetComponent(rods); + var wireStack = SEntMan.GetComponent(wires); + + await RunTicks(5); + var sys = SEntMan.System(); + Assert.That(sys.IsEntityInContainer(rods), Is.False); + Assert.That(sys.IsEntityInContainer(wires), Is.False); + Assert.That(sys.IsEntityInContainer(shard), Is.False); + + await Server.WaitPost(() => SConstruction.TryStartItemConstruction(Spear, Player)); + await RunTicks(1); + + // DoAfter is in progress. Entity not spawned, stacks have been split and someingredients are in a container. + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(1)); + Assert.That(sys.IsEntityInContainer(shard), Is.True); + Assert.That(sys.IsEntityInContainer(rods), Is.False); + Assert.That(sys.IsEntityInContainer(wires), Is.False); + Assert.That(rodStack.Count, Is.EqualTo(8)); + Assert.That(wireStack.Count, Is.EqualTo(8)); + await FindEntity(Spear, shouldSucceed: false); + + // Cancel the DoAfter. Should drop ingredients to the floor. + await CancelDoAfters(); + Assert.That(sys.IsEntityInContainer(rods), Is.False); + Assert.That(sys.IsEntityInContainer(wires), Is.False); + Assert.That(sys.IsEntityInContainer(shard), Is.False); + await FindEntity(Spear, shouldSucceed: false); + await AssertEntityLookup((Rod, 10), (Cable, 10), (ShardGlass, 1)); + + // Re-attempt the do-after + await Server.WaitPost(() => SConstruction.TryStartItemConstruction(Spear, Player)); + await RunTicks(1); + + // DoAfter is in progress. Entity not spawned, ingredients are in a container. + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(1)); + Assert.That(sys.IsEntityInContainer(shard), Is.True); + await FindEntity(Spear, shouldSucceed: false); + + // Finish the DoAfter + await AwaitDoAfters(); + + // Spear has been crafted. Rods and wires are no longer contained. Glass has been consumed. + await FindEntity(Spear); + Assert.That(sys.IsEntityInContainer(rods), Is.False); + Assert.That(sys.IsEntityInContainer(wires), Is.False); + Assert.That(SEntMan.Deleted(shard)); + } +#endif +} + diff --git a/Content.IntegrationTests/Tests/Construction/Interaction/GrilleWindowConstruction.cs b/Content.IntegrationTests/Tests/Construction/Interaction/GrilleWindowConstruction.cs new file mode 100644 index 0000000000..69ea71f10e --- /dev/null +++ b/Content.IntegrationTests/Tests/Construction/Interaction/GrilleWindowConstruction.cs @@ -0,0 +1,59 @@ +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using Content.Shared.Construction.Prototypes; +using NUnit.Framework; +using Robust.Shared.Maths; + +namespace Content.IntegrationTests.Tests.Construction.Interaction; + +/// +/// Check that we can build grilles on top of windows, but not the other way around. +/// +public sealed class GrilleWindowConstruction : InteractionTest +{ + private const string Grille = "Grille"; + private const string Window = "Window"; + + [Test] + public async Task WindowOnGrille() + { + // Construct Grille + await StartConstruction(Grille); + await Interact(Rod, 10); + AssertPrototype(Grille); + + var grille = Target; + + // Construct Window + await StartConstruction(Window); + await Interact(Glass, 10); + AssertPrototype(Window); + + // Deconstruct Window + await Interact(Screw, Wrench); + AssertDeleted(); + + // Deconstruct Grille + Target = grille; + await Interact(Cut); + AssertDeleted(); + } + + [Test] + [TestCase(Grille, Grille)] + [TestCase(Window, Grille)] + [TestCase(Window, Window)] + public async Task ConstructionBlocker(string first, string second) + { + // Spawn blocking entity + await SpawnTarget(first); + + // Further construction attempts fail - blocked by first entity interaction. + await Client.WaitPost(() => + { + var proto = ProtoMan.Index(second); + Assert.That(CConSys.TrySpawnGhost(proto, TargetCoords, Direction.South, out _), Is.False); + }); + } +} + diff --git a/Content.IntegrationTests/Tests/Construction/Interaction/MachineConstruction.cs b/Content.IntegrationTests/Tests/Construction/Interaction/MachineConstruction.cs new file mode 100644 index 0000000000..6f0478a652 --- /dev/null +++ b/Content.IntegrationTests/Tests/Construction/Interaction/MachineConstruction.cs @@ -0,0 +1,89 @@ +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using NUnit.Framework; + +namespace Content.IntegrationTests.Tests.Construction.Interaction; + +public sealed class MachineConstruction : InteractionTest +{ + private const string MachineFrame = "MachineFrame"; + private const string Unfinished = "UnfinishedMachineFrame"; + private const string ProtolatheBoard = "ProtolatheMachineCircuitboard"; + private const string Protolathe = "Protolathe"; + private const string Beaker = "Beaker"; + + [Test] + public async Task ConstructProtolathe() + { + await StartConstruction(MachineFrame); + await Interact(Steel, 5); + AssertPrototype(Unfinished); + await Interact(Wrench, Cable); + AssertPrototype(MachineFrame); + await Interact(ProtolatheBoard, Bin1, Bin1, Manipulator1, Manipulator1, Beaker, Beaker, Screw); + AssertPrototype(Protolathe); + } + + [Test] + public async Task DeconstructProtolathe() + { + await StartDeconstruction(Protolathe); + await Interact(Screw, Pry); + AssertPrototype(MachineFrame); + await Interact(Pry, Cut); + AssertPrototype(Unfinished); + await Interact(Wrench, Screw); + AssertDeleted(); + await AssertEntityLookup( + (Steel, 5), + (Cable, 1), + (Beaker, 2), + (Manipulator1, 2), + (Bin1, 2), + (ProtolatheBoard, 1)); + } + + [Test] + public async Task ChangeMachine() + { + // Partially deconstruct a protolathe. + await SpawnTarget(Protolathe); + await Interact(Screw, Pry, Pry); + AssertPrototype(MachineFrame); + + // Change it into an autolathe + await Interact("AutolatheMachineCircuitboard"); + AssertPrototype(MachineFrame); + await Interact(Bin1, Bin1, Bin1, Manipulator1, Glass, Screw); + AssertPrototype("Autolathe"); + } + + [Test] + public async Task UpgradeLathe() + { + // Partially deconstruct a protolathe. + await SpawnTarget(Protolathe); + + // Initially has all quality-1 parts. + foreach (var part in SConstruction.GetAllParts(Target!.Value)) + { + Assert.That(part.Rating, Is.EqualTo(1)); + } + + // Partially deconstruct lathe + await Interact(Screw, Pry, Pry); + AssertPrototype(MachineFrame); + + // Reconstruct with better parts. + await Interact(ProtolatheBoard, Bin4, Bin4, Manipulator4, Manipulator4, Beaker, Beaker); + await Interact(Screw); + AssertPrototype(Protolathe); + + // Query now returns higher quality parts. + foreach (var part in SConstruction.GetAllParts(Target!.Value)) + { + Assert.That(part.Rating, Is.EqualTo(4)); + } + } +} + diff --git a/Content.IntegrationTests/Tests/Construction/Interaction/PanelScrewing.cs b/Content.IntegrationTests/Tests/Construction/Interaction/PanelScrewing.cs new file mode 100644 index 0000000000..a0b6611072 --- /dev/null +++ b/Content.IntegrationTests/Tests/Construction/Interaction/PanelScrewing.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using Content.Server.Power.Components; +using Content.Shared.Wires; +using NUnit.Framework; +using Robust.Shared.GameObjects; + +namespace Content.IntegrationTests.Tests.Construction.Interaction; + +public sealed class PanelScrewing : InteractionTest +{ + [Test] + public async Task ApcPanel() + { + await SpawnTarget("APCBasic"); + var comp = Comp(); + + // Open & close panel + Assert.That(comp.IsApcOpen, Is.False); + + await Interact(Screw); + Assert.That(comp.IsApcOpen, Is.True); + await Interact(Screw); + Assert.That(comp.IsApcOpen, Is.False); + + // Interrupted DoAfters + await Interact(Screw, awaitDoAfters: false); + await CancelDoAfters(); + Assert.That(comp.IsApcOpen, Is.False); + await Interact(Screw); + Assert.That(comp.IsApcOpen, Is.True); + await Interact(Screw, awaitDoAfters: false); + await CancelDoAfters(); + Assert.That(comp.IsApcOpen, Is.True); + await Interact(Screw); + Assert.That(comp.IsApcOpen, Is.False); + } + + // Test wires panel on both airlocks & tcomms servers. These both use the same component, but comms may have + // conflicting interactions due to encryption key removal interactions. + [Test] + [TestCase("Airlock")] + [TestCase("TelecomServerFilled")] + public async Task WiresPanelScrewing(string prototype) + { + await SpawnTarget(prototype); + var comp = Comp(); + + // Open & close panel + Assert.That(comp.Open, Is.False); + await Interact(Screw); + Assert.That(comp.Open, Is.True); + await Interact(Screw); + Assert.That(comp.Open, Is.False); + + // Interrupted DoAfters + await Interact(Screw, awaitDoAfters: false); + await CancelDoAfters(); + Assert.That(comp.Open, Is.False); + await Interact(Screw); + Assert.That(comp.Open, Is.True); + await Interact(Screw, awaitDoAfters: false); + await CancelDoAfters(); + Assert.That(comp.Open, Is.True); + await Interact(Screw); + Assert.That(comp.Open, Is.False); + } +} + diff --git a/Content.IntegrationTests/Tests/Construction/Interaction/PlaceableDeconstruction.cs b/Content.IntegrationTests/Tests/Construction/Interaction/PlaceableDeconstruction.cs new file mode 100644 index 0000000000..1ce57422dd --- /dev/null +++ b/Content.IntegrationTests/Tests/Construction/Interaction/PlaceableDeconstruction.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using Content.Shared.Placeable; +using NUnit.Framework; + +namespace Content.IntegrationTests.Tests.Construction.Interaction; + +public sealed class PlaceableDeconstruction : InteractionTest +{ + /// + /// Checks that you can deconstruct placeable surfaces (i.e., placing a wrench on a table does not take priority). + /// + [Test] + public async Task DeconstructTable() + { + await StartDeconstruction("Table"); + Assert.That(Comp().IsPlaceable); + await Interact(Wrench); + AssertPrototype("TableFrame"); + await Interact(Wrench); + AssertDeleted(); + await AssertEntityLookup((Steel, 1), (Rod, 2)); + } +} + diff --git a/Content.IntegrationTests/Tests/Construction/Interaction/WallConstruction.cs b/Content.IntegrationTests/Tests/Construction/Interaction/WallConstruction.cs new file mode 100644 index 0000000000..5cdb00ce95 --- /dev/null +++ b/Content.IntegrationTests/Tests/Construction/Interaction/WallConstruction.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using NUnit.Framework; + +namespace Content.IntegrationTests.Tests.Construction.Interaction; + +public sealed class WallConstruction : InteractionTest +{ + public const string Girder = "Girder"; + public const string WallSolid = "WallSolid"; + public const string Wall = "Wall"; + + [Test] + public async Task ConstructWall() + { + await StartConstruction(Wall); + await Interact(Steel, 2); + Assert.IsNull(Hands.ActiveHandEntity); + AssertPrototype(Girder); + await Interact(Steel, 2); + Assert.IsNull(Hands.ActiveHandEntity); + AssertPrototype(WallSolid); + } + + [Test] + public async Task DeconstructWall() + { + await StartDeconstruction(WallSolid); + await Interact(Weld); + AssertPrototype(Girder); + await Interact(Wrench, Screw); + AssertDeleted(); + await AssertEntityLookup((Steel, 4)); + } +} + diff --git a/Content.IntegrationTests/Tests/Construction/Interaction/WindowConstruction.cs b/Content.IntegrationTests/Tests/Construction/Interaction/WindowConstruction.cs new file mode 100644 index 0000000000..60deadcaee --- /dev/null +++ b/Content.IntegrationTests/Tests/Construction/Interaction/WindowConstruction.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using NUnit.Framework; + +namespace Content.IntegrationTests.Tests.Construction.Interaction; + +public sealed class WindowConstruction : InteractionTest +{ + private const string Window = "Window"; + private const string RWindow = "ReinforcedWindow"; + + [Test] + public async Task ConstructWindow() + { + await StartConstruction(Window); + await Interact(Glass, 5); + AssertPrototype(Window); + } + + [Test] + public async Task DeconstructWindow() + { + await StartDeconstruction(Window); + await Interact(Screw, Wrench); + AssertDeleted(); + await AssertEntityLookup((Glass, 2)); + } + + [Test] + public async Task ConstructReinforcedWindow() + { + await StartConstruction(RWindow); + await Interact(RGlass, 5); + AssertPrototype(RWindow); + } + + [Test] + public async Task DeonstructReinforcedWindow() + { + await StartDeconstruction(RWindow); + await Interact( + Weld, + Screw, + Pry, + Weld, + Screw, + Wrench); + AssertDeleted(); + await AssertEntityLookup((RGlass, 2)); + } +} + diff --git a/Content.IntegrationTests/Tests/Construction/Interaction/WindowRepair.cs b/Content.IntegrationTests/Tests/Construction/Interaction/WindowRepair.cs new file mode 100644 index 0000000000..0c104a41be --- /dev/null +++ b/Content.IntegrationTests/Tests/Construction/Interaction/WindowRepair.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using Content.Shared.Damage; +using Content.Shared.Damage.Prototypes; +using Content.Shared.FixedPoint; +using NUnit.Framework; +using Robust.Shared.Prototypes; + +namespace Content.IntegrationTests.Tests.Construction.Interaction; + +public sealed class WindowRepair : InteractionTest +{ + [Test] + public async Task RepairReinforcedWindow() + { + await SpawnTarget("ReinforcedWindow"); + + // Damage the entity. + var sys = SEntMan.System(); + var comp = Comp(); + var damageType = Server.ResolveDependency().Index("Blunt"); + var damage = new DamageSpecifier(damageType, FixedPoint2.New(10)); + Assert.That(comp.Damage.Total, Is.EqualTo(FixedPoint2.Zero)); + await Server.WaitPost(() => sys.TryChangeDamage(Target, damage, ignoreResistances: true)); + await RunTicks(5); + Assert.That(comp.Damage.Total, Is.GreaterThan(FixedPoint2.Zero)); + + // Repair the entity + await Interact(Weld); + Assert.That(comp.Damage.Total, Is.EqualTo(FixedPoint2.Zero)); + + // Validate that we can still deconstruct the entity (i.e., that welding deconstruction is not blocked). + await Interact( + Weld, + Screw, + Pry, + Weld, + Screw, + Wrench); + AssertDeleted(); + await AssertEntityLookup((RGlass, 2)); + } +} + diff --git a/Content.IntegrationTests/Tests/DoAfter/DoAfterCancellationTests.cs b/Content.IntegrationTests/Tests/DoAfter/DoAfterCancellationTests.cs new file mode 100644 index 0000000000..d52a3d9497 --- /dev/null +++ b/Content.IntegrationTests/Tests/DoAfter/DoAfterCancellationTests.cs @@ -0,0 +1,136 @@ +using System.Linq; +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Construction.Interaction; +using Content.IntegrationTests.Tests.Interaction; +using Content.IntegrationTests.Tests.Weldable; +using Content.Server.Tools.Components; +using NUnit.Framework; + +namespace Content.IntegrationTests.Tests.DoAfter; + +/// +/// This class has various tests that verify that cancelled DoAfters do not complete construction or other interactions. +/// It also checks that cancellation of a DoAfter does not block future DoAfters. +/// +public sealed class DoAfterCancellationTests : InteractionTest +{ + [Test] + public async Task CancelWallDeconstruct() + { + await StartDeconstruction(WallConstruction.WallSolid); + await Interact(Weld, awaitDoAfters:false); + + // Failed do-after has no effect + await CancelDoAfters(); + AssertPrototype(WallConstruction.WallSolid); + + // Second attempt works fine + await Interact(Weld); + AssertPrototype(WallConstruction.Girder); + + // Repeat for wrenching interaction + AssertAnchored(); + await Interact(Wrench, awaitDoAfters:false); + await CancelDoAfters(); + AssertAnchored(); + AssertPrototype(WallConstruction.Girder); + await Interact(Wrench); + AssertAnchored(false); + + // Repeat for screwdriver interaction. + AssertDeleted(false); + await Interact(Screw, awaitDoAfters:false); + await CancelDoAfters(); + AssertDeleted(false); + await Interact(Screw); + AssertDeleted(); + } + + [Test] + public async Task CancelWallConstruct() + { + await StartConstruction(WallConstruction.Wall); + await Interact(Steel, 5, awaitDoAfters:false); + await CancelDoAfters(); + Assert.That(Target.HasValue && Target.Value.IsClientSide()); + + await Interact(Steel, 5); + AssertPrototype(WallConstruction.Girder); + await Interact(Steel, 5, awaitDoAfters:false); + await CancelDoAfters(); + AssertPrototype(WallConstruction.Girder); + + await Interact(Steel, 5); + AssertPrototype(WallConstruction.WallSolid); + } + + [Test] + public async Task CancelTilePry() + { + await SetTile(Floor); + await Interact(Pry, awaitDoAfters:false); + await CancelDoAfters(); + await AssertTile(Floor); + + await Interact(Pry); + await AssertTile(Plating); + } + + [Test] + public async Task CancelRepeatedTilePry() + { + await SetTile(Floor); + await Interact(Pry, awaitDoAfters:false); + await RunTicks(1); + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(1)); + await AssertTile(Floor); + + // Second DoAfter cancels the first. + await Server.WaitPost(() => InteractSys.UserInteraction(Player, TargetCoords, Target)); + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(0)); + await AssertTile(Floor); + + // Third do after will work fine + await Interact(Pry); + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(0)); + await AssertTile(Plating); + } + + [Test] + public async Task CancelRepeatedWeld() + { + await SpawnTarget(WeldableTests.Locker); + var comp = Comp(); + + Assert.That(comp.Weldable, Is.True); + Assert.That(comp.IsWelded, Is.False); + + await Interact(Weld, awaitDoAfters:false); + await RunTicks(1); + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(1)); + Assert.That(comp.IsWelded, Is.False); + + // Second DoAfter cancels the first. + // Not using helper, because it runs too many ticks & causes the do-after to finish. + await Server.WaitPost(() => InteractSys.UserInteraction(Player, TargetCoords, Target)); + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(0)); + Assert.That(comp.IsWelded, Is.False); + + // Third do after will work fine + await Interact(Weld); + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(0)); + Assert.That(comp.IsWelded, Is.True); + + // Repeat test for un-welding + await Interact(Weld, awaitDoAfters:false); + await RunTicks(1); + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(1)); + Assert.That(comp.IsWelded, Is.True); + await Server.WaitPost(() => InteractSys.UserInteraction(Player, TargetCoords, Target)); + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(0)); + Assert.That(comp.IsWelded, Is.True); + await Interact(Weld); + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(0)); + Assert.That(comp.IsWelded, Is.False); + } +} diff --git a/Content.IntegrationTests/Tests/EncryptionKeys/RemoveEncryptionKeys.cs b/Content.IntegrationTests/Tests/EncryptionKeys/RemoveEncryptionKeys.cs new file mode 100644 index 0000000000..f25564a4fc --- /dev/null +++ b/Content.IntegrationTests/Tests/EncryptionKeys/RemoveEncryptionKeys.cs @@ -0,0 +1,88 @@ +using System.Linq; +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using Content.Shared.Radio.Components; +using Content.Shared.Wires; +using NUnit.Framework; + +namespace Content.IntegrationTests.Tests.EncryptionKeys; + +public sealed class RemoveEncryptionKeys : InteractionTest +{ + [Test] + public async Task HeadsetKeys() + { + await SpawnTarget("ClothingHeadsetGrey"); + var comp = Comp(); + + Assert.That(comp.KeyContainer.ContainedEntities.Count, Is.EqualTo(1)); + Assert.That(comp.DefaultChannel, Is.EqualTo("Common")); + Assert.That(comp.Channels.Count, Is.EqualTo(1)); + Assert.That(comp.Channels.First(), Is.EqualTo("Common")); + + // Remove the key + await Interact(Screw); + Assert.That(comp.KeyContainer.ContainedEntities.Count, Is.EqualTo(0)); + Assert.IsNull(comp.DefaultChannel); + Assert.That(comp.Channels.Count, Is.EqualTo(0)); + + // Checkl that the key was ejected and not just deleted or something. + await AssertEntityLookup(("EncryptionKeyCommon", 1)); + + // Re-insert a key. + await Interact("EncryptionKeyCentCom"); + Assert.That(comp.KeyContainer.ContainedEntities.Count, Is.EqualTo(1)); + Assert.That(comp.DefaultChannel, Is.EqualTo("CentCom")); + Assert.That(comp.Channels.Count, Is.EqualTo(1)); + Assert.That(comp.Channels.First(), Is.EqualTo("CentCom")); + } + + [Test] + public async Task CommsServerKeys() + { + await SpawnTarget("TelecomServerFilled"); + var comp = Comp(); + var panel = Comp(); + + Assert.That(comp.KeyContainer.ContainedEntities.Count, Is.GreaterThan(0)); + Assert.That(comp.Channels.Count, Is.GreaterThan(0)); + Assert.That(panel.Open, Is.False); + + // cannot remove keys without opening panel + await Interact(Pry); + Assert.That(comp.KeyContainer.ContainedEntities.Count, Is.GreaterThan(0)); + Assert.That(comp.Channels.Count, Is.GreaterThan(0)); + Assert.That(panel.Open, Is.False); + + // Open panel + await Interact(Screw); + Assert.That(panel.Open, Is.True); + + // Keys are still here + Assert.That(comp.KeyContainer.ContainedEntities.Count, Is.GreaterThan(0)); + Assert.That(comp.Channels.Count, Is.GreaterThan(0)); + + // Now remove the keys + await Interact(Pry); + Assert.That(comp.KeyContainer.ContainedEntities.Count, Is.EqualTo(0)); + Assert.That(comp.Channels.Count, Is.EqualTo(0)); + + // Reinsert a key + await Interact("EncryptionKeyCentCom"); + Assert.That(comp.KeyContainer.ContainedEntities.Count, Is.EqualTo(1)); + Assert.That(comp.DefaultChannel, Is.EqualTo("CentCom")); + Assert.That(comp.Channels.Count, Is.EqualTo(1)); + Assert.That(comp.Channels.First(), Is.EqualTo("CentCom")); + + // Remove it again + await Interact(Pry); + Assert.That(comp.KeyContainer.ContainedEntities.Count, Is.EqualTo(0)); + Assert.That(comp.Channels.Count, Is.EqualTo(0)); + + // Prying again will start deconstructing the machine. + AssertPrototype("TelecomServerFilled"); + await Interact(Pry); + AssertPrototype("MachineFrame"); + } +} + diff --git a/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs b/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs index 8bca0071ad..f79c292928 100644 --- a/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs +++ b/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs @@ -92,6 +92,7 @@ namespace Content.IntegrationTests.Tests.Interaction.Click Assert.That(interactUsing); }); + testInteractionSystem.ClearHandlers(); await pairTracker.CleanReturnAsync(); } @@ -154,6 +155,7 @@ namespace Content.IntegrationTests.Tests.Interaction.Click Assert.That(interactUsing, Is.False); }); + testInteractionSystem.ClearHandlers(); await pairTracker.CleanReturnAsync(); } @@ -214,6 +216,7 @@ namespace Content.IntegrationTests.Tests.Interaction.Click Assert.That(interactUsing); }); + testInteractionSystem.ClearHandlers(); await pairTracker.CleanReturnAsync(); } @@ -275,6 +278,7 @@ namespace Content.IntegrationTests.Tests.Interaction.Click Assert.That(interactUsing, Is.False); }); + testInteractionSystem.ClearHandlers(); await pairTracker.CleanReturnAsync(); } @@ -352,6 +356,7 @@ namespace Content.IntegrationTests.Tests.Interaction.Click Assert.That(interactUsing, Is.True); }); + testInteractionSystem.ClearHandlers(); await pairTracker.CleanReturnAsync(); } @@ -367,6 +372,12 @@ namespace Content.IntegrationTests.Tests.Interaction.Click SubscribeLocalEvent((e) => InteractUsingEvent?.Invoke(e)); SubscribeLocalEvent((e) => InteractHandEvent?.Invoke(e)); } + + public void ClearHandlers() + { + InteractUsingEvent = null; + InteractHandEvent = null; + } } } diff --git a/Content.IntegrationTests/Tests/Interaction/InteractionTest.Constants.cs b/Content.IntegrationTests/Tests/Interaction/InteractionTest.Constants.cs new file mode 100644 index 0000000000..2c48ae00e1 --- /dev/null +++ b/Content.IntegrationTests/Tests/Interaction/InteractionTest.Constants.cs @@ -0,0 +1,43 @@ + +namespace Content.IntegrationTests.Tests.Interaction; + +// This partial class contains various constant prototype IDs common to interaction tests. +// Should make it easier to mass-change hard coded strings if prototypes get renamed. +public abstract partial class InteractionTest +{ + protected const string PlayerEntity = "AdminObserver"; + + // Tiles + protected const string Floor = "FloorSteel"; + protected const string FloorItem = "FloorTileItemSteel"; + protected const string Plating = "Plating"; + protected const string Lattice = "Lattice"; + + // Tools/steps + protected const string Wrench = "Wrench"; + protected const string Screw = "Screwdriver"; + protected const string Weld = "WelderExperimental"; + protected const string Pry = "Crowbar"; + protected const string Cut = "Wirecutter"; + + // Materials/stacks + protected const string Steel = "Steel"; + protected const string Glass = "Glass"; + protected const string RGlass = "ReinforcedGlass"; + protected const string Plastic = "Plastic"; + protected const string Cable = "Cable"; + protected const string Rod = "MetalRod"; + + // Parts + protected const string Bin1 = "MatterBinStockPart"; + protected const string Bin4 = "BluespaceMatterBinStockPart"; + protected const string Cap1 = "CapacitorStockPart"; + protected const string Cap4 = "QuadraticCapacitorStockPart"; + protected const string Manipulator1 = "MicroManipulatorStockPart"; + protected const string Manipulator4 = "FemtoManipulatorStockPart"; + protected const string Laser1 = "MicroLaserStockPart"; + protected const string Laser2 = "QuadUltraMicroLaserStockPart"; + protected const string Battery1 = "PowerCellSmall"; + protected const string Battery4 = "PowerCellHyper"; +} + diff --git a/Content.IntegrationTests/Tests/Interaction/InteractionTest.EntitySpecifier.cs b/Content.IntegrationTests/Tests/Interaction/InteractionTest.EntitySpecifier.cs new file mode 100644 index 0000000000..3949389919 --- /dev/null +++ b/Content.IntegrationTests/Tests/Interaction/InteractionTest.EntitySpecifier.cs @@ -0,0 +1,126 @@ +#nullable enable +using System.Threading.Tasks; +using Content.Shared.Stacks; +using NUnit.Framework; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; + +namespace Content.IntegrationTests.Tests.Interaction; + +public abstract partial class InteractionTest +{ + /// + /// Utility class for working with prototypes ids that may refer to stacks or entities. + /// + /// + /// Intended to make tests easier by removing ambiguity around "SheetSteel1", "SheetSteel", and "Steel". All three + /// should be treated identically by interaction tests. + /// + protected sealed class EntitySpecifier + { + /// + /// Either the stack or entity prototype for this entity. Stack prototypes take priority. + /// + public string Prototype; + + /// + /// The quantity. If the entity has a stack component, this is the total stack quantity. + /// Otherwise this is the number of entities. + /// + /// + /// If used for spawning and this number is larger than the max stack size, only a single stack will be spawned. + /// + public int Quantity; + + /// + /// If true, a check has been performed to see if the prototype ia an entity prototype with a stack component, + /// in which case the specifier was converted into a stack-specifier + /// + public bool Converted; + + public EntitySpecifier(string prototype, int quantity, bool converted = false) + { + Assert.That(quantity > 0); + Prototype = prototype; + Quantity = quantity; + Converted = converted; + } + + public static implicit operator EntitySpecifier(string prototype) + => new(prototype, 1); + + public static implicit operator EntitySpecifier((string, int) tuple) + => new(tuple.Item1, tuple.Item2); + + /// + /// Convert applicable entity prototypes into stack prototypes. + /// + public void ConvertToStack(IPrototypeManager protoMan, IComponentFactory factory) + { + if (Converted) + return; + + Converted = true; + if (protoMan.HasIndex(Prototype)) + return; + + if (!protoMan.TryIndex(Prototype, out var entProto)) + { + Assert.Fail($"Unknown prototype: {Prototype}"); + return; + } + + if (entProto.TryGetComponent(factory.GetComponentName(typeof(StackComponent)), + out var stackComp)) + { + Prototype = stackComp.StackTypeId; + } + } + } + + protected async Task SpawnEntity(EntitySpecifier spec, EntityCoordinates coords) + { + EntityUid uid = default!; + if (ProtoMan.TryIndex(spec.Prototype, out var stackProto)) + { + await Server.WaitPost(() => + { + uid = SEntMan.SpawnEntity(stackProto.Spawn, coords); + Stack.SetCount(uid, spec.Quantity); + }); + return uid; + } + + if (!ProtoMan.TryIndex(spec.Prototype, out var entProto)) + { + Assert.Fail($"Unkown prototype: {spec.Prototype}"); + return default; + } + + if (entProto.TryGetComponent(Factory.GetComponentName(typeof(StackComponent)), + out var stackComp)) + { + return await SpawnEntity((stackComp.StackTypeId, spec.Quantity), coords); + } + + Assert.That(spec.Quantity, Is.EqualTo(1), "SpawnEntity only supports returning a singular entity"); + await Server.WaitPost(() => uid = SEntMan.SpawnEntity(spec.Prototype, coords));; + return uid; + } + + /// + /// Convert an entity-uid to a matching entity specifier. Usefull when doing entity lookups & checking that the + /// right quantity of entities/materials werre produced. + /// + protected EntitySpecifier ToEntitySpecifier(EntityUid uid) + { + if (SEntMan.TryGetComponent(uid, out StackComponent? stack)) + return new EntitySpecifier(stack.StackTypeId, stack.Count) {Converted = true}; + + var meta = SEntMan.GetComponent(uid); + Assert.NotNull(meta.EntityPrototype); + + return new (meta.EntityPrototype.ID, 1) { Converted = true }; + } +} diff --git a/Content.IntegrationTests/Tests/Interaction/InteractionTest.EntitySpecifierCollection.cs b/Content.IntegrationTests/Tests/Interaction/InteractionTest.EntitySpecifierCollection.cs new file mode 100644 index 0000000000..9b6b0af8dc --- /dev/null +++ b/Content.IntegrationTests/Tests/Interaction/InteractionTest.EntitySpecifierCollection.cs @@ -0,0 +1,160 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Content.Shared.Stacks; +using NUnit.Framework; +using Robust.Shared.GameObjects; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Content.IntegrationTests.Tests.Interaction; + +public abstract partial class InteractionTest +{ + /// + /// Data structure for representing a collection of s. + /// + protected sealed class EntitySpecifierCollection + { + public Dictionary Entities = new(); + + /// + /// If true, a check has been performed to see if the prototypes correspond to entity prototypes with a stack + /// component, in which case the specifier was converted into a stack-specifier + /// + public bool Converted; + + public EntitySpecifierCollection() + { + Converted = true; + } + + public EntitySpecifierCollection(IEnumerable ents) + { + Converted = true; + foreach (var ent in ents) + { + Add(ent); + } + } + + public static implicit operator EntitySpecifierCollection(string prototype) + { + var result = new EntitySpecifierCollection(); + result.Add(prototype, 1); + return result; + } + + public static implicit operator EntitySpecifierCollection((string, int) tuple) + { + var result = new EntitySpecifierCollection(); + result.Add(tuple.Item1, tuple.Item2); + return result; + } + + public void Remove(EntitySpecifier spec) + => Add(new EntitySpecifier(spec.Prototype, -spec.Quantity, spec.Converted)); + + public void Add(EntitySpecifier spec) + => Add(spec.Prototype, spec.Quantity, spec.Converted); + + public void Add(string id, int quantity, bool converted = false) + { + Converted &= converted; + + if (!Entities.TryGetValue(id, out var existing)) + { + if (quantity != 0) + Entities.Add(id, quantity); + return; + } + + var newQuantity = quantity + existing; + if (newQuantity == 0) + Entities.Remove(id); + else + Entities[id] = newQuantity; + } + + public void Add(EntitySpecifierCollection collection) + { + var converted = Converted && collection.Converted; + foreach (var (id, quantity) in collection.Entities) + { + Add(id, quantity); + } + Converted = converted; + } + + public void Remove(EntitySpecifierCollection collection) + { + var converted = Converted && collection.Converted; + foreach (var (id, quantity) in collection.Entities) + { + Add(id, -quantity); + } + Converted = converted; + } + + public EntitySpecifierCollection Clone() + { + return new EntitySpecifierCollection() + { + Entities = Entities.ShallowClone(), + Converted = Converted + }; + } + + /// + /// Convert applicable entity prototypes into stack prototypes. + /// + public void ConvertToStacks(IPrototypeManager protoMan, IComponentFactory factory) + { + if (Converted) + return; + + HashSet toRemove = new(); + List<(string, int)> toAdd = new(); + foreach (var (id, quantity) in Entities) + { + + if (protoMan.HasIndex(id)) + continue; + + if (!protoMan.TryIndex(id, out var entProto)) + { + Assert.Fail($"Unknown prototype: {id}"); + continue; + } + + if (!entProto.TryGetComponent(factory.GetComponentName(typeof(StackComponent)), + out var stackComp)) + { + continue; + } + + toRemove.Add(id); + toAdd.Add((stackComp.StackTypeId, quantity)); + } + + foreach (var id in toRemove) + { + Entities.Remove(id); + } + + foreach (var (id, quantity) in toAdd) + { + Add(id, quantity); + } + + Converted = true; + } + } + + protected EntitySpecifierCollection ToEntityCollection(IEnumerable entities) + { + var collection = new EntitySpecifierCollection(entities.Select(uid => ToEntitySpecifier(uid))); + Assert.That(collection.Converted); + return collection; + } +} diff --git a/Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs b/Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs new file mode 100644 index 0000000000..d07af1dd52 --- /dev/null +++ b/Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs @@ -0,0 +1,614 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Content.Client.Construction; +using Content.Server.Construction.Components; +using Content.Server.Tools.Components; +using Content.Shared.Construction.Prototypes; +using Content.Shared.Item; +using NUnit.Framework; +using Robust.Shared.GameObjects; +using Robust.Shared.Log; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Maths; + +namespace Content.IntegrationTests.Tests.Interaction; + +// This partial class defines various methods that are useful for performing & validating interactions +public abstract partial class InteractionTest +{ + /// + /// Begin constructing an entity. + /// + protected async Task StartConstruction(string prototype, bool shouldSucceed = true) + { + var proto = ProtoMan.Index(prototype); + Assert.That(proto.Type, Is.EqualTo(ConstructionType.Structure)); + + await Client.WaitPost(() => + { + Assert.That(CConSys.TrySpawnGhost(proto, TargetCoords, Direction.South, out Target), + Is.EqualTo(shouldSucceed)); + + if (!shouldSucceed) + return; + var comp = CEntMan.GetComponent(Target!.Value); + ConstructionGhostId = comp.GhostId; + }); + + await RunTicks(1); + } + + /// + /// Craft an item. + /// + protected async Task CraftItem(string prototype, bool shouldSucceed = true) + { + Assert.That(ProtoMan.Index(prototype).Type, Is.EqualTo(ConstructionType.Item)); + + // Please someone purge async construction code + Task task =default!; + await Server.WaitPost(() => task = SConstruction.TryStartItemConstruction(prototype, Player)); + + Task? tickTask = null; + while (!task.IsCompleted) + { + tickTask = PoolManager.RunTicksSync(PairTracker.Pair, 1); + await Task.WhenAny(task, tickTask); + } + + if (tickTask != null) + await tickTask; + +#pragma warning disable RA0004 + Assert.That(task.Result, Is.EqualTo(shouldSucceed)); +#pragma warning restore RA0004 + + await RunTicks(5); + } + + /// + /// Spawn an entity entity and set it as the target. + /// + protected async Task SpawnTarget(string prototype) + { + await Server.WaitPost(() => + { + Target = SEntMan.SpawnEntity(prototype, TargetCoords); + }); + + await RunTicks(5); + AssertPrototype(prototype); + } + + /// + /// Spawn an entity in preparation for deconstruction + /// + protected async Task StartDeconstruction(string prototype) + { + await SpawnTarget(prototype); + Assert.That(SEntMan.TryGetComponent(Target, out ConstructionComponent? comp)); + await Server.WaitPost(() => SConstruction.SetPathfindingTarget(Target!.Value, comp!.DeconstructionNode, comp)); + await RunTicks(5); + } + + /// + /// Drops and deletes the currently held entity. + /// + protected async Task DeleteHeldEntity() + { + if (Hands.ActiveHandEntity is {} held) + { + await Server.WaitPost(() => + { + Assert.That(HandSys.TryDrop(Player, null, false, true, Hands)); + SEntMan.DeleteEntity(held); + Logger.Debug($"Deleting held entity"); + }); + } + + await RunTicks(1); + Assert.That(Hands.ActiveHandEntity == null); + } + + /// + /// Place an entity prototype into the players hand. Deletes any currently held entity. + /// + /// + /// Automatically enables welders. + /// + protected async Task PlaceInHands(string? id, int quantity = 1, bool enableWelder = true) + => await PlaceInHands(id == null ? null : (id, quantity), enableWelder); + + /// + /// Place an entity prototype into the players hand. Deletes any currently held entity. + /// + /// + /// Automatically enables welders. + /// + protected async Task PlaceInHands(EntitySpecifier? entity, bool enableWelder = true) + { + if (Hands.ActiveHand == null) + { + Assert.Fail("No active hand"); + return default; + } + + await DeleteHeldEntity(); + + if (entity == null) + { + await RunTicks(1); + Assert.That(Hands.ActiveHandEntity == null); + return null; + } + + // spawn and pick up the new item + EntityUid item = await SpawnEntity(entity, PlayerCoords); + WelderComponent? welder = null; + + await Server.WaitPost(() => + { + Assert.That(HandSys.TryPickup(Player, item, Hands.ActiveHand, false, false, false, Hands)); + + // turn on welders + if (enableWelder && SEntMan.TryGetComponent(item, out welder) && !welder.Lit) + Assert.That(ToolSys.TryTurnWelderOn(item, Player, welder)); + }); + + await RunTicks(1); + Assert.That(Hands.ActiveHandEntity, Is.EqualTo(item)); + if (enableWelder && welder != null) + Assert.That(welder.Lit); + + return item; + } + + /// + /// Pick up an entity. Defaults to just deleting the previously held entity. + /// + protected async Task Pickup(EntityUid? uid = null, bool deleteHeld = true) + { + uid ??= Target; + + if (Hands.ActiveHand == null) + { + Assert.Fail("No active hand"); + return; + } + + if (deleteHeld) + await DeleteHeldEntity(); + + if (!SEntMan.TryGetComponent(uid, out ItemComponent? item)) + { + Assert.Fail($"Entity {uid} is not an item"); + return; + } + + await Server.WaitPost(() => + { + Assert.That(HandSys.TryPickup(Player, uid!.Value, Hands.ActiveHand, false, false, false, Hands, item)); + }); + + await RunTicks(1); + Assert.That(Hands.ActiveHandEntity, Is.EqualTo(uid)); + } + + /// + /// Drops the currently held entity. + /// + protected async Task Drop() + { + if (Hands.ActiveHandEntity == null) + { + Assert.Fail("Not holding any entity to drop"); + return; + } + + await Server.WaitPost(() => + { + Assert.That(HandSys.TryDrop(Player, handsComp: Hands)); + }); + + await RunTicks(1); + Assert.IsNull(Hands.ActiveHandEntity); + } + + /// + /// Use the currently held entity. + /// + protected async Task UseInHand() + { + if (Hands.ActiveHandEntity is not {} target) + { + Assert.Fail("Not holding any entity"); + return; + } + + await Server.WaitPost(() => + { + InteractSys.UserInteraction(Player, SEntMan.GetComponent(target).Coordinates, target); + }); + } + + /// + /// Place an entity prototype into the players hand and interact with the given entity (or target position) + /// + protected async Task Interact(string? id, int quantity = 1, bool shouldSucceed = true, bool awaitDoAfters = true) + => await Interact(id == null ? null : (id, quantity), shouldSucceed, awaitDoAfters); + + /// + /// Place an entity prototype into the players hand and interact with the given entity (or target position) + /// + protected async Task Interact(EntitySpecifier? entity, bool shouldSucceed = true, bool awaitDoAfters = true) + { + // For every interaction, we will also examine the entity, just in case this breaks something, somehow. + // (e.g., servers attempt to assemble construction examine hints). + if (Target != null) + { + await Client.WaitPost(() => ExamineSys.DoExamine(Target.Value)); + } + + await PlaceInHands(entity); + + if (Target == null || !Target.Value.IsClientSide()) + { + await Server.WaitPost(() => InteractSys.UserInteraction(Player, TargetCoords, Target)); + await RunTicks(1); + } + else + { + // The entity is client-side, so attempt to start construction + var ghost = CEntMan.GetComponent(Target.Value); + await Client.WaitPost(() => CConSys.TryStartConstruction(ghost.GhostId)); + await RunTicks(5); + } + + if (awaitDoAfters) + await AwaitDoAfters(shouldSucceed); + + await CheckTargetChange(shouldSucceed && awaitDoAfters); + } + + /// + /// Wait for any currently active DoAfters to finish. + /// + protected async Task AwaitDoAfters(bool shouldSucceed = true, int maxExpected = 1) + { + if (!ActiveDoAfters.Any()) + return; + + // Generally expect interactions to only start one DoAfter. + Assert.That(ActiveDoAfters.Count(), Is.LessThanOrEqualTo(maxExpected)); + + // wait out the DoAfters. + var doAfters = ActiveDoAfters.ToList(); + while (ActiveDoAfters.Any()) + { + await RunTicks(10); + } + + if (!shouldSucceed) + return; + + foreach (var doAfter in doAfters) + { + Assert.That(!doAfter.Cancelled); + } + } + + /// + /// Cancel any currently active DoAfters. Default arguments are such that it also checks that there is at least one + /// active DoAfter to cancel. + /// + protected async Task CancelDoAfters(int minExpected = 1, int maxExpected = 1) + { + Assert.That(ActiveDoAfters.Count(), Is.GreaterThanOrEqualTo(minExpected)); + Assert.That(ActiveDoAfters.Count(), Is.LessThanOrEqualTo(maxExpected)); + + if (!ActiveDoAfters.Any()) + return; + + // Cancel all the do-afters + var doAfters = ActiveDoAfters.ToList(); + await Server.WaitPost(() => + { + foreach (var doAfter in doAfters) + { + DoAfterSys.Cancel(Player, doAfter.Index, DoAfters); + } + }); + + await RunTicks(1); + + foreach (var doAfter in doAfters) + { + Assert.That(doAfter.Cancelled); + } + + Assert.That(ActiveDoAfters.Count(), Is.EqualTo(0)); + } + + /// + /// Check if the test's target entity has changed. E.g., construction interactions will swap out entities while + /// a structure is being built. + /// + protected async Task CheckTargetChange(bool shouldSucceed) + { + EntityUid newTarget = default; + if (Target == null) + return; + var target = Target.Value; + + await RunTicks(5); + + if (target.IsClientSide()) + { + Assert.That(CEntMan.Deleted(target), Is.EqualTo(shouldSucceed), + $"Construction ghost was {(shouldSucceed ? "not deleted" : "deleted")}."); + + if (shouldSucceed) + { + Assert.That(CTestSystem.Ghosts.TryGetValue(ConstructionGhostId, out newTarget), + $"Failed to get construction entity from ghost Id"); + + await Client.WaitPost(() => Logger.Debug($"Construction ghost {ConstructionGhostId} became entity {newTarget}")); + Target = newTarget; + } + } + + if (STestSystem.EntChanges.TryGetValue(Target.Value, out newTarget)) + { + await Server.WaitPost( + () => Logger.Debug($"Construction entity {Target.Value} changed to {newTarget}")); + + Target = newTarget; + } + + if (Target != target) + await CheckTargetChange(shouldSucceed); + } + + /// + /// Variant of that performs several interactions using different entities. + /// + protected async Task Interact(params EntitySpecifier?[] specifiers) + { + foreach (var spec in specifiers) + { + await Interact(spec); + } + } + + #region Asserts + + protected void AssertPrototype(string? prototype) + { + var meta = Comp(); + Assert.That(meta.EntityPrototype?.ID, Is.EqualTo(prototype)); + } + + protected void AssertAnchored(bool anchored = true) + { + var sXform = SEntMan.GetComponent(Target!.Value); + var cXform = CEntMan.GetComponent(Target.Value); + Assert.That(sXform.Anchored, Is.EqualTo(anchored)); + Assert.That(cXform.Anchored, Is.EqualTo(anchored)); + } + + protected void AssertDeleted(bool deleted = true) + { + Assert.That(SEntMan.Deleted(Target), Is.EqualTo(deleted)); + Assert.That(CEntMan.Deleted(Target), Is.EqualTo(deleted)); + } + + /// + /// Assert whether or not the target has the given component. + /// + protected void AssertComp(bool hasComp = true) + { + Assert.That(SEntMan.HasComponent(Target), Is.EqualTo(hasComp)); + } + + /// + /// Check that the tile at the target position matches some prototype. + /// + protected async Task AssertTile(string? proto, EntityCoordinates? coords = null) + { + var targetTile = proto == null + ? Tile.Empty + : new Tile(TileMan[proto].TileId); + + Tile tile = Tile.Empty; + var pos = (coords ?? TargetCoords).ToMap(SEntMan, Transform); + await Server.WaitPost(() => + { + if (MapMan.TryFindGridAt(pos, out var grid)) + tile = grid.GetTileRef(coords ?? TargetCoords).Tile; + }); + + Assert.That(tile.TypeId, Is.EqualTo(targetTile.TypeId)); + } + + #endregion + + #region Entity lookups + + /// + /// Returns entities in an area around the target. Ignores the map, grid, player, target, and contained entities. + /// + protected async Task> DoEntityLookup(LookupFlags flags = LookupFlags.Uncontained) + { + var lookup = SEntMan.System(); + + HashSet entities = default!; + await Server.WaitPost(() => + { + // Get all entities left behind by deconstruction + entities = lookup.GetEntitiesIntersecting(MapId, Box2.CentredAroundZero((10, 10)), flags); + + var xformQuery = SEntMan.GetEntityQuery(); + + HashSet toRemove = new(); + foreach (var ent in entities) + { + var transform = xformQuery.GetComponent(ent); + + if (ent == transform.MapUid + || ent == transform.GridUid + || ent == Player + || ent == Target) + { + toRemove.Add(ent); + } + } + + entities.ExceptWith(toRemove); + }); + + return entities; + } + + /// + /// Performs an entity lookup and asserts that only the listed entities exist and that they are all present. + /// Ignores the grid, map, player, target and contained entities. + /// + protected async Task AssertEntityLookup(params EntitySpecifier[] entities) + { + var collection = new EntitySpecifierCollection(entities); + await AssertEntityLookup(collection); + } + + /// + /// Performs an entity lookup and asserts that only the listed entities exist and that they are all present. + /// Ignores the grid, map, player, target and contained entities. + /// + protected async Task AssertEntityLookup( + EntitySpecifierCollection collection, + bool failOnMissing = true, + bool failOnExcess = true, + LookupFlags flags = LookupFlags.Uncontained) + { + var expected = collection.Clone(); + var entities = await DoEntityLookup(flags); + var found = ToEntityCollection(entities); + expected.Remove(found); + expected.ConvertToStacks(ProtoMan, Factory); + + if (expected.Entities.Count == 0) + return; + + Assert.Multiple(() => + { + foreach (var (proto, quantity) in expected.Entities) + { + if (quantity < 0 && failOnExcess) + Assert.Fail($"Unexpected entity/stack: {proto}, quantity: {-quantity}"); + + if (quantity > 0 && failOnMissing) + Assert.Fail($"Missing entity/stack: {proto}, quantity: {quantity}"); + + if (quantity == 0) + throw new Exception("Error in entity collection math."); + } + }); + } + + /// + /// Performs an entity lookup and attempts to find an entity matching the given entity specifier. + /// + /// + /// This is used to check that an item-crafting attempt was successful. Ideally crafting items would just return the + /// entity or raise an event or something. + /// + protected async Task FindEntity( + EntitySpecifier spec, + LookupFlags flags = LookupFlags.Uncontained | LookupFlags.Contained, + bool shouldSucceed = true) + { + spec.ConvertToStack(ProtoMan, Factory); + + var entities = await DoEntityLookup(flags); + foreach (var uid in entities) + { + var found = ToEntitySpecifier(uid); + if (spec.Prototype != found.Prototype) + continue; + + if (found.Quantity >= spec.Quantity) + return uid; + + // TODO combine stacks? + } + + if (shouldSucceed) + Assert.Fail($"Could not find stack/entity with prototype {spec.Prototype}"); + + return default; + } + + #endregion + + + /// + /// List of currently active DoAfters on the player. + /// + protected IEnumerable ActiveDoAfters + => DoAfters.DoAfters.Values.Where(x => !x.Cancelled && !x.Completed); + + /// + /// Convenience method to get components on the target. Returns SERVER-SIDE components. + /// + protected T Comp() => SEntMan.GetComponent(Target!.Value); + + /// + /// Set the tile at the target position to some prototype. + /// + protected async Task SetTile(string? proto, EntityCoordinates? coords = null, MapGridComponent? grid = null) + { + var tile = proto == null + ? Tile.Empty + : new Tile(TileMan[proto].TileId); + + var pos = (coords ?? TargetCoords).ToMap(SEntMan, Transform); + + await Server.WaitPost(() => + { + if (grid != null || MapMan.TryFindGridAt(pos, out grid)) + { + grid.SetTile(coords ?? TargetCoords, tile); + return; + } + + if (proto == null) + return; + + grid = MapMan.CreateGrid(MapData.MapId); + var gridXform = SEntMan.GetComponent(grid.Owner); + Transform.SetWorldPosition(gridXform, pos.Position); + grid.SetTile(coords ?? TargetCoords, tile); + + if (!MapMan.TryFindGridAt(pos, out grid)) + Assert.Fail("Failed to create grid?"); + }); + await AssertTile(proto, coords); + } + + protected async Task Delete(EntityUid uid) + { + await Server.WaitPost(() => SEntMan.DeleteEntity(uid)); + await RunTicks(5); + } + + protected async Task RunTicks(int ticks) + { + await PoolManager.RunTicksSync(PairTracker.Pair, ticks); + } + + protected async Task RunSeconds(float seconds) + => await RunTicks((int) Math.Ceiling(seconds / TickPeriod)); +} diff --git a/Content.IntegrationTests/Tests/Interaction/InteractionTest.cs b/Content.IntegrationTests/Tests/Interaction/InteractionTest.cs new file mode 100644 index 0000000000..582e68aac7 --- /dev/null +++ b/Content.IntegrationTests/Tests/Interaction/InteractionTest.cs @@ -0,0 +1,198 @@ +#nullable enable +using System.Linq; +using System.Threading.Tasks; +using Content.Client.Construction; +using Content.Client.Examine; +using Content.Server.Body.Systems; +using Content.Server.Mind.Components; +using Content.Server.Stack; +using Content.Server.Tools; +using Content.Shared.Body.Part; +using Content.Shared.DoAfter; +using Content.Shared.Hands.Components; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.Interaction; +using NUnit.Framework; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; +using Robust.Shared.Timing; +using Robust.UnitTesting; + +namespace Content.IntegrationTests.Tests.Interaction; + +/// +/// This is a base class designed to make it easier to test various interactions like construction & DoAfters. +/// +/// For construction tests, the interactions are intentionally hard-coded and not pulled automatically from the +/// construction graph, even though this may be a pain to maintain. This is because otherwise these tests could not +/// detect errors in the graph pathfinding (e.g., infinite loops, missing steps, etc). +/// +[TestFixture] +[FixtureLifeCycle(LifeCycle.InstancePerTestCase)] +public abstract partial class InteractionTest +{ + protected PairTracker PairTracker = default!; + protected TestMapData MapData = default!; + + protected RobustIntegrationTest.ServerIntegrationInstance Server => PairTracker.Pair.Server; + protected RobustIntegrationTest.ClientIntegrationInstance Client => PairTracker.Pair.Client; + + protected MapId MapId => MapData.MapId; + + /// + /// Target coordinates. Note that this does not necessarily correspond to the position of the + /// entity. + /// + protected EntityCoordinates TargetCoords; + + /// + /// Initial player coordinates. Note that this does not necessarily correspond to the position of the + /// entity. + /// + protected EntityCoordinates PlayerCoords; + + /// + /// The player entity that performs all these interactions. Defaults to an admin-observer with 1 hand. + /// + protected EntityUid Player; + + /// + /// The current target entity. This is the default entity for various helper functions. + /// + /// + /// Note that this target may be automatically modified by various interactions, in particular construction + /// interactions often swap out entities, and there are helper methods that attempt to automatically upddate + /// the target entity. See + /// + protected EntityUid? Target; + + /// + /// When attempting to start construction, this is the client-side ID of the construction ghost. + /// + protected int ConstructionGhostId; + + // SERVER dependencies + protected IEntityManager SEntMan = default!; + protected ITileDefinitionManager TileMan = default!; + protected IMapManager MapMan = default!; + protected IPrototypeManager ProtoMan = default!; + protected IGameTiming Timing = default!; + protected IComponentFactory Factory = default!; + protected SharedHandsSystem HandSys = default!; + protected StackSystem Stack = default!; + protected SharedInteractionSystem InteractSys = default!; + protected Content.Server.Construction.ConstructionSystem SConstruction = default!; + protected SharedDoAfterSystem DoAfterSys = default!; + protected ToolSystem ToolSys = default!; + protected InteractionTestSystem STestSystem = default!; + protected SharedTransformSystem Transform = default!; + + // CLIENT dependencies + protected IEntityManager CEntMan = default!; + protected ConstructionSystem CConSys = default!; + protected ExamineSystem ExamineSys = default!; + protected InteractionTestSystem CTestSystem = default!; + + // player components + protected HandsComponent Hands = default!; + protected DoAfterComponent DoAfters = default!; + + public float TickPeriod => (float)Timing.TickPeriod.TotalSeconds; + + [SetUp] + public async Task Setup() + { + PairTracker = await PoolManager.GetServerClient(new PoolSettings()); + + // server dependencies + SEntMan = Server.ResolveDependency(); + TileMan = Server.ResolveDependency(); + MapMan = Server.ResolveDependency(); + ProtoMan = Server.ResolveDependency(); + Factory = Server.ResolveDependency(); + Timing = Server.ResolveDependency(); + HandSys = SEntMan.System(); + InteractSys = SEntMan.System(); + ToolSys = SEntMan.System(); + DoAfterSys = SEntMan.System(); + Transform = SEntMan.System(); + SConstruction = SEntMan.System(); + STestSystem = SEntMan.System(); + Stack = SEntMan.System(); + + // client dependencies + CEntMan = Client.ResolveDependency(); + CTestSystem = CEntMan.System(); + CConSys = CEntMan.System(); + ExamineSys = CEntMan.System(); + + // Setup map. + MapData = await PoolManager.CreateTestMap(PairTracker); + PlayerCoords = MapData.GridCoords.Offset((0.5f, 0.5f)).WithEntityId(MapData.MapUid, Transform, SEntMan); + TargetCoords = MapData.GridCoords.Offset((1.5f, 0.5f)).WithEntityId(MapData.MapUid, Transform, SEntMan); + await SetTile(Plating, grid: MapData.MapGrid); + + // Get player data + var sPlayerMan = Server.ResolveDependency(); + var cPlayerMan = Client.ResolveDependency(); + if (cPlayerMan.LocalPlayer?.Session == null) + Assert.Fail("No player"); + var cSession = cPlayerMan.LocalPlayer!.Session!; + var sSession = sPlayerMan.GetSessionByUserId(cSession.UserId); + + // Spawn player entity & attach + EntityUid? old = default; + await Server.WaitPost(() => + { + old = cPlayerMan.LocalPlayer.ControlledEntity; + Player = SEntMan.SpawnEntity(PlayerEntity, PlayerCoords); + sSession.AttachToEntity(Player); + Hands = SEntMan.GetComponent(Player); + DoAfters = SEntMan.GetComponent(Player); + }); + + // Check player got attached. + await RunTicks(5); + Assert.That(cPlayerMan.LocalPlayer.ControlledEntity, Is.EqualTo(Player)); + + // Delete old player entity. + await Server.WaitPost(() => + { + if (old == null) + return; + + // Fuck you mind system I want an hour of my life back + if (SEntMan.TryGetComponent(old, out MindComponent? mind)) + mind.GhostOnShutdown = false; + + SEntMan.DeleteEntity(old.Value); + }); + + // Ensure that the player only has one hand, so that they do not accidentally pick up deconstruction protucts + await Server.WaitPost(() => + { + var bodySystem = SEntMan.System(); + var hands = bodySystem.GetBodyChildrenOfType(Player, BodyPartType.Hand).ToArray(); + + for (var i = 1; i < hands.Length; i++) + { + bodySystem.DropPart(hands[i].Id); + SEntMan.DeleteEntity(hands[i].Id); + } + }); + + // Final player asserts/checks. + await PoolManager.ReallyBeIdle(PairTracker.Pair, 5); + Assert.That(cPlayerMan.LocalPlayer.ControlledEntity, Is.EqualTo(Player)); + Assert.That(sPlayerMan.GetSessionByUserId(cSession.UserId).AttachedEntity, Is.EqualTo(Player)); + } + + [TearDown] + public async Task Cleanup() + { + await Server.WaitPost(() => MapMan.DeleteMap(MapId)); + await PairTracker.CleanReturnAsync(); + } +} + diff --git a/Content.IntegrationTests/Tests/Interaction/InteractionTestSystem.cs b/Content.IntegrationTests/Tests/Interaction/InteractionTestSystem.cs new file mode 100644 index 0000000000..810a0c24cc --- /dev/null +++ b/Content.IntegrationTests/Tests/Interaction/InteractionTestSystem.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using Content.Server.Construction; +using Content.Shared.Construction; +using Robust.Shared.GameObjects; + +namespace Content.IntegrationTests.Tests.Interaction; + +/// +/// System for listening to events that get raised when construction entities change. +/// In particular, when construction ghosts become real entities, and when existing entities get replaced with +/// new ones. +/// +public sealed class InteractionTestSystem : EntitySystem +{ + public Dictionary Ghosts = new(); + public Dictionary EntChanges = new(); + + public override void Initialize() + { + SubscribeNetworkEvent(OnAck); + SubscribeLocalEvent(OnEntChange); + } + + private void OnEntChange(ConstructionChangeEntityEvent ev) + { + EntChanges[ev.Old] = ev.New; + } + + private void OnAck(AckStructureConstructionMessage ev) + { + if (ev.Uid != null) + Ghosts[ev.GhostId] = ev.Uid.Value; + } +} diff --git a/Content.IntegrationTests/Tests/Payload/ModularGrenadeTests.cs b/Content.IntegrationTests/Tests/Payload/ModularGrenadeTests.cs new file mode 100644 index 0000000000..a404e63629 --- /dev/null +++ b/Content.IntegrationTests/Tests/Payload/ModularGrenadeTests.cs @@ -0,0 +1,76 @@ +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using Content.Server.Explosion.Components; +using NUnit.Framework; +using Robust.Shared.Containers; +using Robust.Shared.GameObjects; + +namespace Content.IntegrationTests.Tests.Payload; + +public sealed class ModularGrenadeTests : InteractionTest +{ + public const string Trigger = "TimerTrigger"; + public const string Payload = "ExplosivePayload"; + + /// + /// Test that a modular grenade can be fully crafted and detonated. + /// + [Test] + public async Task AssembleAndDetonateGrenade() + { + await PlaceInHands(Steel, 5); + await CraftItem("ModularGrenadeRecipe"); + Target = await FindEntity("ModularGrenade"); + + await Drop(); + await Interact(Cable); + + // Insert & remove trigger + AssertComp(false); + await Interact(Trigger); + AssertComp(); + await FindEntity(Trigger, LookupFlags.Uncontained, shouldSucceed: false); + await Interact(Pry); + AssertComp(false); + + // Trigger was dropped to floor, not deleted. + await FindEntity(Trigger, LookupFlags.Uncontained); + + // Re-insert + await Interact(Trigger); + AssertComp(); + + // Insert & remove payload. + await Interact(Payload); + await FindEntity(Payload, LookupFlags.Uncontained, shouldSucceed: false); + await Interact(Pry); + var ent = await FindEntity(Payload, LookupFlags.Uncontained); + await Delete(ent); + + // successfully insert a second time + await Interact(Payload); + ent = await FindEntity(Payload); + var sys = SEntMan.System(); + Assert.That(sys.IsEntityInContainer(ent)); + + // Activate trigger. + await Pickup(); + AssertComp(false); + await UseInHand(); + + // So uhhh grenades in hands don't destroy themselves when exploding. Maybe that will be fixed eventually. + await Drop(); + + // Wait until grenade explodes + var timer = Comp(); + while (timer.TimeRemaining >= 0) + { + await RunTicks(10); + } + + // Grenade has exploded. + await RunTicks(5); + AssertDeleted(); + } +} + diff --git a/Content.IntegrationTests/Tests/Tiles/TileConstructionTests.cs b/Content.IntegrationTests/Tests/Tiles/TileConstructionTests.cs new file mode 100644 index 0000000000..3854a6a053 --- /dev/null +++ b/Content.IntegrationTests/Tests/Tiles/TileConstructionTests.cs @@ -0,0 +1,121 @@ +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using NUnit.Framework; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; + +namespace Content.IntegrationTests.Tests.Tiles; + +public sealed class TileConstructionTests : InteractionTest +{ + private void AssertGridCount(int value) + { + var count = 0; + var query = SEntMan.AllEntityQueryEnumerator(); + while (query.MoveNext(out _, out var xform)) + { + if (xform.MapUid == MapData.MapUid) + count++; + } + + Assert.That(count, Is.EqualTo(value)); + } + + /// + /// Test placing and cutting a single lattice. + /// + [Test] + public async Task PlaceThenCutLattice() + { + await AssertTile(Plating); + await AssertTile(Plating, PlayerCoords); + AssertGridCount(1); + await SetTile(null); + await Interact(Rod); + await AssertTile(Lattice); + Assert.IsNull(Hands.ActiveHandEntity); + await Interact(Cut); + await AssertTile(null); + await AssertEntityLookup((Rod, 1)); + AssertGridCount(1); + } + + /// + /// Test placing and cutting a single lattice in space (not adjacent to any existing grid. + /// + [Test] + public async Task CutThenPlaceLatticeNewGrid() + { + await AssertTile(Plating); + await AssertTile(Plating, PlayerCoords); + AssertGridCount(1); + + // Remove grid + await SetTile(null); + await SetTile(null, PlayerCoords); + Assert.That(MapData.MapGrid.Deleted); + AssertGridCount(0); + + // Place Lattice + var oldPos = TargetCoords; + TargetCoords = new EntityCoordinates(MapData.MapUid, 1, 0); + await Interact(Rod); + TargetCoords = oldPos; + await AssertTile(Lattice); + AssertGridCount(1); + + // Cut lattice + Assert.IsNull(Hands.ActiveHandEntity); + await Interact(Cut); + await AssertTile(null); + AssertGridCount(0); + + await AssertEntityLookup((Rod, 1)); + } + + /// + /// Test space -> floor -> plating + /// + [Test] + public async Task FloorConstructDeconstruct() + { + await AssertTile(Plating); + await AssertTile(Plating, PlayerCoords); + AssertGridCount(1); + + // Remove grid + await SetTile(null); + await SetTile(null, PlayerCoords); + Assert.That(MapData.MapGrid.Deleted); + AssertGridCount(0); + + // Space -> Lattice + var oldPos = TargetCoords; + TargetCoords = new EntityCoordinates(MapData.MapUid, 1, 0); + await Interact(Rod); + TargetCoords = oldPos; + await AssertTile(Lattice); + AssertGridCount(1); + + // Lattice -> Plating + await Interact(Steel); + Assert.IsNull(Hands.ActiveHandEntity); + await AssertTile(Plating); + AssertGridCount(1); + + // Plating -> Tile + await Interact(FloorItem); + Assert.IsNull(Hands.ActiveHandEntity); + await AssertTile(Floor); + AssertGridCount(1); + + // Tile -> Plating + await Interact(Pry); + await AssertTile(Plating); + AssertGridCount(1); + + await AssertEntityLookup((FloorItem, 1)); + } +} + diff --git a/Content.IntegrationTests/Tests/Weldable/WeldableTests.cs b/Content.IntegrationTests/Tests/Weldable/WeldableTests.cs new file mode 100644 index 0000000000..337747deca --- /dev/null +++ b/Content.IntegrationTests/Tests/Weldable/WeldableTests.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using Content.IntegrationTests.Tests.Interaction; +using Content.Server.Tools.Components; +using NUnit.Framework; + +namespace Content.IntegrationTests.Tests.Weldable; + +/// +/// Simple test to check that using a welder on a locker will weld it shut. +/// +public sealed class WeldableTests : InteractionTest +{ + public const string Locker = "LockerFreezer"; + + [Test] + public async Task WeldLocker() + { + await SpawnTarget(Locker); + var comp = Comp(); + + Assert.That(comp.Weldable, Is.True); + Assert.That(comp.IsWelded, Is.False); + + await Interact(Weld); + Assert.That(comp.IsWelded, Is.True); + AssertPrototype(Locker); // Prototype did not change. + } +} diff --git a/Content.Server/Construction/Completions/BuildComputer.cs b/Content.Server/Construction/Completions/BuildComputer.cs index 93beb9c04c..1c9ac27e70 100644 --- a/Content.Server/Construction/Completions/BuildComputer.cs +++ b/Content.Server/Construction/Completions/BuildComputer.cs @@ -13,6 +13,7 @@ namespace Content.Server.Construction.Completions { [DataField("container")] public string Container { get; private set; } = string.Empty; + // TODO use or generalize ConstructionSystem.ChangeEntity(); public void PerformAction(EntityUid uid, EntityUid? userUid, IEntityManager entityManager) { if (!entityManager.TryGetComponent(uid, out ContainerManagerComponent? containerManager)) @@ -62,8 +63,12 @@ namespace Content.Server.Construction.Completions // We only add this container. If some construction needs to take other containers into account, fix this. entityManager.EntitySysManager.GetEntitySystem().AddContainer(computer, Container); + var entChangeEv = new ConstructionChangeEntityEvent(computer, uid); + entityManager.EventBus.RaiseLocalEvent(uid, entChangeEv); + entityManager.EventBus.RaiseLocalEvent(computer, entChangeEv, broadcast: true); + // Delete the original entity. - entityManager.DeleteEntity(uid); + entityManager.QueueDeleteEntity(uid); } } } diff --git a/Content.Server/Construction/Completions/BuildMachine.cs b/Content.Server/Construction/Completions/BuildMachine.cs index 5de944648d..c37c057321 100644 --- a/Content.Server/Construction/Completions/BuildMachine.cs +++ b/Content.Server/Construction/Completions/BuildMachine.cs @@ -12,6 +12,7 @@ namespace Content.Server.Construction.Completions [DataDefinition] public sealed class BuildMachine : IGraphAction { + // TODO use or generalize ConstructionSystem.ChangeEntity(); public void PerformAction(EntityUid uid, EntityUid? userUid, IEntityManager entityManager) { if (!entityManager.TryGetComponent(uid, out ContainerManagerComponent? containerManager)) @@ -102,7 +103,10 @@ namespace Content.Server.Construction.Completions constructionSystem.RefreshParts(machineComp); } - entityManager.DeleteEntity(uid); + var entChangeEv = new ConstructionChangeEntityEvent(machine, uid); + entityManager.EventBus.RaiseLocalEvent(uid, entChangeEv); + entityManager.EventBus.RaiseLocalEvent(machine, entChangeEv, broadcast: true); + entityManager.QueueDeleteEntity(uid); } } } diff --git a/Content.Server/Construction/Completions/BuildMech.cs b/Content.Server/Construction/Completions/BuildMech.cs index 0a7c4a68f6..b801daac26 100644 --- a/Content.Server/Construction/Completions/BuildMech.cs +++ b/Content.Server/Construction/Completions/BuildMech.cs @@ -23,6 +23,7 @@ public sealed class BuildMech : IGraphAction [DataField("container")] public string Container = "battery-container"; + // TODO use or generalize ConstructionSystem.ChangeEntity(); public void PerformAction(EntityUid uid, EntityUid? userUid, IEntityManager entityManager) { if (!entityManager.TryGetComponent(uid, out ContainerManagerComponent? containerManager)) @@ -64,8 +65,10 @@ public sealed class BuildMech : IGraphAction mechComp.BatterySlot.Insert(cell); } - // Delete the original entity. - entityManager.DeleteEntity(uid); + var entChangeEv = new ConstructionChangeEntityEvent(mech, uid); + entityManager.EventBus.RaiseLocalEvent(uid, entChangeEv); + entityManager.EventBus.RaiseLocalEvent(mech, entChangeEv, broadcast: true); + entityManager.QueueDeleteEntity(uid); } } diff --git a/Content.Server/Construction/ConstructionSystem.Graph.cs b/Content.Server/Construction/ConstructionSystem.Graph.cs index 4b5c530ae0..b2be31d836 100644 --- a/Content.Server/Construction/ConstructionSystem.Graph.cs +++ b/Content.Server/Construction/ConstructionSystem.Graph.cs @@ -352,6 +352,10 @@ namespace Content.Server.Construction } } + var entChangeEv = new ConstructionChangeEntityEvent(newUid, uid); + RaiseLocalEvent(uid, entChangeEv); + RaiseLocalEvent(newUid, entChangeEv, broadcast: true); + QueueDel(uid); return newUid; @@ -384,4 +388,20 @@ namespace Content.Server.Construction return ChangeNode(uid, userUid, nodeId, performActions, construction); } } + + /// + /// This event gets raised when an entity changes prototype / uid during construction. The event is raised + /// directed both at the old and new entity. + /// + public sealed class ConstructionChangeEntityEvent : EntityEventArgs + { + public readonly EntityUid New; + public readonly EntityUid Old; + + public ConstructionChangeEntityEvent(EntityUid newUid, EntityUid oldUid) + { + New = newUid; + Old = oldUid; + } + } } diff --git a/Content.Server/Construction/ConstructionSystem.Initial.cs b/Content.Server/Construction/ConstructionSystem.Initial.cs index c5481edfcf..7bb5422945 100644 --- a/Content.Server/Construction/ConstructionSystem.Initial.cs +++ b/Content.Server/Construction/ConstructionSystem.Initial.cs @@ -169,6 +169,8 @@ namespace Content.Server.Construction if (!materialStep.EntityValid(entity, out var stack)) continue; + // TODO allow taking from several stacks. + // Also update crafting steps to check if it works. var splitStack = _stackSystem.Split(entity, materialStep.Amount, user.ToCoordinates(0, 0), stack); if (splitStack == null) @@ -288,43 +290,49 @@ namespace Content.Server.Construction return newEntity; } - // LEGACY CODE. See warning at the top of the file! private async void HandleStartItemConstruction(TryStartItemConstructionMessage ev, EntitySessionEventArgs args) { - if (!_prototypeManager.TryIndex(ev.PrototypeName, out ConstructionPrototype? constructionPrototype)) + if (args.SenderSession.AttachedEntity is {Valid: true} user) + await TryStartItemConstruction(ev.PrototypeName, user); + } + + // LEGACY CODE. See warning at the top of the file! + public async Task TryStartItemConstruction(string prototype, EntityUid user) + { + if (!_prototypeManager.TryIndex(prototype, out ConstructionPrototype? constructionPrototype)) { - _sawmill.Error($"Tried to start construction of invalid recipe '{ev.PrototypeName}'!"); - return; + _sawmill.Error($"Tried to start construction of invalid recipe '{prototype}'!"); + return false; } if (!_prototypeManager.TryIndex(constructionPrototype.Graph, out ConstructionGraphPrototype? constructionGraph)) { _sawmill.Error( - $"Invalid construction graph '{constructionPrototype.Graph}' in recipe '{ev.PrototypeName}'!"); - return; + $"Invalid construction graph '{constructionPrototype.Graph}' in recipe '{prototype}'!"); + return false; } var startNode = constructionGraph.Nodes[constructionPrototype.StartNode]; var targetNode = constructionGraph.Nodes[constructionPrototype.TargetNode]; var pathFind = constructionGraph.Path(startNode.Name, targetNode.Name); - if (args.SenderSession.AttachedEntity is not {Valid: true} user || !_actionBlocker.CanInteract(user, null)) - return; + if (!_actionBlocker.CanInteract(user, null)) + return false; if (!HasComp(user)) - return; + return false; foreach (var condition in constructionPrototype.Conditions) { if (!condition.Condition(user, user.ToCoordinates(0, 0), Direction.South)) - return; + return false; } if (pathFind == null) { throw new InvalidDataException( - $"Can't find path from starting node to target node in construction! Recipe: {ev.PrototypeName}"); + $"Can't find path from starting node to target node in construction! Recipe: {prototype}"); } var edge = startNode.GetEdge(pathFind[0].Name); @@ -332,7 +340,7 @@ namespace Content.Server.Construction if (edge == null) { throw new InvalidDataException( - $"Can't find edge from starting node to the next node in pathfinding! Recipe: {ev.PrototypeName}"); + $"Can't find edge from starting node to the next node in pathfinding! Recipe: {prototype}"); } // No support for conditions here! @@ -347,11 +355,12 @@ namespace Content.Server.Construction } if (await Construct(user, "item_construction", constructionGraph, edge, targetNode) is not { Valid: true } item) - return; + return false; // Just in case this is a stack, attempt to merge it. If it isn't a stack, this will just normally pick up // or drop the item as normal. _stackSystem.TryMergeToHands(item, user); + return true; } // LEGACY CODE. See warning at the top of the file! @@ -490,7 +499,7 @@ namespace Content.Server.Construction xform.LocalRotation = constructionPrototype.CanRotate ? ev.Angle : Angle.Zero; xform.Anchored = wasAnchored; - RaiseNetworkEvent(new AckStructureConstructionMessage(ev.Ack)); + RaiseNetworkEvent(new AckStructureConstructionMessage(ev.Ack, structure)); _adminLogger.Add(LogType.Construction, LogImpact.Low, $"{ToPrettyString(user):player} has turned a {ev.PrototypeName} construction ghost into {ToPrettyString(structure)} at {Transform(structure).Coordinates}"); Cleanup(); } diff --git a/Content.Server/Construction/ConstructionSystem.Machine.cs b/Content.Server/Construction/ConstructionSystem.Machine.cs index ad708a4f39..dcaeab79a2 100644 --- a/Content.Server/Construction/ConstructionSystem.Machine.cs +++ b/Content.Server/Construction/ConstructionSystem.Machine.cs @@ -59,6 +59,14 @@ public sealed partial class ConstructionSystem args.Verbs.Add(verb); } + public List GetAllParts(EntityUid uid, MachineComponent? component = null) + { + if (!Resolve(uid, ref component)) + return new List(); + + return GetAllParts(component); + } + public List GetAllParts(MachineComponent component) { var parts = new List(); diff --git a/Content.Server/Mind/Components/MindComponent.cs b/Content.Server/Mind/Components/MindComponent.cs index 325e92123c..cdc2f67d0f 100644 --- a/Content.Server/Mind/Components/MindComponent.cs +++ b/Content.Server/Mind/Components/MindComponent.cs @@ -31,6 +31,7 @@ namespace Content.Server.Mind.Components /// [ViewVariables(VVAccess.ReadWrite)] [DataField("ghostOnShutdown")] + [Access(typeof(MindSystem), Other = AccessPermissions.ReadWriteExecute)] // FIXME Friends public bool GhostOnShutdown { get; set; } = true; } diff --git a/Content.Shared/Construction/Events.cs b/Content.Shared/Construction/Events.cs index 1a4bace014..f36686a53c 100644 --- a/Content.Shared/Construction/Events.cs +++ b/Content.Shared/Construction/Events.cs @@ -64,9 +64,15 @@ public sealed class AckStructureConstructionMessage : EntityEventArgs { public readonly int GhostId; - public AckStructureConstructionMessage(int ghostId) + /// + /// The entity that is now being constructed, if any. + /// + public readonly EntityUid? Uid; + + public AckStructureConstructionMessage(int ghostId, EntityUid? uid = null) { GhostId = ghostId; + Uid = uid; } } diff --git a/Content.Shared/DoAfter/DoAfterComponent.cs b/Content.Shared/DoAfter/DoAfterComponent.cs index 748fd40dfa..cebd171176 100644 --- a/Content.Shared/DoAfter/DoAfterComponent.cs +++ b/Content.Shared/DoAfter/DoAfterComponent.cs @@ -27,7 +27,20 @@ public sealed class DoAfterComponentState : ComponentState public DoAfterComponentState(DoAfterComponent component) { NextId = component.NextId; + + // Cursed test bugs - See CraftingTests.CancelCraft + // The following is wrapped in an if DEBUG. This is tests don't (de)serialize net messages and just copy objects + // by reference. This means that the server will directly modify cached server states on the client's end. + // Crude fix at the moment is to used modified state handling while in debug mode Otherwise, this test cannot work. +#if !DEBUG DoAfters = component.DoAfters; +#else + DoAfters = new(); + foreach (var (id, doafter) in component.DoAfters) + { + DoAfters.Add(id, new DoAfter(doafter)); + } +#endif } } diff --git a/Content.Shared/DoAfter/SharedDoAfterSystem.cs b/Content.Shared/DoAfter/SharedDoAfterSystem.cs index fab1ece9e7..49bd76735d 100644 --- a/Content.Shared/DoAfter/SharedDoAfterSystem.cs +++ b/Content.Shared/DoAfter/SharedDoAfterSystem.cs @@ -123,7 +123,6 @@ public abstract partial class SharedDoAfterSystem : EntitySystem EnsureComp(uid); } - #region Creation /// /// Tasks that are delayed until the specified time has passed diff --git a/Content.Shared/Radio/EntitySystems/EncryptionKeySystem.cs b/Content.Shared/Radio/EntitySystems/EncryptionKeySystem.cs index 8fca649f7a..1246e33e34 100644 --- a/Content.Shared/Radio/EntitySystems/EncryptionKeySystem.cs +++ b/Content.Shared/Radio/EntitySystems/EncryptionKeySystem.cs @@ -62,6 +62,7 @@ public sealed class EncryptionKeySystem : EntitySystem // TODO add predicted pop-up overrides. if (_net.IsServer) _popup.PopupEntity(Loc.GetString("encryption-keys-all-extracted"), uid, args.User); + _audio.PlayPredicted(component.KeyExtractionSound, uid, args.User); } diff --git a/Content.Shared/Stacks/StackComponent.cs b/Content.Shared/Stacks/StackComponent.cs index 719229c8a5..96df85efc0 100644 --- a/Content.Shared/Stacks/StackComponent.cs +++ b/Content.Shared/Stacks/StackComponent.cs @@ -9,7 +9,7 @@ namespace Content.Shared.Stacks { [ViewVariables(VVAccess.ReadWrite)] [DataField("stackType", required: true, customTypeSerializer: typeof(PrototypeIdSerializer))] - public string? StackTypeId { get; private set; } + public string StackTypeId { get; private set; } = default!; /// /// Current stack count.