Add pulse contract and isolation valves

This commit is contained in:
2026-05-14 10:11:35 +02:00
parent c5688d2c0d
commit 6db3e60fd1
15 changed files with 195 additions and 27 deletions

View File

@@ -40,9 +40,10 @@ public partial class InventoryStrip : HBoxContainer
return item; return item;
} }
private Label m_WaterLabel = null!;
private Label m_ElectricLabel = null!; private Label m_ElectricLabel = null!;
private Label m_FuelLabel = null!; private Label m_FuelLabel = null!;
private Label m_HeatLabel = null!; private Label m_HeatLabel = null!;
private Label m_WaterLabel = null!;
} }

View File

@@ -121,6 +121,7 @@ public abstract class Balancing
public abstract int DefaultLevelHeight { get; } public abstract int DefaultLevelHeight { get; }
public abstract int MinimumLevelSize { get; } public abstract int MinimumLevelSize { get; }
public abstract int ForecastHorizon { get; } public abstract int ForecastHorizon { get; }
public abstract int StepsPerPulse { get; }
public abstract float MinValue { get; } public abstract float MinValue { get; }
public abstract float MaxValue { get; } public abstract float MaxValue { get; }
public abstract float FuelSafe { get; } public abstract float FuelSafe { get; }

View File

@@ -6,6 +6,7 @@ public class NormalBalancing : Balancing
public override int DefaultLevelHeight => 12; public override int DefaultLevelHeight => 12;
public override int MinimumLevelSize => 4; public override int MinimumLevelSize => 4;
public override int ForecastHorizon => 6; public override int ForecastHorizon => 6;
public override int StepsPerPulse => 3;
public override float MinValue => 0; public override float MinValue => 0;
public override float MaxValue => 10; public override float MaxValue => 10;
public override float FuelSafe => 1.5f; public override float FuelSafe => 1.5f;

View File

@@ -7,6 +7,7 @@ public enum EEditorTool
Wall, Wall,
Underground, Underground,
Flow, Flow,
IsolationValve,
Consumer, Consumer,
Junction, Junction,
Door, Door,

View File

