Add a crew monitoring server (#7542)

This commit is contained in:
Julian Giebel
2023-01-23 02:07:57 +01:00
committed by GitHub
parent ad9c5ae5e9
commit c2b87dfeda
20 changed files with 518 additions and 58 deletions

View File

@@ -0,0 +1,22 @@
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
namespace Content.Server.DeviceNetwork.Components
{
[RegisterComponent]
public sealed class StationLimitedNetworkComponent : Component
{
/// <summary>
/// The station id the device is limited to.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
public EntityUid? StationId;
/// <summary>
/// Whether the entity is allowed to receive packets from entities that are not tied to any station
/// </summary>
[DataField("allowNonStationPackets")]
[ViewVariables(VVAccess.ReadWrite)]
public bool AllowNonStationPackets = false;
}
}

View File

@@ -82,7 +82,7 @@ public sealed class DeviceNet
/// </summary>
public bool Remove(DeviceNetworkComponent device)
{
if (device.Address == null || Devices.Remove(device.Address))
if (device.Address == null || !Devices.Remove(device.Address))
return false;
if (device.ReceiveFrequency is not uint freq)

View File

@@ -48,18 +48,22 @@ namespace Content.Server.DeviceNetwork.Systems
/// <param name="address">The address of the entity that the packet gets sent to. If null, the message is broadcast to all devices on that frequency (except the sender)</param>
/// <param name="frequency">The frequency to send on</param>
/// <param name="data">The data to be sent</param>
public void QueuePacket(EntityUid uid, string? address, NetworkPayload data, uint? frequency = null, DeviceNetworkComponent? device = null)
/// <returns>Returns true when the packet was successfully enqueued.</returns>
public bool QueuePacket(EntityUid uid, string? address, NetworkPayload data, uint? frequency = null, DeviceNetworkComponent? device = null)
{
if (!Resolve(uid, ref device, false))
return;
return false;
if (device.Address == string.Empty)
return;
return false;
frequency ??= device.TransmitFrequency;
if (frequency != null)
_packets.Enqueue(new DeviceNetworkPacketEvent(device.DeviceNetId, address, frequency.Value, device.Address, uid, data));
if (frequency == null)
return false;
_packets.Enqueue(new DeviceNetworkPacketEvent(device.DeviceNetId, address, frequency.Value, device.Address, uid, data));
return true;
}
private void OnExamine(EntityUid uid, DeviceNetworkComponent device, ExaminedEvent args)
@@ -137,6 +141,32 @@ namespace Content.Server.DeviceNetwork.Systems
return GetNetwork(device.DeviceNetId).Remove(device);
}
/// <summary>
/// Checks if a device is already connected to its network
/// </summary>
/// <returns>True if the device was found in the network with its corresponding network id</returns>
public bool IsDeviceConnected(EntityUid uid, DeviceNetworkComponent? device)
{
if (!Resolve(uid, ref device, false))
return false;
if (!_networks.TryGetValue(device.DeviceNetId, out var deviceNet))
return false;
return deviceNet.Devices.ContainsValue(device);
}
/// <summary>
/// Checks if an address exists in the network with the given netId
/// </summary>
public bool IsAddressPresent(int netId, string? address)
{
if (address == null || !_networks.TryGetValue(netId, out var network))
return false;
return network.Devices.ContainsKey(address);
}
public void SetReceiveFrequency(EntityUid uid, uint? frequency, DeviceNetworkComponent? device = null)
{
if (!Resolve(uid, ref device, false))

View File

@@ -0,0 +1,68 @@
using Content.Server.DeviceNetwork.Components;
using Content.Server.Station.Systems;
using JetBrains.Annotations;
using Robust.Shared.Map;
namespace Content.Server.DeviceNetwork.Systems
{
/// <summary>
/// This system requires the StationLimitedNetworkComponent to be on the the sending entity as well as the receiving entity
/// </summary>
[UsedImplicitly]
public sealed class StationLimitedNetworkSystem : EntitySystem
{
[Dependency] private readonly StationSystem _stationSystem = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<StationLimitedNetworkComponent, MapInitEvent>(OnMapInit);
SubscribeLocalEvent<StationLimitedNetworkComponent, BeforePacketSentEvent>(OnBeforePacketSent);
}
/// <summary>
/// Sets the station id the device is limited to.
/// </summary>
public void SetStation(EntityUid uid, EntityUid? stationId, StationLimitedNetworkComponent? component = null)
{
if (!Resolve(uid, ref component))
return;
component.StationId = stationId;
}
/// <summary>
/// Set the station id to the one the entity is on when the station limited component is added
/// </summary>
private void OnMapInit(EntityUid uid, StationLimitedNetworkComponent networkComponent, MapInitEvent args)
{
networkComponent.StationId = _stationSystem.GetOwningStation(uid);
}
/// <summary>
/// Checks if both devices are limited to the same station
/// </summary>
private void OnBeforePacketSent(EntityUid uid, StationLimitedNetworkComponent component, BeforePacketSentEvent args)
{
if (!CheckStationId(args.Sender, component.AllowNonStationPackets, component.StationId))
{
args.Cancel();
}
}
/// <summary>
/// Compares the station IDs of the sending and receiving network components.
/// Returns false if either of them doesn't have a station ID or if their station ID isn't equal.
/// Returns true even when the sending entity isn't tied to a station if `allowNonStationPackets` is set to true.
/// </summary>
private bool CheckStationId(EntityUid senderUid, bool allowNonStationPackets, EntityUid? receiverStationId, StationLimitedNetworkComponent? sender = null)
{
if (!receiverStationId.HasValue)
return false;
if (!Resolve(senderUid, ref sender, false))
return allowNonStationPackets;
return sender.StationId == receiverStationId;
}
}
}

View File

@@ -1,9 +1,11 @@
using System.Linq;
using Content.Server.DeviceNetwork;
using Content.Server.DeviceNetwork.Systems;
using Content.Server.Medical.SuitSensors;
using Content.Server.UserInterface;
using Content.Shared.Medical.CrewMonitoring;
using Robust.Shared.Map;
using Content.Shared.Medical.SuitSensor;
using Robust.Shared.Timing;
namespace Content.Server.Medical.CrewMonitoring
@@ -13,33 +15,14 @@ namespace Content.Server.Medical.CrewMonitoring
[Dependency] private readonly SuitSensorSystem _sensors = default!;
[Dependency] private readonly SharedTransformSystem _xform = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
private const float UpdateRate = 3f;
private float _updateDif;
[Dependency] private readonly IMapManager _mapManager = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<CrewMonitoringConsoleComponent, ComponentRemove>(OnRemove);
SubscribeLocalEvent<CrewMonitoringConsoleComponent, DeviceNetworkPacketEvent>(OnPacketReceived);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
// check update rate
_updateDif += frameTime;
if (_updateDif < UpdateRate)
return;
_updateDif = 0f;
var consoles = EntityManager.EntityQuery<CrewMonitoringConsoleComponent>();
foreach (var console in consoles)
{
UpdateTimeouts(console.Owner, console);
UpdateUserInterface(console.Owner, console);
}
SubscribeLocalEvent<CrewMonitoringConsoleComponent, BoundUIOpenedEvent>(OnUIOpened);
}
private void OnRemove(EntityUid uid, CrewMonitoringConsoleComponent component, ComponentRemove args)
@@ -49,12 +32,22 @@ namespace Content.Server.Medical.CrewMonitoring
private void OnPacketReceived(EntityUid uid, CrewMonitoringConsoleComponent component, DeviceNetworkPacketEvent args)
{
var suitSensor = _sensors.PacketToSuitSensor(args.Data);
if (suitSensor == null)
var payload = args.Data;
// check command
if (!payload.TryGetValue(DeviceNetworkConstants.Command, out string? command))
return;
if (command != DeviceNetworkConstants.CmdUpdatedState)
return;
if (!payload.TryGetValue(SuitSensorConstants.NET_STATUS_COLLECTION, out Dictionary<string, SuitSensorStatus>? sensorStatus))
return;
suitSensor.Timestamp = _gameTiming.CurTime;
component.ConnectedSensors[args.SenderAddress] = suitSensor;
component.ConnectedSensors = sensorStatus;
UpdateUserInterface(uid, component);
}
private void OnUIOpened(EntityUid uid, CrewMonitoringConsoleComponent component, BoundUIOpenedEvent args)
{
UpdateUserInterface(uid, component);
}
private void UpdateUserInterface(EntityUid uid, CrewMonitoringConsoleComponent? component = null)
@@ -72,19 +65,5 @@ namespace Content.Server.Medical.CrewMonitoring
var uiState = new CrewMonitoringState(allSensors, xform.WorldPosition, component.Snap, component.Precision);
ui.SetState(uiState);
}
private void UpdateTimeouts(EntityUid uid, CrewMonitoringConsoleComponent? component = null)
{
if (!Resolve(uid, ref component))
return;
foreach (var (address, sensor) in component.ConnectedSensors)
{
// if too many time passed - sensor just dropped connection
var dif = _gameTiming.CurTime - sensor.Timestamp;
if (dif.Seconds > component.SensorTimeout)
component.ConnectedSensors.Remove(address);
}
}
}
}

