From 637e9f7fbcc5000359250ffffd959ce16f641e42 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Fri, 8 May 2026 21:26:19 +0200 Subject: [PATCH] Refactor simulation effects and forecast hazards --- .../CellIntegrityEffect.cs | 33 +++ .../FireAndElectricalHazardEffect.cs | 28 +++ src/ReactorMaintenance.Simulation/Hazard.cs | 6 + .../IAreaSimulationEffect.cs | 6 + .../ISimulationEffect.cs | 6 + .../IgnitionHazard.cs | 18 ++ .../MachineEffect.cs | 16 ++ .../MeltdownHazard.cs | 10 + .../PipeBurstHazard.cs | 18 ++ .../PipeLeakEffect.cs | 26 +++ .../SimulationEngine.cs | 219 +++++++----------- .../SmokeSpreadEffect.cs | 35 +++ .../StabilityCollapseHazard.cs | 10 + .../SimulationEngineTests.cs | 84 ++++++- 14 files changed, 374 insertions(+), 141 deletions(-) create mode 100644 src/ReactorMaintenance.Simulation/CellIntegrityEffect.cs create mode 100644 src/ReactorMaintenance.Simulation/FireAndElectricalHazardEffect.cs create mode 100644 src/ReactorMaintenance.Simulation/Hazard.cs create mode 100644 src/ReactorMaintenance.Simulation/IAreaSimulationEffect.cs create mode 100644 src/ReactorMaintenance.Simulation/ISimulationEffect.cs create mode 100644 src/ReactorMaintenance.Simulation/IgnitionHazard.cs create mode 100644 src/ReactorMaintenance.Simulation/MachineEffect.cs create mode 100644 src/ReactorMaintenance.Simulation/MeltdownHazard.cs create mode 100644 src/ReactorMaintenance.Simulation/PipeBurstHazard.cs create mode 100644 src/ReactorMaintenance.Simulation/PipeLeakEffect.cs create mode 100644 src/ReactorMaintenance.Simulation/SmokeSpreadEffect.cs create mode 100644 src/ReactorMaintenance.Simulation/StabilityCollapseHazard.cs diff --git a/src/ReactorMaintenance.Simulation/CellIntegrityEffect.cs b/src/ReactorMaintenance.Simulation/CellIntegrityEffect.cs new file mode 100644 index 0000000..ebf2d49 --- /dev/null +++ b/src/ReactorMaintenance.Simulation/CellIntegrityEffect.cs @@ -0,0 +1,33 @@ +namespace ReactorMaintenance.Simulation; + +public sealed class CellIntegrityEffect : ISimulationEffect +{ + public CellState Apply(CellState cell) + { + var integrity = cell.Integrity; + var hazards = cell.Hazards; + + if (cell is { HasPipe: true, Pressure: > 7 }) + integrity -= cell.Pressure - 7; + + if (hazards.Heat >= 10 || hazards.Fire) + { + integrity -= cell.HasPipe ? 1 : 0; + hazards = hazards with { Stability = hazards.Stability - 1 }; + } + + cell = cell with { + Integrity = Rules.Clamp(integrity), + Hazards = hazards.Clamp() + }; + + if (integrity > 0 || !cell.HasPipe) + return cell; + + return cell with { + LeakRate = Math.Max(cell.LeakRate, 3), + Flow = 0, + PipeOpen = false + }; + } +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/FireAndElectricalHazardEffect.cs b/src/ReactorMaintenance.Simulation/FireAndElectricalHazardEffect.cs new file mode 100644 index 0000000..689f77d --- /dev/null +++ b/src/ReactorMaintenance.Simulation/FireAndElectricalHazardEffect.cs @@ -0,0 +1,28 @@ +namespace ReactorMaintenance.Simulation; + +public sealed class FireAndElectricalHazardEffect : ISimulationEffect +{ + public CellState Apply(CellState cell) + { + var hazards = cell.Hazards; + if (hazards.CoolantPooling >= 3 && cell.Powered) + hazards = hazards with { ElectricalCharge = hazards.ElectricalCharge + 2 }; + + var hasFuel = hazards.FuelVapor >= 4 || hazards.LiquidFuel >= 6; + var hasIgnition = hazards.Heat >= 8 || hazards.ElectricalCharge >= 4 || cell is { Kind: ECellKind.Generator, Powered: true }; + if ((hasFuel && hasIgnition) || hazards.Fire) + { + hazards = hazards with { + Fire = hasFuel || hazards.Fire, + Heat = hazards.Heat + 2, + Smoke = hazards.Smoke + 2, + LiquidFuel = Math.Max(0, hazards.LiquidFuel - 1), + FuelVapor = Math.Max(0, hazards.FuelVapor - 1) + }; + } + else if (hazards.Smoke > 0) + hazards = hazards with { Smoke = hazards.Smoke - 1 }; + + return cell with { Hazards = hazards.Clamp() }; + } +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Hazard.cs b/src/ReactorMaintenance.Simulation/Hazard.cs new file mode 100644 index 0000000..d4f35ab --- /dev/null +++ b/src/ReactorMaintenance.Simulation/Hazard.cs @@ -0,0 +1,6 @@ +namespace ReactorMaintenance.Simulation; + +public abstract class Hazard +{ + public abstract IEnumerable Predict(LevelState level, int turns); +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/IAreaSimulationEffect.cs b/src/ReactorMaintenance.Simulation/IAreaSimulationEffect.cs new file mode 100644 index 0000000..059edda --- /dev/null +++ b/src/ReactorMaintenance.Simulation/IAreaSimulationEffect.cs @@ -0,0 +1,6 @@ +namespace ReactorMaintenance.Simulation; + +public interface IAreaSimulationEffect +{ + CellState[] Apply(LevelState level, CellState[] cells); +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/ISimulationEffect.cs b/src/ReactorMaintenance.Simulation/ISimulationEffect.cs new file mode 100644 index 0000000..3508659 --- /dev/null +++ b/src/ReactorMaintenance.Simulation/ISimulationEffect.cs @@ -0,0 +1,6 @@ +namespace ReactorMaintenance.Simulation; + +public interface ISimulationEffect +{ + CellState Apply(CellState cell); +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/IgnitionHazard.cs b/src/ReactorMaintenance.Simulation/IgnitionHazard.cs new file mode 100644 index 0000000..73a4a02 --- /dev/null +++ b/src/ReactorMaintenance.Simulation/IgnitionHazard.cs @@ -0,0 +1,18 @@ +namespace ReactorMaintenance.Simulation; + +public sealed class IgnitionHazard : Hazard +{ + public override IEnumerable Predict(LevelState level, int turns) + { + for (var y = 0; y < level.Height; y++) + { + for (var x = 0; x < level.Width; x++) + { + var position = new GridPosition(x, y); + var cell = level.GetCell(position); + if (cell.Hazards.Fire) + yield return new(EFailureKind.Ignition, position, turns, turns == 1 ? $"FUEL IGNITION PREDICTED AT {x},{y} NEXT TURN" : $"FUEL IGNITION PREDICTED AT {x},{y} IN {turns} TURNS"); + } + } + } +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/MachineEffect.cs b/src/ReactorMaintenance.Simulation/MachineEffect.cs new file mode 100644 index 0000000..9ebbec1 --- /dev/null +++ b/src/ReactorMaintenance.Simulation/MachineEffect.cs @@ -0,0 +1,16 @@ +namespace ReactorMaintenance.Simulation; + +public sealed class MachineEffect : ISimulationEffect +{ + public CellState Apply(CellState cell) + { + var hazards = cell.Kind switch { + ECellKind.Generator when cell.Powered => cell.Hazards with { Heat = cell.Hazards.Heat + 1 }, + ECellKind.CoolingPump when cell.Powered => cell.Hazards with { Heat = cell.Hazards.Heat - 2 }, + ECellKind.Reactor => cell.Hazards with { Heat = cell.Hazards.Heat + 1 }, + _ => cell.Hazards + }; + + return cell with { Hazards = hazards.Clamp() }; + } +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/MeltdownHazard.cs b/src/ReactorMaintenance.Simulation/MeltdownHazard.cs new file mode 100644 index 0000000..f2e121e --- /dev/null +++ b/src/ReactorMaintenance.Simulation/MeltdownHazard.cs @@ -0,0 +1,10 @@ +namespace ReactorMaintenance.Simulation; + +public sealed class MeltdownHazard : Hazard +{ + public override IEnumerable Predict(LevelState level, int turns) + { + if (level.Global is { Lost: true, Status: "CORE MELTDOWN" }) + yield return new(EFailureKind.Meltdown, null, turns, "CORE MELTDOWN APPROACHING"); + } +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/PipeBurstHazard.cs b/src/ReactorMaintenance.Simulation/PipeBurstHazard.cs new file mode 100644 index 0000000..e0ab611 --- /dev/null +++ b/src/ReactorMaintenance.Simulation/PipeBurstHazard.cs @@ -0,0 +1,18 @@ +namespace ReactorMaintenance.Simulation; + +public sealed class PipeBurstHazard : Hazard +{ + public override IEnumerable Predict(LevelState level, int turns) + { + for (var y = 0; y < level.Height; y++) + { + for (var x = 0; x < level.Width; x++) + { + var position = new GridPosition(x, y); + var cell = level.GetCell(position); + if (cell is { HasPipe: true, PipeOpen: false, Flow: 0, LeakRate: >= 3 }) + yield return new(EFailureKind.PipeBurst, position, turns, $"PIPE BURST PREDICTED AT {x},{y} IN {turns} TURNS"); + } + } + } +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/PipeLeakEffect.cs b/src/ReactorMaintenance.Simulation/PipeLeakEffect.cs new file mode 100644 index 0000000..22c8a1f --- /dev/null +++ b/src/ReactorMaintenance.Simulation/PipeLeakEffect.cs @@ -0,0 +1,26 @@ +namespace ReactorMaintenance.Simulation; + +public sealed class PipeLeakEffect : ISimulationEffect +{ + public CellState Apply(CellState cell) + { + if (!cell.HasPipe || cell.LeakRate <= 0) + return cell; + + var hazards = cell.Pipe switch { + EPipeMedium.Fuel => cell.Hazards with { + LiquidFuel = cell.Hazards.LiquidFuel + cell.LeakRate, + FuelVapor = cell.Hazards.FuelVapor + (cell.Pressure >= 7 ? cell.LeakRate : Math.Max(0, cell.Hazards.Heat - 3) / 3) + }, + EPipeMedium.Coolant => cell.Hazards with { + CoolantPooling = cell.Hazards.CoolantPooling + cell.LeakRate, + Heat = cell.Hazards.Heat - Math.Max(1, cell.LeakRate / 2), + Smoke = cell.Hazards.Smoke + (cell.Hazards.Heat >= 7 ? 2 : 0) + }, + EPipeMedium.Pressure => cell.Hazards with { Smoke = cell.Hazards.Smoke + (cell.Pressure >= 8 ? 1 : 0) }, + _ => cell.Hazards + }; + + return cell with { Hazards = hazards.Clamp() }; + } +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/SimulationEngine.cs b/src/ReactorMaintenance.Simulation/SimulationEngine.cs index a69acbe..bcf0228 100644 --- a/src/ReactorMaintenance.Simulation/SimulationEngine.cs +++ b/src/ReactorMaintenance.Simulation/SimulationEngine.cs @@ -2,93 +2,51 @@ public sealed class SimulationEngine { + private const int c_MaxForecastStepCount = 12; + + public SimulationEngine() + : this( + [new PipeLeakEffect(), new MachineEffect(), new FireAndElectricalHazardEffect(), new CellIntegrityEffect()], + [new SmokeSpreadEffect()], + [new PipeBurstHazard(), new IgnitionHazard(), new MeltdownHazard(), new StabilityCollapseHazard()]) + { + } + + public SimulationEngine(IEnumerable effects, IEnumerable areaEffects, IEnumerable hazards) + { + m_Effects = effects.ToArray(); + m_AreaEffects = areaEffects.ToArray(); + m_Hazards = hazards.ToArray(); + } + public LevelState AdvanceTurn(LevelState level) { - var cells = level.Cells.ToArray(); - - for (var y = 0; y < level.Height; y++) - { - for (var x = 0; x < level.Width; x++) - { - var position = new GridPosition(x, y); - var index = level.Index(position); - var cell = cells[index]; - - if (!cell.IsWalkable) - continue; - - var hazards = ApplyPipeLeaks(cell); - hazards = ApplyMachineEffects(cell, hazards); - hazards = ApplyFireAndElectricalHazards(cell, hazards); - hazards = hazards.Clamp(); - - var integrity = cell.Integrity; - if (cell is { HasPipe: true, Pressure: > 7 }) - integrity -= cell.Pressure - 7; - - if (hazards.Heat >= 10 || hazards.Fire) - { - integrity -= cell.HasPipe ? 1 : 0; - hazards = hazards with { Stability = hazards.Stability - 1 }; - } - - if (integrity <= 0 && cell.HasPipe) - { - cell = cell with { - LeakRate = Math.Max(cell.LeakRate, 3), - Flow = 0, - PipeOpen = false - }; - } - - cells[index] = cell with { - Integrity = Rules.Clamp(integrity), - Hazards = hazards.Clamp() - }; - } - } - - cells = SpreadSmoke(level, cells); - - var global = UpdateGlobal(level, cells); - var next = level with { - Cells = cells, - Global = global with { Turn = level.Global.Turn + 1 } - }; - - return next with { Forecasts = Forecast(next) }; + return AdvanceTurn(level, true); } public IReadOnlyList Forecast(LevelState level) { var forecasts = new List(); + var seen = new HashSet(); + var forecastLevel = level with { Cells = level.Cells.ToArray(), Forecasts = Array.Empty() }; - for (var y = 0; y < level.Height; y++) - for (var x = 0; x < level.Width; x++) + if (forecastLevel.Global.Lost) + AddHazardForecasts(forecasts, seen, forecastLevel, 0); + + AddReactorReadyForecast(forecasts, seen, forecastLevel, 0); + if (IsReactorReady(forecastLevel) || forecastLevel.Global.Lost || forecastLevel.Global.ReactorActivated) + return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray(); + + for (var step = 1; step <= c_MaxForecastStepCount; step++) { - var position = new GridPosition(x, y); - var cell = level.GetCell(position); + forecastLevel = AdvanceTurn(forecastLevel, false); + AddHazardForecasts(forecasts, seen, forecastLevel, step); + AddReactorReadyForecast(forecasts, seen, forecastLevel, step); - if (cell is { HasPipe: true, Pressure: > 7, Integrity: > 0 }) - { - var damagePerTurn = Math.Max(1, cell.Pressure - 7); - var turns = (int)Math.Ceiling(cell.Integrity / (double)damagePerTurn); - if (turns <= 4) - forecasts.Add(new(EFailureKind.PipeBurst, position, turns, $"PIPE BURST PREDICTED AT {x},{y} IN {turns} TURNS")); - } - - var fuelLeakNearIgnition = cell is { Pipe: EPipeMedium.Fuel, LeakRate: > 0 } && (cell.Pressure >= 7 || cell.Kind == ECellKind.Generator); - var ignitionRisk = (cell.Hazards.FuelVapor >= 4 || cell.Hazards.LiquidFuel >= 6 || fuelLeakNearIgnition) && (cell.Hazards.Heat >= 8 || cell.Hazards.ElectricalCharge >= 4 || cell.Kind == ECellKind.Generator); - if (ignitionRisk && !cell.Hazards.Fire) - forecasts.Add(new(EFailureKind.Ignition, position, 1, $"FUEL IGNITION PREDICTED AT {x},{y} NEXT TURN")); + if (forecastLevel.Global.Lost || IsReactorReady(forecastLevel) || forecastLevel.Global.ReactorActivated) + break; } - if (level.Global.CoreHeat >= 8) - forecasts.Add(new(EFailureKind.Meltdown, null, Math.Max(1, 11 - level.Global.CoreHeat), "CORE MELTDOWN APPROACHING")); - - if (IsReactorReady(level)) - forecasts.Add(new(EFailureKind.ReactorReady, null, 0, "REACTOR READY")); - return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray(); } @@ -105,84 +63,59 @@ public sealed class SimulationEngine }; } - private static HazardState ApplyPipeLeaks(CellState cell) + private LevelState AdvanceTurn(LevelState level, bool updateForecasts) { - var hazards = cell.Hazards; - if (!cell.HasPipe || cell.LeakRate <= 0) - return hazards; + var cells = level.Cells.ToArray(); - return cell.Pipe switch { - EPipeMedium.Fuel => hazards with { - LiquidFuel = hazards.LiquidFuel + cell.LeakRate, - FuelVapor = hazards.FuelVapor + (cell.Pressure >= 7 ? cell.LeakRate : Math.Max(0, hazards.Heat - 3) / 3) - }, - EPipeMedium.Coolant => hazards with { - CoolantPooling = hazards.CoolantPooling + cell.LeakRate, - Heat = hazards.Heat - Math.Max(1, cell.LeakRate / 2), - Smoke = hazards.Smoke + (hazards.Heat >= 7 ? 2 : 0) - }, - EPipeMedium.Pressure => hazards with { Smoke = hazards.Smoke + (cell.Pressure >= 8 ? 1 : 0) }, - _ => hazards - }; - } - - private static HazardState ApplyMachineEffects(CellState cell, HazardState hazards) - { - return cell.Kind switch { - ECellKind.Generator when cell.Powered => hazards with { Heat = hazards.Heat + 1 }, - ECellKind.CoolingPump when cell.Powered => hazards with { Heat = hazards.Heat - 2 }, - ECellKind.Reactor => hazards with { Heat = hazards.Heat + 1 }, - _ => hazards - }; - } - - private static HazardState ApplyFireAndElectricalHazards(CellState cell, HazardState hazards) - { - if (hazards.CoolantPooling >= 3 && cell.Powered) - hazards = hazards with { ElectricalCharge = hazards.ElectricalCharge + 2 }; - - var hasFuel = hazards.FuelVapor >= 4 || hazards.LiquidFuel >= 6; - var hasIgnition = hazards.Heat >= 8 || hazards.ElectricalCharge >= 4 || cell is { Kind: ECellKind.Generator, Powered: true }; - if ((hasFuel && hasIgnition) || hazards.Fire) - { - hazards = hazards with { - Fire = hasFuel || hazards.Fire, - Heat = hazards.Heat + 2, - Smoke = hazards.Smoke + 2, - LiquidFuel = Math.Max(0, hazards.LiquidFuel - 1), - FuelVapor = Math.Max(0, hazards.FuelVapor - 1) - }; - } - else if (hazards.Smoke > 0) - hazards = hazards with { Smoke = hazards.Smoke - 1 }; - - return hazards; - } - - private static CellState[] SpreadSmoke(LevelState level, CellState[] cells) - { - var next = cells.ToArray(); for (var y = 0; y < level.Height; y++) { for (var x = 0; x < level.Width; x++) { var position = new GridPosition(x, y); - var cell = cells[level.Index(position)]; - if (cell.Hazards.Smoke < 6) + var index = level.Index(position); + var cell = cells[index]; + + if (!cell.IsWalkable) continue; - foreach (var neighbor in position.Neighbors().Where(level.InBounds)) - { - var neighborCell = next[level.Index(neighbor)]; - if (!neighborCell.IsWalkable || neighborCell.DoorLocked) - continue; + foreach (var effect in m_Effects) + cell = effect.Apply(cell); - next[level.Index(neighbor)] = neighborCell with { Hazards = neighborCell.Hazards with { Smoke = Rules.Clamp(neighborCell.Hazards.Smoke + 1) } }; - } + cells[index] = cell with { Hazards = cell.Hazards.Clamp() }; } } - return next; + foreach (var areaEffect in m_AreaEffects) + cells = areaEffect.Apply(level, cells); + + var global = UpdateGlobal(level, cells); + var next = level with { + Cells = cells, + Global = global with { Turn = level.Global.Turn + 1 } + }; + + return updateForecasts ? next with { Forecasts = Forecast(next) } : next; + } + + private void AddHazardForecasts(List forecasts, HashSet seen, LevelState level, int turns) + { + foreach (var hazard in m_Hazards) + { + foreach (var forecast in hazard.Predict(level, turns)) + AddForecast(forecasts, seen, forecast); + } + } + + private static void AddReactorReadyForecast(List forecasts, HashSet seen, LevelState level, int turns) + { + if (IsReactorReady(level)) + AddForecast(forecasts, seen, new(EFailureKind.ReactorReady, null, turns, "REACTOR READY")); + } + + private static void AddForecast(List forecasts, HashSet seen, Forecast forecast) + { + if (seen.Add(new(forecast.Kind, forecast.Position))) + forecasts.Add(forecast); } private static GlobalState UpdateGlobal(LevelState level, CellState[] cells) @@ -214,4 +147,10 @@ public sealed class SimulationEngine var reactorStable = level.Global.CoreHeat < 8; return hasReactor && hasStablePower && hasCooling && reactorStable && !level.Global.Lost; } + + private readonly IReadOnlyList m_Effects; + private readonly IReadOnlyList m_AreaEffects; + private readonly IReadOnlyList m_Hazards; + + private sealed record ForecastKey(EFailureKind Kind, GridPosition? Position); } \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/SmokeSpreadEffect.cs b/src/ReactorMaintenance.Simulation/SmokeSpreadEffect.cs new file mode 100644 index 0000000..7507a24 --- /dev/null +++ b/src/ReactorMaintenance.Simulation/SmokeSpreadEffect.cs @@ -0,0 +1,35 @@ +namespace ReactorMaintenance.Simulation; + +public sealed class SmokeSpreadEffect : IAreaSimulationEffect +{ + public CellState[] Apply(LevelState level, CellState[] cells) + { + var next = cells.ToArray(); + for (var y = 0; y < level.Height; y++) + { + for (var x = 0; x < level.Width; x++) + { + var position = new GridPosition(x, y); + var cell = cells[level.Index(position)]; + if (cell.Hazards.Smoke < 6) + continue; + + SpreadToNeighbors(level, next, position); + } + } + + return next; + } + + private static void SpreadToNeighbors(LevelState level, CellState[] next, GridPosition position) + { + foreach (var neighbor in position.Neighbors().Where(level.InBounds)) + { + var neighborCell = next[level.Index(neighbor)]; + if (!neighborCell.IsWalkable || neighborCell.DoorLocked) + continue; + + next[level.Index(neighbor)] = neighborCell with { Hazards = neighborCell.Hazards with { Smoke = Rules.Clamp(neighborCell.Hazards.Smoke + 1) } }; + } + } +} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/StabilityCollapseHazard.cs b/src/ReactorMaintenance.Simulation/StabilityCollapseHazard.cs new file mode 100644 index 0000000..2cd8c38 --- /dev/null +++ b/src/ReactorMaintenance.Simulation/StabilityCollapseHazard.cs @@ -0,0 +1,10 @@ +namespace ReactorMaintenance.Simulation; + +public sealed class StabilityCollapseHazard : Hazard +{ + public override IEnumerable Predict(LevelState level, int turns) + { + if (level.Global is { Lost: true, Status: "FACILITY STABILITY COLLAPSE" }) + yield return new(EFailureKind.StabilityCollapse, null, turns, "FACILITY STABILITY COLLAPSE APPROACHING"); + } +} \ No newline at end of file diff --git a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs index d15dd3d..f1d5c8e 100644 --- a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs +++ b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs @@ -17,7 +17,7 @@ public sealed class SimulationEngineTests var forecasts = m_Engine.Forecast(level); - Assert.Contains(forecasts, forecast => forecast.Kind == EFailureKind.Ignition && forecast.Position == new GridPosition(2, 2)); + Assert.Contains(forecasts, forecast => forecast.Kind == EFailureKind.Ignition && forecast.Position == new GridPosition(2, 2) && forecast.Turns == 1); } [Fact] @@ -35,6 +35,22 @@ public sealed class SimulationEngineTests Assert.True(next.GetCell(new(3, 3)).Hazards.ElectricalCharge >= 2); } + [Fact] + public void ActiveFireSpreadsSmokeToOpenNeighbors() + { + var level = LevelState.Create("Smoke", 6, 6) + .SetCell(new(2, 2), new() { + Hazards = new() { + Fire = true, + Smoke = 6 + } + }); + + var next = m_Engine.AdvanceTurn(level); + + Assert.True(next.GetCell(new(2, 3)).Hazards.Smoke > 0); + } + [Fact] public void OverpressurePredictsPipeBurst() { @@ -50,6 +66,64 @@ public sealed class SimulationEngineTests 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(12, forecasts.Count); + Assert.Equal(12, forecasts.Max(forecast => forecast.Turns)); + } + + [Fact] + public void ForecastPredictsMeltdownFromFutureSimulation() + { + var level = LevelState.Create("Meltdown", 6, 6) + .SetCell(new(2, 2), new() { + Kind = ECellKind.Reactor, + Hazards = new() { Heat = 9 } + }); + + 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 = 10, + 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() { + Kind = ECellKind.Generator, + Hazards = new() { Stability = 3 } + }) with { + Global = new() { FacilityStability = 1 } + }; + + var forecasts = m_Engine.Forecast(level); + + Assert.Contains(forecasts, forecast => forecast.Kind == EFailureKind.StabilityCollapse && forecast.Turns == 1); + } + [Fact] public void StableReactorWithPowerAndCoolingCanActivate() { @@ -92,4 +166,12 @@ public sealed class SimulationEngineTests } 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}"); + } + } } \ No newline at end of file