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" ]