View File

@@ -0,0 +1,34 @@
using Content.Shared.Medical.SuitSensor;
using Robust.Shared.Map;
namespace Content.Server.Medical.CrewMonitoring;
[RegisterComponent]
[Access(typeof(CrewMonitoringServerSystem))]
public sealed class CrewMonitoringServerComponent : Component
{
/// <summary>
/// List of all currently connected sensors to this server.
/// </summary>
public readonly Dictionary<string, SuitSensorStatus> SensorStatus = new();
/// <summary>
/// After what time sensor consider to be lost.
/// </summary>
[DataField("sensorTimeout"), ViewVariables(VVAccess.ReadWrite)]
public float SensorTimeout = 10f;
/// <summary>
/// Whether the server can become the currently active server. The server being unavailable usually means that it isn't powered
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
public bool Available = true;
/// <summary>
/// Whether the server is the currently active server for the station it's on
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
public bool Active = true;
}

View File

@@ -0,0 +1,198 @@
using Content.Server.DeviceNetwork;
using Content.Server.DeviceNetwork.Components;
using Content.Server.DeviceNetwork.Systems;
using Content.Server.Medical.SuitSensors;
using Content.Server.Power.Components;
using Content.Server.Station.Systems;
using Content.Shared.Medical.SuitSensor;
using Robust.Shared.Timing;
namespace Content.Server.Medical.CrewMonitoring;
public sealed class CrewMonitoringServerSystem : EntitySystem
{
[Dependency] private readonly SuitSensorSystem _sensors = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly DeviceNetworkSystem _deviceNetworkSystem = default!;
[Dependency] private readonly StationSystem _stationSystem = default!;
private const float UpdateRate = 3f;
private float _updateDiff;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<CrewMonitoringServerComponent, ComponentRemove>(OnRemove);
SubscribeLocalEvent<CrewMonitoringServerComponent, DeviceNetworkPacketEvent>(OnPacketReceived);
SubscribeLocalEvent<CrewMonitoringServerComponent, PowerChangedEvent>(OnPowerChanged);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
// check update rate
_updateDiff += frameTime;
if (_updateDiff < UpdateRate)
return;
_updateDiff -= UpdateRate;
var servers = EntityManager.EntityQuery<CrewMonitoringServerComponent>();
List<EntityUid> activeServers = new();
foreach (var server in servers)
{
//Make sure the server is disconnected when it becomes unavailable
if (!server.Available)
{
if (server.Active)
DisconnectServer(server.Owner, server);
continue;
}
if (!server.Active)
continue;
activeServers.Add(server.Owner);
}
foreach (var activeServer in activeServers)
{
UpdateTimeout(activeServer);
BroadcastSensorStatus(activeServer);
}
}
/// <summary>
/// Returns the address of the currently active server for the given station id if there is one
/// </summary>
public bool TryGetActiveServerAddress(EntityUid stationId, out string? address)
{
var servers = EntityManager.EntityQuery<CrewMonitoringServerComponent, DeviceNetworkComponent>();
(CrewMonitoringServerComponent, DeviceNetworkComponent)? last = default;
foreach (var (server, device) in servers)
{
if (!_stationSystem.GetOwningStation(server.Owner)?.Equals(stationId) ?? false)
continue;
if (!server.Available)
{
DisconnectServer(server.Owner,server, device);
continue;
}
last = (server, device);
if (server.Active)
{
address = device.Address;
return true;
}
}
//If there was no active server for the station make the last available inactive one active
if (last.HasValue)
{
ConnectServer(last.Value.Item1.Owner, last.Value.Item1, last.Value.Item2);
address = last.Value.Item2.Address;
return true;
}
address = null;
return address != null;
}
/// <summary>
/// Adds or updates a sensor status entry if the received package is a sensor status update
/// </summary>
private void OnPacketReceived(EntityUid uid, CrewMonitoringServerComponent component, DeviceNetworkPacketEvent args)
{
var sensorStatus = _sensors.PacketToSuitSensor(args.Data);
if (sensorStatus == null)
return;
sensorStatus.Timestamp = _gameTiming.CurTime;
component.SensorStatus[args.SenderAddress] = sensorStatus;
}
/// <summary>
/// Clears the servers sensor status list
/// </summary>
private void OnRemove(EntityUid uid, CrewMonitoringServerComponent component, ComponentRemove args)
{
component.SensorStatus.Clear();
}
/// <summary>
/// Disconnects the server losing power
/// </summary>
private void OnPowerChanged(EntityUid uid, CrewMonitoringServerComponent component, ref PowerChangedEvent args)
{
component.Available = args.Powered;
if (!args.Powered)
DisconnectServer(uid, component);
}
/// <summary>
/// Drop the sensor status if it hasn't been updated for to long
/// </summary>
private void UpdateTimeout(EntityUid uid, CrewMonitoringServerComponent? component = null)
{
if (!Resolve(uid, ref component))
return;
foreach (var (address, sensor) in component.SensorStatus)
{
var dif = _gameTiming.CurTime - sensor.Timestamp;
if (dif.Seconds > component.SensorTimeout)
component.SensorStatus.Remove(address);
}
}
/// <summary>
/// Broadcasts the status of all connected sensors
/// </summary>
private void BroadcastSensorStatus(EntityUid uid, CrewMonitoringServerComponent? serverComponent = null, DeviceNetworkComponent? device = null)
{
if (!Resolve(uid, ref serverComponent, ref device))
return;
var payload = new NetworkPayload()
{
[DeviceNetworkConstants.Command] = DeviceNetworkConstants.CmdUpdatedState,
[SuitSensorConstants.NET_STATUS_COLLECTION] = serverComponent.SensorStatus
};
_deviceNetworkSystem.QueuePacket(uid, null, payload, device: device);
}
private void ConnectServer(EntityUid uid, CrewMonitoringServerComponent? server = null, DeviceNetworkComponent? device = null)
{
if (!Resolve(uid, ref server, ref device))
return;
server.Active = true;
if (_deviceNetworkSystem.IsDeviceConnected(uid, device))
return;
_deviceNetworkSystem.ConnectDevice(uid, device);
}
/// <summary>
/// Disconnects a server from the device network and clears the currently active server
/// </summary>
private void DisconnectServer(EntityUid uid, CrewMonitoringServerComponent? server = null, DeviceNetworkComponent? device = null)
{
if (!Resolve(uid, ref server, ref device))
return;
server.SensorStatus.Clear();
server.Active = false;
_deviceNetworkSystem.DisconnectDevice(uid, device, false);
}
}

