Rewrite simulation core for design model
This commit is contained in:
@@ -1,298 +1,203 @@
|
||||
using ReactorMaintenance.Simulation.Difficulties;
|
||||
using ReactorMaintenance.Simulation.Effects;
|
||||
using ReactorMaintenance.Simulation.Hazards;
|
||||
|
||||
namespace ReactorMaintenance.Simulation.Tests;
|
||||
|
||||
public sealed class SimulationEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void FuelLeakNearPoweredGeneratorCreatesIgnitionForecast()
|
||||
{
|
||||
var level = LevelState.Create("Fuel leak", 6, 6)
|
||||
.SetCell(new(2, 2), new() {
|
||||
Prop = ECellProp.Generator,
|
||||
Pipe = EPipeMedium.Fuel,
|
||||
LeakRate = Balancing.Current.FuelVaporFireThreshold,
|
||||
Pressure = Balancing.Current.PressurizedFuelLeakPressureThreshold + Balancing.Current.NeighborDistance,
|
||||
Integrity = Balancing.Current.DefaultEditedPipeIntegrity,
|
||||
Powered = true
|
||||
});
|
||||
|
||||
var forecasts = m_Engine.Forecast(level);
|
||||
|
||||
Assert.Contains(forecasts, forecast => forecast.Kind == EFailureKind.Ignition && forecast.Position == new GridPosition(2, 2) && forecast.Turns == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CoolantLeakOnPoweredCellRaisesElectricalCharge()
|
||||
{
|
||||
var level = LevelState.Create("Wet cable", 6, 6)
|
||||
.SetCell(new(3, 3), new() {
|
||||
Pipe = EPipeMedium.Coolant,
|
||||
LeakRate = Balancing.Current.ElectrifiedCoolantPoolingThreshold,
|
||||
Powered = true
|
||||
});
|
||||
|
||||
var next = m_Engine.AdvanceTurn(level);
|
||||
|
||||
Assert.True(next.GetCell(new(3, 3)).Hazards.ElectricalCharge >= Balancing.Current.ElectricalChargeIncrease);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveFireSpreadsSmokeToOpenNeighbors()
|
||||
{
|
||||
var level = LevelState.Create("Smoke", 6, 6)
|
||||
.SetCell(new(2, 2), new() {
|
||||
Hazards = new() {
|
||||
Fire = true,
|
||||
Smoke = Balancing.Current.SmokeSpreadThreshold
|
||||
}
|
||||
});
|
||||
|
||||
var next = m_Engine.AdvanceTurn(level);
|
||||
|
||||
Assert.True(next.GetCell(new(2, 3)).Hazards.Smoke > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvanceTurnRunsConfiguredCellEffects()
|
||||
{
|
||||
var engine = new SimulationEngine([new TestCellEffect()], [], []);
|
||||
var level = LevelState.Create("Custom effect", 6, 6)
|
||||
.SetCell(new(2, 2), new() {
|
||||
Hazards = new() { Heat = 1 }
|
||||
});
|
||||
|
||||
var next = engine.AdvanceTurn(level);
|
||||
|
||||
Assert.Equal(5, next.GetCell(new(2, 2)).Hazards.Heat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvanceTurnRunsConfiguredAreaEffects()
|
||||
{
|
||||
var engine = new SimulationEngine([], [new TestAreaEffect()], []);
|
||||
var level = LevelState.Create("Custom area effect", 6, 6);
|
||||
|
||||
var next = engine.AdvanceTurn(level);
|
||||
|
||||
Assert.Equal(7, next.GetCell(new(2, 2)).Hazards.Smoke);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverpressurePredictsPipeBurst()
|
||||
{
|
||||
var level = LevelState.Create("Pressure", 6, 6)
|
||||
.SetCell(new(1, 2), new() {
|
||||
Pipe = EPipeMedium.Pressure,
|
||||
Pressure = 10,
|
||||
Integrity = 6
|
||||
});
|
||||
|
||||
var forecasts = m_Engine.Forecast(level);
|
||||
|
||||
Assert.Contains(forecasts, forecast => forecast.Kind == EFailureKind.PipeBurst && forecast.Turns == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForecastCapsFutureSimulationWhenNoTerminalConditionOccurs()
|
||||
{
|
||||
var engine = new SimulationEngine([], [], [new StepCountingHazard()]);
|
||||
var level = LevelState.Create("Stable", 6, 6);
|
||||
|
||||
var forecasts = engine.Forecast(level);
|
||||
|
||||
Assert.Equal(Balancing.Current.MaxForecastStepCount, forecasts.Count);
|
||||
Assert.Equal(Balancing.Current.MaxForecastStepCount, forecasts.Max(forecast => forecast.Turns));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForecastUsesCurrentBalancingProfile()
|
||||
{
|
||||
var previous = Balancing.Current;
|
||||
try
|
||||
{
|
||||
Balancing.Current = new TestBalancing();
|
||||
var engine = new SimulationEngine([], [], [new StepCountingHazard()]);
|
||||
var level = LevelState.Create("Stable", 6, 6);
|
||||
|
||||
var forecasts = engine.Forecast(level);
|
||||
|
||||
Assert.Equal(Balancing.Current.MaxForecastStepCount, forecasts.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Balancing.Current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForecastPredictsMeltdownFromFutureSimulation()
|
||||
{
|
||||
var level = LevelState.Create("Meltdown", 6, 6)
|
||||
.SetCell(new(2, 2), new() {
|
||||
Prop = ECellProp.Reactor,
|
||||
Hazards = new() { Heat = Balancing.Current.MeltdownCoreHeatThreshold - Balancing.Current.ReactorHeatIncrease }
|
||||
});
|
||||
|
||||
var forecasts = m_Engine.Forecast(level);
|
||||
|
||||
Assert.Contains(forecasts, forecast => forecast.Kind == EFailureKind.Meltdown && forecast.Turns == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForecastReportsAlreadyLostLevelAtCurrentTurn()
|
||||
{
|
||||
var level = LevelState.Create("Lost", 6, 6) with {
|
||||
Global = new() {
|
||||
CoreHeat = Balancing.Current.MeltdownCoreHeatThreshold,
|
||||
Lost = true,
|
||||
Status = "CORE MELTDOWN"
|
||||
}
|
||||
};
|
||||
|
||||
var forecasts = m_Engine.Forecast(level);
|
||||
|
||||
Assert.Contains(forecasts, forecast => forecast.Kind == EFailureKind.Meltdown && forecast.Turns == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForecastPredictsStabilityCollapseFromFutureSimulation()
|
||||
{
|
||||
var level = LevelState.Create("Collapse", 6, 6)
|
||||
.SetCell(new(2, 2), new() {
|
||||
Prop = ECellProp.Generator,
|
||||
Hazards = new() { Stability = Balancing.Current.CriticalCellStabilityThreshold }
|
||||
}) with {
|
||||
Global = new() { FacilityStability = Balancing.Current.FireStabilityDamage }
|
||||
};
|
||||
|
||||
var forecasts = m_Engine.Forecast(level);
|
||||
|
||||
Assert.Contains(forecasts, forecast => forecast.Kind == EFailureKind.StabilityCollapse && forecast.Turns == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableReactorWithPowerAndCoolingCanActivate()
|
||||
{
|
||||
var level = LevelState.Create("Ready", 8, 6)
|
||||
.SetCell(new(2, 2), new() {
|
||||
Prop = ECellProp.Reactor,
|
||||
Hazards = new() { Heat = 3 }
|
||||
})
|
||||
.SetCell(new(3, 2), new() {
|
||||
Prop = ECellProp.Generator,
|
||||
Powered = true
|
||||
})
|
||||
.SetCell(new(4, 2), new() {
|
||||
Prop = ECellProp.CoolingPump,
|
||||
Powered = true
|
||||
});
|
||||
|
||||
var next = m_Engine.AdvanceTurn(level);
|
||||
var activated = m_Engine.ActivateReactor(next);
|
||||
|
||||
Assert.Equal("REACTOR ONLINE", activated.Global.Status);
|
||||
Assert.True(activated.Global.ReactorActivated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LevelSerializationRoundTripsEditableState()
|
||||
{
|
||||
var level = LevelState.Create("Round trip", 5, 5);
|
||||
level = LevelEditor.Apply(level, new(2, 2), EEditorTool.Reactor);
|
||||
level = LevelEditor.Apply(level, new(1, 2), EEditorTool.CoolantPipe);
|
||||
level = LevelEditor.Apply(level, new(1, 2), EEditorTool.Leak);
|
||||
|
||||
var json = LevelSerializer.Serialize(level);
|
||||
var loaded = LevelSerializer.Deserialize(json);
|
||||
|
||||
Assert.Contains("\"Version\": 1", json);
|
||||
Assert.Equal(level.Name, loaded.Name);
|
||||
Assert.Equal(ECellProp.Reactor, loaded.GetCell(new(2, 2)).Prop);
|
||||
Assert.Equal(EPipeMedium.Coolant, loaded.GetCell(new(1, 2)).Pipe);
|
||||
Assert.Equal(1, loaded.GetCell(new(1, 2)).LeakRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LevelSerializationRejectsUnsupportedVersion()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"Version": 999,
|
||||
"Level": {}
|
||||
}
|
||||
""";
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => LevelSerializer.Deserialize(json));
|
||||
|
||||
Assert.Contains("Unsupported level file version 999", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WallToolClearsCellPropsPipesAndHazards()
|
||||
{
|
||||
var level = LevelState.Create("Wall", 5, 5);
|
||||
level = LevelEditor.Apply(level, new(2, 2), EEditorTool.Generator);
|
||||
level = LevelEditor.Apply(level, new(2, 2), EEditorTool.CoolantPipe);
|
||||
level = LevelEditor.Apply(level, new(2, 2), EEditorTool.Fire);
|
||||
|
||||
var edited = LevelEditor.Apply(level, new(2, 2), EEditorTool.Wall);
|
||||
var cell = edited.GetCell(new(2, 2));
|
||||
|
||||
Assert.Equal(ECellTerrain.Wall, cell.Terrain);
|
||||
Assert.Equal(ECellProp.None, cell.Prop);
|
||||
Assert.Equal(EPipeMedium.None, cell.Pipe);
|
||||
Assert.False(cell.Powered);
|
||||
Assert.False(cell.Hazards.Fire);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PropToolsKeepFloorTerrain()
|
||||
{
|
||||
var level = LevelState.Create("Prop", 5, 5);
|
||||
level = LevelEditor.Apply(level, new(1, 1), EEditorTool.Wall);
|
||||
|
||||
var edited = LevelEditor.Apply(level, new(1, 1), EEditorTool.Reactor);
|
||||
var cell = edited.GetCell(new(1, 1));
|
||||
|
||||
Assert.Equal(ECellTerrain.Floor, cell.Terrain);
|
||||
Assert.Equal(ECellProp.Reactor, cell.Prop);
|
||||
}
|
||||
|
||||
private readonly SimulationEngine m_Engine = new();
|
||||
|
||||
private sealed class StepCountingHazard : Hazard
|
||||
{
|
||||
public override IEnumerable<Forecast> Predict(LevelState level, int turns)
|
||||
{
|
||||
yield return new(EFailureKind.PipeBurst, new(turns, 0), turns, $"STEP {turns}");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestBalancing : NormalBalancing
|
||||
{
|
||||
public override int MaxForecastStepCount => 2;
|
||||
}
|
||||
|
||||
private sealed class TestCellEffect : ISimulationEffect
|
||||
{
|
||||
public CellState Apply(CellState cell)
|
||||
{
|
||||
return cell with { Hazards = cell.Hazards with { Heat = 5 } };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAreaEffect : IAreaSimulationEffect
|
||||
{
|
||||
public CellState[] Apply(LevelState level, CellState[] cells)
|
||||
{
|
||||
var next = cells.ToArray();
|
||||
var position = new GridPosition(2, 2);
|
||||
var cell = next[level.Index(position)];
|
||||
next[level.Index(position)] = cell with { Hazards = cell.Hazards with { Smoke = 7 } };
|
||||
return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace ReactorMaintenance.Simulation.Tests;
|
||||
|
||||
public sealed class SimulationEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void NetworkPropagationSuppliesBoundConsumersAndReadiesReactor()
|
||||
{
|
||||
var level = BuildReadyLevel();
|
||||
|
||||
var next = m_Engine.AdvanceTurn(level);
|
||||
|
||||
Assert.Equal(EConsumerServiceState.Producing, next.GetProp(new(3, 2)).ServiceState);
|
||||
Assert.Equal(EConsumerServiceState.Producing, next.GetProp(new(3, 3)).ServiceState);
|
||||
Assert.Equal(EConsumerServiceState.Producing, next.GetProp(new(3, 4)).ServiceState);
|
||||
Assert.Equal(ELevelState.Ready, next.Global.LevelState);
|
||||
Assert.Contains(next.Forecasts, forecast => forecast.Kind == EForecastKind.ReactorReady);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReactorActivatesOnlyAtReadyControl()
|
||||
{
|
||||
var level = m_Engine.AdvanceTurn(BuildReadyLevel()) with {
|
||||
Robot = new() { Position = new(5, 3) }
|
||||
};
|
||||
|
||||
var activated = m_Engine.ActivateReactor(level);
|
||||
|
||||
Assert.Equal(ELevelState.Won, activated.Global.LevelState);
|
||||
Assert.True(activated.Reactors[0].Activated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeakingUndergroundCellInjectsMatchingSurfaceHazard()
|
||||
{
|
||||
var level = LevelState.Create("Leak", 6, 6);
|
||||
level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, new() { State = EUndergroundState.Leaking, Amount = 5, Intensity = 5 }) with {
|
||||
Leaks = [new LeakState { Carrier = ECarrierType.Fuel, UndergroundPosition = new(2, 2), AccessPosition = new(2, 2) }]
|
||||
};
|
||||
level = level.SetProp(new(2, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
|
||||
|
||||
var next = m_Engine.AdvanceTurn(level);
|
||||
|
||||
Assert.True(next.GetSurface(new(2, 2)).Fuel > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ElementRemedyClearsHazardAndBlocksImmediateReentry()
|
||||
{
|
||||
var level = LevelState.Create("Remedy", 6, 6);
|
||||
level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, new() { State = EUndergroundState.Leaking, Amount = 5, Intensity = 5 });
|
||||
level = level.SetSurface(new(2, 2), new() { Fuel = 5 }) with {
|
||||
Robot = new() { Position = new(2, 2), FuelNeutralizers = 1 },
|
||||
Leaks = [new LeakState { Carrier = ECarrierType.Fuel, UndergroundPosition = new(2, 2), AccessPosition = new(2, 2) }]
|
||||
};
|
||||
|
||||
var next = m_Engine.InteractLeak(level, ECarrierType.Fuel, true);
|
||||
|
||||
Assert.Equal(0, next.GetSurface(new(2, 2)).Fuel);
|
||||
Assert.True(next.GetSurface(new(2, 2)).FuelBlockTurns > 0);
|
||||
Assert.Equal(0, next.Robot.FuelNeutralizers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClosedDoorBlocksAdjacentHeatFlow()
|
||||
{
|
||||
var level = LevelState.Create("Door", 6, 6);
|
||||
level = level.SetSurface(new(2, 2), new() { Heat = 8 }) with {
|
||||
Doors = [new DoorState { A = new(2, 2), B = new(3, 2), State = EDoorState.Closed }]
|
||||
};
|
||||
|
||||
var next = m_Engine.AdvanceTurn(level);
|
||||
|
||||
Assert.Equal(0, next.GetSurface(new(3, 2)).Heat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeatShieldPreventsRobotHeatLoss()
|
||||
{
|
||||
var level = LevelState.Create("Heat shield", 6, 6);
|
||||
level = level.SetSurface(new(2, 2), new() { Heat = Balancing.Current.RobotHeatSafetyThreshold }) with {
|
||||
Robot = new() { Position = new(2, 2), HeatImmunitySteps = 1 }
|
||||
};
|
||||
|
||||
var next = m_Engine.AdvanceTurn(level);
|
||||
|
||||
Assert.NotEqual(ELevelState.Lost, next.Global.LevelState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RobotLosesOnUnsafeElementHazard()
|
||||
{
|
||||
var level = LevelState.Create("Unsafe", 6, 6);
|
||||
level = level.SetSurface(new(2, 2), new() { Electricity = Balancing.Current.MaxValue }) with {
|
||||
Robot = new() { Position = new(2, 2) }
|
||||
};
|
||||
|
||||
var next = m_Engine.AdvanceTurn(level);
|
||||
|
||||
Assert.Equal(ELevelState.Lost, next.Global.LevelState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuleEventCanCreateTerminalLossForecast()
|
||||
{
|
||||
var level = LevelState.Create("Rule", 6, 6) with {
|
||||
RuleEvents = [
|
||||
new RuleEventState {
|
||||
Phase = ERuleEventPhase.EndOfTurn,
|
||||
ForecastText = "containment failure",
|
||||
Predicates = [new RulePredicate { Kind = ERulePredicateKind.TurnAtLeast, Turn = 0 }],
|
||||
Effects = [new RuleEffect { Kind = ERuleEffectKind.MarkTerminalLoss, Message = "CONTAINMENT FAILURE" }]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var forecasts = m_Engine.Forecast(level);
|
||||
|
||||
Assert.Contains(forecasts, forecast => forecast.Kind == EForecastKind.RuleEvent && forecast.Message == "containment failure");
|
||||
Assert.Contains(forecasts, forecast => forecast.Kind == EForecastKind.TerminalLoss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorRejectsWallHazardsAndInvalidReactorBinding()
|
||||
{
|
||||
var level = LevelState.Create("Invalid", 6, 6);
|
||||
level = level.SetTerrain(new(2, 2), ECellTerrain.Wall);
|
||||
level = level with {
|
||||
Surface = level.Surface.ToArray(),
|
||||
Reactors = [new ReactorBinding { ControlPosition = new(3, 3), FuelConsumerPosition = new(1, 1), CoolantConsumerPosition = new(1, 1), ElectricityConsumerPosition = new(1, 1) }]
|
||||
};
|
||||
level.Surface[level.Index(new(2, 2))] = new() { Heat = 1 };
|
||||
|
||||
var report = new LevelValidator().Validate(level);
|
||||
|
||||
Assert.False(report.IsValid);
|
||||
Assert.Contains(report.Errors, error => error.Message.Contains("Wall cell", StringComparison.Ordinal));
|
||||
Assert.Contains(report.Errors, error => error.Message.Contains("Reactor binding", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LevelSerializationRoundTripsCurrentSchemaOnly()
|
||||
{
|
||||
var level = BuildReadyLevel();
|
||||
|
||||
var json = LevelSerializer.Serialize(level);
|
||||
var loaded = LevelSerializer.Deserialize(json);
|
||||
|
||||
Assert.Contains("\"Version\": 2", json);
|
||||
Assert.Equal(level.Name, loaded.Name);
|
||||
Assert.Equal(EPropType.Flow, loaded.GetProp(new(2, 2)).Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LevelSerializationRejectsOldSchema()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"Version": 1,
|
||||
"Level": {}
|
||||
}
|
||||
""";
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => LevelSerializer.Deserialize(json));
|
||||
|
||||
Assert.Contains("Unsupported level file version 1", exception.Message);
|
||||
}
|
||||
|
||||
private static LevelState BuildReadyLevel()
|
||||
{
|
||||
var level = LevelState.Create("Ready", 8, 7);
|
||||
level = AddLine(level, ECarrierType.Fuel, new(2, 2), new(3, 2));
|
||||
level = AddLine(level, ECarrierType.Coolant, new(2, 3), new(3, 3));
|
||||
level = AddLine(level, ECarrierType.Electricity, new(2, 4), new(3, 4));
|
||||
level = level.SetProp(new(2, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
|
||||
level = level.SetProp(new(2, 3), new() { Type = EPropType.Flow, Carrier = ECarrierType.Coolant });
|
||||
level = level.SetProp(new(2, 4), new() { Type = EPropType.Flow, Carrier = ECarrierType.Electricity });
|
||||
level = level.SetProp(new(3, 2), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Fuel });
|
||||
level = level.SetProp(new(3, 3), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Coolant });
|
||||
level = level.SetProp(new(3, 4), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Electricity });
|
||||
level = level.SetProp(new(5, 3), new() { Type = EPropType.ReactorControl, ReactorId = 1 });
|
||||
return level with {
|
||||
Robot = new() { Position = new(5, 3) },
|
||||
Reactors = [
|
||||
new ReactorBinding {
|
||||
ReactorId = 1,
|
||||
ControlPosition = new(5, 3),
|
||||
FuelConsumerPosition = new(3, 2),
|
||||
CoolantConsumerPosition = new(3, 3),
|
||||
ElectricityConsumerPosition = new(3, 4)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static LevelState AddLine(LevelState level, ECarrierType carrier, GridPosition a, GridPosition b)
|
||||
{
|
||||
level = level.SetUnderground(a, carrier, new() { State = EUndergroundState.Intact });
|
||||
level = level.SetUnderground(b, carrier, new() { State = EUndergroundState.Intact });
|
||||
return level;
|
||||
}
|
||||
|
||||
private readonly SimulationEngine m_Engine = new();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user