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 -->
<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-->

View File

@@ -40,7 +40,14 @@ namespace Content.Client.Power.APC.UI
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)

View File

@@ -7,11 +7,11 @@ using Content.Server.Power.NodeGroups;
using Content.Server.Power.Pow3r;
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>
@@ -21,27 +21,20 @@ public sealed class StationPowerTests
private static readonly string[] GameMaps =
[
"Fland",
"Meta",
"Packed",
"Omega",
"Amber",
"Bagel",
"Box",
"Core",
"Marathon",
"Saltern",
"Reach",
"Train",
"Oasis",
"Gate",
"Amber",
"Loop",
"Plasma",
"Elkridge",
"Convex",
"Relic",
"Fland",
"Marathon",
"Oasis",
"Packed",
"Plasma",
"Reach",
"Exo",
];
[Explicit]
[Test, TestCaseSource(nameof(GameMaps))]
public async Task TestStationStartingPowerWindow(string mapProtoId)
{
@@ -100,6 +93,54 @@ public sealed class StationPowerTests
$"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();
}

View File

@@ -5,7 +5,7 @@ using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
namespace Content.Server.Power.Components;
[RegisterComponent]
[RegisterComponent, AutoGenerateComponentPause]
public sealed partial class ApcComponent : BaseApcNetComponent
{
[DataField("onReceiveMessageSound")]
@@ -34,6 +34,32 @@ public sealed partial class ApcComponent : BaseApcNetComponent
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)

View File

@@ -43,11 +43,12 @@ public sealed class ApcSystem : EntitySystem
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);
}
@@ -55,6 +56,28 @@ public sealed class ApcSystem : EntitySystem
{
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;
battery.CanDischarge = apc.MainBreakerEnabled;
if (apc.MainBreakerEnabled)
apc.TripFlag = false;
UpdateUIState(uid, apc);
_audio.PlayPvs(apc.OnReceiveMessageSound, uid, AudioParams.Default.WithVolume(-2f));
}
@@ -169,7 +195,9 @@ public sealed class ApcSystem : EntitySystem
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);
}

View File

@@ -181,13 +181,17 @@ namespace Content.Shared.APC
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)
@@ -197,7 +201,9 @@ namespace Content.Shared.APC
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)
@@ -207,7 +213,7 @@ namespace Content.Shared.APC
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-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

View File

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