<!-- Power On/Off -->
<Label Text="{Loc 'apc-menu-breaker-label'}" HorizontalExpand="True"
StyleClasses="highlight" MinWidth="120"/>
- <BoxContainer Orientation="Horizontal" MinWidth="90">
+ <BoxContainer Orientation="Horizontal" MinWidth="150">
<Button Name="BreakerButton" Text="{Loc 'apc-menu-breaker-button'}" HorizontalExpand="True" ToggleMode="True"/>
</BoxContainer>
<!--Charging Status-->
if (PowerLabel != null)
{
- PowerLabel.Text = Loc.GetString("apc-menu-power-state-label-text", ("power", castState.Power));
+ if (castState.Tripped)
+ {
+ PowerLabel.Text = Loc.GetString("apc-menu-power-state-label-tripped");
+ }
+ else
+ {
+ PowerLabel.Text = Loc.GetString("apc-menu-power-state-label-text", ("power", castState.Power), ("maxLoad", castState.MaxLoad));
+ }
}
if (ExternalPowerStateLabel != null)
using Content.Shared.Maps;
using Content.Shared.Power.Components;
using Content.Shared.NodeContainer;
+using Robust.Server.GameObjects;
using Robust.Shared.EntitySerialization;
namespace Content.IntegrationTests.Tests.Power;
-[Explicit]
public sealed class StationPowerTests
{
/// <summary>
private static readonly string[] GameMaps =
[
- "Fland",
- "Meta",
- "Packed",
- "Omega",
"Bagel",
"Box",
- "Core",
+ "Elkridge",
+ "Fland",
"Marathon",
- "Saltern",
- "Reach",
- "Train",
"Oasis",
- "Gate",
- "Amber",
- "Loop",
+ "Packed",
"Plasma",
- "Elkridge",
- "Convex",
"Relic",
+ "Snowball",
+ "Reach",
+ "Exo",
];
+ [Explicit]
[Test, TestCaseSource(nameof(GameMaps))]
public async Task TestStationStartingPowerWindow(string mapProtoId)
{
$"Needs at least {requiredStoredPower - totalStartingCharge} more stored power!");
});
+ await pair.CleanReturnAsync();
+ }
+
+ [Test, TestCaseSource(nameof(GameMaps))]
+ public async Task TestApcLoad(string mapProtoId)
+ {
+ await using var pair = await PoolManager.GetServerClient(new PoolSettings
+ {
+ Dirty = true,
+ });
+ var server = pair.Server;
+
+ var entMan = server.EntMan;
+ var protoMan = server.ProtoMan;
+ var ticker = entMan.System<GameTicker>();
+ var xform = entMan.System<TransformSystem>();
+
+ // Load the map
+ await server.WaitAssertion(() =>
+ {
+ Assert.That(protoMan.TryIndex<GameMapPrototype>(mapProtoId, out var mapProto));
+ var opts = DeserializationOptions.Default with { InitializeMaps = true };
+ ticker.LoadGameMap(mapProto, out var mapId, opts);
+ });
+
+ // Wait long enough for power to ramp up, but before anything can trip
+ await pair.RunSeconds(2);
+
+ // Check that no APCs start overloaded
+ var apcQuery = entMan.EntityQueryEnumerator<ApcComponent, PowerNetworkBatteryComponent>();
+ Assert.Multiple(() =>
+ {
+ while (apcQuery.MoveNext(out var uid, out var apc, out var battery))
+ {
+ // Uncomment the following line to log starting APC load to the console
+ //Console.WriteLine($"ApcLoad:{mapProtoId}:{uid}:{battery.CurrentSupply}");
+ if (xform.TryGetMapOrGridCoordinates(uid, out var coord))
+ {
+ Assert.That(apc.MaxLoad, Is.GreaterThanOrEqualTo(battery.CurrentSupply),
+ $"APC {uid} on {mapProtoId} ({coord.Value.X}, {coord.Value.Y}) is overloaded {battery.CurrentSupply} / {apc.MaxLoad}");
+ }
+ else
+ {
+ Assert.That(apc.MaxLoad, Is.GreaterThanOrEqualTo(battery.CurrentSupply),
+ $"APC {uid} on {mapProtoId} is overloaded {battery.CurrentSupply} / {apc.MaxLoad}");
+ }
+ }
+ });
await pair.CleanReturnAsync();
}
namespace Content.Server.Power.Components;
-[RegisterComponent]
+[RegisterComponent, AutoGenerateComponentPause]
public sealed partial class ApcComponent : BaseApcNetComponent
{
[DataField("onReceiveMessageSound")]
public const float HighPowerThreshold = 0.9f;
public static TimeSpan VisualsChangeDelay = TimeSpan.FromSeconds(1);
+ /// <summary>
+ /// Maximum continuous load in Watts that this APC can supply to loads. Exceeding this starts a
+ /// timer, which after enough overloading causes the APC to "trip" off.
+ /// </summary>
+ [DataField]
+ public float MaxLoad = 20e3f;
+
+ /// <summary>
+ /// Time that the APC can be continuously overloaded before tripping off.
+ /// </summary>
+ [DataField]
+ public TimeSpan TripTime = TimeSpan.FromSeconds(3);
+
+ /// <summary>
+ /// Time that overloading began.
+ /// </summary>
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField]
+ public TimeSpan? TripStartTime;
+
+ /// <summary>
+ /// Set to true if the APC tripped off. Used to indicate problems in the UI. Reset by switching
+ /// APC on.
+ /// </summary>
+ [DataField]
+ public bool TripFlag;
+
// TODO ECS power a little better!
// End the suffering
protected override void AddSelfToNet(IApcNet apcNet)
public override void Update(float deltaTime)
{
var query = EntityQueryEnumerator<ApcComponent, PowerNetworkBatteryComponent, UserInterfaceComponent>();
+ var curTime = _gameTiming.CurTime;
while (query.MoveNext(out var uid, out var apc, out var battery, out var ui))
{
- if (apc.LastUiUpdate + ApcComponent.VisualsChangeDelay < _gameTiming.CurTime && _ui.IsUiOpen((uid, ui), ApcUiKey.Key))
+ if (apc.LastUiUpdate + ApcComponent.VisualsChangeDelay < curTime && _ui.IsUiOpen((uid, ui), ApcUiKey.Key))
{
- apc.LastUiUpdate = _gameTiming.CurTime;
+ apc.LastUiUpdate = curTime;
UpdateUIState(uid, apc, battery);
}
{
UpdateApcState(uid, apc, battery);
}
+
+ // Overload
+ if (apc.MainBreakerEnabled && battery.CurrentSupply > apc.MaxLoad)
+ {
+ // Not already overloaded, start timer
+ if (apc.TripStartTime == null)
+ {
+ apc.TripStartTime = curTime;
+ }
+ else
+ {
+ if (curTime - apc.TripStartTime > apc.TripTime)
+ {
+ apc.TripFlag = true;
+ ApcToggleBreaker(uid, apc, battery); // off, we already checked MainBreakerEnabled above
+ }
+ }
+ }
+ else
+ {
+ apc.TripStartTime = null;
+ }
}
}
apc.MainBreakerEnabled = !apc.MainBreakerEnabled;
battery.CanDischarge = apc.MainBreakerEnabled;
+ if (apc.MainBreakerEnabled)
+ apc.TripFlag = false;
+
UpdateUIState(uid, apc);
_audio.PlayPvs(apc.OnReceiveMessageSound, uid, AudioParams.Default.WithVolume(-2f));
}
var state = new ApcBoundInterfaceState(apc.MainBreakerEnabled,
(int) MathF.Ceiling(battery.CurrentSupply), apc.LastExternalState,
- charge);
+ charge,
+ apc.MaxLoad,
+ apc.TripFlag);
_ui.SetUiState((uid, ui), ApcUiKey.Key, state);
}
public readonly int Power;
public readonly ApcExternalPowerState ApcExternalPower;
public readonly float Charge;
+ public readonly float MaxLoad;
+ public readonly bool Tripped;
- public ApcBoundInterfaceState(bool mainBreaker, int power, ApcExternalPowerState apcExternalPower, float charge)
+ public ApcBoundInterfaceState(bool mainBreaker, int power, ApcExternalPowerState apcExternalPower, float charge, float maxLoad, bool tripped)
{
MainBreaker = mainBreaker;
Power = power;
ApcExternalPower = apcExternalPower;
Charge = charge;
+ MaxLoad = maxLoad;
+ Tripped = tripped;
}
public bool Equals(ApcBoundInterfaceState? other)
return MainBreaker == other.MainBreaker &&
Power == other.Power &&
ApcExternalPower == other.ApcExternalPower &&
- MathHelper.CloseTo(Charge, other.Charge);
+ MathHelper.CloseTo(Charge, other.Charge) &&
+ MathHelper.CloseTo(MaxLoad, other.MaxLoad) &&
+ Tripped == other.Tripped;
}
public override bool Equals(object? obj)
public override int GetHashCode()
{
- return HashCode.Combine(MainBreaker, Power, (int) ApcExternalPower, Charge);
+ return HashCode.Combine(MainBreaker, Power, (int) ApcExternalPower, Charge, MaxLoad, Tripped);
}
}
apc-menu-power-state-good = Good
apc-menu-power-state-low = Low
apc-menu-power-state-none = None
-apc-menu-power-state-label-text = { POWERWATTS($power) }
+apc-menu-power-state-label-text = { POWERWATTS($power) } / { POWERWATTS($maxLoad) }
+apc-menu-power-state-label-tripped = OVERLOAD
# For the flavor text on the footer
- !type:DoActsBehavior
acts: [ "Destruction" ]
- type: ApcPowerReceiver
- powerLoad: 15000
+ powerLoad: 5000
- type: StaticPrice
price: 7500