View File

@@ -56,5 +56,19 @@ namespace Content.Server.Medical.SuitSensors
/// Last time when sensor updated owners status
/// </summary>
public TimeSpan LastUpdate = TimeSpan.Zero;
/// <summary>
/// The station this suit sensor belongs to. If it's null the suit didn't spawn on a station and the sensor doesn't work.
/// </summary>
[DataField("station")]
public EntityUid? StationId = null;
/// <summary>
/// The server the suit sensor sends it state to.
/// The suit sensor will try connecting to a new server when no server is connected.
/// It does this by calling the servers entity system for performance reasons.
/// </summary>
[DataField("server")]
public string? ConnectedServer = null;
}
}

View File

@@ -2,7 +2,9 @@ using Content.Server.Access.Systems;
using Content.Server.DeviceNetwork;
using Content.Server.DeviceNetwork.Components;
using Content.Server.DeviceNetwork.Systems;
using Content.Server.Medical.CrewMonitoring;
using Content.Server.Popups;
using Content.Server.Station.Systems;
using Content.Shared.Damage;
using Content.Shared.Examine;
using Content.Shared.Inventory.Events;
@@ -25,6 +27,8 @@ namespace Content.Server.Medical.SuitSensors
[Dependency] private readonly MobStateSystem _mobStateSystem = default!;
[Dependency] private readonly DeviceNetworkSystem _deviceNetworkSystem = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly CrewMonitoringServerSystem _monitoringServerSystem = default!;
[Dependency] private readonly StationSystem _stationSystem = default!;
[Dependency] private readonly SharedTransformSystem _xform = default!;
private const float UpdateRate = 1f;
@@ -57,27 +61,48 @@ namespace Content.Server.Medical.SuitSensors
var sensors = EntityManager.EntityQuery<SuitSensorComponent, DeviceNetworkComponent>();
foreach (var (sensor, device) in sensors)
{
if (device.TransmitFrequency is not uint frequency)
if (!device.TransmitFrequency.HasValue || !sensor.StationId.HasValue)
continue;
// check if sensor is ready to update
if (curTime - sensor.LastUpdate < sensor.UpdateRate)
continue;
sensor.LastUpdate = curTime;
// Add a random offset to the next update time that isn't longer than the sensors update rate
sensor.LastUpdate = curTime.Add(TimeSpan.FromSeconds(_random.Next(0, sensor.UpdateRate.Seconds)));
// get sensor status
var status = GetSensorState(sensor.Owner, sensor);
if (status == null)
continue;
// broadcast it to device network
//Retrieve active server address if the sensor isn't connected to a server
if (sensor.ConnectedServer == null)
{
if (!_monitoringServerSystem.TryGetActiveServerAddress(sensor.StationId.Value, out var address))
continue;
sensor.ConnectedServer = address;
}
// Send it to the connected server
var payload = SuitSensorToPacket(status);
_deviceNetworkSystem.QueuePacket(sensor.Owner, null, payload, device: device);
// Clear the connected server if its address isn't on the network
if (!_deviceNetworkSystem.IsAddressPresent(device.DeviceNetId, sensor.ConnectedServer))
{
sensor.ConnectedServer = null;
continue;
}
_deviceNetworkSystem.QueuePacket(sensor.Owner, sensor.ConnectedServer, payload, device: device);
}
}
private void OnMapInit(EntityUid uid, SuitSensorComponent component, MapInitEvent args)
{
component.StationId = _stationSystem.GetOwningStation(uid);
// generate random mode
if (component.RandomMode)
{
@@ -277,7 +302,7 @@ namespace Content.Server.Medical.SuitSensors
}
/// <summary>
/// Serialize suit sensor status into device network package.
/// Serialize create a device network package from the suit sensors status.
/// </summary>
public NetworkPayload SuitSensorToPacket(SuitSensorStatus status)
{
@@ -299,7 +324,7 @@ namespace Content.Server.Medical.SuitSensors
}
/// <summary>
/// Try to deserialize device network message into suit sensor status
/// Try to create the suit sensors status from the device network message
/// </summary>
public SuitSensorStatus? PacketToSuitSensor(NetworkPayload payload)
{

View File

@@ -51,5 +51,8 @@ namespace Content.Shared.Medical.SuitSensor
public const string NET_IS_ALIVE = "alive";
public const string NET_TOTAL_DAMAGE = "vitals";
public const string NET_CORDINATES = "cords";
///Used by the CrewMonitoringServerSystem to send the status of all connected suit sensors to each crew monitor
public const string NET_STATUS_COLLECTION = "suit-status-collection";
}
}

