Add sprinkler valve simulation contract

This commit is contained in:
2026-05-14 10:15:12 +02:00
parent 6db3e60fd1
commit 2ad7feef96
13 changed files with 194 additions and 6 deletions

View File

@@ -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; }

View File

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

View File

@@ -12,6 +12,8 @@ public enum EEditorTool
Junction,
Door,
AllSeeingEyeTerminal,
SprinklerControl,
SprinklerValve,
RemedySupply,
ReactorControl,
Leak,

View File

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

View File

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

View File

@@ -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<ValidationIssue> 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<ValidationIssue> errors)
{
foreach (var leak in level.Leaks)

View File

@@ -9,6 +9,8 @@ public enum EPropType
Junction,
Door,
AllSeeingEyeTerminal,
SprinklerControl,
SprinklerValve,
RemedySupply,
ReactorControl
}

View File

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

View File

@@ -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 } };

View File

@@ -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);

View File

@@ -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<LevelState, LevelState> resolveLengthyAction)

View File

@@ -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<GridPosition> 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);
}
}

View File

@@ -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();
}