// SPDX-FileCopyrightText: 2025 Ark
// SPDX-FileCopyrightText: 2025 Redrover1760
// SPDX-FileCopyrightText: 2025 RikuTheKiller
// SPDX-FileCopyrightText: 2025 ScyronX
// SPDX-FileCopyrightText: 2025 ark1368
// SPDX-FileCopyrightText: 2025 sleepyyapril
// SPDX-FileCopyrightText: 2025 starch
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright Rane (elijahrane@gmail.com) 2025
// All rights reserved. Relicensed under AGPL with permission
using Content.Server.Weapons.Ranged.Systems;
using Content.Shared._Mono.FireControl;
using Content.Shared.Power;
using Content.Shared.Weapons.Ranged.Components;
using Robust.Shared.Map;
using Robust.Shared.Physics;
using Robust.Shared.Physics.Systems;
using System.Linq;
using Content.Shared.Physics;
using System.Numerics;
using Content.Server._Mono.SpaceArtillery;
using Content.Server.Power.EntitySystems;
using Content.Shared.Shuttles.Components;
using Robust.Shared.Timing;
using Content.Shared.Interaction;
using Content.Shared._Mono.ShipGuns;
using Content.Shared.Examine;
using Content.Shared.UserInterface;
using Content.Server.Salvage.Expeditions;
namespace Content.Server._Mono.FireControl;
public sealed partial class FireControlSystem : EntitySystem
{
[Dependency] private readonly SharedTransformSystem _xform = default!;
[Dependency] private readonly GunSystem _gun = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly PowerReceiverSystem _power = default!;
[Dependency] private readonly RotateToFaceSystem _rotateToFace = default!;
///
/// Dictionary of entities that have visualization enabled
///
private readonly HashSet _visualizedEntities = new();
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent(OnPowerChanged);
SubscribeLocalEvent(OnShutdown);
SubscribeLocalEvent(OnExamined);
SubscribeLocalEvent(OnServerTerminating);
SubscribeLocalEvent(OnControllablePowerChanged);
SubscribeLocalEvent(OnControllableShutdown);
SubscribeLocalEvent(OnControllableParentChanged);
// Subscribe to grid split events to ensure we update when grids change
SubscribeLocalEvent(OnGridSplit);
InitializeConsole();
InitializeTargetGuided();
}
private void OnPowerChanged(EntityUid uid, FireControlServerComponent component, PowerChangedEvent args)
{
if (args.Powered)
TryConnect(uid, component);
else
Disconnect(uid, component);
}
private void OnShutdown(EntityUid uid, FireControlServerComponent component, ComponentShutdown args)
{
Disconnect(uid, component);
}
private void OnServerTerminating(EntityUid uid, FireControlServerComponent component, ref EntityTerminatingEvent args)
{
Disconnect(uid, component);
}
private void OnExamined(EntityUid uid, FireControlServerComponent component, ExaminedEvent args)
{
if (!args.IsInDetailsRange)
return;
args.PushMarkup(
Loc.GetString(
"gunnery-server-examine-detail",
("usedProcessingPower", component.UsedProcessingPower),
("processingPower", component.ProcessingPower),
("valueColor", component.UsedProcessingPower <= component.ProcessingPower - 2 ? "green" : "yellow")
)
);
}
private void OnControllablePowerChanged(EntityUid uid, FireControllableComponent component, PowerChangedEvent args)
{
if (args.Powered)
TryRegister(uid, component);
else
Unregister(uid, component);
}
private void OnControllableShutdown(EntityUid uid, FireControllableComponent component, ComponentShutdown args)
{
if (component.ControllingServer != null && TryComp(component.ControllingServer, out var server))
{
Unregister(uid, component);
foreach (var console in server.Consoles)
{
if (TryComp(console, out var consoleComp))
{
UpdateUi(console, consoleComp);
}
}
}
}
private void OnControllableParentChanged(EntityUid uid, FireControllableComponent component, ref EntParentChangedMessage args)
{
if (component.ControllingServer == null)
return;
// Check if the weapon is still on the same grid as its controlling server
if (!TryComp(component.ControllingServer, out var server) ||
server.ConnectedGrid == null)
return;
var currentGrid = _xform.GetGrid(uid);
if (currentGrid != server.ConnectedGrid)
{
// Weapon is no longer on the same grid - unregister it
Unregister(uid, component);
// Update UI for any connected consoles
foreach (var console in server.Consoles)
{
if (TryComp(console, out var consoleComp))
{
UpdateUi(console, consoleComp);
}
}
}
}
private void Disconnect(EntityUid server, FireControlServerComponent? component = null)
{
if (!Resolve(server, ref component))
return;
// Clean up grid connection if it exists
if (component.ConnectedGrid != null && Exists(component.ConnectedGrid) && TryComp(component.ConnectedGrid, out var controlGrid))
{
if (controlGrid.ControllingServer == server)
{
controlGrid.ControllingServer = null;
RemComp((EntityUid)component.ConnectedGrid);
}
}
// Unregister all controlled entities
var controlledCopy = component.Controlled.ToList(); // Create copy to avoid modification during iteration
foreach (var controllable in controlledCopy)
{
if (Exists(controllable))
Unregister(controllable);
}
// Unregister all consoles
var consolesCopy = component.Consoles.ToList(); // Create copy to avoid modification during iteration
foreach (var console in consolesCopy)
{
if (Exists(console))
UnregisterConsole(console);
}
// Clear the server's state
component.Controlled.Clear();
component.Consoles.Clear();
component.ConnectedGrid = null;
component.UsedProcessingPower = 0;
}
public void RefreshControllables(EntityUid grid, FireControlGridComponent? component = null)
{
if (!Resolve(grid, ref component))
return;
if (component.ControllingServer == null)
return;
// Check if the controlling server still exists
if (!Exists(component.ControllingServer) || !TryComp(component.ControllingServer, out var server))
{
// Clear the invalid reference
component.ControllingServer = null;
return;
}
server.Controlled.Clear();
server.UsedProcessingPower = 0;
var query = EntityQueryEnumerator();
while (query.MoveNext(out var controllable, out var controlComp))
{
if (_xform.GetGrid(controllable) == grid && EntityManager.GetComponent(controllable).Anchored)
TryRegister(controllable, controlComp);
}
foreach (var console in server.Consoles)
UpdateUi(console);
}
private bool TryConnect(EntityUid server, FireControlServerComponent? component = null)
{
if (!Resolve(server, ref component))
return false;
var grid = _xform.GetGrid(server);
if (grid == null)
return false;
var controlGrid = EnsureComp((EntityUid)grid);
// Check if there's already a controlling server and if it's valid
if (controlGrid.ControllingServer != null)
{
// If the controlling server no longer exists, clear the reference
if (!Exists(controlGrid.ControllingServer) || !TryComp(controlGrid.ControllingServer, out _))
{
controlGrid.ControllingServer = null;
}
else
{
// Valid server already exists, cannot connect
return false;
}
}
controlGrid.ControllingServer = server;
component.ConnectedGrid = grid;
RefreshControllables((EntityUid)grid, controlGrid);
return true;
}
private void Unregister(EntityUid controllable, FireControllableComponent? component = null)
{
if (!Resolve(controllable, ref component))
return;
if (component.ControllingServer == null || !TryComp(component.ControllingServer, out var controlComp))
return;
controlComp.Controlled.Remove(controllable);
controlComp.UsedProcessingPower -= GetProcessingPowerCost(controllable, component);
component.ControllingServer = null;
}
private bool TryRegister(EntityUid controllable, FireControllableComponent? component = null)
{
if (!Resolve(controllable, ref component))
return false;
var gridServer = TryGetGridServer(controllable);
if (gridServer.ServerUid == null || gridServer.ServerComponent == null)
return false;
var processingPowerCost = GetProcessingPowerCost(controllable, component);
if (processingPowerCost > GetRemainingProcessingPower(gridServer.ServerUid.Value, gridServer.ServerComponent))
return false;
if (gridServer.ServerComponent.Controlled.Add(controllable))
{
gridServer.ServerComponent.UsedProcessingPower += processingPowerCost;
component.ControllingServer = gridServer.ServerUid;
return true;
}
else
{
return false;
}
}
public int GetRemainingProcessingPower(EntityUid server, FireControlServerComponent? component = null)
{
if (!Resolve(server, ref component))
return 0;
return component.ProcessingPower - component.UsedProcessingPower;
}
public int GetProcessingPowerCost(EntityUid controllable, FireControllableComponent? component = null)
{
if (!Resolve(controllable, ref component))
return 0;
if (!TryComp(controllable, out var classComponent))
return 0;
return classComponent.Class switch
{
ShipGunClass.Superlight => 1,
ShipGunClass.Light => 3,
ShipGunClass.Medium => 6,
ShipGunClass.Heavy => 9,
ShipGunClass.Superheavy => 12,
_ => 0,
};
}
private (EntityUid? ServerUid, FireControlServerComponent? ServerComponent) TryGetGridServer(EntityUid uid)
{
var grid = _xform.GetGrid(uid);
if (grid == null)
return (null, null);
if (!TryComp(grid, out var controlGrid))
return (null, null);
if (controlGrid.ControllingServer == null)
return (null, null);
// Check if the controlling server still exists and has the component
if (!Exists(controlGrid.ControllingServer) || !TryComp(controlGrid.ControllingServer, out var server))
{
// Clear the invalid reference
controlGrid.ControllingServer = null;
return (null, null);
}
return (controlGrid.ControllingServer, server);
}
///
/// Cleans up all invalid server references across all grids
///
public void CleanupInvalidServerReferences()
{
var gridQuery = EntityQueryEnumerator();
while (gridQuery.MoveNext(out var gridUid, out var gridComponent))
{
if (gridComponent.ControllingServer != null)
{
if (!Exists(gridComponent.ControllingServer) || !TryComp(gridComponent.ControllingServer, out _))
{
gridComponent.ControllingServer = null;
RemComp(gridUid);
}
}
}
}
///
/// Forces all powered servers on a specific grid to attempt reconnection
///
public void ForceServerReconnectionOnGrid(EntityUid gridUid)
{
var serverQuery = EntityQueryEnumerator();
while (serverQuery.MoveNext(out var serverUid, out var serverComponent))
{
var serverGrid = _xform.GetGrid(serverUid);
if (serverGrid == gridUid && _power.IsPowered(serverUid))
{
// Force reconnection attempt
TryConnect(serverUid, serverComponent);
}
}
}
public void FireWeapons(EntityUid server, List weapons, NetCoordinates coordinates, FireControlServerComponent? component = null)
{
if (!Resolve(server, ref component))
return;
// Check if the weapon's grid is in FTL
var grid = component.ConnectedGrid;
if (grid != null && TryComp((EntityUid)grid, out var ftlComp))
return;
// Check if the weapon's grid is pacified
if (grid != null && TryComp((EntityUid)grid, out var pacifiedComp))
return;
// Check if the weapon is an expedition
if (grid != null &&
TryComp((EntityUid)grid, out var gridXform) &&
gridXform.MapUid != null &&
HasComp(gridXform.MapUid.Value))
return;
var targetCoords = GetCoordinates(coordinates);
foreach (var weapon in weapons)
{
var localWeapon = GetEntity(weapon);
if (!Exists(localWeapon) || !component.Controlled.Contains(localWeapon))
continue;
if (!TryComp(localWeapon, out var gun))
continue;
if (TryComp(localWeapon, out var weaponXform))
{
var currentMapCoords = _xform.GetMapCoordinates(localWeapon, weaponXform);
var destinationMapCoords = targetCoords.ToMap(EntityManager, _xform);
if (destinationMapCoords.MapId == currentMapCoords.MapId && currentMapCoords.MapId != MapId.Nullspace)
{
var diff = destinationMapCoords.Position - currentMapCoords.Position;
if (TryComp(localWeapon, out var rotateEnabled))
if (diff.LengthSquared() > 0.01f)
{
// Only rotate the gun if it has line of sight to the target
if (HasLineOfSight(localWeapon, currentMapCoords.Position, destinationMapCoords.Position, currentMapCoords.MapId))
{
var goalAngle = Angle.FromWorldVec(diff);
_rotateToFace.TryRotateTo(localWeapon, goalAngle, 0f, Angle.FromDegrees(1), float.MaxValue, weaponXform);
}
}
}
}
var weaponX = Transform(localWeapon);
var targetPos = targetCoords.ToMap(EntityManager, _xform);
if (targetPos.MapId != weaponX.MapID)
continue;
var weaponPos = _xform.GetWorldPosition(weaponX);
// Get direction to target
var direction = (targetPos.Position - weaponPos);
var distance = direction.Length();
if (distance <= 0)
continue;
direction = Vector2.Normalize(direction);
// Check for obstacles in the firing direction
if (!CanFireInDirection(localWeapon, weaponPos, direction, targetPos.Position, weaponX.MapID))
continue;
// If we can fire, fire the weapon
_gun.AttemptShoot(localWeapon, localWeapon, gun, targetCoords);
}
}
///
/// Checks all controllables on a grid and unregisters any that don't belong.
///
/// The GCS server entity
/// The server component
public void UpdateAllControllables(EntityUid server, FireControlServerComponent? component = null)
{
if (!Resolve(server, ref component) || component.ConnectedGrid == null)
return;
// Get a copy of the controlled entities list to avoid modification during iteration
var controlled = component.Controlled.ToList();
foreach (var controllable in controlled)
{
if (TryComp(controllable, out var controlComp))
{
var currentGrid = _xform.GetGrid(controllable);
if (currentGrid != component.ConnectedGrid)
{
Unregister(controllable, controlComp);
}
}
}
// Update UI for all consoles
foreach (var console in component.Consoles)
{
if (TryComp(console, out var consoleComp))
{
UpdateUi(console, consoleComp);
}
}
}
private void OnGridSplit(ref GridSplitEvent ev)
{
// Check all GCS servers for affected grids
var query = EntityQueryEnumerator();
while (query.MoveNext(out var serverUid, out var server))
{
if (server.ConnectedGrid == ev.Grid)
{
// Grid has been split, check all controllables
UpdateAllControllables(serverUid, server);
}
}
}
///
/// Attempts to fire a weapon, handling aiming and firing logic.
///
public bool AttemptFire(EntityUid weapon, EntityUid user, EntityCoordinates coords, FireControllableComponent? comp = null)
{
if (!Resolve(weapon, ref comp))
return false;
// Check if the weapon is ready to fire
if (!CanFire(weapon, comp))
return false;
// Get weapon and target positions
var weaponXform = Transform(weapon);
var weaponPos = _xform.GetWorldPosition(weaponXform);
var targetPos = coords.ToMap(EntityManager, _xform).Position;
// Calculate direction
var direction = targetPos - weaponPos;
var distance = direction.Length();
if (distance <= float.Epsilon)
return false; // Can't fire at the same position
direction = Vector2.Normalize(direction);
// Check for obstacles in the firing direction
if (!CanFireInDirection(weapon, weaponPos, direction, targetPos, weaponXform.MapID))
return false;
// Set the cooldown for next firing
comp.NextFire = _timing.CurTime + TimeSpan.FromSeconds(comp.FireCooldown);
// Try to get a gun component and fire the weapon
if (TryComp(weapon, out var gun))
{
_gun.AttemptShoot(weapon, user, gun, coords);
return true;
}
return false;
}
///
/// Checks if a weapon is ready to fire.
///
private bool CanFire(EntityUid weapon, FireControllableComponent comp)
{
// Check if weapon is powered
if (!_power.IsPowered(weapon))
return false;
// Check if weapon is connected to a server
if (comp.ControllingServer == null)
return false;
// Check for other conditions like cooldowns if needed
if (comp.NextFire > _timing.CurTime)
return false;
return true;
}
///
/// Checks if a weapon has line of sight to a target position
///
/// The weapon entity
/// The weapon's position
/// The target position
/// The map ID
/// Maximum raycast distance in meters
/// True if the weapon has line of sight to the target
private bool HasLineOfSight(EntityUid weapon, Vector2 weaponPos, Vector2 targetPos, MapId mapId, float maxDistance = 500f)
{
// Calculate direction to target
var direction = (targetPos - weaponPos);
var distance = direction.Length();
if (distance <= 0)
return false; // Can't have LOS to the same position
direction = Vector2.Normalize(direction);
// Get the weapon's grid for grid filtering
var weaponTransform = Transform(weapon);
var weaponGridUid = weaponTransform.GridUid;
// Calculate distance to target (capped at maximum distance)
var targetDistance = Vector2.Distance(weaponPos, targetPos);
var rayDistance = Math.Min(targetDistance, maxDistance);
// Initialize ray collision
var ray = new CollisionRay(weaponPos, direction, collisionMask: (int)(CollisionGroup.Opaque | CollisionGroup.Impassable));
// Create a predicate that ignores entities not on the same grid
bool IgnoreEntityNotOnSameGrid(EntityUid entity, EntityUid sourceWeapon)
{
// Always ignore the source weapon itself
if (entity == sourceWeapon)
return true;
// If the weapon isn't on a grid, we'll check against all entities
if (weaponGridUid == null)
return false;
// Get the entity's grid
var entityTransform = Transform(entity);
var entityGridUid = entityTransform.GridUid;
// Ignore if not on the same grid
return entityGridUid != weaponGridUid;
}
// Check if there's any obstacles in the line of sight, only considering entities on the same grid
var raycastResults = _physics.IntersectRayWithPredicate(
mapId,
ray,
weapon,
IgnoreEntityNotOnSameGrid,
rayDistance,
returnOnFirstHit: true // We only need to know if there's ANY obstacle
).ToList();
// Has line of sight if there are no obstacles in the path
return raycastResults.Count == 0;
}
///
/// Checks if a weapon can fire in a specific direction without obstacles
///
/// The weapon entity
/// The weapon's position
/// Normalized direction vector
/// The target position
/// The map ID
/// Maximum raycast distance in meters
/// True if the weapon can fire in that direction
private bool CanFireInDirection(EntityUid weapon, Vector2 weaponPos, Vector2 direction, Vector2 targetPos, MapId mapId, float maxDistance = 500f)
{
// Use the HasLineOfSight method for consistency
return HasLineOfSight(weapon, weaponPos, targetPos, mapId, maxDistance);
}
///
/// Checks if a weapon can fire in a full 360-degree circle around it to find clear firing lanes
///
/// The weapon entity
/// Maximum raycast distance in meters
/// Number of rays to cast around the entity
/// Dictionary mapping directions (angles in degrees) to whether they're clear for firing
public Dictionary CheckAllDirections(EntityUid weapon, float maxDistance = 500f, int rayCount = 256)
{
var directions = new Dictionary();
var transform = Transform(weapon);
var position = _xform.GetWorldPosition(transform);
var mapId = transform.MapID;
var weaponGridUid = transform.GridUid;
// Create a predicate that ignores entities not on the same grid
bool IgnoreEntityNotOnSameGrid(EntityUid entity, EntityUid sourceWeapon)
{
// Always ignore the source weapon itself
if (entity == sourceWeapon)
return true;
// If the weapon isn't on a grid, we'll check against all entities
if (weaponGridUid == null)
return false;
// Get the entity's grid
var entityTransform = Transform(entity);
var entityGridUid = entityTransform.GridUid;
// Ignore if not on the same grid
return entityGridUid != weaponGridUid;
}
// Cast rays in all directions to check for clear firing lanes
for (var i = 0; i < rayCount; i++)
{
// Calculate angle and direction for this ray
var angle = (i / (float)rayCount) * MathF.Tau;
var direction = new Vector2(MathF.Cos(angle), MathF.Sin(angle));
// Initialize ray collision
var ray = new CollisionRay(position, direction, collisionMask: (int)(CollisionGroup.Opaque | CollisionGroup.Impassable));
// Check if there's any obstacles in this direction, only considering entities on the same grid
var raycastResults = _physics.IntersectRayWithPredicate(
mapId,
ray,
weapon,
IgnoreEntityNotOnSameGrid,
maxDistance,
returnOnFirstHit: false
).ToList();
// Direction is clear if there are no obstacles
var canFire = raycastResults.Count == 0;
directions[angle * 180 / MathF.PI] = canFire;
}
return directions;
}
///
/// Sends a visualization event to all clients
///
/// Entity to visualize
/// Firing direction data
public void SendVisualizationEvent(EntityUid entityUid, Dictionary directions)
{
var netEntity = GetNetEntity(entityUid);
var ev = new FireControlVisualizationEvent(
netEntity,
directions
);
RaiseNetworkEvent(ev);
}
///
/// Toggles visualization for an entity
///
/// Entity to toggle visualization for
/// True if visualization was enabled, false if disabled
public bool ToggleVisualization(EntityUid entityUid)
{
var netEntity = GetNetEntity(entityUid);
// Check if already visualized
if (_visualizedEntities.Contains(entityUid))
{
// Turn off visualization
_visualizedEntities.Remove(entityUid);
RaiseNetworkEvent(new FireControlVisualizationEvent(netEntity));
return false;
}
// Turn on visualization
_visualizedEntities.Add(entityUid);
var directions = CheckAllDirections(entityUid);
RaiseNetworkEvent(new FireControlVisualizationEvent(netEntity, directions));
return true;
}
}
public sealed class FireControllableStatusReportEvent : EntityEventArgs
{
public List<(string type, string content)> StatusReports = new();
}