@@ -139,6 +139,7 @@ public static class LevelEditor
EEditorTool.Wall => level.SetTerrain(position, ECellTerrain.Wall), EEditorTool.Wall => level.SetTerrain(position, ECellTerrain.Wall),
EEditorTool.Underground => SetUnderground(level, position, command.Carrier), EEditorTool.Underground => SetUnderground(level, position, command.Carrier),
EEditorTool.Flow => SetCarrierProp(level, position, EPropType.Flow, 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.Consumer => SetFloorProp(level, position, new() { Type = EPropType.Consumer, SwitchState = EPropSwitchState.Enabled }),
EEditorTool.Junction => SetFloorProp(level, position, new() { Type = EPropType.Junction }), EEditorTool.Junction => SetFloorProp(level, position, new() { Type = EPropType.Junction }),
EEditorTool.Door => ToggleOrSetDoor(level, position), EEditorTool.Door => ToggleOrSetDoor(level, position),

View File

@@ -11,6 +11,7 @@ public sealed class LevelValidator
ValidateRobot(level, errors); ValidateRobot(level, errors);
ValidateCells(level, errors); ValidateCells(level, errors);
ValidateDoors(level, errors); ValidateDoors(level, errors);
ValidateIsolationValves(level, errors);
ValidateLeaks(level, errors); ValidateLeaks(level, errors);
ValidateReactors(level, errors, warnings); ValidateReactors(level, errors, warnings);
ValidateJunctions(level, errors); ValidateJunctions(level, errors);
@@ -19,6 +20,25 @@ public sealed class LevelValidator
return new() { Errors = errors, Warnings = warnings }; return new() { Errors = errors, Warnings = warnings };
} }
private static void ValidateIsolationValves(LevelState level, List<ValidationIssue> 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<ValidationIssue> errors) private static void ValidateDimensions(LevelState level, List<ValidationIssue> errors)
{ {
if (level.Width < Balancing.Current.MinimumLevelSize || level.Height < Balancing.Current.MinimumLevelSize) if (level.Width < Balancing.Current.MinimumLevelSize || level.Height < Balancing.Current.MinimumLevelSize)

View File

@@ -4,6 +4,7 @@ public enum EPropType
{ {
None, None,
Flow, Flow,
IsolationValve,
Consumer, Consumer,
Junction, Junction,
Door, Door,

View File

@@ -3,6 +3,8 @@
public sealed record GlobalState public sealed record GlobalState
{ {
public int Turn { get; init; } public int Turn { get; init; }
public int Pulse { get; init; }
public int Step { get; init; }
public ELevelState LevelState { get; init; } = ELevelState.Stable; public ELevelState LevelState { get; init; } = ELevelState.Stable;
public string Status { get; init; } = "STABLE"; public string Status { get; init; } = "STABLE";
public bool TerminalLoss { get; init; } public bool TerminalLoss { get; init; }

View File

@@ -26,4 +26,5 @@ public sealed record PropState
public EDoorState DoorState { get; init; } = EDoorState.Closed; public EDoorState DoorState { get; init; } = EDoorState.Closed;
public bool IsEnabled => SwitchState == EPropSwitchState.Enabled; public bool IsEnabled => SwitchState == EPropSwitchState.Enabled;
public bool IsOpen => SwitchState == EPropSwitchState.Enabled;
} }

View File

@@ -9,22 +9,17 @@ public sealed class SimulationEngine
public LevelState InteractProp(LevelState level) public LevelState InteractProp(LevelState level)
{ {
return PlayerActionSystem.InteractProp(level, ResolveStep); return PlayerActionSystem.InteractProp(level, ResolvePulse);
} }
public LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy) 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) public LevelState ApplyHeatShield(LevelState level)
{ {
return PlayerActionSystem.ApplyHeatShield(level, ResolveStep); return PlayerActionSystem.ApplyHeatShield(level, ResolvePulse);
}
private LevelState ResolveStep(LevelState level)
{
return ResolveStep(level, true);
} }
public LevelState ActivateReactor(LevelState level) public LevelState ActivateReactor(LevelState level)
@@ -32,14 +27,26 @@ public sealed class SimulationEngine
return ReactorSystem.Activate(level); 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); 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) public LevelState AdvanceTurn(LevelState level)
{ {
return ResolveStep(level); return AdvancePulseForDebug(level);
} }
public IReadOnlyList<Forecast> Forecast(LevelState level) public IReadOnlyList<Forecast> Forecast(LevelState level)
@@ -47,7 +54,41 @@ public sealed class SimulationEngine
return ForecastSystem.Forecast(level, simulated => ResolveStep(simulated, false)); 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); var report = m_Validator.Validate(level);
if (!report.IsValid) if (!report.IsValid)
@@ -57,18 +98,16 @@ public sealed class SimulationEngine
next = NetworkPropagationSystem.Propagate(next); next = NetworkPropagationSystem.Propagate(next);
next = ConsumerSystem.Resolve(next); next = ConsumerSystem.Resolve(next);
next = StructuralIntegritySystem.Resolve(next); next = StructuralIntegritySystem.Resolve(next);
return next;
}
private static LevelState ResolveStepContent(LevelState level)
{
var next = level;
next = LeakSystem.Inject(next); next = LeakSystem.Inject(next);
next = SurfaceInteractionSystem.Resolve(next); next = SurfaceInteractionSystem.Resolve(next);
next = RobotSafetySystem.Resolve(next); next = next with { Global = next.Global with { Step = next.Global.Step + 1 } };
next = ReactorSystem.DeriveState(next); return 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;
} }
private readonly LevelValidator m_Validator = new(); private readonly LevelValidator m_Validator = new();

View File

