From 6db3e60fd1c21cad3b8736732555c0589a73b705 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 14 May 2026 10:11:35 +0200 Subject: [PATCH] Add pulse contract and isolation valves --- .../Controls/InventoryStrip.cs | 3 +- .../Balancing.cs | 1 + .../Difficulties/NormalBalancing.cs | 1 + .../EEditorTool.cs | 1 + .../LevelEditor.cs | 1 + .../LevelValidator.cs | 20 +++++ .../Models/EPropType.cs | 1 + .../Models/GlobalState.cs | 2 + .../Models/PropState.cs | 1 + .../SimulationEngine.cs | 81 ++++++++++++++----- .../SurfaceStateExtensions.cs | 12 +++ .../Systems/NetworkPropagationSystem.cs | 14 ++++ .../Systems/PlayerActionSystem.cs | 6 +- .../Systems/RobotSafetySystem.cs | 9 ++- .../SimulationEngineTests.cs | 69 +++++++++++++++- 15 files changed, 195 insertions(+), 27 deletions(-) diff --git a/src/ReactorMaintenance.Godot/Controls/InventoryStrip.cs b/src/ReactorMaintenance.Godot/Controls/InventoryStrip.cs index 2033bc6..5530419 100644 --- a/src/ReactorMaintenance.Godot/Controls/InventoryStrip.cs +++ b/src/ReactorMaintenance.Godot/Controls/InventoryStrip.cs @@ -40,9 +40,10 @@ public partial class InventoryStrip : HBoxContainer return item; } - private Label m_WaterLabel = null!; private Label m_ElectricLabel = null!; private Label m_FuelLabel = null!; private Label m_HeatLabel = null!; + + private Label m_WaterLabel = null!; } \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Balancing.cs b/src/ReactorMaintenance.Simulation/Balancing.cs index bf28c73..6f7e622 100644 --- a/src/ReactorMaintenance.Simulation/Balancing.cs +++ b/src/ReactorMaintenance.Simulation/Balancing.cs @@ -121,6 +121,7 @@ public abstract class Balancing public abstract int DefaultLevelHeight { get; } public abstract int MinimumLevelSize { get; } public abstract int ForecastHorizon { get; } + public abstract int StepsPerPulse { get; } public abstract float MinValue { get; } public abstract float MaxValue { get; } public abstract float FuelSafe { get; } diff --git a/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs b/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs index ba3b6ad..3c5bf47 100644 --- a/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs +++ b/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs @@ -6,6 +6,7 @@ public class NormalBalancing : Balancing public override int DefaultLevelHeight => 12; public override int MinimumLevelSize => 4; public override int ForecastHorizon => 6; + public override int StepsPerPulse => 3; public override float MinValue => 0; public override float MaxValue => 10; public override float FuelSafe => 1.5f; diff --git a/src/ReactorMaintenance.Simulation/EEditorTool.cs b/src/ReactorMaintenance.Simulation/EEditorTool.cs index cd1d18d..f0c799a 100644 --- a/src/ReactorMaintenance.Simulation/EEditorTool.cs +++ b/src/ReactorMaintenance.Simulation/EEditorTool.cs @@ -7,6 +7,7 @@ public enum EEditorTool Wall, Underground, Flow, + IsolationValve, Consumer, Junction, Door, diff --git a/src/ReactorMaintenance.Simulation/LevelEditor.cs b/src/ReactorMaintenance.Simulation/LevelEditor.cs index 64a59ba..0321c32 100644 --- a/src/ReactorMaintenance.Simulation/LevelEditor.cs +++ b/src/ReactorMaintenance.Simulation/LevelEditor.cs @@ -139,6 +139,7 @@ public static class LevelEditor EEditorTool.Wall => level.SetTerrain(position, ECellTerrain.Wall), EEditorTool.Underground => SetUnderground(level, position, command.Carrier), EEditorTool.Flow => SetCarrierProp(level, position, EPropType.Flow, command.Carrier), + EEditorTool.IsolationValve => SetCarrierProp(level, position, EPropType.IsolationValve, command.Carrier), EEditorTool.Consumer => SetFloorProp(level, position, new() { Type = EPropType.Consumer, SwitchState = EPropSwitchState.Enabled }), EEditorTool.Junction => SetFloorProp(level, position, new() { Type = EPropType.Junction }), EEditorTool.Door => ToggleOrSetDoor(level, position), diff --git a/src/ReactorMaintenance.Simulation/LevelValidator.cs b/src/ReactorMaintenance.Simulation/LevelValidator.cs index 78a3fcf..a1909ec 100644 --- a/src/ReactorMaintenance.Simulation/LevelValidator.cs +++ b/src/ReactorMaintenance.Simulation/LevelValidator.cs @@ -11,6 +11,7 @@ public sealed class LevelValidator ValidateRobot(level, errors); ValidateCells(level, errors); ValidateDoors(level, errors); + ValidateIsolationValves(level, errors); ValidateLeaks(level, errors); ValidateReactors(level, errors, warnings); ValidateJunctions(level, errors); @@ -19,6 +20,25 @@ public sealed class LevelValidator return new() { Errors = errors, Warnings = warnings }; } + private static void ValidateIsolationValves(LevelState level, List errors) + { + foreach (var position in LevelTraversal.AllPositions(level)) + { + var prop = level.GetProp(position); + if (prop.Type != EPropType.IsolationValve) + continue; + + if (!level.IsFloor(position)) + { + errors.Add(new("Isolation valve must be placed on a floor cell.", position)); + continue; + } + + if (!level.GetUnderground(position, prop.Carrier).IsPresent) + errors.Add(new("Isolation valve must sit on its matching underground carrier.", position)); + } + } + private static void ValidateDimensions(LevelState level, List errors) { if (level.Width < Balancing.Current.MinimumLevelSize || level.Height < Balancing.Current.MinimumLevelSize) diff --git a/src/ReactorMaintenance.Simulation/Models/EPropType.cs b/src/ReactorMaintenance.Simulation/Models/EPropType.cs index 687a7eb..6e63318 100644 --- a/src/ReactorMaintenance.Simulation/Models/EPropType.cs +++ b/src/ReactorMaintenance.Simulation/Models/EPropType.cs @@ -4,6 +4,7 @@ public enum EPropType { None, Flow, + IsolationValve, Consumer, Junction, Door, diff --git a/src/ReactorMaintenance.Simulation/Models/GlobalState.cs b/src/ReactorMaintenance.Simulation/Models/GlobalState.cs index 79e0c6e..c3c0b7d 100644 --- a/src/ReactorMaintenance.Simulation/Models/GlobalState.cs +++ b/src/ReactorMaintenance.Simulation/Models/GlobalState.cs @@ -3,6 +3,8 @@ public sealed record GlobalState { public int Turn { get; init; } + public int Pulse { get; init; } + public int Step { get; init; } public ELevelState LevelState { get; init; } = ELevelState.Stable; public string Status { get; init; } = "STABLE"; public bool TerminalLoss { get; init; } diff --git a/src/ReactorMaintenance.Simulation/Models/PropState.cs b/src/ReactorMaintenance.Simulation/Models/PropState.cs index 287f7de..740237f 100644 --- a/src/ReactorMaintenance.Simulation/Models/PropState.cs +++ b/src/ReactorMaintenance.Simulation/Models/PropState.cs @@ -26,4 +26,5 @@ public sealed record PropState public EDoorState DoorState { get; init; } = EDoorState.Closed; public bool IsEnabled => SwitchState == EPropSwitchState.Enabled; + public bool IsOpen => SwitchState == EPropSwitchState.Enabled; } \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/SimulationEngine.cs b/src/ReactorMaintenance.Simulation/SimulationEngine.cs index 273aa8c..855b763 100644 --- a/src/ReactorMaintenance.Simulation/SimulationEngine.cs +++ b/src/ReactorMaintenance.Simulation/SimulationEngine.cs @@ -9,22 +9,17 @@ public sealed class SimulationEngine public LevelState InteractProp(LevelState level) { - return PlayerActionSystem.InteractProp(level, ResolveStep); + return PlayerActionSystem.InteractProp(level, ResolvePulse); } public LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy) { - return PlayerActionSystem.InteractLeak(level, carrier, useRemedy, ResolveStep); + return PlayerActionSystem.InteractLeak(level, carrier, useRemedy, ResolvePulse); } public LevelState ApplyHeatShield(LevelState level) { - return PlayerActionSystem.ApplyHeatShield(level, ResolveStep); - } - - private LevelState ResolveStep(LevelState level) - { - return ResolveStep(level, true); + return PlayerActionSystem.ApplyHeatShield(level, ResolvePulse); } public LevelState ActivateReactor(LevelState level) @@ -32,14 +27,26 @@ public sealed class SimulationEngine return ReactorSystem.Activate(level); } - public LevelState EndTurn(LevelState level) + public LevelState AdvancePulseForDebug(LevelState level) + { + return ResolvePulse(level); + } + + public LevelState AdvanceStepForDebug(LevelState level) { return ResolveStep(level); } + [Obsolete("Use AdvancePulseForDebug. Player-facing wait/turn advancement is not part of normal gameplay.")] + public LevelState EndTurn(LevelState level) + { + return AdvancePulseForDebug(level); + } + + [Obsolete("Use AdvancePulseForDebug. Player-facing wait/turn advancement is not part of normal gameplay.")] public LevelState AdvanceTurn(LevelState level) { - return ResolveStep(level); + return AdvancePulseForDebug(level); } public IReadOnlyList Forecast(LevelState level) @@ -47,7 +54,41 @@ public sealed class SimulationEngine return ForecastSystem.Forecast(level, simulated => ResolveStep(simulated, false)); } - private LevelState ResolveStep(LevelState level, bool refreshForecasts) + private LevelState ResolvePulse(LevelState level) + { + var next = ValidateAndPropagate(level); + if (next.Global.LevelState == ELevelState.Lost) + return next; + + for (var i = 0; i < Balancing.Current.StepsPerPulse; i++) + next = ResolveStepContent(next); + + next = ReactorSystem.DeriveState(next); + next = SurfaceInteractionSystem.AdvanceDurations(next); + next = next with { + Global = next.Global with { + Turn = next.Global.Pulse + 1, + Pulse = next.Global.Pulse + 1 + } + }; + + return next with { Forecasts = Forecast(next) }; + } + + private LevelState ResolveStep(LevelState level, bool refreshForecasts = true) + { + var next = ValidateAndPropagate(level); + if (next.Global.LevelState == ELevelState.Lost) + return next; + + next = ResolveStepContent(next); + next = ReactorSystem.DeriveState(next); + next = SurfaceInteractionSystem.AdvanceDurations(next); + + return refreshForecasts ? next with { Forecasts = Forecast(next) } : next; + } + + private LevelState ValidateAndPropagate(LevelState level) { var report = m_Validator.Validate(level); if (!report.IsValid) @@ -57,18 +98,16 @@ public sealed class SimulationEngine next = NetworkPropagationSystem.Propagate(next); next = ConsumerSystem.Resolve(next); next = StructuralIntegritySystem.Resolve(next); + return next; + } + + private static LevelState ResolveStepContent(LevelState level) + { + var next = level; next = LeakSystem.Inject(next); next = SurfaceInteractionSystem.Resolve(next); - next = RobotSafetySystem.Resolve(next); - next = ReactorSystem.DeriveState(next); - next = SurfaceInteractionSystem.AdvanceDurations(next); - next = next with { - Global = next.Global with { - Turn = next.Global.Turn + 1 - } - }; - - return refreshForecasts ? next with { Forecasts = Forecast(next) } : next; + next = next with { Global = next.Global with { Step = next.Global.Step + 1 } }; + return next; } private readonly LevelValidator m_Validator = new(); diff --git a/src/ReactorMaintenance.Simulation/SurfaceStateExtensions.cs b/src/ReactorMaintenance.Simulation/SurfaceStateExtensions.cs index afad58d..61ed5ca 100644 --- a/src/ReactorMaintenance.Simulation/SurfaceStateExtensions.cs +++ b/src/ReactorMaintenance.Simulation/SurfaceStateExtensions.cs @@ -25,4 +25,16 @@ public static class SurfaceStateExtensions _ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.") }; } + + public static bool IsUnsafe(this SurfaceState surface) + { + return surface.Heat >= Balancing.Current.RobotHeatSafetyThreshold + || surface.Electricity >= Balancing.Current.RobotElectricitySafetyThreshold + || surface.IsWetElectricUnsafe(); + } + + public static bool IsWetElectricUnsafe(this SurfaceState surface) + { + return surface.Water > Balancing.Current.WaterSafe && surface.Electricity > Balancing.Current.ElectricitySafe; + } } \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Systems/NetworkPropagationSystem.cs b/src/ReactorMaintenance.Simulation/Systems/NetworkPropagationSystem.cs index 17e5595..0db4ed2 100644 --- a/src/ReactorMaintenance.Simulation/Systems/NetworkPropagationSystem.cs +++ b/src/ReactorMaintenance.Simulation/Systems/NetworkPropagationSystem.cs @@ -60,6 +60,9 @@ internal static class NetworkPropagationSystem if (!level.GetUnderground(next, carrier).CarriesFlow) continue; + if (IsClosedValveBoundary(level, current.Position, next, carrier)) + continue; + var weights = BranchWeights(current.Position, next, junctions); var amountFactor = current.AmountFactor * weights.Amount; var intensityFactor = current.IntensityFactor * weights.Intensity; @@ -75,6 +78,17 @@ internal static class NetworkPropagationSystem } } + private static bool IsClosedValveBoundary(LevelState level, GridPosition from, GridPosition to, ECarrierType carrier) + { + return IsClosedValve(level, from, carrier) || IsClosedValve(level, to, carrier); + } + + private static bool IsClosedValve(LevelState level, GridPosition position, ECarrierType carrier) + { + return level.GetProp(position) is { Type: EPropType.IsolationValve, Carrier: var valveCarrier, SwitchState: EPropSwitchState.Disabled } + && valveCarrier == carrier; + } + private static (float Amount, float Intensity) BranchWeights(GridPosition from, GridPosition to, IReadOnlyDictionary junctions) { if (!junctions.TryGetValue(from, out var junction)) diff --git a/src/ReactorMaintenance.Simulation/Systems/PlayerActionSystem.cs b/src/ReactorMaintenance.Simulation/Systems/PlayerActionSystem.cs index b427aec..cc08b99 100644 --- a/src/ReactorMaintenance.Simulation/Systems/PlayerActionSystem.cs +++ b/src/ReactorMaintenance.Simulation/Systems/PlayerActionSystem.cs @@ -7,12 +7,14 @@ internal static class PlayerActionSystem if (!CanAct(level) || !level.IsFloor(destination) || level.Robot.Position.ManhattanDistance(destination) != 1) return Refuse(level, "MOVE BLOCKED"); - return level with { + var next = level with { Robot = level.Robot with { Position = destination, HeatImmunitySteps = Math.Max(0, level.Robot.HeatImmunitySteps - 1) } }; + + return RobotSafetySystem.ResolveEntry(next); } public static LevelState InteractProp(LevelState level, Func resolveLengthyAction) @@ -26,7 +28,7 @@ internal static class PlayerActionSystem return Refuse(level, "NO PROP"); var next = prop.Type switch { - EPropType.Flow or EPropType.Consumer => ToggleProp(level, position, prop), + EPropType.Flow or EPropType.Consumer or EPropType.IsolationValve => ToggleProp(level, position, prop), EPropType.Junction => CycleJunctionMode(level, position, prop), EPropType.Door => ToggleDoor(level, position, prop), EPropType.AllSeeingEyeTerminal => level with { Global = level.Global with { Status = "ALL-SEEING-EYE AVAILABLE" } }, diff --git a/src/ReactorMaintenance.Simulation/Systems/RobotSafetySystem.cs b/src/ReactorMaintenance.Simulation/Systems/RobotSafetySystem.cs index 584cd21..0d145d2 100644 --- a/src/ReactorMaintenance.Simulation/Systems/RobotSafetySystem.cs +++ b/src/ReactorMaintenance.Simulation/Systems/RobotSafetySystem.cs @@ -3,12 +3,17 @@ internal static class RobotSafetySystem { public static LevelState Resolve(LevelState level) + { + return level; + } + + public static LevelState ResolveEntry(LevelState level) { var surface = level.GetSurface(level.Robot.Position); - var unsafeElement = surface.Fuel >= Balancing.Current.RobotFuelSafetyThreshold || surface.Water >= Balancing.Current.RobotWaterSafetyThreshold || surface.Electricity >= Balancing.Current.RobotElectricitySafetyThreshold; + var unsafeElement = surface.Electricity >= Balancing.Current.RobotElectricitySafetyThreshold || surface.IsWetElectricUnsafe(); var unsafeHeat = surface.Heat >= Balancing.Current.RobotHeatSafetyThreshold && level.Robot.HeatImmunitySteps <= 0; return unsafeElement || unsafeHeat - ? level with { Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = "ROBOT LOST" } } + ? level with { Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = "UNSAFE ENTRY LOSS" } } : level; } } \ No newline at end of file diff --git a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs index 9fc4d96..bee126e 100644 --- a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs +++ b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs @@ -79,7 +79,57 @@ public sealed class SimulationEngineTests var next = m_Engine.InteractProp(level); Assert.Equal(EDoorState.Open, next.GetProp(new(3, 2)).DoorState); - Assert.Equal(1, next.Global.Turn); + Assert.Equal(1, next.Global.Pulse); + Assert.Equal(Balancing.Current.StepsPerPulse, next.Global.Step); + } + + [Fact] + public void EveryAcceptedLengthyActionAdvancesOneFixedPulse() + { + var level = DoorLevel() with { Robot = new() { Position = new(3, 2) } }; + + var next = m_Engine.InteractProp(level); + + Assert.Equal(1, next.Global.Pulse); + Assert.Equal(Balancing.Current.StepsPerPulse, next.Global.Step); + } + + [Fact] + public void DebugStepAdvancementDoesNotAdvancePulse() + { + var level = BuildReadyLevel(); + + var next = m_Engine.AdvanceStepForDebug(level); + + Assert.Equal(0, next.Global.Pulse); + Assert.Equal(1, next.Global.Step); + } + + [Fact] + public void IsolationValveOpenAllowsPropagationAndClosedBlocksDownstreamFeed() + { + var open = IsolationValveLevel(EPropSwitchState.Enabled); + var closed = IsolationValveLevel(EPropSwitchState.Disabled); + + var openResult = m_Engine.AdvancePulseForDebug(open); + var closedResult = m_Engine.AdvancePulseForDebug(closed); + + Assert.True(openResult.GetUnderground(new(3, 2), ECarrierType.Fuel).Amount > 0); + Assert.Equal(ELevelState.Ready, openResult.Global.LevelState); + Assert.Equal(0, closedResult.GetUnderground(new(3, 2), ECarrierType.Fuel).Amount); + Assert.NotEqual(ELevelState.Ready, closedResult.Global.LevelState); + } + + [Fact] + public void TogglingIsolationValveIsLengthyAndAdvancesOneFixedPulse() + { + var level = IsolationValveLevel(EPropSwitchState.Enabled) with { Robot = new() { Position = new(2, 2) } }; + + var next = m_Engine.InteractProp(level); + + Assert.Equal(EPropSwitchState.Disabled, next.GetProp(new(2, 2)).SwitchState); + Assert.Equal(1, next.Global.Pulse); + Assert.Equal(Balancing.Current.StepsPerPulse, next.Global.Step); } [Fact] @@ -320,5 +370,22 @@ public sealed class SimulationEngineTests return level.SetProp(new(2, 3), new() { Type = EPropType.Junction, Carrier = ECarrierType.Fuel, JunctionMode = mode }); } + private static LevelState IsolationValveLevel(EPropSwitchState valveState) + { + var level = LevelState.Create("Valve", 6, 5); + level = AddLine(level, ECarrierType.Fuel, new(1, 2), new(2, 2)); + level = AddLine(level, ECarrierType.Fuel, new(2, 2), new(3, 2)); + level = level.SetProp(new(1, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel }); + level = level.SetProp(new(2, 2), new() { Type = EPropType.IsolationValve, Carrier = ECarrierType.Fuel, SwitchState = valveState }); + level = level.SetProp(new(3, 2), new() { Type = EPropType.ReactorControl, ReactorId = 1 }); + return level with { + RequiredFuelConsumers = 0, + RequiredWaterConsumers = 0, + RequiredElectricityConsumers = 0, + Robot = new() { Position = new(3, 2) }, + Reactors = [new() { ReactorId = 1, ControlPosition = new(3, 2) }] + }; + } + private readonly SimulationEngine m_Engine = new(); } \ No newline at end of file