Trip APCs when they exceed a power limit (#41377)

* Implement APC overloading

* Add power test

* Review

* Some more reviews

* Show map coordinates for test failures

* Widen column 2

* Reduce singularity beacon power consumption

* Try to get grid coordinates
This commit is contained in:
Partmedia
2025-11-21 07:01:23 -08:00
committed by GitHub
parent bb441b7d3e
commit a5c223c0c3
8 changed files with 137 additions and 28 deletions

View File

@@ -19,7 +19,7 @@
<!-- Power On/Off --> <!-- Power On/Off -->
<Label Text="{Loc 'apc-menu-breaker-label'}" HorizontalExpand="True" <Label Text="{Loc 'apc-menu-breaker-label'}" HorizontalExpand="True"
StyleClasses="highlight" MinWidth="120"/> 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"/> <Button Name="BreakerButton" Text="{Loc 'apc-menu-breaker-button'}" HorizontalExpand="True" ToggleMode="True"/>
</BoxContainer> </BoxContainer>
<!--Charging Status--> <!--Charging Status-->

View File

@@ -40,7 +40,14 @@ namespace Content.Client.Power.APC.UI
if (PowerLabel != null) 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) if (ExternalPowerStateLabel != null)

View File

@@ -7,11 +7,11 @@ using Content.Server.Power.NodeGroups;
using Content.Server.Power.Pow3r; using Content.Server.Power.Pow3r;
using Content.Shared.Power.Components; using Content.Shared.Power.Components;
using Content.Shared.NodeContainer; using Content.Shared.NodeContainer;
using Robust.Server.GameObjects;
using Robust.Shared.EntitySerialization; using Robust.Shared.EntitySerialization;
namespace Content.IntegrationTests.Tests.Power; namespace Content.IntegrationTests.Tests.Power;
[Explicit]
public sealed class StationPowerTests public sealed class StationPowerTests
{ {
/// <summary> /// <summary>
@@ -21,27 +21,20 @@ public sealed class StationPowerTests
private static readonly string[] GameMaps = private static readonly string[] GameMaps =
[ [
"Fland", "Amber",
"Meta",
"Packed",
"Omega",
"Bagel", "Bagel",
"Box", "Box",
"Core",
"Marathon",
"Saltern",
"Reach",
"Train",
"Oasis",
"Gate",
"Amber",
"Loop",
"Plasma",
"Elkridge", "Elkridge",
"Convex", "Fland",
"Relic", "Marathon",
"Oasis",
"Packed",
"Plasma",
"Reach",
"Exo",
]; ];
[Explicit]
[Test, TestCaseSource(nameof(GameMaps))] [Test, TestCaseSource(nameof(GameMaps))]
public async Task TestStationStartingPowerWindow(string mapProtoId) public async Task TestStationStartingPowerWindow(string mapProtoId)
{ {
@@ -100,6 +93,54 @@ public sealed class StationPowerTests
$"Needs at least {requiredStoredPower - totalStartingCharge} more stored power!"); $"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(); await pair.CleanReturnAsync();
} }

View File

@@ -5,7 +5,7 @@ using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
namespace Content.Server.Power.Components; namespace Content.Server.Power.Components;
[RegisterComponent] [RegisterComponent, AutoGenerateComponentPause]
public sealed partial class ApcComponent : BaseApcNetComponent public sealed partial class ApcComponent : BaseApcNetComponent
{ {
[DataField("onReceiveMessageSound")] [DataField("onReceiveMessageSound")]
@@ -34,6 +34,32 @@ public sealed partial class ApcComponent : BaseApcNetComponent
public const float HighPowerThreshold = 0.9f; public const float HighPowerThreshold = 0.9f;
public static TimeSpan VisualsChangeDelay = TimeSpan.FromSeconds(1); 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! // TODO ECS power a little better!
// End the suffering // End the suffering
protected override void AddSelfToNet(IApcNet apcNet) protected override void AddSelfToNet(IApcNet apcNet)

View File

@@ -43,11 +43,12 @@ public sealed class ApcSystem : EntitySystem
public override void Update(float deltaTime) public override void Update(float deltaTime)
{ {
var query = EntityQueryEnumerator<ApcComponent, PowerNetworkBatteryComponent, UserInterfaceComponent>(); 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)) 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); UpdateUIState(uid, apc, battery);
} }
@@ -55,6 +56,28 @@ public sealed class ApcSystem : EntitySystem
{ {
UpdateApcState(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;
}
} }
} }
@@ -106,6 +129,9 @@ public sealed class ApcSystem : EntitySystem
apc.MainBreakerEnabled = !apc.MainBreakerEnabled; apc.MainBreakerEnabled = !apc.MainBreakerEnabled;
battery.CanDischarge = apc.MainBreakerEnabled; battery.CanDischarge = apc.MainBreakerEnabled;
if (apc.MainBreakerEnabled)
apc.TripFlag = false;
UpdateUIState(uid, apc); UpdateUIState(uid, apc);
_audio.PlayPvs(apc.OnReceiveMessageSound, uid, AudioParams.Default.WithVolume(-2f)); _audio.PlayPvs(apc.OnReceiveMessageSound, uid, AudioParams.Default.WithVolume(-2f));
} }
@@ -169,7 +195,9 @@ public sealed class ApcSystem : EntitySystem
var state = new ApcBoundInterfaceState(apc.MainBreakerEnabled, var state = new ApcBoundInterfaceState(apc.MainBreakerEnabled,
(int) MathF.Ceiling(battery.CurrentSupply), apc.LastExternalState, (int) MathF.Ceiling(battery.CurrentSupply), apc.LastExternalState,
charge); charge,
apc.MaxLoad,
apc.TripFlag);
_ui.SetUiState((uid, ui), ApcUiKey.Key, state); _ui.SetUiState((uid, ui), ApcUiKey.Key, state);
} }