View File

@@ -1,6 +1,7 @@
# named frequencies
device-frequency-prototype-name-atmos = Atmospheric Devices
device-frequency-prototype-name-suit-sensors = Suit Sensors
device-frequency-prototype-name-crew-monitor = Crew Monitor
device-frequency-prototype-name-lights = Smart Lights
device-frequency-prototype-name-mailing-units = Mailing Units
device-frequency-prototype-name-pdas = PDAs

View File

@@ -53,12 +53,19 @@
name: device-frequency-prototype-name-atmos
frequency: 1621
# Only listen to this frequency if you are a health or GPS monitor. Otherwise you will just slow down the server by constantly receiving periodic broadcasts from every player-entity.
# Only listen to this frequency if you are a crew monitor server. Otherwise you will just slow down the server by constantly receiving periodic broadcasts from every player-entity.
- type: deviceFrequency
id: SuitSensor
name: device-frequency-prototype-name-suit-sensors
frequency: 1262
# Crew monitors listen to this for a list of suit sensor statuses
- type: deviceFrequency
id: CrewMonitor
name: device-frequency-prototype-name-crew-monitor
frequency: 1261
# This frequency will likely have a LARGE number of listening entities. Please don't broadcast on this frequency.
- type: deviceFrequency
id: SmartLight #used by powered lights.

