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
This commit is contained in:
slarticodefast
2026-02-07 11:17:20 +01:00
committed by GitHub
parent fd870f3e77
commit d72a59f6b7
7 changed files with 218 additions and 84 deletions

View File

@@ -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;
/// <summary>
/// Tests for different devices using <see cref="HolosignProjectorComponent"/>.
/// </summary>
[TestOf(typeof(HolosignProjectorComponent))]
public sealed class HolosignProjectorTest : MovementTest
{
private static readonly EntProtoId HoloBarrierProjectorProtoId = "HoloprojectorSecurity";
private static readonly EntProtoId HoloSignProjectorProtoId = "Holoprojector";
/// <summary>
/// Tests the janitors holosign projector.
/// </summary>
[Test]
public async Task HoloSignTest()
{
var projector = await PlaceInHands(HoloSignProjectorProtoId);
var projectorComp = Comp<HolosignProjectorComponent>(projector);
var signProtoId = projectorComp.SignProto;
// No holosigns before using the item.
await AssertEntityLookup((WallPrototype, 2));
var powerCellSystem = SEntMan.System<PowerCellSystem>();
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.");
}
/// <summary>
/// Tests the security holo barrier projector and the barrier.
/// </summary>
[Test]
public async Task HoloBarrierTest()
{
var projector = await PlaceInHands(HoloBarrierProjectorProtoId);
var holoBarrierProtoId = Comp<HolosignProjectorComponent>(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<TimedDespawnComponent>(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.");
}
}

View File

@@ -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";
/// <summary>
/// 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
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Get the relative horizontal between a set of coordinates and an entity. Defaults to using the target coordinates and the player entity.
/// </summary>
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;
}
}

View File

@@ -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<EntityPrototype>))]
public string SignProto = "HolosignWetFloor";
/// <summary>
/// How much charge a single use expends.
/// </summary>
[ViewVariables(VVAccess.ReadWrite), DataField("chargeUse")]
public float ChargeUse = 50f;
}
}

View File

@@ -0,0 +1,31 @@
using Robust.Shared.Prototypes;
using Robust.Shared.GameStates;
namespace Content.Shared.Holosign;
/// <summary>
/// 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.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class HolosignProjectorComponent : Component
{
/// <summary>
/// The prototype to spawn on use.
/// </summary>
[DataField, AutoNetworkedField]
public EntProtoId SignProto = "HolosignWetFloor";
/// <summary>
/// How much charge a single use expends, in watts.
/// </summary>
[DataField, AutoNetworkedField]
public float ChargeUse = 50f;
/// <summary>
/// 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.
/// </summary>
[DataField, AutoNetworkedField]
public bool PredictedSpawn;
}

View File

@@ -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<HolosignProjectorComponent, BeforeRangedInteractEvent>(OnBeforeInteract);
SubscribeLocalEvent<HolosignProjectorComponent, ExaminedEvent>(OnExamine);
}
private void OnExamine(EntityUid uid, HolosignProjectorComponent component, ExaminedEvent args)
private void OnExamine(Entity<HolosignProjectorComponent> 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<HolosignProjectorComponent> ent, ref BeforeRangedInteractEvent args)
{
if (args.Handled
|| !args.CanReach // prevent placing out of range
|| HasComp<StorageComponent>(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;
}

View File

@@ -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

View File

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