@@ -25,4 +25,16 @@ public static class SurfaceStateExtensions
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.") _ => 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;
}
} }

View File

@@ -60,6 +60,9 @@ internal static class NetworkPropagationSystem
if (!level.GetUnderground(next, carrier).CarriesFlow) if (!level.GetUnderground(next, carrier).CarriesFlow)
continue; continue;
if (IsClosedValveBoundary(level, current.Position, next, carrier))
continue;
var weights = BranchWeights(current.Position, next, junctions); var weights = BranchWeights(current.Position, next, junctions);
var amountFactor = current.AmountFactor * weights.Amount; var amountFactor = current.AmountFactor * weights.Amount;
var intensityFactor = current.IntensityFactor * weights.Intensity; 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<GridPosition, JunctionFlow> junctions) private static (float Amount, float Intensity) BranchWeights(GridPosition from, GridPosition to, IReadOnlyDictionary<GridPosition, JunctionFlow> junctions)
{ {
if (!junctions.TryGetValue(from, out var junction)) if (!junctions.TryGetValue(from, out var junction))

View File

@@ -7,12 +7,14 @@ internal static class PlayerActionSystem
if (!CanAct(level) || !level.IsFloor(destination) || level.Robot.Position.ManhattanDistance(destination) != 1) if (!CanAct(level) || !level.IsFloor(destination) || level.Robot.Position.ManhattanDistance(destination) != 1)
return Refuse(level, "MOVE BLOCKED"); return Refuse(level, "MOVE BLOCKED");
return level with { var next = level with {
Robot = level.Robot with { Robot = level.Robot with {
Position = destination, Position = destination,
HeatImmunitySteps = Math.Max(0, level.Robot.HeatImmunitySteps - 1) HeatImmunitySteps = Math.Max(0, level.Robot.HeatImmunitySteps - 1)
} }
}; };
return RobotSafetySystem.ResolveEntry(next);
} }
public static LevelState InteractProp(LevelState level, Func<LevelState, LevelState> resolveLengthyAction) public static LevelState InteractProp(LevelState level, Func<LevelState, LevelState> resolveLengthyAction)
@@ -26,7 +28,7 @@ internal static class PlayerActionSystem
return Refuse(level, "NO PROP"); return Refuse(level, "NO PROP");
var next = prop.Type switch { 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.Junction => CycleJunctionMode(level, position, prop),
EPropType.Door => ToggleDoor(level, position, prop), EPropType.Door => ToggleDoor(level, position, prop),
EPropType.AllSeeingEyeTerminal => level with { Global = level.Global with { Status = "ALL-SEEING-EYE AVAILABLE" } }, EPropType.AllSeeingEyeTerminal => level with { Global = level.Global with { Status = "ALL-SEEING-EYE AVAILABLE" } },

View File

@@ -3,12 +3,17 @@
internal static class RobotSafetySystem internal static class RobotSafetySystem
{ {
public static LevelState Resolve(LevelState level) public static LevelState Resolve(LevelState level)
{
return level;
}
public static LevelState ResolveEntry(LevelState level)
{ {
var surface = level.GetSurface(level.Robot.Position); 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; var unsafeHeat = surface.Heat >= Balancing.Current.RobotHeatSafetyThreshold && level.Robot.HeatImmunitySteps <= 0;
return unsafeElement || unsafeHeat 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; : level;
} }
} }

View File

@@ -79,7 +79,57 @@ public sealed class SimulationEngineTests
var next = m_Engine.InteractProp(level); var next = m_Engine.InteractProp(level);
Assert.Equal(EDoorState.Open, next.GetProp(new(3, 2)).DoorState); 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] [Fact]
@@ -320,5 +370,22 @@ public sealed class SimulationEngineTests
return level.SetProp(new(2, 3), new() { Type = EPropType.Junction, Carrier = ECarrierType.Fuel, JunctionMode = mode }); 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(); private readonly SimulationEngine m_Engine = new();
} }