View File

@@ -181,13 +181,17 @@ namespace Content.Shared.APC
public readonly int Power; public readonly int Power;
public readonly ApcExternalPowerState ApcExternalPower; public readonly ApcExternalPowerState ApcExternalPower;
public readonly float Charge; 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; MainBreaker = mainBreaker;
Power = power; Power = power;
ApcExternalPower = apcExternalPower; ApcExternalPower = apcExternalPower;
Charge = charge; Charge = charge;
MaxLoad = maxLoad;
Tripped = tripped;
} }
public bool Equals(ApcBoundInterfaceState? other) public bool Equals(ApcBoundInterfaceState? other)
@@ -197,7 +201,9 @@ namespace Content.Shared.APC
return MainBreaker == other.MainBreaker && return MainBreaker == other.MainBreaker &&
Power == other.Power && Power == other.Power &&
ApcExternalPower == other.ApcExternalPower && 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 bool Equals(object? obj)
@@ -207,7 +213,7 @@ namespace Content.Shared.APC
public override int GetHashCode() public override int GetHashCode()
{ {
return HashCode.Combine(MainBreaker, Power, (int) ApcExternalPower, Charge); return HashCode.Combine(MainBreaker, Power, (int) ApcExternalPower, Charge, MaxLoad, Tripped);
} }
} }

View File

@@ -10,7 +10,8 @@ apc-menu-charge-label = {$percent} Charged
apc-menu-power-state-good = Good apc-menu-power-state-good = Good
apc-menu-power-state-low = Low apc-menu-power-state-low = Low
apc-menu-power-state-none = None 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 # For the flavor text on the footer

View File

@@ -38,6 +38,6 @@
- !type:DoActsBehavior - !type:DoActsBehavior
acts: [ "Destruction" ] acts: [ "Destruction" ]
- type: ApcPowerReceiver - type: ApcPowerReceiver
powerLoad: 15000 powerLoad: 5000
- type: StaticPrice - type: StaticPrice
price: 7500 price: 7500