View File

@@ -35,7 +35,8 @@
deviceNetId: Wireless
transmitFrequencyId: SuitSensor
- type: WirelessNetworkConnection
range: 500
range: 1200
- type: StationLimitedNetwork
- type: entity
abstract: true

View File

@@ -98,9 +98,10 @@
precision: 3
- type: DeviceNetwork
deviceNetId: Wireless
receiveFrequencyId: SuitSensor
receiveFrequencyId: CrewMonitor
- type: WirelessNetworkConnection
range: 500
- type: StationLimitedNetwork
- type: Thieving
stripTimeReduction: 9999
stealthy: true

View File

@@ -302,6 +302,21 @@
Glass: 5
Cable: 1
- type: entity
id: CrewMonitoringServerMachineCircuitboard
parent: BaseMachineCircuitboard
name: crew monitoring server machine board
description: A machine printed circuit board for a crew monitoring server
components:
- type: MachineBoard
prototype: CrewMonitoringServer
requirements:
Capacitor: 1
ScanningModule: 2
materialRequirements:
Steel: 1
Cable: 2
- type: entity
id: CryoPodMachineCircuitboard
parent: BaseMachineCircuitboard

View File

@@ -17,8 +17,9 @@
- type: CrewMonitoringConsole
- type: DeviceNetwork
deviceNetId: Wireless
receiveFrequencyId: SuitSensor
receiveFrequencyId: CrewMonitor
- type: WirelessNetworkConnection
range: 500
- type: StationLimitedNetwork
- type: StaticPrice
price: 500

