Files
tbd-station-14/Content.Shared/RCD/Systems/RCDSystem.cs
Pieter-Jan Briers 0c97520276 Fix usages of TryIndex() (#39124)
* Fix usages of TryIndex()

Most usages of TryIndex() were using it incorrectly. Checking whether prototype IDs specified in prototypes actually existed before using them. This is not appropriate as it's just hiding bugs that should be getting caught by the YAML linter and other tools. (#39115)

This then resulted in TryIndex() getting modified to log errors (94f98073b0), which is incorrect as it causes false-positive errors in proper uses of the API: external data validation. (#39098)

This commit goes through and checks every call site of TryIndex() to see whether they were correct. Most call sites were replaced with the new Resolve(), which is suitable for these "defensive programming" use cases.

Fixes #39115

Breaking change: while doing this I noticed IdCardComponent and related systems were erroneously using ProtoId<AccessLevelPrototype> for job prototypes. This has been corrected.

* fix tests

---------

Co-authored-by: slarticodefast <161409025+slarticodefast@users.noreply.github.com>
2025-09-09 18:17:56 +02:00

626 lines
23 KiB
C#

using Content.Shared.Administration.Logs;
using Content.Shared.Charges.Components;
using Content.Shared.Charges.Systems;
using Content.Shared.Construction;
using Content.Shared.Database;
using Content.Shared.DoAfter;
using Content.Shared.Examine;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Interaction;
using Content.Shared.Maps;
using Content.Shared.Physics;
using Content.Shared.Popups;
using Content.Shared.RCD.Components;
using Content.Shared.Tag;
using Content.Shared.Tiles;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Network;
using Robust.Shared.Physics;
using Robust.Shared.Physics.Collision.Shapes;
using Robust.Shared.Physics.Dynamics;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
using System.Linq;
namespace Content.Shared.RCD.Systems;
public sealed class RCDSystem : EntitySystem
{
[Dependency] private readonly INetManager _net = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly ITileDefinitionManager _tileDefMan = default!;
[Dependency] private readonly FloorTileSystem _floors = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedChargesSystem _sharedCharges = default!;
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
[Dependency] private readonly SharedHandsSystem _hands = default!;
[Dependency] private readonly SharedInteractionSystem _interaction = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly TurfSystem _turf = default!;
[Dependency] private readonly EntityLookupSystem _lookup = default!;
[Dependency] private readonly IPrototypeManager _protoManager = default!;
[Dependency] private readonly SharedMapSystem _mapSystem = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
[Dependency] private readonly TagSystem _tags = default!;
private readonly int _instantConstructionDelay = 0;
private readonly EntProtoId _instantConstructionFx = "EffectRCDConstruct0";
private readonly ProtoId<RCDPrototype> _deconstructTileProto = "DeconstructTile";
private readonly ProtoId<RCDPrototype> _deconstructLatticeProto = "DeconstructLattice";
private static readonly ProtoId<TagPrototype> CatwalkTag = "Catwalk";
private HashSet<EntityUid> _intersectingEntities = new();
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<RCDComponent, MapInitEvent>(OnMapInit);
SubscribeLocalEvent<RCDComponent, ExaminedEvent>(OnExamine);
SubscribeLocalEvent<RCDComponent, AfterInteractEvent>(OnAfterInteract);
SubscribeLocalEvent<RCDComponent, RCDDoAfterEvent>(OnDoAfter);
SubscribeLocalEvent<RCDComponent, DoAfterAttemptEvent<RCDDoAfterEvent>>(OnDoAfterAttempt);
SubscribeLocalEvent<RCDComponent, RCDSystemMessage>(OnRCDSystemMessage);
SubscribeNetworkEvent<RCDConstructionGhostRotationEvent>(OnRCDconstructionGhostRotationEvent);
}
#region Event handling
private void OnMapInit(EntityUid uid, RCDComponent component, MapInitEvent args)
{
// On init, set the RCD to its first available recipe
if (component.AvailablePrototypes.Count > 0)
{
component.ProtoId = component.AvailablePrototypes.ElementAt(0);
Dirty(uid, component);
return;
}
// The RCD has no valid recipes somehow? Get rid of it
QueueDel(uid);
}
private void OnRCDSystemMessage(EntityUid uid, RCDComponent component, RCDSystemMessage args)
{
// Exit if the RCD doesn't actually know the supplied prototype
if (!component.AvailablePrototypes.Contains(args.ProtoId))
return;
if (!_protoManager.HasIndex(args.ProtoId))
return;
// Set the current RCD prototype to the one supplied
component.ProtoId = args.ProtoId;
Dirty(uid, component);
}
private void OnExamine(EntityUid uid, RCDComponent component, ExaminedEvent args)
{
if (!args.IsInDetailsRange)
return;
var prototype = _protoManager.Index(component.ProtoId);
var msg = Loc.GetString("rcd-component-examine-mode-details", ("mode", Loc.GetString(prototype.SetName)));
if (prototype.Mode == RcdMode.ConstructTile || prototype.Mode == RcdMode.ConstructObject)
{
var name = Loc.GetString(prototype.SetName);
if (prototype.Prototype != null &&
_protoManager.Resolve(prototype.Prototype, out var proto))
name = proto.Name;
msg = Loc.GetString("rcd-component-examine-build-details", ("name", name));
}
args.PushMarkup(msg);
}
private void OnAfterInteract(EntityUid uid, RCDComponent component, AfterInteractEvent args)
{
if (args.Handled || !args.CanReach)
return;
var user = args.User;
var location = args.ClickLocation;
var prototype = _protoManager.Index(component.ProtoId);
// Initial validity checks
if (!location.IsValid(EntityManager))
return;
var gridUid = _transform.GetGrid(location);
if (!TryComp<MapGridComponent>(gridUid, out var mapGrid))
{
_popup.PopupClient(Loc.GetString("rcd-component-no-valid-grid"), uid, user);
return;
}
var tile = _mapSystem.GetTileRef(gridUid.Value, mapGrid, location);
var position = _mapSystem.TileIndicesFor(gridUid.Value, mapGrid, location);
if (!IsRCDOperationStillValid(uid, component, gridUid.Value, mapGrid, tile, position, args.Target, args.User))
return;
if (!_net.IsServer)
return;
// Get the starting cost, delay, and effect from the prototype
var cost = prototype.Cost;
var delay = prototype.Delay;
var effectPrototype = prototype.Effect;
#region: Operation modifiers
// Deconstruction modifiers
switch (prototype.Mode)
{
case RcdMode.Deconstruct:
// Deconstructing an object
if (args.Target != null)
{
if (TryComp<RCDDeconstructableComponent>(args.Target, out var destructible))
{
cost = destructible.Cost;
delay = destructible.Delay;
effectPrototype = destructible.Effect;
}
}
// Deconstructing a tile
else
{
var deconstructedTile = _mapSystem.GetTileRef(gridUid.Value, mapGrid, location);
var protoName = !_turf.IsSpace(deconstructedTile) ? _deconstructTileProto : _deconstructLatticeProto;
if (_protoManager.Resolve(protoName, out var deconProto))
{
cost = deconProto.Cost;
delay = deconProto.Delay;
effectPrototype = deconProto.Effect;
}
}
break;
case RcdMode.ConstructTile:
// If replacing a tile, make the construction instant
var contructedTile = _mapSystem.GetTileRef(gridUid.Value, mapGrid, location);
if (!contructedTile.Tile.IsEmpty)
{
delay = _instantConstructionDelay;
effectPrototype = _instantConstructionFx;
}
break;
}
#endregion
// Try to start the do after
var effect = Spawn(effectPrototype, location);
var ev = new RCDDoAfterEvent(GetNetCoordinates(location), component.ConstructionDirection, component.ProtoId, cost, GetNetEntity(effect));
var doAfterArgs = new DoAfterArgs(EntityManager, user, delay, ev, uid, target: args.Target, used: uid)
{
BreakOnDamage = true,
BreakOnHandChange = true,
BreakOnMove = true,
AttemptFrequency = AttemptFrequency.EveryTick,
CancelDuplicate = false,
BlockDuplicate = false
};
args.Handled = true;
if (!_doAfter.TryStartDoAfter(doAfterArgs))
QueueDel(effect);
}
private void OnDoAfterAttempt(EntityUid uid, RCDComponent component, DoAfterAttemptEvent<RCDDoAfterEvent> args)
{
if (args.Event?.DoAfter?.Args == null)
return;
// Exit if the RCD prototype has changed
if (component.ProtoId != args.Event.StartingProtoId)
{
args.Cancel();
return;
}
// Ensure the RCD operation is still valid
var location = GetCoordinates(args.Event.Location);
var gridUid = _transform.GetGrid(location);
if (!TryComp<MapGridComponent>(gridUid, out var mapGrid))
{
args.Cancel();
return;
}
var tile = _mapSystem.GetTileRef(gridUid.Value, mapGrid, location);
var position = _mapSystem.TileIndicesFor(gridUid.Value, mapGrid, location);
if (!IsRCDOperationStillValid(uid, component, gridUid.Value, mapGrid, tile, position, args.Event.Target, args.Event.User))
args.Cancel();
}
private void OnDoAfter(EntityUid uid, RCDComponent component, RCDDoAfterEvent args)
{
if (args.Cancelled)
{
// Delete the effect entity if the do-after was cancelled (server-side only)
if (_net.IsServer)
QueueDel(GetEntity(args.Effect));
return;
}
if (args.Handled)
return;
args.Handled = true;
var location = GetCoordinates(args.Location);
var gridUid = _transform.GetGrid(location);
if (!TryComp<MapGridComponent>(gridUid, out var mapGrid))
return;
var tile = _mapSystem.GetTileRef(gridUid.Value, mapGrid, location);
var position = _mapSystem.TileIndicesFor(gridUid.Value, mapGrid, location);
// Ensure the RCD operation is still valid
if (!IsRCDOperationStillValid(uid, component, gridUid.Value, mapGrid, tile, position, args.Target, args.User))
return;
// Finalize the operation (this should handle prediction properly)
FinalizeRCDOperation(uid, component, gridUid.Value, mapGrid, tile, position, args.Direction, args.Target, args.User);
// Play audio and consume charges
_audio.PlayPredicted(component.SuccessSound, uid, args.User);
_sharedCharges.AddCharges(uid, -args.Cost);
}
private void OnRCDconstructionGhostRotationEvent(RCDConstructionGhostRotationEvent ev, EntitySessionEventArgs session)
{
var uid = GetEntity(ev.NetEntity);
// Determine if player that send the message is carrying the specified RCD in their active hand
if (session.SenderSession.AttachedEntity is not { } player)
return;
if (_hands.GetActiveItem(player) != uid)
return;
if (!TryComp<RCDComponent>(uid, out var rcd))
return;
// Update the construction direction
rcd.ConstructionDirection = ev.Direction;
Dirty(uid, rcd);
}
#endregion
#region Entity construction/deconstruction rule checks
public bool IsRCDOperationStillValid(EntityUid uid, RCDComponent component, EntityUid gridUid, MapGridComponent mapGrid, TileRef tile, Vector2i position, EntityUid? target, EntityUid user, bool popMsgs = true)
{
var prototype = _protoManager.Index(component.ProtoId);
// Check that the RCD has enough ammo to get the job done
var charges = _sharedCharges.GetCurrentCharges(uid);
// Both of these were messages were suppose to be predicted, but HasInsufficientCharges wasn't being checked on the client for some reason?
if (charges == 0)
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-no-ammo-message"), uid, user);
return false;
}
if (prototype.Cost > charges)
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-insufficient-ammo-message"), uid, user);
return false;
}
// Exit if the target / target location is obstructed
var unobstructed = (target == null)
? _interaction.InRangeUnobstructed(user, _mapSystem.GridTileToWorld(gridUid, mapGrid, position), popup: popMsgs)
: _interaction.InRangeUnobstructed(user, target.Value, popup: popMsgs);
if (!unobstructed)
return false;
// Return whether the operation location is valid
switch (prototype.Mode)
{
case RcdMode.ConstructTile:
case RcdMode.ConstructObject:
return IsConstructionLocationValid(uid, component, gridUid, mapGrid, tile, position, user, popMsgs);
case RcdMode.Deconstruct:
return IsDeconstructionStillValid(uid, tile, target, user, popMsgs);
}
return false;
}
private bool IsConstructionLocationValid(EntityUid uid, RCDComponent component, EntityUid gridUid, MapGridComponent mapGrid, TileRef tile, Vector2i position, EntityUid user, bool popMsgs = true)
{
var prototype = _protoManager.Index(component.ProtoId);
// Check rule: Must build on empty tile
if (prototype.ConstructionRules.Contains(RcdConstructionRule.MustBuildOnEmptyTile) && !tile.Tile.IsEmpty)
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-must-build-on-empty-tile-message"), uid, user);
return false;
}
// Check rule: Must build on non-empty tile
if (!prototype.ConstructionRules.Contains(RcdConstructionRule.CanBuildOnEmptyTile) && tile.Tile.IsEmpty)
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-cannot-build-on-empty-tile-message"), uid, user);
return false;
}
// Check rule: Must place on subfloor
if (prototype.ConstructionRules.Contains(RcdConstructionRule.MustBuildOnSubfloor) && !_turf.GetContentTileDefinition(tile).IsSubFloor)
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-must-build-on-subfloor-message"), uid, user);
return false;
}
// Tile specific rules
if (prototype.Mode == RcdMode.ConstructTile)
{
// Check rule: Tile placement is valid
if (!_floors.CanPlaceTile(gridUid, mapGrid, tile.GridIndices, out var reason))
{
if (popMsgs)
_popup.PopupClient(reason, uid, user);
return false;
}
// Check rule: Tiles can't be identical
if (_turf.GetContentTileDefinition(tile).ID == prototype.Prototype)
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-cannot-build-identical-tile"), uid, user);
return false;
}
// Ensure that all construction rules shared between tiles and object are checked before exiting here
return true;
}
// Entity specific rules
// Check rule: The tile is unoccupied
var isWindow = prototype.ConstructionRules.Contains(RcdConstructionRule.IsWindow);
var isCatwalk = prototype.ConstructionRules.Contains(RcdConstructionRule.IsCatwalk);
_intersectingEntities.Clear();
_lookup.GetLocalEntitiesIntersecting(gridUid, position, _intersectingEntities, -0.05f, LookupFlags.Uncontained);
foreach (var ent in _intersectingEntities)
{
if (isWindow && HasComp<SharedCanBuildWindowOnTopComponent>(ent))
continue;
if (isCatwalk && _tags.HasTag(ent, CatwalkTag))
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-cannot-build-on-occupied-tile-message"), uid, user);
return false;
}
if (prototype.CollisionMask != CollisionGroup.None && TryComp<FixturesComponent>(ent, out var fixtures))
{
foreach (var fixture in fixtures.Fixtures.Values)
{
// Continue if no collision is possible
if (!fixture.Hard || fixture.CollisionLayer <= 0 || (fixture.CollisionLayer & (int) prototype.CollisionMask) == 0)
continue;
// Continue if our custom collision bounds are not intersected
if (prototype.CollisionPolygon != null &&
!DoesCustomBoundsIntersectWithFixture(prototype.CollisionPolygon, component.ConstructionTransform, ent, fixture))
continue;
// Collision was detected
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-cannot-build-on-occupied-tile-message"), uid, user);
return false;
}
}
}
return true;
}
private bool IsDeconstructionStillValid(EntityUid uid, TileRef tile, EntityUid? target, EntityUid user, bool popMsgs = true)
{
// Attempt to deconstruct a floor tile
if (target == null)
{
// The tile is empty
if (tile.Tile.IsEmpty)
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-nothing-to-deconstruct-message"), uid, user);
return false;
}
// The tile has a structure sitting on it
if (_turf.IsTileBlocked(tile, CollisionGroup.MobMask))
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-tile-obstructed-message"), uid, user);
return false;
}
// The tile cannot be destroyed
var tileDef = _turf.GetContentTileDefinition(tile);
if (tileDef.Indestructible)
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-tile-indestructible-message"), uid, user);
return false;
}
}
// Attempt to deconstruct an object
else
{
// The object is not in the whitelist
if (!TryComp<RCDDeconstructableComponent>(target, out var deconstructible) || !deconstructible.Deconstructable)
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-deconstruct-target-not-on-whitelist-message"), uid, user);
return false;
}
}
return true;
}
#endregion
#region Entity construction/deconstruction
private void FinalizeRCDOperation(EntityUid uid, RCDComponent component, EntityUid gridUid, MapGridComponent mapGrid, TileRef tile, Vector2i position, Direction direction, EntityUid? target, EntityUid user)
{
if (!_net.IsServer)
return;
var prototype = _protoManager.Index(component.ProtoId);
if (prototype.Prototype == null)
return;
switch (prototype.Mode)
{
case RcdMode.ConstructTile:
_mapSystem.SetTile(gridUid, mapGrid, position, new Tile(_tileDefMan[prototype.Prototype].TileId));
_adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(user):user} used RCD to set grid: {gridUid} {position} to {prototype.Prototype}");
break;
case RcdMode.ConstructObject:
var ent = Spawn(prototype.Prototype, _mapSystem.GridTileToLocal(gridUid, mapGrid, position));
switch (prototype.Rotation)
{
case RcdRotation.Fixed:
Transform(ent).LocalRotation = Angle.Zero;
break;
case RcdRotation.Camera:
Transform(ent).LocalRotation = Transform(uid).LocalRotation;
break;
case RcdRotation.User:
Transform(ent).LocalRotation = direction.ToAngle();
break;
}
_adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(user):user} used RCD to spawn {ToPrettyString(ent)} at {position} on grid {gridUid}");
break;
case RcdMode.Deconstruct:
if (target == null)
{
// Deconstruct tile (either converts the tile to lattice, or removes lattice)
var tileDef = (_turf.GetContentTileDefinition(tile).ID != "Lattice") ? new Tile(_tileDefMan["Lattice"].TileId) : Tile.Empty;
_mapSystem.SetTile(gridUid, mapGrid, position, tileDef);
_adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(user):user} used RCD to set grid: {gridUid} tile: {position} open to space");
}
else
{
// Deconstruct object
_adminLogger.Add(LogType.RCD, LogImpact.High, $"{ToPrettyString(user):user} used RCD to delete {ToPrettyString(target):target}");
QueueDel(target);
}
break;
}
}
#endregion
#region Utility functions
private bool DoesCustomBoundsIntersectWithFixture(PolygonShape boundingPolygon, Transform boundingTransform, EntityUid fixtureOwner, Fixture fixture)
{
var entXformComp = Transform(fixtureOwner);
var entXform = new Transform(new(), entXformComp.LocalRotation);
return boundingPolygon.ComputeAABB(boundingTransform, 0).Intersects(fixture.Shape.ComputeAABB(entXform, 0));
}
#endregion
}
[Serializable, NetSerializable]
public sealed partial class RCDDoAfterEvent : DoAfterEvent
{
[DataField(required: true)]
public NetCoordinates Location { get; private set; }
[DataField]
public Direction Direction { get; private set; }
[DataField]
public ProtoId<RCDPrototype> StartingProtoId { get; private set; }
[DataField]
public int Cost { get; private set; } = 1;
[DataField("fx")]
public NetEntity? Effect { get; private set; }
private RCDDoAfterEvent() { }
public RCDDoAfterEvent(NetCoordinates location, Direction direction, ProtoId<RCDPrototype> startingProtoId, int cost, NetEntity? effect = null)
{
Location = location;
Direction = direction;
StartingProtoId = startingProtoId;
Cost = cost;
Effect = effect;
}
public override DoAfterEvent Clone()
{
return this;
}
}