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