From 2ad7feef962bbfba174deeb9b3472a4e28c997dc Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 14 May 2026 10:15:12 +0200 Subject: [PATCH] Add sprinkler valve simulation contract --- .../Balancing.cs | 2 + .../Difficulties/NormalBalancing.cs | 2 + .../EEditorTool.cs | 2 + .../LevelEditor.cs | 7 ++ .../LevelSerializer.cs | 2 +- .../LevelValidator.cs | 36 +++++++++- .../Models/EPropType.cs | 2 + .../Models/PropState.cs | 2 + .../SimulationEngine.cs | 2 + .../Systems/LeakSystem.cs | 2 +- .../Systems/PlayerActionSystem.cs | 10 ++- .../Systems/SprinklerSystem.cs | 63 +++++++++++++++++ .../SimulationEngineTests.cs | 68 ++++++++++++++++++- 13 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 src/ReactorMaintenance.Simulation/Systems/SprinklerSystem.cs diff --git a/src/ReactorMaintenance.Simulation/Balancing.cs b/src/ReactorMaintenance.Simulation/Balancing.cs index 6f7e622..a43c6b8 100644 --- a/src/ReactorMaintenance.Simulation/Balancing.cs +++ b/src/ReactorMaintenance.Simulation/Balancing.cs @@ -155,6 +155,8 @@ public abstract class Balancing public abstract float LeakBaseAmount { get; } public abstract float LeakAmountScale { get; } public abstract float LeakIntensityScale { get; } + public abstract float SprinklerWaterPerStep { get; } + public abstract float SprinklerPressureDebt { get; } public abstract float FlowTransferRatio { get; } public abstract float WarmCautionAmount { get; } public abstract float WarmCriticalAmount { get; } diff --git a/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs b/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs index 3c5bf47..47e7fc2 100644 --- a/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs +++ b/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs @@ -54,6 +54,8 @@ public class NormalBalancing : Balancing public override float LeakBaseAmount => 0.5f; public override float LeakAmountScale => 0.15f; public override float LeakIntensityScale => 0.1f; + public override float SprinklerWaterPerStep => 0.8f; + public override float SprinklerPressureDebt => 3.0f; public override float FlowTransferRatio => 0.05f; public override float WarmCautionAmount => 0.5f; public override float WarmCriticalAmount => 1.0f; diff --git a/src/ReactorMaintenance.Simulation/EEditorTool.cs b/src/ReactorMaintenance.Simulation/EEditorTool.cs index f0c799a..6ae91c9 100644 --- a/src/ReactorMaintenance.Simulation/EEditorTool.cs +++ b/src/ReactorMaintenance.Simulation/EEditorTool.cs @@ -12,6 +12,8 @@ public enum EEditorTool Junction, Door, AllSeeingEyeTerminal, + SprinklerControl, + SprinklerValve, RemedySupply, ReactorControl, Leak, diff --git a/src/ReactorMaintenance.Simulation/LevelEditor.cs b/src/ReactorMaintenance.Simulation/LevelEditor.cs index 0321c32..71dbcf5 100644 --- a/src/ReactorMaintenance.Simulation/LevelEditor.cs +++ b/src/ReactorMaintenance.Simulation/LevelEditor.cs @@ -144,6 +144,8 @@ public static class LevelEditor EEditorTool.Junction => SetFloorProp(level, position, new() { Type = EPropType.Junction }), EEditorTool.Door => ToggleOrSetDoor(level, position), EEditorTool.AllSeeingEyeTerminal => SetFloorProp(level, position, new() { Type = EPropType.AllSeeingEyeTerminal }), + EEditorTool.SprinklerControl => SetFloorProp(level, position, new() { Type = EPropType.SprinklerControl, SwitchState = EPropSwitchState.Enabled }), + EEditorTool.SprinklerValve => SetWallProp(level, position, new() { Type = EPropType.SprinklerValve, Carrier = ECarrierType.Water, OutletPosition = position.Neighbors().FirstOrDefault(level.IsFloor) }), EEditorTool.RemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = command.RemedyType }), EEditorTool.ReactorControl => SetReactorControl(level, position), EEditorTool.Leak => SetLeak(level, position, command.Carrier), @@ -210,6 +212,11 @@ public static class LevelEditor return level.IsFloor(position) ? level.SetProp(position, prop) : level; } + private static LevelState SetWallProp(LevelState level, GridPosition position, PropState prop) + { + return level.InBounds(position) && level.GetTerrain(position) == ECellTerrain.Wall ? level.SetProp(position, prop) : level; + } + private static LevelState ToggleOrSetDoor(LevelState level, GridPosition position) { if (!level.IsFloor(position)) diff --git a/src/ReactorMaintenance.Simulation/LevelSerializer.cs b/src/ReactorMaintenance.Simulation/LevelSerializer.cs index 824e51c..cf088d8 100644 --- a/src/ReactorMaintenance.Simulation/LevelSerializer.cs +++ b/src/ReactorMaintenance.Simulation/LevelSerializer.cs @@ -33,7 +33,7 @@ public static class LevelSerializer return level; } - private const int c_CurrentVersion = 3; + private const int c_CurrentVersion = 4; private static readonly JsonSerializerOptions s_Options = new() { WriteIndented = true, diff --git a/src/ReactorMaintenance.Simulation/LevelValidator.cs b/src/ReactorMaintenance.Simulation/LevelValidator.cs index a1909ec..1c5d4af 100644 --- a/src/ReactorMaintenance.Simulation/LevelValidator.cs +++ b/src/ReactorMaintenance.Simulation/LevelValidator.cs @@ -12,6 +12,7 @@ public sealed class LevelValidator ValidateCells(level, errors); ValidateDoors(level, errors); ValidateIsolationValves(level, errors); + ValidateSprinklers(level, errors); ValidateLeaks(level, errors); ValidateReactors(level, errors, warnings); ValidateJunctions(level, errors); @@ -70,7 +71,7 @@ public sealed class LevelValidator if (surface.Fuel > 0 || surface.Water > 0 || surface.Electricity > 0 || surface.Heat > 0) errors.Add(new("Wall cell cannot store surface hazards.", position)); - if (prop.Type != EPropType.None) + if (prop.Type is not (EPropType.None or EPropType.SprinklerValve)) errors.Add(new("Prop must be placed on floor terrain.", position)); } } @@ -97,6 +98,39 @@ public sealed class LevelValidator } } + private static void ValidateSprinklers(LevelState level, List errors) + { + foreach (var position in LevelTraversal.AllPositions(level)) + { + var prop = level.GetProp(position); + if (prop.Type == EPropType.SprinklerControl) + { + if (!level.IsFloor(position)) + errors.Add(new("Sprinkler control must be placed on a floor cell.", position)); + + if (prop.LinkedPosition is not { } linked || !level.InBounds(linked) || level.GetProp(linked).Type != EPropType.SprinklerValve) + errors.Add(new("Sprinkler control must link to exactly one sprinkler valve.", position)); + } + + if (prop.Type != EPropType.SprinklerValve) + continue; + + if (level.GetTerrain(position) != ECellTerrain.Wall) + errors.Add(new("Sprinkler valve must be wall-mounted.", position)); + + if (prop.OutletPosition is not { } outlet || !level.IsFloor(outlet) || position.ManhattanDistance(outlet) != 1) + errors.Add(new("Sprinkler valve must have one adjacent floor outlet.", position)); + + if (!level.GetUnderground(position, ECarrierType.Water).IsPresent) + errors.Add(new("Sprinkler valve must connect to a water underground cell.", position)); + + var linkedControls = LevelTraversal.AllPositions(level) + .Count(controlPosition => level.GetProp(controlPosition) is { Type: EPropType.SprinklerControl, LinkedPosition: var linkedPosition } && linkedPosition == position); + if (linkedControls != 1) + errors.Add(new("Sprinkler valve must have exactly one linked control.", position)); + } + } + private static void ValidateLeaks(LevelState level, List errors) { foreach (var leak in level.Leaks) diff --git a/src/ReactorMaintenance.Simulation/Models/EPropType.cs b/src/ReactorMaintenance.Simulation/Models/EPropType.cs index 6e63318..d4bc39f 100644 --- a/src/ReactorMaintenance.Simulation/Models/EPropType.cs +++ b/src/ReactorMaintenance.Simulation/Models/EPropType.cs @@ -9,6 +9,8 @@ public enum EPropType Junction, Door, AllSeeingEyeTerminal, + SprinklerControl, + SprinklerValve, RemedySupply, ReactorControl } \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Models/PropState.cs b/src/ReactorMaintenance.Simulation/Models/PropState.cs index 740237f..0a5305c 100644 --- a/src/ReactorMaintenance.Simulation/Models/PropState.cs +++ b/src/ReactorMaintenance.Simulation/Models/PropState.cs @@ -24,6 +24,8 @@ public sealed record PropState public bool Depleted { get; init; } public int ReactorId { get; init; } public EDoorState DoorState { get; init; } = EDoorState.Closed; + public GridPosition? LinkedPosition { get; init; } + public GridPosition? OutletPosition { get; init; } public bool IsEnabled => SwitchState == EPropSwitchState.Enabled; public bool IsOpen => SwitchState == EPropSwitchState.Enabled; diff --git a/src/ReactorMaintenance.Simulation/SimulationEngine.cs b/src/ReactorMaintenance.Simulation/SimulationEngine.cs index 855b763..3f9e2fc 100644 --- a/src/ReactorMaintenance.Simulation/SimulationEngine.cs +++ b/src/ReactorMaintenance.Simulation/SimulationEngine.cs @@ -96,6 +96,7 @@ public sealed class SimulationEngine var next = level; next = NetworkPropagationSystem.Propagate(next); + next = SprinklerSystem.ApplyPressureDebt(next); next = ConsumerSystem.Resolve(next); next = StructuralIntegritySystem.Resolve(next); return next; @@ -104,6 +105,7 @@ public sealed class SimulationEngine private static LevelState ResolveStepContent(LevelState level) { var next = level; + next = SprinklerSystem.Discharge(next); next = LeakSystem.Inject(next); next = SurfaceInteractionSystem.Resolve(next); next = next with { Global = next.Global with { Step = next.Global.Step + 1 } }; diff --git a/src/ReactorMaintenance.Simulation/Systems/LeakSystem.cs b/src/ReactorMaintenance.Simulation/Systems/LeakSystem.cs index 7988d09..ef2b5b0 100644 --- a/src/ReactorMaintenance.Simulation/Systems/LeakSystem.cs +++ b/src/ReactorMaintenance.Simulation/Systems/LeakSystem.cs @@ -8,7 +8,7 @@ internal static class LeakSystem foreach (var leak in level.Leaks.Where(leak => !leak.Repaired)) { var underground = level.GetUnderground(leak.UndergroundPosition, leak.Carrier); - if (underground.State != EUndergroundState.Leaking) + if (underground is not { State: EUndergroundState.Leaking, Amount: > 0, Intensity: > 0 }) continue; var accessIndex = level.Index(leak.AccessPosition); diff --git a/src/ReactorMaintenance.Simulation/Systems/PlayerActionSystem.cs b/src/ReactorMaintenance.Simulation/Systems/PlayerActionSystem.cs index cc08b99..8fd828d 100644 --- a/src/ReactorMaintenance.Simulation/Systems/PlayerActionSystem.cs +++ b/src/ReactorMaintenance.Simulation/Systems/PlayerActionSystem.cs @@ -27,17 +27,23 @@ internal static class PlayerActionSystem if (prop.Type == EPropType.None) return Refuse(level, "NO PROP"); + var accepted = true; var next = prop.Type switch { EPropType.Flow or EPropType.Consumer or EPropType.IsolationValve => ToggleProp(level, position, prop), + EPropType.SprinklerControl => 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" } }, EPropType.RemedySupply => PickUpRemedy(level, position, prop), EPropType.ReactorControl => ReactorSystem.Activate(level), - _ => level + _ => Refuse(level, "PROP NOT INTERACTIVE") }; - return prop.Type == EPropType.AllSeeingEyeTerminal || prop.Type == EPropType.ReactorControl ? next : resolveLengthyAction(next); + accepted = next.Global.Status != "PROP NOT INTERACTIVE"; + if (!accepted || prop.Type == EPropType.ReactorControl) + return next; + + return resolveLengthyAction(next); } public static LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy, Func resolveLengthyAction) diff --git a/src/ReactorMaintenance.Simulation/Systems/SprinklerSystem.cs b/src/ReactorMaintenance.Simulation/Systems/SprinklerSystem.cs new file mode 100644 index 0000000..9629566 --- /dev/null +++ b/src/ReactorMaintenance.Simulation/Systems/SprinklerSystem.cs @@ -0,0 +1,63 @@ +namespace ReactorMaintenance.Simulation; + +internal static class SprinklerSystem +{ + public static LevelState ApplyPressureDebt(LevelState level) + { + var water = level.Water.ToArray(); + foreach (var valvePosition in ActiveFedValvePositions(level)) + { + var index = level.Index(valvePosition); + water[index] = water[index] with { + Intensity = Balancing.Current.ClampValue(water[index].Intensity - Balancing.Current.SprinklerPressureDebt) + }; + } + + return level with { Water = water }; + } + + public static LevelState Discharge(LevelState level) + { + var surface = level.Surface.ToArray(); + foreach (var valvePosition in ActiveFedValvePositions(level)) + { + var valve = level.GetProp(valvePosition); + if (valve.OutletPosition is not { } outlet || !level.IsFloor(outlet)) + continue; + + var index = level.Index(outlet); + if (surface[index].Blocks(ECarrierType.Water)) + continue; + + surface[index] = surface[index] with { + Water = Balancing.Current.ClampValue(surface[index].Water + Balancing.Current.SprinklerWaterPerStep) + }; + } + + return level with { Surface = surface }; + } + + private static IEnumerable ActiveFedValvePositions(LevelState level) + { + foreach (var position in LevelTraversal.AllPositions(level)) + { + var valve = level.GetProp(position); + if (valve.Type != EPropType.SprinklerValve || !HasEnabledLinkedControl(level, position)) + continue; + + var underground = level.GetUnderground(position, ECarrierType.Water); + if (underground is { Amount: > 0, Intensity: > 0 }) + yield return position; + } + } + + private static bool HasEnabledLinkedControl(LevelState level, GridPosition valvePosition) + { + return LevelTraversal.AllPositions(level) + .Any(position => level.GetProp(position) is { + Type: EPropType.SprinklerControl, + SwitchState: EPropSwitchState.Enabled, + LinkedPosition: var linkedPosition + } && linkedPosition == valvePosition); + } +} \ No newline at end of file diff --git a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs index bee126e..fefba18 100644 --- a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs +++ b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs @@ -285,7 +285,7 @@ public sealed class SimulationEngineTests var json = LevelSerializer.Serialize(level); var loaded = LevelSerializer.Deserialize(json); - Assert.Contains("\"Version\": 3", json); + Assert.Contains("\"Version\": 4", json); Assert.Equal(level.Name, loaded.Name); Assert.Equal(EPropType.Consumer, loaded.GetProp(new(3, 3)).Type); Assert.Equal(level.RequiredFuelConsumers, loaded.RequiredFuelConsumers); @@ -323,6 +323,55 @@ public sealed class SimulationEngineTests Assert.Contains("Unsupported level file version 2", exception.Message); } + [Fact] + public void SprinklerValveDischargesOnlyWithLinkedEnabledControlAndFedWaterBranch() + { + var enabled = SprinklerLevel(EPropSwitchState.Enabled); + var disabled = SprinklerLevel(EPropSwitchState.Disabled); + + var enabledResult = m_Engine.AdvancePulseForDebug(enabled); + var disabledResult = m_Engine.AdvancePulseForDebug(disabled); + + Assert.True(enabledResult.GetSurface(new(2, 2)).Water > 0); + Assert.Equal(0, disabledResult.GetSurface(new(2, 2)).Water); + } + + [Fact] + public void SprinklerDischargeAppliesLocalPressureDebt() + { + var enabled = m_Engine.AdvancePulseForDebug(SprinklerLevel(EPropSwitchState.Enabled)); + var disabled = m_Engine.AdvancePulseForDebug(SprinklerLevel(EPropSwitchState.Disabled)); + + Assert.True(enabled.GetUnderground(new(2, 1), ECarrierType.Water).Intensity < disabled.GetUnderground(new(2, 1), ECarrierType.Water).Intensity); + } + + [Fact] + public void DirectSprinklerValveInteractionIsInvalidAndDoesNotPulse() + { + var level = SprinklerLevel(EPropSwitchState.Enabled) with { Robot = new() { Position = new(2, 1) } }; + + var next = m_Engine.InteractProp(level); + + Assert.Equal(0, next.Global.Pulse); + Assert.Equal("PROP NOT INTERACTIVE", next.Global.Status); + } + + [Fact] + public void UnfedLeakDoesNotInjectFreshSurfaceWater() + { + var level = LevelState.Create("Unfed leak", 5, 5); + level = level.SetUnderground(new(2, 2), ECarrierType.Water, new() { State = EUndergroundState.Leaking }) with { + RequiredFuelConsumers = 0, + RequiredWaterConsumers = 0, + RequiredElectricityConsumers = 0, + Leaks = [new() { Carrier = ECarrierType.Water, UndergroundPosition = new(2, 2), AccessPosition = new(2, 2) }] + }; + + var next = m_Engine.AdvancePulseForDebug(level); + + Assert.Equal(0, next.GetSurface(new(2, 2)).Water); + } + private static LevelState BuildReadyLevel() { var level = LevelState.Create("Ready", 8, 7); @@ -387,5 +436,22 @@ public sealed class SimulationEngineTests }; } + private static LevelState SprinklerLevel(EPropSwitchState controlState) + { + var level = LevelState.Create("Sprinkler", 5, 5); + level = level.SetTerrain(new(2, 1), ECellTerrain.Wall); + level = level.SetUnderground(new(1, 1), ECarrierType.Water, new() { State = EUndergroundState.Intact }); + level = level.SetUnderground(new(2, 1), ECarrierType.Water, new() { State = EUndergroundState.Intact }); + level = level.SetProp(new(1, 1), new() { Type = EPropType.Flow, Carrier = ECarrierType.Water }); + level = level.SetProp(new(2, 1), new() { Type = EPropType.SprinklerValve, Carrier = ECarrierType.Water, OutletPosition = new(2, 2) }); + level = level.SetProp(new(3, 2), new() { Type = EPropType.SprinklerControl, SwitchState = controlState, LinkedPosition = new(2, 1) }); + return level with { + RequiredFuelConsumers = 0, + RequiredWaterConsumers = 0, + RequiredElectricityConsumers = 0, + Robot = new() { Position = new(3, 2) } + }; + } + private readonly SimulationEngine m_Engine = new(); } \ No newline at end of file