View File

@@ -311,9 +311,9 @@
- type: CrewMonitoringConsole
- type: DeviceNetwork
deviceNetId: Wireless
receiveFrequencyId: SuitSensor
receiveFrequencyId: CrewMonitor
- type: WirelessNetworkConnection
range: 500
range: 1200
- type: entity
parent: BaseComputer

View File

@@ -0,0 +1,57 @@
- type: entity
id: CrewMonitoringServer
parent: BaseMachinePowered
name: crew monitoring server
description: Receives and relays the status of all active suit sensors on the station.
components:
- type: Sprite
sprite: Structures/Machines/server.rsi
layers:
- state: server
- state: variant-crew
- type: Construction
graph: Machine
node: machine
containers:
- machine_board
- machine_parts
- type: Machine
board: CrewMonitoringServerMachineCircuitboard
- type: ContainerContainer
containers:
machine_board: !type:Container
machine_parts: !type:Container
- type: CrewMonitoringServer
- type: DeviceNetwork
deviceNetId: Wireless
transmitFrequencyId: CrewMonitor
receiveFrequencyId: SuitSensor
autoConnect: false
- type: WirelessNetworkConnection
range: 500
- type: StationLimitedNetwork
- type: ApcPowerReceiver
powerLoad: 200
priority: Low
- type: ExtensionCableReceiver
- type: Destructible
thresholds:
- trigger:
!type:DamageTrigger
damage: 300
behaviors:
- !type:DoActsBehavior
acts: ["Destruction"]
- !type:PlaySoundBehavior
sound:
path: /Audio/Effects/metalbreak.ogg
- !type:SpawnEntitiesBehavior
spawn:
SheetSteel1:
min: 1
max: 2
- type: AmbientSound
volume: -9
range: 5
sound:
path: /Audio/Ambience/Objects/server_fans.ogg

View File

@@ -26,6 +26,10 @@
{
"name": "server_o"
},
{
"name": "variant-crew"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B