From d72a59f6b75d35eec95f2fff084937c8767afc05 Mon Sep 17 00:00:00 2001 From: slarticodefast <161409025+slarticodefast@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:17:20 +0100 Subject: [PATCH] Predict holoprojectors and add an integration test for them (#41569) * cleanup * fix fixtures * prediction * fix test * review * fix svalinn visuals * fix chargers * fix portable recharger and its unlit visuals * fix borgs * oomba review * fix examination prediction * predict holosign --- .../Tests/Holosign/HolosignProjectorTest.cs | 113 ++++++++++++++++++ .../Tests/Movement/MovementTest.cs | 21 +++- .../Holosign/HolosignProjectorComponent.cs | 19 --- .../Holosign/HolosignProjectorComponent.cs | 31 +++++ .../Holosign/HolosignSystem.cs | 28 ++--- .../Objects/Devices/holoprojectors.yml | 50 ++++---- .../Structures/Holographic/projections.yml | 40 +++---- 7 files changed, 218 insertions(+), 84 deletions(-) create mode 100644 Content.IntegrationTests/Tests/Holosign/HolosignProjectorTest.cs delete mode 100644 Content.Server/Holosign/HolosignProjectorComponent.cs create mode 100644 Content.Shared/Holosign/HolosignProjectorComponent.cs rename {Content.Server => Content.Shared}/Holosign/HolosignSystem.cs (56%) diff --git a/Content.IntegrationTests/Tests/Holosign/HolosignProjectorTest.cs b/Content.IntegrationTests/Tests/Holosign/HolosignProjectorTest.cs new file mode 100644 index 00000000000..2fc1ce6401c --- /dev/null +++ b/Content.IntegrationTests/Tests/Holosign/HolosignProjectorTest.cs @@ -0,0 +1,113 @@ +#nullable enable +using Content.IntegrationTests.Tests.Movement; +using Content.Shared.Holosign; +using Content.Shared.PowerCell; +using Robust.Shared.Maths; +using Robust.Shared.Prototypes; +using Robust.Shared.Spawners; + +namespace Content.IntegrationTests.Tests.Holosign; + +/// +/// Tests for different devices using . +/// +[TestOf(typeof(HolosignProjectorComponent))] +public sealed class HolosignProjectorTest : MovementTest +{ + private static readonly EntProtoId HoloBarrierProjectorProtoId = "HoloprojectorSecurity"; + private static readonly EntProtoId HoloSignProjectorProtoId = "Holoprojector"; + + /// + /// Tests the janitors holosign projector. + /// + [Test] + public async Task HoloSignTest() + { + var projector = await PlaceInHands(HoloSignProjectorProtoId); + var projectorComp = Comp(projector); + var signProtoId = projectorComp.SignProto; + + // No holosigns before using the item. + await AssertEntityLookup((WallPrototype, 2)); + + var powerCellSystem = SEntMan.System(); + var initialUses = powerCellSystem.GetRemainingUses(ToServer(projector), projectorComp.ChargeUse); + Assert.That(initialUses, Is.GreaterThan(0), "Holoprojector spawned without usable charges."); + + // Click on the tile next to the player. + await Interact(null, TargetCoords); + + // We should have one charge less. + var remainingUses = powerCellSystem.GetRemainingUses(ToServer(projector), projectorComp.ChargeUse); + Assert.That(remainingUses, Is.EqualTo(initialUses - 1), "Holoprojector did not use the right amount of charge when used."); + + // We should have spawned exactly one holosign. + await AssertEntityLookup( + (signProtoId, 1), + (WallPrototype, 2)); + + // Try spawn more holosigns than we have charge. + for (var i = 0; i < initialUses; i++) + { + await Interact(null, TargetCoords); + } + + // The total should be the same as the initial charges. + await AssertEntityLookup( + (signProtoId, initialUses), + (WallPrototype, 2)); + + // We should have no charges left. + remainingUses = powerCellSystem.GetRemainingUses(ToServer(projector), projectorComp.ChargeUse); + Assert.That(remainingUses, Is.Zero, "Holoprojector did not use up all charges."); + } + + /// + /// Tests the security holo barrier projector and the barrier. + /// + [Test] + public async Task HoloBarrierTest() + { + var projector = await PlaceInHands(HoloBarrierProjectorProtoId); + var holoBarrierProtoId = Comp(projector).SignProto; + // No holobarriers before using the item. + await AssertEntityLookup((WallPrototype, 2)); + + // Click on the tile next to the player. + await Interact(null, TargetCoords); + + // We should have spawned exactly one holobarrier. + await AssertEntityLookup( + (holoBarrierProtoId, 1), + (WallPrototype, 2)); + Target = FromServer(await FindEntity(holoBarrierProtoId)); + var timeRemaining = Comp(Target).Lifetime; + + // Check that the barrier is at the location we clicked at. + AssertLocation(Target, TargetCoords); + + // Try moving past the barrier. + Assert.That(Delta(), Is.GreaterThan(0.5), "Player was not located west of the holobarrier."); + await Move(DirectionFlag.East, 0.5f); + Assert.That(Delta(), Is.GreaterThan(0.5), "Player was able to walk through a holobarrier."); + + // Try to climb the barrier. + await Interact(Target, TargetCoords, altInteract: true); + + // We should be able to move past the barrier now. + await Move(DirectionFlag.East, 0.5f); + Assert.That(Delta(), Is.LessThan(-0.5), "Player was not able to climb over a holobarrier."); + + // We should not be able to walk back without climbing again. + await Move(DirectionFlag.West, 0.5f); + Assert.That(Delta(), Is.LessThan(-0.5), "Player was able to walk through a holobarrier."); + + // Wait until the barrier despawns. + await RunSeconds(timeRemaining); + AssertDeleted(Target); + + // We should be able to walk back now. + await Move(DirectionFlag.West, 0.5f); + Assert.That(DeltaCoordinates(), Is.GreaterThan(0.5), "Player was able to walk past a deleted holobarrier."); + } +} diff --git a/Content.IntegrationTests/Tests/Movement/MovementTest.cs b/Content.IntegrationTests/Tests/Movement/MovementTest.cs index 44ef02043e8..ee9d1f77ea6 100644 --- a/Content.IntegrationTests/Tests/Movement/MovementTest.cs +++ b/Content.IntegrationTests/Tests/Movement/MovementTest.cs @@ -2,6 +2,8 @@ using System.Numerics; using Content.IntegrationTests.Tests.Interaction; using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; namespace Content.IntegrationTests.Tests.Movement; @@ -13,6 +15,7 @@ namespace Content.IntegrationTests.Tests.Movement; public abstract class MovementTest : InteractionTest { protected override string PlayerPrototype => "MobHuman"; + protected static readonly EntProtoId WallPrototype = "WallSolid"; /// /// Number of tiles to add either side of the player. @@ -47,8 +50,8 @@ public abstract class MovementTest : InteractionTest if (AddWalls) { - var sWallLeft = await SpawnEntity("WallSolid", pCoords.Offset(new Vector2(-Tiles, 0))); - var sWallRight = await SpawnEntity("WallSolid", pCoords.Offset(new Vector2(Tiles, 0))); + var sWallLeft = await SpawnEntity(WallPrototype, pCoords.Offset(new Vector2(-Tiles, 0))); + var sWallRight = await SpawnEntity(WallPrototype, pCoords.Offset(new Vector2(Tiles, 0))); WallLeft = SEntMan.GetNetEntity(sWallLeft); WallRight = SEntMan.GetNetEntity(sWallRight); @@ -59,7 +62,7 @@ public abstract class MovementTest : InteractionTest } /// - /// Get the relative horizontal between two entities. Defaults to using the target & player entity. + /// Get the relative horizontal between two entities. Defaults to using the target & player entity. /// protected float Delta(NetEntity? target = null, NetEntity? other = null) { @@ -73,5 +76,17 @@ public abstract class MovementTest : InteractionTest var delta = Transform.GetWorldPosition(SEntMan.GetEntity(target.Value)) - Transform.GetWorldPosition(SEntMan.GetEntity(other ?? Player)); return delta.X; } + + /// + /// Get the relative horizontal between a set of coordinates and an entity. Defaults to using the target coordinates and the player entity. + /// + protected float DeltaCoordinates(NetCoordinates? coords = null, NetEntity? other = null) + { + other ??= Player; + coords ??= TargetCoords; + + var delta = Transform.ToWorldPosition(ToServer(coords.Value)) - Transform.GetWorldPosition(ToServer(other.Value)); + return delta.X; + } } diff --git a/Content.Server/Holosign/HolosignProjectorComponent.cs b/Content.Server/Holosign/HolosignProjectorComponent.cs deleted file mode 100644 index bdc826f1304..00000000000 --- a/Content.Server/Holosign/HolosignProjectorComponent.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Robust.Shared.Prototypes; -using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; - -namespace Content.Server.Holosign -{ - [RegisterComponent] - public sealed partial class HolosignProjectorComponent : Component - { - [ViewVariables(VVAccess.ReadWrite)] - [DataField("signProto", customTypeSerializer:typeof(PrototypeIdSerializer))] - public string SignProto = "HolosignWetFloor"; - - /// - /// How much charge a single use expends. - /// - [ViewVariables(VVAccess.ReadWrite), DataField("chargeUse")] - public float ChargeUse = 50f; - } -} diff --git a/Content.Shared/Holosign/HolosignProjectorComponent.cs b/Content.Shared/Holosign/HolosignProjectorComponent.cs new file mode 100644 index 00000000000..532e80fbd84 --- /dev/null +++ b/Content.Shared/Holosign/HolosignProjectorComponent.cs @@ -0,0 +1,31 @@ +using Robust.Shared.Prototypes; +using Robust.Shared.GameStates; + +namespace Content.Shared.Holosign; + +/// +/// Added to an item and allows it to spawn a specified prototype at the location you click on, using charge from a power cell. +/// Used for holosigns, holofans and holobarriers. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class HolosignProjectorComponent : Component +{ + /// + /// The prototype to spawn on use. + /// + [DataField, AutoNetworkedField] + public EntProtoId SignProto = "HolosignWetFloor"; + + /// + /// How much charge a single use expends, in watts. + /// + [DataField, AutoNetworkedField] + public float ChargeUse = 50f; + + /// + /// Whether or not to use predictive spawning. + /// At the moment this does not support entities with animated sprites, so set this to false in that case. + /// + [DataField, AutoNetworkedField] + public bool PredictedSpawn; +} diff --git a/Content.Server/Holosign/HolosignSystem.cs b/Content.Shared/Holosign/HolosignSystem.cs similarity index 56% rename from Content.Server/Holosign/HolosignSystem.cs rename to Content.Shared/Holosign/HolosignSystem.cs index 7d01ffb9752..730ac1140a2 100644 --- a/Content.Server/Holosign/HolosignSystem.cs +++ b/Content.Shared/Holosign/HolosignSystem.cs @@ -1,29 +1,30 @@ using Content.Shared.Examine; -using Content.Shared.Coordinates.Helpers; -using Content.Shared.PowerCell; using Content.Shared.Interaction; +using Content.Shared.PowerCell; using Content.Shared.Storage; +using Robust.Shared.Network; -namespace Content.Server.Holosign; +namespace Content.Shared.Holosign; public sealed class HolosignSystem : EntitySystem { [Dependency] private readonly PowerCellSystem _powerCell = default!; - [Dependency] private readonly SharedTransformSystem _transform = default!; + [Dependency] private readonly INetManager _net = default!; public override void Initialize() { base.Initialize(); + SubscribeLocalEvent(OnBeforeInteract); SubscribeLocalEvent(OnExamine); } - private void OnExamine(EntityUid uid, HolosignProjectorComponent component, ExaminedEvent args) + private void OnExamine(Entity ent, ref ExaminedEvent args) { // TODO: This should probably be using an itemstatus // TODO: I'm too lazy to do this rn but it's literally copy-paste from emag. - var charges = _powerCell.GetRemainingUses(uid, component.ChargeUse); - var maxCharges = _powerCell.GetMaxUses(uid, component.ChargeUse); + var charges = _powerCell.GetRemainingUses(ent.Owner, ent.Comp.ChargeUse); + var maxCharges = _powerCell.GetMaxUses(ent.Owner, ent.Comp.ChargeUse); using (args.PushGroup(nameof(HolosignProjectorComponent))) { @@ -36,23 +37,18 @@ public sealed class HolosignSystem : EntitySystem } } - private void OnBeforeInteract(EntityUid uid, HolosignProjectorComponent component, BeforeRangedInteractEvent args) + private void OnBeforeInteract(Entity ent, ref BeforeRangedInteractEvent args) { - if (args.Handled || !args.CanReach // prevent placing out of range || HasComp(args.Target) // if it's a storage component like a bag, we ignore usage so it can be stored - || !_powerCell.TryUseCharge(uid, component.ChargeUse, user: args.User) // if no battery or no charge, doesn't work + || !_powerCell.TryUseCharge(ent.Owner, ent.Comp.ChargeUse, user: args.User, predicted: true) // if no battery or no charge, doesn't work ) return; - // places the holographic sign at the click location, snapped to grid. // overlapping of the same holo on one tile remains allowed to allow holofan refreshes - var holoUid = Spawn(component.SignProto, args.ClickLocation.SnapToGrid(EntityManager)); - var xform = Transform(holoUid); - // TODO: Just make the prototype anchored - if (!xform.Anchored) - _transform.AnchorEntity(holoUid, xform); // anchor to prevent any tempering with (don't know what could even interact with it) + if (ent.Comp.PredictedSpawn || _net.IsServer) + PredictedSpawnAtPosition(ent.Comp.SignProto, args.ClickLocation); args.Handled = true; } diff --git a/Resources/Prototypes/Entities/Objects/Devices/holoprojectors.yml b/Resources/Prototypes/Entities/Objects/Devices/holoprojectors.yml index e1a15b74935..e7b057ba5a0 100644 --- a/Resources/Prototypes/Entities/Objects/Devices/holoprojectors.yml +++ b/Resources/Prototypes/Entities/Objects/Devices/holoprojectors.yml @@ -23,7 +23,7 @@ state: icon - type: Tag tags: - - HolosignProjector + - HolosignProjector - type: entity parent: Holoprojector @@ -87,7 +87,7 @@ state: icon - type: Tag tags: - - HolofanProjector + - HolofanProjector - type: StaticPrice price: 50 - type: GuideHelp @@ -126,17 +126,17 @@ name: force field projector description: Creates an impassable forcefield that won't let anything through. Close proximity may or may not cause cancer. components: - - type: HolosignProjector - signProto: HolosignForcefield - chargeUse: 120 - - type: Sprite - sprite: Objects/Devices/Holoprojectors/field.rsi - state: icon - - type: Tag - tags: - - HolofanProjector - - type: StaticPrice - price: 130 + - type: HolosignProjector + signProto: HolosignForcefield + chargeUse: 120 + - type: Sprite + sprite: Objects/Devices/Holoprojectors/field.rsi + state: icon + - type: Tag + tags: + - HolofanProjector + - type: StaticPrice + price: 130 - type: entity parent: HoloprojectorField @@ -154,18 +154,18 @@ name: holobarrier projector description: Creates a solid but fragile holographic barrier. components: - - type: HolosignProjector - signProto: HolosignSecurity - chargeUse: 90 - - type: Sprite - sprite: Objects/Devices/Holoprojectors/security.rsi - state: icon - - type: Tag - tags: - - HolofanProjector - - SecBeltEquip - - type: StaticPrice - price: 50 + - type: HolosignProjector + signProto: HolosignSecurity + chargeUse: 90 + - type: Sprite + sprite: Objects/Devices/Holoprojectors/security.rsi + state: icon + - type: Tag + tags: + - HolofanProjector + - SecBeltEquip + - type: StaticPrice + price: 50 - type: entity parent: HoloprojectorSecurity diff --git a/Resources/Prototypes/Entities/Structures/Holographic/projections.yml b/Resources/Prototypes/Entities/Structures/Holographic/projections.yml index 4867fc84d80..ba51f12e3e3 100644 --- a/Resources/Prototypes/Entities/Structures/Holographic/projections.yml +++ b/Resources/Prototypes/Entities/Structures/Holographic/projections.yml @@ -15,16 +15,17 @@ state: icon - type: TimedDespawn lifetime: 90 + - type: Clickable - type: Damageable damageContainer: Inorganic - type: Destructible thresholds: - - trigger: - !type:DamageTrigger - damage: 30 - behaviors: - - !type:DoActsBehavior - acts: [ "Destruction" ] + - trigger: + !type:DamageTrigger + damage: 30 + behaviors: + - !type:DoActsBehavior + acts: [ "Destruction" ] - type: entity id: HoloFan @@ -74,15 +75,14 @@ - SlipLayer - type: TimedDespawn lifetime: 30 - - type: Clickable - type: Destructible thresholds: - - trigger: - !type:DamageTrigger - damage: 10 - behaviors: - - !type:DoActsBehavior - acts: [ "Destruction" ] + - trigger: + !type:DamageTrigger + damage: 10 + behaviors: + - !type:DoActsBehavior + acts: [ "Destruction" ] - type: entity id: HolosignSecurity @@ -113,7 +113,6 @@ radius: 3 color: red - type: Climbable - - type: Clickable - type: entity id: HolosignForcefield @@ -143,14 +142,13 @@ enabled: true radius: 3 color: blue - - type: Clickable - type: ContainmentField throwForce: 0 - type: Destructible thresholds: - - trigger: - !type:DamageTrigger - damage: 60 - behaviors: - - !type:DoActsBehavior - acts: [ "Destruction" ] + - trigger: + !type:DamageTrigger + damage: 60 + behaviors: + - !type:DoActsBehavior + acts: [ "Destruction" ]