From 851f6d27e89083971ce3ca148c19bc196685e42c Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 10 May 2026 18:41:17 +0200 Subject: [PATCH] Rewrite simulation core for design model --- TASKS.md | 34 +- .../Balancing.cs | 150 ++- .../Difficulties/NormalBalancing.cs | 131 ++- .../Effects/CellIntegrityEffect.cs | 35 - .../Effects/FireAndElectricalHazardEffect.cs | 30 - .../Effects/IAreaSimulationEffect.cs | 8 - .../Effects/ISimulationEffect.cs | 8 - .../Effects/MachineEffect.cs | 18 - .../Effects/PipeLeakEffect.cs | 28 - .../Effects/SmokeSpreadEffect.cs | 37 - .../Hazards/Hazard.cs | 8 - .../Hazards/IgnitionHazard.cs | 20 - .../Hazards/MeltdownHazard.cs | 12 - .../Hazards/PipeBurstHazard.cs | 20 - .../Hazards/StabilityCollapseHazard.cs | 12 - .../LevelEditor.cs | 284 +++--- .../LevelSerializer.cs | 81 +- .../LevelValidator.cs | 203 ++++ src/ReactorMaintenance.Simulation/Models.cs | 716 ++++++++++---- .../ReactorMaintenance.Simulation.csproj | 2 +- .../SimulationEngine.cs | 885 +++++++++++++++--- ...ReactorMaintenance.Simulation.Tests.csproj | 2 +- .../SimulationEngineTests.cs | 501 ++++------ 23 files changed, 2033 insertions(+), 1192 deletions(-) delete mode 100644 src/ReactorMaintenance.Simulation/Effects/CellIntegrityEffect.cs delete mode 100644 src/ReactorMaintenance.Simulation/Effects/FireAndElectricalHazardEffect.cs delete mode 100644 src/ReactorMaintenance.Simulation/Effects/IAreaSimulationEffect.cs delete mode 100644 src/ReactorMaintenance.Simulation/Effects/ISimulationEffect.cs delete mode 100644 src/ReactorMaintenance.Simulation/Effects/MachineEffect.cs delete mode 100644 src/ReactorMaintenance.Simulation/Effects/PipeLeakEffect.cs delete mode 100644 src/ReactorMaintenance.Simulation/Effects/SmokeSpreadEffect.cs delete mode 100644 src/ReactorMaintenance.Simulation/Hazards/Hazard.cs delete mode 100644 src/ReactorMaintenance.Simulation/Hazards/IgnitionHazard.cs delete mode 100644 src/ReactorMaintenance.Simulation/Hazards/MeltdownHazard.cs delete mode 100644 src/ReactorMaintenance.Simulation/Hazards/PipeBurstHazard.cs delete mode 100644 src/ReactorMaintenance.Simulation/Hazards/StabilityCollapseHazard.cs create mode 100644 src/ReactorMaintenance.Simulation/LevelValidator.cs diff --git a/TASKS.md b/TASKS.md index f515f37..812e0be 100644 --- a/TASKS.md +++ b/TASKS.md @@ -4,8 +4,9 @@ - Branch: `design-rewrite` - Scope approved: implement `docs/design.md` end-to-end with deterministic defaults and no backward compatibility. -- Existing implementation is the previous combined-cell integer simulation and editor. It will be replaced instead of migrated. -- First commit establishes this tracker only. +- Simulation core has been replaced with the first design-native model and deterministic engine slice. +- Simulation and test projects now target `net10.0` because this Linux environment only has the .NET 10 runtime. +- Win2D editor still references the removed legacy model and is the next major implementation area. ## Completed Work @@ -13,20 +14,27 @@ - Confirmed deterministic balance defaults should be chosen during implementation. - Confirmed a full Win2D editor is required. - Created branch `design-rewrite`. +- Added `TASKS.md` as the required per-commit work tracker. +- Removed the legacy integer hazard/effect/hazard plug-in simulation surface. +- Added design-native terrain, underground carrier layers, surface hazards, props, leaks, doors, reactor bindings, robot inventory, rule events, validation, serialization, and forecasts. +- Added deterministic default balancing values. +- Added a first deterministic simulation pipeline for network propagation, consumers, leaks, surface interactions, robot safety, reactor readiness, rule events, and forecasts. +- Replaced old tests with design-based simulation tests. +- Verified `dotnet test tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj` passes: 11 passed. +- Attempted `dotnet jb cleanupcode --build=False ...`; unavailable in this environment because `dotnet-jb` is not installed. +- Reviewed the first slice and fixed an action-resolution maintainability issue before commit. +- Verified `git diff --check` reports no whitespace errors. ## Current Work -- Establish task tracking before code changes. +- Commit the first simulation-core rewrite slice. ## Future Work -1. Replace the simulation domain model with terrain, underground carrier layers, props, leaks, doors, inventory, reactor bindings, rule events, forecasts, and float-valued runtime state. -2. Replace balancing with deterministic defaults for all values named by the design. -3. Implement validation errors and warnings from the design. -4. Implement the turn pipeline: actions, runtime validation, rule events, network propagation, consumers, leak injection, surface interactions, robot safety, reactor state, event advancement, and forecasts. -5. Implement player actions and editor operations for the new model. -6. Replace serialization with a schema-valid current format only. -7. Update the Win2D editor for all authored layers and new runtime inspection. -8. Replace tests with design-based behavior coverage. -9. Update documentation to reflect the new implementation. -10. Run cleanup, tests, code review, and iterate until the implementation is clean and maintainable. +1. Expand simulation fidelity where the first slice is intentionally simplified: junction branch inference, ambiguity validation, complete pair table coverage, richer rule predicates/effects, and stronger forecast proof cases. +2. Update the Win2D editor for all authored layers and new runtime inspection. +3. Add editor workflows for reactor bindings, door edge selection, electricity wall leak faces, rule events, and layer-specific painting. +4. Update README and any affected docs to reflect the new schema, .NET target, editor controls, and deterministic defaults. +5. Build the Win2D project on a Windows-capable environment after the editor rewrite. +6. Add broader tests for junction ratios, ambiguous junctions, all rule event families, serialization edge cases, and editor operations. +7. Run cleanup when `dotnet-jb` is available, tests, code review, and iterate until the implementation is clean and maintainable. diff --git a/src/ReactorMaintenance.Simulation/Balancing.cs b/src/ReactorMaintenance.Simulation/Balancing.cs index 6c61a62..b1b4e5e 100644 --- a/src/ReactorMaintenance.Simulation/Balancing.cs +++ b/src/ReactorMaintenance.Simulation/Balancing.cs @@ -1,78 +1,72 @@ -using ReactorMaintenance.Simulation.Difficulties; - -namespace ReactorMaintenance.Simulation; - -public abstract class Balancing -{ - public static Balancing Current { get; set; } = new NormalBalancing(); - - public abstract int MinHazardValue { get; } - public abstract int MaxHazardValue { get; } - public abstract int DefaultHazardStability { get; } - public abstract int DefaultCellIntegrity { get; } - public abstract int DefaultActionsPerTurn { get; } - public abstract int DefaultCoreHeat { get; } - public abstract int DefaultFacilityStability { get; } - public abstract int DefaultPower { get; } - public abstract int DefaultCooling { get; } - public abstract int FirstGridCoordinate { get; } - public abstract int NeighborDistance { get; } - public abstract int CurrentForecastTurn { get; } - public abstract int MinimumLevelSize { get; } - public abstract int DefaultLevelWidth { get; } - public abstract int DefaultLevelHeight { get; } - public abstract int DefaultRobotCoordinate { get; } - public abstract int DefaultPipeFlow { get; } - public abstract int DefaultPipePressure { get; } - public abstract int DefaultPressurePipeFlow { get; } - public abstract int DefaultPressurePipePressure { get; } - public abstract int DefaultEditedPipeIntegrity { get; } - public abstract int MinimumLeakRate { get; } - public abstract int DamagedPipeIntegrity { get; } - public abstract int RepairedLeakRate { get; } - public abstract int RepairedElectricalCharge { get; } - public abstract int HeatToolIncrease { get; } - public abstract int FireToolMinimumHeat { get; } - public abstract int FireToolMinimumSmoke { get; } - public abstract int MaxForecastStepCount { get; } - public abstract int TurnIncrement { get; } - public abstract int OverpressureThreshold { get; } - public abstract int HeatIntegrityDamageThreshold { get; } - public abstract int PipeFireIntegrityDamage { get; } - public abstract int FireStabilityDamage { get; } - public abstract int BurstLeakRate { get; } - public abstract int BrokenPipeFlow { get; } - public abstract int ElectrifiedCoolantPoolingThreshold { get; } - public abstract int ElectricalChargeIncrease { get; } - public abstract int FuelVaporFireThreshold { get; } - public abstract int LiquidFuelFireThreshold { get; } - public abstract int HeatIgnitionThreshold { get; } - public abstract int ElectricalIgnitionThreshold { get; } - public abstract int FireHeatIncrease { get; } - public abstract int FireSmokeIncrease { get; } - public abstract int FireLiquidFuelConsumption { get; } - public abstract int FireFuelVaporConsumption { get; } - public abstract int SmokeDecay { get; } - public abstract int PressurizedFuelLeakPressureThreshold { get; } - public abstract int PassiveFuelVaporHeatOffset { get; } - public abstract int PassiveFuelVaporDivisor { get; } - public abstract int MinimumCoolantHeatReduction { get; } - public abstract int CoolantHeatReductionDivisor { get; } - public abstract int CoolantSteamHeatThreshold { get; } - public abstract int CoolantSteamSmokeIncrease { get; } - public abstract int PressureLeakSmokeThreshold { get; } - public abstract int PressureLeakSmokeIncrease { get; } - public abstract int GeneratorHeatIncrease { get; } - public abstract int CoolingPumpHeatReduction { get; } - public abstract int ReactorHeatIncrease { get; } - public abstract int SmokeSpreadThreshold { get; } - public abstract int SmokeSpreadIncrease { get; } - public abstract int CriticalCellStabilityThreshold { get; } - public abstract int MeltdownCoreHeatThreshold { get; } - public abstract int StabilityCollapseThreshold { get; } - public abstract int GeneratorPowerOutput { get; } - public abstract int CoolingPumpOutput { get; } - public abstract int ReactorReadyPowerThreshold { get; } - public abstract int ReactorReadyCoolingThreshold { get; } - public abstract int ReactorReadyCoreHeatThreshold { get; } -} \ No newline at end of file +using ReactorMaintenance.Simulation.Difficulties; + +namespace ReactorMaintenance.Simulation; + +public abstract class Balancing +{ + public static Balancing Current { get; set; } = new NormalBalancing(); + + public float ClampValue(float value) + { + return Math.Clamp(value, MinValue, MaxValue); + } + + public EBand Band(float value, float caution, float critical) + { + if (value >= critical) + return EBand.Critical; + + return value >= caution ? EBand.Caution : EBand.Safe; + } + + public abstract int DefaultLevelWidth { get; } + public abstract int DefaultLevelHeight { get; } + public abstract int MinimumLevelSize { get; } + public abstract int ActionsPerTurn { get; } + public abstract int ForecastHorizon { get; } + public abstract float MinValue { get; } + public abstract float MaxValue { get; } + public abstract float FuelSafe { get; } + public abstract float FuelCaution { get; } + public abstract float FuelCritical { get; } + public abstract float CoolantSafe { get; } + public abstract float CoolantCaution { get; } + public abstract float CoolantCritical { get; } + public abstract float ElectricitySafe { get; } + public abstract float ElectricityCaution { get; } + public abstract float ElectricityCritical { get; } + public abstract float HeatSafe { get; } + public abstract float HeatCaution { get; } + public abstract float HeatCritical { get; } + public abstract float TerminalHeat { get; } + public abstract float RobotFuelSafetyThreshold { get; } + public abstract float RobotCoolantSafetyThreshold { get; } + public abstract float RobotElectricitySafetyThreshold { get; } + public abstract float RobotHeatSafetyThreshold { get; } + public abstract float SourceAmount { get; } + public abstract float SourceIntensity { get; } + public abstract float DistanceAmountFalloff { get; } + public abstract float DistanceIntensityFalloff { get; } + public abstract float ConsumerRequiredAmount { get; } + public abstract float ConsumerRequiredIntensity { get; } + public abstract float LeakBaseAmount { get; } + public abstract float LeakAmountScale { get; } + public abstract float LeakIntensityScale { get; } + public abstract float FlowTransferRatio { get; } + public abstract float StrongFlowTransferRatio { get; } + public abstract float Warm1Amount { get; } + public abstract float Warm2Amount { get; } + public abstract float Quench1Amount { get; } + public abstract float Quench2Amount { get; } + public abstract float Short1Heat { get; } + public abstract float Short1Discharge { get; } + public abstract float Short2Heat { get; } + public abstract float Short2Discharge { get; } + public abstract float Ignite1Heat { get; } + public abstract float Ignite1FuelConsumption { get; } + public abstract float Ignite2Heat { get; } + public abstract float Ignite2FuelConsumption { get; } + public abstract int RemedyBlockTurns { get; } + public abstract int HeatShieldSteps { get; } + public abstract int InventoryCapacityPerRemedy { get; } +} diff --git a/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs b/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs index e42b020..f8d9c13 100644 --- a/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs +++ b/src/ReactorMaintenance.Simulation/Difficulties/NormalBalancing.cs @@ -1,76 +1,55 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Difficulties; - -public class NormalBalancing : Balancing -{ - public override int MinHazardValue => 0; - public override int MaxHazardValue => 10; - public override int DefaultHazardStability => 10; - public override int DefaultCellIntegrity => 10; - public override int DefaultActionsPerTurn => 3; - public override int DefaultCoreHeat => 5; - public override int DefaultFacilityStability => 10; - public override int DefaultPower => 5; - public override int DefaultCooling => 0; - public override int FirstGridCoordinate => 0; - public override int NeighborDistance => 1; - public override int CurrentForecastTurn => 0; - public override int MinimumLevelSize => 4; - public override int DefaultLevelWidth => 16; - public override int DefaultLevelHeight => 12; - public override int DefaultRobotCoordinate => 1; - public override int DefaultPipeFlow => 4; - public override int DefaultPipePressure => 4; - public override int DefaultPressurePipeFlow => 5; - public override int DefaultPressurePipePressure => 6; - public override int DefaultEditedPipeIntegrity => 8; - public override int MinimumLeakRate => 1; - public override int DamagedPipeIntegrity => 4; - public override int RepairedLeakRate => 0; - public override int RepairedElectricalCharge => 0; - public override int HeatToolIncrease => 2; - public override int FireToolMinimumHeat => 7; - public override int FireToolMinimumSmoke => 3; - public override int MaxForecastStepCount => 12; - public override int TurnIncrement => 1; - public override int OverpressureThreshold => 7; - public override int HeatIntegrityDamageThreshold => 10; - public override int PipeFireIntegrityDamage => 1; - public override int FireStabilityDamage => 1; - public override int BurstLeakRate => 3; - public override int BrokenPipeFlow => 0; - public override int ElectrifiedCoolantPoolingThreshold => 3; - public override int ElectricalChargeIncrease => 2; - public override int FuelVaporFireThreshold => 4; - public override int LiquidFuelFireThreshold => 6; - public override int HeatIgnitionThreshold => 8; - public override int ElectricalIgnitionThreshold => 4; - public override int FireHeatIncrease => 2; - public override int FireSmokeIncrease => 2; - public override int FireLiquidFuelConsumption => 1; - public override int FireFuelVaporConsumption => 1; - public override int SmokeDecay => 1; - public override int PressurizedFuelLeakPressureThreshold => 7; - public override int PassiveFuelVaporHeatOffset => 3; - public override int PassiveFuelVaporDivisor => 3; - public override int MinimumCoolantHeatReduction => 1; - public override int CoolantHeatReductionDivisor => 2; - public override int CoolantSteamHeatThreshold => 7; - public override int CoolantSteamSmokeIncrease => 2; - public override int PressureLeakSmokeThreshold => 8; - public override int PressureLeakSmokeIncrease => 1; - public override int GeneratorHeatIncrease => 1; - public override int CoolingPumpHeatReduction => 2; - public override int ReactorHeatIncrease => 1; - public override int SmokeSpreadThreshold => 6; - public override int SmokeSpreadIncrease => 1; - public override int CriticalCellStabilityThreshold => 3; - public override int MeltdownCoreHeatThreshold => 10; - public override int StabilityCollapseThreshold => 0; - public override int GeneratorPowerOutput => 3; - public override int CoolingPumpOutput => 3; - public override int ReactorReadyPowerThreshold => 3; - public override int ReactorReadyCoolingThreshold => 3; - public override int ReactorReadyCoreHeatThreshold => 8; -} \ No newline at end of file +namespace ReactorMaintenance.Simulation.Difficulties; + +public class NormalBalancing : Balancing +{ + public override int DefaultLevelWidth => 16; + public override int DefaultLevelHeight => 12; + public override int MinimumLevelSize => 4; + public override int ActionsPerTurn => 3; + public override int ForecastHorizon => 6; + public override float MinValue => 0; + public override float MaxValue => 10; + public override float FuelSafe => 1.5f; + public override float FuelCaution => 3.5f; + public override float FuelCritical => 6.5f; + public override float CoolantSafe => 1.5f; + public override float CoolantCaution => 3.5f; + public override float CoolantCritical => 6.5f; + public override float ElectricitySafe => 1.5f; + public override float ElectricityCaution => 3.5f; + public override float ElectricityCritical => 6.5f; + public override float HeatSafe => 2; + public override float HeatCaution => 5; + public override float HeatCritical => 8; + public override float TerminalHeat => 10; + public override float RobotFuelSafetyThreshold => 6.5f; + public override float RobotCoolantSafetyThreshold => 8; + public override float RobotElectricitySafetyThreshold => 6.5f; + public override float RobotHeatSafetyThreshold => 8; + public override float SourceAmount => 8; + public override float SourceIntensity => 8; + public override float DistanceAmountFalloff => 0.5f; + public override float DistanceIntensityFalloff => 0.4f; + public override float ConsumerRequiredAmount => 2.5f; + public override float ConsumerRequiredIntensity => 2.5f; + public override float LeakBaseAmount => 0.5f; + public override float LeakAmountScale => 0.15f; + public override float LeakIntensityScale => 0.1f; + public override float FlowTransferRatio => 0.05f; + public override float StrongFlowTransferRatio => 0.1f; + public override float Warm1Amount => 0.5f; + public override float Warm2Amount => 1.0f; + public override float Quench1Amount => 0.6f; + public override float Quench2Amount => 1.2f; + public override float Short1Heat => 0.8f; + public override float Short1Discharge => 0.8f; + public override float Short2Heat => 1.6f; + public override float Short2Discharge => 1.5f; + public override float Ignite1Heat => 1.2f; + public override float Ignite1FuelConsumption => 0.4f; + public override float Ignite2Heat => 2.4f; + public override float Ignite2FuelConsumption => 0.8f; + public override int RemedyBlockTurns => 2; + public override int HeatShieldSteps => 3; + public override int InventoryCapacityPerRemedy => 3; +} diff --git a/src/ReactorMaintenance.Simulation/Effects/CellIntegrityEffect.cs b/src/ReactorMaintenance.Simulation/Effects/CellIntegrityEffect.cs deleted file mode 100644 index 0e5ffa8..0000000 --- a/src/ReactorMaintenance.Simulation/Effects/CellIntegrityEffect.cs +++ /dev/null @@ -1,35 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Effects; - -public sealed class CellIntegrityEffect : ISimulationEffect -{ - public CellState Apply(CellState cell) - { - var integrity = cell.Integrity; - var hazards = cell.Hazards; - - if (cell is { HasPipe: true } && cell.Pressure > Balancing.Current.OverpressureThreshold) - integrity -= cell.Pressure - Balancing.Current.OverpressureThreshold; - - if (hazards.Heat >= Balancing.Current.HeatIntegrityDamageThreshold || hazards.Fire) - { - integrity -= cell.HasPipe ? Balancing.Current.PipeFireIntegrityDamage : Balancing.Current.MinHazardValue; - hazards = hazards with { Stability = hazards.Stability - Balancing.Current.FireStabilityDamage }; - } - - cell = cell with { - Integrity = Rules.Clamp(integrity), - Hazards = hazards.Clamp() - }; - - if (integrity > Balancing.Current.MinHazardValue || !cell.HasPipe) - return cell; - - return cell with { - LeakRate = Math.Max(cell.LeakRate, Balancing.Current.BurstLeakRate), - Flow = Balancing.Current.BrokenPipeFlow, - PipeOpen = false - }; - } -} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Effects/FireAndElectricalHazardEffect.cs b/src/ReactorMaintenance.Simulation/Effects/FireAndElectricalHazardEffect.cs deleted file mode 100644 index 5e0ec88..0000000 --- a/src/ReactorMaintenance.Simulation/Effects/FireAndElectricalHazardEffect.cs +++ /dev/null @@ -1,30 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Effects; - -public sealed class FireAndElectricalHazardEffect : ISimulationEffect -{ - public CellState Apply(CellState cell) - { - var hazards = cell.Hazards; - if (hazards.CoolantPooling >= Balancing.Current.ElectrifiedCoolantPoolingThreshold && cell.Powered) - hazards = hazards with { ElectricalCharge = hazards.ElectricalCharge + Balancing.Current.ElectricalChargeIncrease }; - - var hasFuel = hazards.FuelVapor >= Balancing.Current.FuelVaporFireThreshold || hazards.LiquidFuel >= Balancing.Current.LiquidFuelFireThreshold; - var hasIgnition = hazards.Heat >= Balancing.Current.HeatIgnitionThreshold || hazards.ElectricalCharge >= Balancing.Current.ElectricalIgnitionThreshold || cell is { Prop: ECellProp.Generator, Powered: true }; - if ((hasFuel && hasIgnition) || hazards.Fire) - { - hazards = hazards with { - Fire = hasFuel || hazards.Fire, - Heat = hazards.Heat + Balancing.Current.FireHeatIncrease, - Smoke = hazards.Smoke + Balancing.Current.FireSmokeIncrease, - LiquidFuel = Math.Max(Balancing.Current.MinHazardValue, hazards.LiquidFuel - Balancing.Current.FireLiquidFuelConsumption), - FuelVapor = Math.Max(Balancing.Current.MinHazardValue, hazards.FuelVapor - Balancing.Current.FireFuelVaporConsumption) - }; - } - else if (hazards.Smoke > Balancing.Current.MinHazardValue) - hazards = hazards with { Smoke = hazards.Smoke - Balancing.Current.SmokeDecay }; - - return cell with { Hazards = hazards.Clamp() }; - } -} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Effects/IAreaSimulationEffect.cs b/src/ReactorMaintenance.Simulation/Effects/IAreaSimulationEffect.cs deleted file mode 100644 index 0dd929c..0000000 --- a/src/ReactorMaintenance.Simulation/Effects/IAreaSimulationEffect.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Effects; - -public interface IAreaSimulationEffect -{ - CellState[] Apply(LevelState level, CellState[] cells); -} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Effects/ISimulationEffect.cs b/src/ReactorMaintenance.Simulation/Effects/ISimulationEffect.cs deleted file mode 100644 index 8811633..0000000 --- a/src/ReactorMaintenance.Simulation/Effects/ISimulationEffect.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Effects; - -public interface ISimulationEffect -{ - CellState Apply(CellState cell); -} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Effects/MachineEffect.cs b/src/ReactorMaintenance.Simulation/Effects/MachineEffect.cs deleted file mode 100644 index f9ba603..0000000 --- a/src/ReactorMaintenance.Simulation/Effects/MachineEffect.cs +++ /dev/null @@ -1,18 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Effects; - -public sealed class MachineEffect : ISimulationEffect -{ - public CellState Apply(CellState cell) - { - var hazards = cell.Prop switch { - ECellProp.Generator when cell.Powered => cell.Hazards with { Heat = cell.Hazards.Heat + Balancing.Current.GeneratorHeatIncrease }, - ECellProp.CoolingPump when cell.Powered => cell.Hazards with { Heat = cell.Hazards.Heat - Balancing.Current.CoolingPumpHeatReduction }, - ECellProp.Reactor => cell.Hazards with { Heat = cell.Hazards.Heat + Balancing.Current.ReactorHeatIncrease }, - _ => cell.Hazards - }; - - return cell with { Hazards = hazards.Clamp() }; - } -} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Effects/PipeLeakEffect.cs b/src/ReactorMaintenance.Simulation/Effects/PipeLeakEffect.cs deleted file mode 100644 index 3e2b213..0000000 --- a/src/ReactorMaintenance.Simulation/Effects/PipeLeakEffect.cs +++ /dev/null @@ -1,28 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Effects; - -public sealed class PipeLeakEffect : ISimulationEffect -{ - public CellState Apply(CellState cell) - { - if (!cell.HasPipe || cell.LeakRate <= Balancing.Current.MinHazardValue) - return cell; - - var hazards = cell.Pipe switch { - EPipeMedium.Fuel => cell.Hazards with { - LiquidFuel = cell.Hazards.LiquidFuel + cell.LeakRate, - FuelVapor = cell.Hazards.FuelVapor + (cell.Pressure >= Balancing.Current.PressurizedFuelLeakPressureThreshold ? cell.LeakRate : Math.Max(Balancing.Current.MinHazardValue, cell.Hazards.Heat - Balancing.Current.PassiveFuelVaporHeatOffset) / Balancing.Current.PassiveFuelVaporDivisor) - }, - EPipeMedium.Coolant => cell.Hazards with { - CoolantPooling = cell.Hazards.CoolantPooling + cell.LeakRate, - Heat = cell.Hazards.Heat - Math.Max(Balancing.Current.MinimumCoolantHeatReduction, cell.LeakRate / Balancing.Current.CoolantHeatReductionDivisor), - Smoke = cell.Hazards.Smoke + (cell.Hazards.Heat >= Balancing.Current.CoolantSteamHeatThreshold ? Balancing.Current.CoolantSteamSmokeIncrease : Balancing.Current.MinHazardValue) - }, - EPipeMedium.Pressure => cell.Hazards with { Smoke = cell.Hazards.Smoke + (cell.Pressure >= Balancing.Current.PressureLeakSmokeThreshold ? Balancing.Current.PressureLeakSmokeIncrease : Balancing.Current.MinHazardValue) }, - _ => cell.Hazards - }; - - return cell with { Hazards = hazards.Clamp() }; - } -} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Effects/SmokeSpreadEffect.cs b/src/ReactorMaintenance.Simulation/Effects/SmokeSpreadEffect.cs deleted file mode 100644 index 80c185d..0000000 --- a/src/ReactorMaintenance.Simulation/Effects/SmokeSpreadEffect.cs +++ /dev/null @@ -1,37 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Effects; - -public sealed class SmokeSpreadEffect : IAreaSimulationEffect -{ - public CellState[] Apply(LevelState level, CellState[] cells) - { - var next = cells.ToArray(); - for (var y = Balancing.Current.FirstGridCoordinate; y < level.Height; y++) - { - for (var x = Balancing.Current.FirstGridCoordinate; x < level.Width; x++) - { - var position = new GridPosition(x, y); - var cell = cells[level.Index(position)]; - if (cell.Hazards.Smoke < Balancing.Current.SmokeSpreadThreshold) - 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 + Balancing.Current.SmokeSpreadIncrease) } }; - } - } -} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Hazards/Hazard.cs b/src/ReactorMaintenance.Simulation/Hazards/Hazard.cs deleted file mode 100644 index b8fd677..0000000 --- a/src/ReactorMaintenance.Simulation/Hazards/Hazard.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Hazards; - -public abstract class Hazard -{ - public abstract IEnumerable Predict(LevelState level, int turns); -} \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/Hazards/IgnitionHazard.cs b/src/ReactorMaintenance.Simulation/Hazards/IgnitionHazard.cs deleted file mode 100644 index 3575e71..0000000 --- a/src/ReactorMaintenance.Simulation/Hazards/IgnitionHazard.cs +++ /dev/null @@ -1,20 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Hazards; - -public sealed class IgnitionHazard : Hazard -{ - public override IEnumerable Predict(LevelState level, int turns) - { - for (var y = Balancing.Current.FirstGridCoordinate; y < level.Height; y++) - { - for (var x = Balancing.Current.FirstGridCoordinate; 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 == Balancing.Current.TurnIncrement ? $"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/Hazards/MeltdownHazard.cs b/src/ReactorMaintenance.Simulation/Hazards/MeltdownHazard.cs deleted file mode 100644 index 2df3721..0000000 --- a/src/ReactorMaintenance.Simulation/Hazards/MeltdownHazard.cs +++ /dev/null @@ -1,12 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Hazards; - -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/Hazards/PipeBurstHazard.cs b/src/ReactorMaintenance.Simulation/Hazards/PipeBurstHazard.cs deleted file mode 100644 index cf7d76d..0000000 --- a/src/ReactorMaintenance.Simulation/Hazards/PipeBurstHazard.cs +++ /dev/null @@ -1,20 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Hazards; - -public sealed class PipeBurstHazard : Hazard -{ - public override IEnumerable Predict(LevelState level, int turns) - { - for (var y = Balancing.Current.FirstGridCoordinate; y < level.Height; y++) - { - for (var x = Balancing.Current.FirstGridCoordinate; x < level.Width; x++) - { - var position = new GridPosition(x, y); - var cell = level.GetCell(position); - if (cell is { HasPipe: true, PipeOpen: false } && cell.Flow == Balancing.Current.BrokenPipeFlow && cell.LeakRate >= Balancing.Current.BurstLeakRate) - 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/Hazards/StabilityCollapseHazard.cs b/src/ReactorMaintenance.Simulation/Hazards/StabilityCollapseHazard.cs deleted file mode 100644 index 61a97e4..0000000 --- a/src/ReactorMaintenance.Simulation/Hazards/StabilityCollapseHazard.cs +++ /dev/null @@ -1,12 +0,0 @@ -using ReactorMaintenance.Simulation; - -namespace ReactorMaintenance.Simulation.Hazards; - -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/src/ReactorMaintenance.Simulation/LevelEditor.cs b/src/ReactorMaintenance.Simulation/LevelEditor.cs index 5d86556..c7091c0 100644 --- a/src/ReactorMaintenance.Simulation/LevelEditor.cs +++ b/src/ReactorMaintenance.Simulation/LevelEditor.cs @@ -1,119 +1,165 @@ -namespace ReactorMaintenance.Simulation; - -public enum EEditorTool -{ - Cursor, - Floor, - Wall, - Reactor, - CoolingPump, - Generator, - PressureRegulator, - DiagnosticTerminal, - ControlTerminal, - CoolantPipe, - FuelPipe, - PressurePipe, - Leak, - Repair, - Heat, - Fire, - Robot -} - -public static class LevelEditor -{ - public static LevelState Apply(LevelState level, GridPosition position, EEditorTool tool) - { - if (!level.InBounds(position)) - return level; - - if (tool == EEditorTool.Robot) - return level.GetCell(position).IsWalkable ? level with { Robot = position } : level; - - var cell = level.GetCell(position); - cell = tool switch { - EEditorTool.Cursor => cell, - EEditorTool.Floor => cell with { Terrain = ECellTerrain.Floor }, - EEditorTool.Wall => cell with { - Terrain = ECellTerrain.Wall, - Prop = ECellProp.None, - Pipe = EPipeMedium.None, - Flow = Balancing.Current.MinHazardValue, - Pressure = Balancing.Current.MinHazardValue, - LeakRate = Balancing.Current.MinHazardValue, - PipeOpen = false, - Powered = false - }, - EEditorTool.Reactor => cell with { Terrain = ECellTerrain.Floor, Prop = ECellProp.Reactor }, - EEditorTool.CoolingPump => cell with { - Terrain = ECellTerrain.Floor, - Prop = ECellProp.CoolingPump, - Powered = true - }, - EEditorTool.Generator => cell with { - Terrain = ECellTerrain.Floor, - Prop = ECellProp.Generator, - Powered = true - }, - EEditorTool.PressureRegulator => cell with { Terrain = ECellTerrain.Floor, Prop = ECellProp.PressureRegulator }, - EEditorTool.DiagnosticTerminal => cell with { - Terrain = ECellTerrain.Floor, - Prop = ECellProp.DiagnosticTerminal, - Powered = true - }, - EEditorTool.ControlTerminal => cell with { - Terrain = ECellTerrain.Floor, - Prop = ECellProp.ControlTerminal, - Powered = true - }, - EEditorTool.CoolantPipe => cell with { - Pipe = EPipeMedium.Coolant, - Flow = Balancing.Current.DefaultPipeFlow, - Pressure = Balancing.Current.DefaultPipePressure, - Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity), - PipeOpen = true - }, - EEditorTool.FuelPipe => cell with { - Pipe = EPipeMedium.Fuel, - Flow = Balancing.Current.DefaultPipeFlow, - Pressure = Balancing.Current.DefaultPipePressure, - Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity), - PipeOpen = true - }, - EEditorTool.PressurePipe => cell with { - Pipe = EPipeMedium.Pressure, - Flow = Balancing.Current.DefaultPressurePipeFlow, - Pressure = Balancing.Current.DefaultPressurePipePressure, - Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity), - PipeOpen = true - }, - EEditorTool.Leak => cell with { - LeakRate = Math.Max(Balancing.Current.MinimumLeakRate, cell.LeakRate), - Integrity = Math.Min(cell.Integrity, Balancing.Current.DamagedPipeIntegrity) - }, - EEditorTool.Repair => cell with { - LeakRate = Balancing.Current.RepairedLeakRate, - Integrity = Balancing.Current.DefaultCellIntegrity, - Hazards = cell.Hazards with { - Fire = false, - ElectricalCharge = Balancing.Current.RepairedElectricalCharge - } - }, - EEditorTool.Heat => cell with { Hazards = cell.Hazards with { Heat = Rules.Clamp(cell.Hazards.Heat + Balancing.Current.HeatToolIncrease) } }, - EEditorTool.Fire => cell with { - Hazards = cell.Hazards with { - Fire = !cell.Hazards.Fire, - Heat = Math.Max(cell.Hazards.Heat, Balancing.Current.FireToolMinimumHeat), - Smoke = Math.Max(cell.Hazards.Smoke, Balancing.Current.FireToolMinimumSmoke) - } - }, - _ => cell - }; - - if (cell.Terrain == ECellTerrain.Wall) - cell = cell with { Hazards = new() }; - - return level.SetCell(position, cell); - } -} \ No newline at end of file +namespace ReactorMaintenance.Simulation; + +public enum EEditorTool +{ + Cursor, + Floor, + Wall, + FuelUnderground, + CoolantUnderground, + ElectricityUnderground, + FuelFlow, + CoolantFlow, + ElectricityFlow, + FuelConsumer, + CoolantConsumer, + ElectricityConsumer, + TJunction, + CrossJunction, + Door, + AllSeeingEyeTerminal, + FuelRemedySupply, + CoolantRemedySupply, + ElectricityRemedySupply, + HeatRemedySupply, + ReactorControl, + FuelLeak, + CoolantLeak, + ElectricityLeak, + FuelHazard, + CoolantHazard, + ElectricityHazard, + Heat, + Robot +} + +public static class LevelEditor +{ + public static LevelState Apply(LevelState level, GridPosition position, EEditorTool tool) + { + if (!level.InBounds(position)) + return level; + + return tool switch { + EEditorTool.Cursor => level, + EEditorTool.Floor => level.SetTerrain(position, ECellTerrain.Floor), + EEditorTool.Wall => level.SetTerrain(position, ECellTerrain.Wall), + EEditorTool.FuelUnderground => SetUnderground(level, position, ECarrierType.Fuel), + EEditorTool.CoolantUnderground => SetUnderground(level, position, ECarrierType.Coolant), + EEditorTool.ElectricityUnderground => SetUnderground(level, position, ECarrierType.Electricity), + EEditorTool.FuelFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Fuel), + EEditorTool.CoolantFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Coolant), + EEditorTool.ElectricityFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Electricity), + EEditorTool.FuelConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Fuel), + EEditorTool.CoolantConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Coolant), + EEditorTool.ElectricityConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Electricity), + EEditorTool.TJunction => SetFloorProp(level, position, new() { Type = EPropType.TJunction }), + EEditorTool.CrossJunction => SetFloorProp(level, position, new() { Type = EPropType.CrossJunction }), + EEditorTool.Door => SetDoor(level, position), + EEditorTool.AllSeeingEyeTerminal => SetFloorProp(level, position, new() { Type = EPropType.AllSeeingEyeTerminal }), + EEditorTool.FuelRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.FuelNeutralizer }), + EEditorTool.CoolantRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.CoolantNeutralizer }), + EEditorTool.ElectricityRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.ElectricityNeutralizer }), + EEditorTool.HeatRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.HeatShield }), + EEditorTool.ReactorControl => SetReactorControl(level, position), + EEditorTool.FuelLeak => SetLeak(level, position, ECarrierType.Fuel), + EEditorTool.CoolantLeak => SetLeak(level, position, ECarrierType.Coolant), + EEditorTool.ElectricityLeak => SetLeak(level, position, ECarrierType.Electricity), + EEditorTool.FuelHazard => level.SetSurface(position, level.GetSurface(position) with { Fuel = level.GetSurface(position).Fuel + 1 }), + EEditorTool.CoolantHazard => level.SetSurface(position, level.GetSurface(position) with { Coolant = level.GetSurface(position).Coolant + 1 }), + EEditorTool.ElectricityHazard => level.SetSurface(position, level.GetSurface(position) with { Electricity = level.GetSurface(position).Electricity + 1 }), + EEditorTool.Heat => level.SetSurface(position, level.GetSurface(position) with { Heat = level.GetSurface(position).Heat + 1 }), + EEditorTool.Robot => level.IsFloor(position) ? level with { Robot = level.Robot with { Position = position } } : level, + _ => level + }; + } + + public static LevelState BindFirstReactorToConsumers(LevelState level, GridPosition fuelConsumer, GridPosition coolantConsumer, GridPosition electricityConsumer) + { + if (level.Reactors.Count == 0) + return level; + + var reactors = level.Reactors.ToArray(); + reactors[0] = reactors[0] with { + FuelConsumerPosition = fuelConsumer, + CoolantConsumerPosition = coolantConsumer, + ElectricityConsumerPosition = electricityConsumer + }; + return level with { Reactors = reactors }; + } + + private static LevelState SetUnderground(LevelState level, GridPosition position, ECarrierType carrier) + { + return level.SetUnderground(position, carrier, new() { State = EUndergroundState.Intact }); + } + + private static LevelState SetCarrierProp(LevelState level, GridPosition position, EPropType type, ECarrierType carrier) + { + return SetFloorProp(level, position, new() { Type = type, Carrier = carrier, SwitchState = EPropSwitchState.Enabled }); + } + + private static LevelState SetFloorProp(LevelState level, GridPosition position, PropState prop) + { + return level.IsFloor(position) ? level.SetProp(position, prop) : level; + } + + private static LevelState SetDoor(LevelState level, GridPosition position) + { + if (!level.IsFloor(position)) + return level; + + var neighbor = position.Neighbors().FirstOrDefault(level.IsFloor); + if (neighbor is null) + return SetFloorProp(level, position, new() { Type = EPropType.Door }); + + return SetFloorProp(level, position, new() { Type = EPropType.Door }) with { + Doors = [.. level.Doors, new DoorState { A = position, B = neighbor }] + }; + } + + private static LevelState SetReactorControl(LevelState level, GridPosition position) + { + if (!level.IsFloor(position)) + return level; + + var id = level.Reactors.Count == 0 ? 1 : level.Reactors.Max(reactor => reactor.ReactorId) + 1; + var levelWithProp = level.SetProp(position, new() { Type = EPropType.ReactorControl, ReactorId = id }); + return levelWithProp with { + Reactors = [ + .. level.Reactors, + new ReactorBinding { + ReactorId = id, + ControlPosition = position, + FuelConsumerPosition = position, + CoolantConsumerPosition = position, + ElectricityConsumerPosition = position + } + ] + }; + } + + private static LevelState SetLeak(LevelState level, GridPosition position, ECarrierType carrier) + { + if (!level.InBounds(position)) + return level; + + var accessPosition = carrier == ECarrierType.Electricity && level.GetTerrain(position) == ECellTerrain.Wall + ? position.Neighbors().FirstOrDefault(level.IsFloor) + : position; + + if (accessPosition is null || !level.IsFloor(accessPosition)) + return level; + + var next = level.SetUnderground(position, carrier, new() { State = EUndergroundState.Leaking }); + return next with { + Leaks = [ + .. next.Leaks, + new LeakState { + Carrier = carrier, + UndergroundPosition = position, + AccessPosition = accessPosition + } + ] + }; + } +} diff --git a/src/ReactorMaintenance.Simulation/LevelSerializer.cs b/src/ReactorMaintenance.Simulation/LevelSerializer.cs index 95b6e33..0fe62f8 100644 --- a/src/ReactorMaintenance.Simulation/LevelSerializer.cs +++ b/src/ReactorMaintenance.Simulation/LevelSerializer.cs @@ -1,39 +1,42 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace ReactorMaintenance.Simulation; - -public static class LevelSerializer -{ - private const int c_CurrentVersion = 1; - - public static string Serialize(LevelState level) - { - return JsonSerializer.Serialize(new LevelFile { - Version = c_CurrentVersion, - Level = level - }, Options); - } - - public static LevelState Deserialize(string json) - { - var file = JsonSerializer.Deserialize(json, Options) ?? throw new InvalidOperationException("Level file did not contain a level."); - var level = file.Version switch { - c_CurrentVersion => file.Level, - _ => throw new InvalidOperationException($"Unsupported level file version {file.Version}.") - }; - - return level.Cells.Length != level.Width * level.Height ? throw new InvalidOperationException("Level cell count does not match its dimensions.") : level; - } - - private static readonly JsonSerializerOptions Options = new() { - WriteIndented = true, - Converters = { new JsonStringEnumConverter() } - }; - - private sealed record LevelFile - { - public int Version { get; init; } - public LevelState Level { get; init; } = new(); - } -} +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ReactorMaintenance.Simulation; + +public static class LevelSerializer +{ + private const int c_CurrentVersion = 2; + + public static string Serialize(LevelState level) + { + return JsonSerializer.Serialize(new LevelFile { + Version = c_CurrentVersion, + Level = level + }, s_Options); + } + + public static LevelState Deserialize(string json) + { + var file = JsonSerializer.Deserialize(json, s_Options) ?? throw new InvalidOperationException("Level file did not contain a level."); + if (file.Version != c_CurrentVersion) + throw new InvalidOperationException($"Unsupported level file version {file.Version}. Expected {c_CurrentVersion}."); + + var level = file.Level ?? throw new InvalidOperationException("Level file did not contain a level."); + var report = new LevelValidator().Validate(level); + if (!report.IsValid) + throw new InvalidOperationException(report.Errors[0].Message); + + return level; + } + + private static readonly JsonSerializerOptions s_Options = new() { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + private sealed record LevelFile + { + public int Version { get; init; } + public LevelState? Level { get; init; } + } +} diff --git a/src/ReactorMaintenance.Simulation/LevelValidator.cs b/src/ReactorMaintenance.Simulation/LevelValidator.cs new file mode 100644 index 0000000..4b875c6 --- /dev/null +++ b/src/ReactorMaintenance.Simulation/LevelValidator.cs @@ -0,0 +1,203 @@ +namespace ReactorMaintenance.Simulation; + +public sealed class LevelValidator +{ + public ValidationReport Validate(LevelState level) + { + var errors = new List(); + var warnings = new List(); + + ValidateDimensions(level, errors); + ValidateRobot(level, errors); + ValidateCells(level, errors); + ValidateDoors(level, errors); + ValidateLeaks(level, errors); + ValidateReactors(level, errors, warnings); + ValidateJunctions(level, errors); + ValidateRuleEvents(level, errors); + ValidateWarnings(level, warnings); + + return new() { Errors = errors, Warnings = warnings }; + } + + private static void ValidateDimensions(LevelState level, List errors) + { + if (level.Width < Balancing.Current.MinimumLevelSize || level.Height < Balancing.Current.MinimumLevelSize) + errors.Add(new("Invalid level dimensions.")); + + var expected = level.Width * level.Height; + if (level.Terrain.Length != expected || level.Fuel.Length != expected || level.Coolant.Length != expected || level.Electricity.Length != expected || level.Surface.Length != expected || level.Props.Length != expected) + errors.Add(new("Cell array counts do not match level dimensions.")); + } + + private static void ValidateRobot(LevelState level, List errors) + { + if (!level.IsFloor(level.Robot.Position)) + errors.Add(new("Robot must be in bounds on a floor cell.", level.Robot.Position)); + } + + private static void ValidateCells(LevelState level, List errors) + { + for (var y = 0; y < level.Height; y++) + { + for (var x = 0; x < level.Width; x++) + { + var position = new GridPosition(x, y); + var surface = level.GetSurface(position); + var prop = level.GetProp(position); + + if (level.GetTerrain(position) == ECellTerrain.Wall) + { + if (surface.Fuel > 0 || surface.Coolant > 0 || surface.Electricity > 0 || surface.Heat > 0) + errors.Add(new("Wall cell cannot store surface hazards.", position)); + + if (prop.Type != EPropType.None) + errors.Add(new("Prop must be placed on floor terrain.", position)); + } + } + } + } + + private static void ValidateDoors(LevelState level, List errors) + { + foreach (var door in level.Doors) + { + if (!level.IsFloor(door.A) || !level.IsFloor(door.B) || door.A.ManhattanDistance(door.B) != 1) + errors.Add(new("Door edge must connect two adjacent floor cells.", door.A)); + } + } + + private static void ValidateLeaks(LevelState level, List errors) + { + foreach (var leak in level.Leaks) + { + if (!level.InBounds(leak.UndergroundPosition) || !level.IsFloor(leak.AccessPosition)) + { + errors.Add(new("Leak must have valid floor access.", leak.AccessPosition)); + continue; + } + + var underground = level.GetUnderground(leak.UndergroundPosition, leak.Carrier); + if (!underground.IsPresent) + errors.Add(new("Leak target must point to an underground cell.", leak.UndergroundPosition)); + + if (leak.Carrier is ECarrierType.Fuel or ECarrierType.Coolant && leak.UndergroundPosition != leak.AccessPosition) + errors.Add(new("Fuel and coolant leaks must use their underground coordinate as access.", leak.AccessPosition)); + + if (leak.Carrier == ECarrierType.Electricity && leak.UndergroundPosition.ManhattanDistance(leak.AccessPosition) != 1) + errors.Add(new("Electricity leak access must be an adjacent floor face.", leak.AccessPosition)); + } + } + + private static void ValidateReactors(LevelState level, List errors, List warnings) + { + foreach (var reactor in level.Reactors) + { + if (!IsProp(level, reactor.ControlPosition, EPropType.ReactorControl)) + errors.Add(new("Reactor binding control position must point to a reactor control prop.", reactor.ControlPosition)); + + ValidateConsumerBinding(level, reactor.FuelConsumerPosition, ECarrierType.Fuel, errors); + ValidateConsumerBinding(level, reactor.CoolantConsumerPosition, ECarrierType.Coolant, errors); + ValidateConsumerBinding(level, reactor.ElectricityConsumerPosition, ECarrierType.Electricity, errors); + + if (!reactor.Ready) + warnings.Add(new("Reactor is initially unready.", reactor.ControlPosition)); + } + } + + private static void ValidateConsumerBinding(LevelState level, GridPosition position, ECarrierType carrier, List errors) + { + if (!level.InBounds(position) || level.GetProp(position) is not { Type: EPropType.Consumer } prop || prop.Carrier != carrier) + errors.Add(new($"Missing or invalid {carrier} consumer binding.", position)); + } + + private static void ValidateJunctions(LevelState level, List errors) + { + for (var y = 0; y < level.Height; y++) + { + for (var x = 0; x < level.Width; x++) + { + var position = new GridPosition(x, y); + var prop = level.GetProp(position); + if (prop.Type is not (EPropType.TJunction or EPropType.CrossJunction)) + continue; + + var carrierCount = Enum.GetValues().Count(carrier => level.GetUnderground(position, carrier).IsPresent); + if (carrierCount != 1) + errors.Add(new("Junction must regulate exactly one underground carrier.", position)); + } + } + } + + private static void ValidateRuleEvents(LevelState level, List errors) + { + foreach (var ruleEvent in level.RuleEvents) + { + foreach (var effect in ruleEvent.Effects) + { + if (!level.InBounds(effect.Position) && effect.Kind != ERuleEffectKind.EmitWarning && effect.Kind != ERuleEffectKind.MarkTerminalLoss && effect.Kind != ERuleEffectKind.AddInventory) + errors.Add(new("Rule effect target is out of bounds.", effect.Position)); + } + } + } + + private static void ValidateWarnings(LevelState level, List warnings) + { + foreach (var carrier in Enum.GetValues()) + { + for (var y = 0; y < level.Height; y++) + { + for (var x = 0; x < level.Width; x++) + { + var position = new GridPosition(x, y); + if (level.GetUnderground(position, carrier).IsPresent && !HasSourcePath(level, position, carrier)) + warnings.Add(new($"Underground {carrier} cell has no source path.", position)); + } + } + } + + for (var y = 0; y < level.Height; y++) + { + for (var x = 0; x < level.Width; x++) + { + var position = new GridPosition(x, y); + var prop = level.GetProp(position); + if (prop.Type == EPropType.Consumer && prop.SwitchState == EPropSwitchState.Enabled && !HasSourcePath(level, position, prop.Carrier)) + warnings.Add(new("Enabled consumer is initially starved.", position)); + } + } + } + + private static bool HasSourcePath(LevelState level, GridPosition start, ECarrierType carrier) + { + if (!level.GetUnderground(start, carrier).CarriesFlow) + return false; + + var visited = new HashSet(); + var open = new Queue(); + open.Enqueue(start); + visited.Add(start); + + while (open.Count > 0) + { + var current = open.Dequeue(); + if (level.GetProp(current) is { Type: EPropType.Flow, Carrier: var sourceCarrier, SwitchState: EPropSwitchState.Enabled } && sourceCarrier == carrier) + return true; + + foreach (var next in current.Neighbors().Where(level.InBounds)) + { + if (!visited.Add(next) || !level.GetUnderground(next, carrier).CarriesFlow) + continue; + + open.Enqueue(next); + } + } + + return false; + } + + private static bool IsProp(LevelState level, GridPosition position, EPropType propType) + { + return level.InBounds(position) && level.GetProp(position).Type == propType; + } +} diff --git a/src/ReactorMaintenance.Simulation/Models.cs b/src/ReactorMaintenance.Simulation/Models.cs index cfe0289..20d3d6a 100644 --- a/src/ReactorMaintenance.Simulation/Models.cs +++ b/src/ReactorMaintenance.Simulation/Models.cs @@ -1,181 +1,535 @@ -namespace ReactorMaintenance.Simulation; - -public enum ECellTerrain -{ - Floor, - Wall -} - -public enum ECellProp -{ - None, - Reactor, - CoolingPump, - Generator, - PressureRegulator, - DiagnosticTerminal, - ControlTerminal -} - -public enum EPipeMedium -{ - None, - Pressure, - Coolant, - Fuel -} - -public enum EFailureKind -{ - PipeBurst, - Ignition, - Meltdown, - StabilityCollapse, - ReactorReady -} - -public sealed record GridPosition(int X, int Y) -{ - public IEnumerable Neighbors() - { - yield return new(X - Balancing.Current.NeighborDistance, Y); - yield return new(X + Balancing.Current.NeighborDistance, Y); - yield return new(X, Y - Balancing.Current.NeighborDistance); - yield return new(X, Y + Balancing.Current.NeighborDistance); - } -} - -public sealed record HazardState -{ - public HazardState Clamp() - { - return this with { - Heat = Rules.Clamp(Heat), - Smoke = Rules.Clamp(Smoke), - FuelVapor = Rules.Clamp(FuelVapor), - LiquidFuel = Rules.Clamp(LiquidFuel), - CoolantPooling = Rules.Clamp(CoolantPooling), - ElectricalCharge = Rules.Clamp(ElectricalCharge), - Stability = Rules.Clamp(Stability) - }; - } - - public int Heat { get; init; } - public int Smoke { get; init; } - public int FuelVapor { get; init; } - public int LiquidFuel { get; init; } - public int CoolantPooling { get; init; } - public int ElectricalCharge { get; init; } - public int Stability { get; init; } = Balancing.Current.DefaultHazardStability; - public bool Fire { get; init; } -} - -public sealed record CellState -{ - public ECellTerrain Terrain { get; init; } = ECellTerrain.Floor; - public ECellProp Prop { get; init; } - public EPipeMedium Pipe { get; init; } - public int Flow { get; init; } - public int Pressure { get; init; } - public int Integrity { get; init; } = Balancing.Current.DefaultCellIntegrity; - public int LeakRate { get; init; } - public bool PipeOpen { get; init; } = true; - public bool Powered { get; init; } - public bool DoorLocked { get; init; } - public HazardState Hazards { get; init; } = new(); - public bool IsWalkable => Terrain != ECellTerrain.Wall; - public bool HasPipe => Pipe != EPipeMedium.None; -} - -public sealed record GlobalState -{ - public int Turn { get; init; } - public int ActionsPerTurn { get; init; } = Balancing.Current.DefaultActionsPerTurn; - public int CoreHeat { get; init; } = Balancing.Current.DefaultCoreHeat; - public int FacilityStability { get; init; } = Balancing.Current.DefaultFacilityStability; - public int Power { get; init; } = Balancing.Current.DefaultPower; - public int Cooling { get; init; } = Balancing.Current.DefaultCooling; - public bool ReactorActivated { get; init; } - public bool Lost { get; init; } - public string Status { get; init; } = "STABILIZE SYSTEMS"; -} - -public sealed record Forecast(EFailureKind Kind, GridPosition? Position, int Turns, string Message); - -public sealed record LevelState -{ - public static LevelState Create(string name, int width, int height) - { - if (width < Balancing.Current.MinimumLevelSize || height < Balancing.Current.MinimumLevelSize) - throw new ArgumentOutOfRangeException(nameof(width), $"Levels must be at least {Balancing.Current.MinimumLevelSize}x{Balancing.Current.MinimumLevelSize}."); - - var cells = CreateCells(width, height); - for (var y = Balancing.Current.FirstGridCoordinate; y < height; y++) - { - for (var x = Balancing.Current.FirstGridCoordinate; x < width; x++) - { - if (x == Balancing.Current.FirstGridCoordinate || y == Balancing.Current.FirstGridCoordinate || x == width - Balancing.Current.NeighborDistance || y == height - Balancing.Current.NeighborDistance) - cells[y * width + x] = cells[y * width + x] with { Terrain = ECellTerrain.Wall }; - } - } - - return new() { - Name = name, - Width = width, - Height = height, - Cells = cells, - Robot = new(Balancing.Current.DefaultRobotCoordinate, Balancing.Current.DefaultRobotCoordinate) - }; - } - - public CellState GetCell(GridPosition position) - { - EnsureInBounds(position); - return Cells[Index(position)]; - } - - public LevelState SetCell(GridPosition position, CellState cell) - { - EnsureInBounds(position); - var cells = Cells.ToArray(); - cells[Index(position)] = cell; - return this with { Cells = cells }; - } - - public bool InBounds(GridPosition position) - { - return position.X >= Balancing.Current.FirstGridCoordinate && position.Y >= Balancing.Current.FirstGridCoordinate && position.X < Width && position.Y < Height; - } - - public int Index(GridPosition position) - { - return position.Y * Width + position.X; - } - - private void EnsureInBounds(GridPosition position) - { - if (!InBounds(position)) - throw new ArgumentOutOfRangeException(nameof(position), $"Position {position.X},{position.Y} is outside {Width}x{Height}."); - } - - private static CellState[] CreateCells(int width, int height) - { - return Enumerable.Range(Balancing.Current.FirstGridCoordinate, width * height).Select(_ => new CellState()).ToArray(); - } - - public string Name { get; init; } = "New Reactor"; - public int Width { get; init; } = Balancing.Current.DefaultLevelWidth; - public int Height { get; init; } = Balancing.Current.DefaultLevelHeight; - public CellState[] Cells { get; init; } = CreateCells(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight); - public GridPosition Robot { get; init; } = new(Balancing.Current.DefaultRobotCoordinate, Balancing.Current.DefaultRobotCoordinate); - public GlobalState Global { get; init; } = new(); - public IReadOnlyList Forecasts { get; init; } = Array.Empty(); -} - -internal static class Rules -{ - public static int Clamp(int value) - { - return Math.Clamp(value, Balancing.Current.MinHazardValue, Balancing.Current.MaxHazardValue); - } -} \ No newline at end of file +namespace ReactorMaintenance.Simulation; + +public enum ECellTerrain +{ + Floor, + Wall +} + +public enum ECarrierType +{ + Fuel, + Coolant, + Electricity +} + +public enum EUndergroundState +{ + Absent, + Intact, + Leaking +} + +public enum EPropType +{ + None, + Flow, + Consumer, + TJunction, + CrossJunction, + Door, + AllSeeingEyeTerminal, + RemedySupply, + ReactorControl +} + +public enum EPropSwitchState +{ + Disabled, + Enabled +} + +public enum EConsumerServiceState +{ + Unknown, + Disabled, + Starved, + Supplied, + Producing +} + +public enum ETJunctionMode +{ + ZeroFour, + OneThree, + TwoTwo, + ThreeOne, + FourZero +} + +public enum ECrossJunctionMode +{ + ZeroThreeThree, + ThreeZeroThree, + ThreeThreeZero, + TwoTwoTwo +} + +public enum EDoorState +{ + Open, + Closed +} + +public enum ERemedyType +{ + FuelNeutralizer, + CoolantNeutralizer, + ElectricityNeutralizer, + HeatShield +} + +public enum ELevelState +{ + Stable, + Caution, + Critical, + Ready, + Lost, + Won +} + +public enum EForecastKind +{ + TerminalLoss, + ReactorReady, + ConsumerStarved, + HazardGrowth, + RuleEvent +} + +public enum ERuleEventPhase +{ + StartOfSimulation, + EndOfTurn +} + +public enum ERulePredicateKind +{ + TurnAtLeast, + LevelStateIs, + PropStateAt, + ConsumerStateAt, + SurfaceBandAt, + RobotAt, + AllSeeingEyeUnlocked +} + +public enum ERuleEffectKind +{ + StartLeak, + WorsenLeak, + RepairNetworkCell, + DisableNetworkCell, + SetPropEnabled, + AddSurfaceHazard, + AddHeat, + AddInventory, + MarkTerminalLoss, + EmitWarning +} + +public enum EBand +{ + Safe, + Caution, + Critical +} + +public enum EPairEffect +{ + Hold, + FuelFlow, + CoolFlow, + ChargeFlow, + HeatFlow, + HeatFlow2, + Warm1, + Warm2, + Quench1, + Quench2, + Short1, + Short2, + Ignite1, + Ignite2 +} + +public sealed record GridPosition(int X, int Y) +{ + public IEnumerable Neighbors() + { + yield return new(X, Y - 1); + yield return new(X + 1, Y); + yield return new(X, Y + 1); + yield return new(X - 1, Y); + } + + public int ManhattanDistance(GridPosition other) + { + return Math.Abs(X - other.X) + Math.Abs(Y - other.Y); + } +} + +public sealed record UndergroundCell +{ + public EUndergroundState State { get; init; } + public float Amount { get; init; } + public float Intensity { get; init; } + + public bool IsPresent => State != EUndergroundState.Absent; + public bool CarriesFlow => State is EUndergroundState.Intact or EUndergroundState.Leaking; +} + +public sealed record SurfaceState +{ + public float Fuel { get; init; } + public float Coolant { get; init; } + public float Electricity { get; init; } + public float Heat { get; init; } + public int FuelBlockTurns { get; init; } + public int CoolantBlockTurns { get; init; } + public int ElectricityBlockTurns { get; init; } + + public SurfaceState Clamp() + { + var balancing = Balancing.Current; + return this with { + Fuel = balancing.ClampValue(Fuel), + Coolant = balancing.ClampValue(Coolant), + Electricity = balancing.ClampValue(Electricity), + Heat = balancing.ClampValue(Heat), + FuelBlockTurns = Math.Max(0, FuelBlockTurns), + CoolantBlockTurns = Math.Max(0, CoolantBlockTurns), + ElectricityBlockTurns = Math.Max(0, ElectricityBlockTurns) + }; + } + + public bool Blocks(ECarrierType carrier) + { + return carrier switch { + ECarrierType.Fuel => FuelBlockTurns > 0, + ECarrierType.Coolant => CoolantBlockTurns > 0, + ECarrierType.Electricity => ElectricityBlockTurns > 0, + _ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.") + }; + } +} + +public sealed record PropState +{ + public EPropType Type { get; init; } + public ECarrierType Carrier { get; init; } + public EPropSwitchState SwitchState { get; init; } = EPropSwitchState.Enabled; + public EConsumerServiceState ServiceState { get; init; } = EConsumerServiceState.Unknown; + public ETJunctionMode TJunctionMode { get; init; } = ETJunctionMode.TwoTwo; + public ECrossJunctionMode CrossJunctionMode { get; init; } = ECrossJunctionMode.TwoTwoTwo; + public ERemedyType RemedyType { get; init; } + public bool Depleted { get; init; } + public int ReactorId { get; init; } + + public bool IsEnabled => SwitchState == EPropSwitchState.Enabled; +} + +public sealed record DoorState +{ + public GridPosition A { get; init; } = new(0, 0); + public GridPosition B { get; init; } = new(0, 0); + public EDoorState State { get; init; } = EDoorState.Closed; +} + +public sealed record LeakState +{ + public ECarrierType Carrier { get; init; } + public GridPosition UndergroundPosition { get; init; } = new(0, 0); + public GridPosition AccessPosition { get; init; } = new(0, 0); + public bool Repaired { get; init; } +} + +public sealed record ReactorBinding +{ + public int ReactorId { get; init; } + public GridPosition ControlPosition { get; init; } = new(0, 0); + public GridPosition FuelConsumerPosition { get; init; } = new(0, 0); + public GridPosition CoolantConsumerPosition { get; init; } = new(0, 0); + public GridPosition ElectricityConsumerPosition { get; init; } = new(0, 0); + public bool Ready { get; init; } + public bool Activated { get; init; } +} + +public sealed record RobotState +{ + public GridPosition Position { get; init; } = new(1, 1); + public int FuelNeutralizers { get; init; } + public int CoolantNeutralizers { get; init; } + public int ElectricityNeutralizers { get; init; } + public int HeatShields { get; init; } + public int HeatImmunitySteps { get; init; } + + public int Count(ERemedyType remedy) + { + return remedy switch { + ERemedyType.FuelNeutralizer => FuelNeutralizers, + ERemedyType.CoolantNeutralizer => CoolantNeutralizers, + ERemedyType.ElectricityNeutralizer => ElectricityNeutralizers, + ERemedyType.HeatShield => HeatShields, + _ => throw new ArgumentOutOfRangeException(nameof(remedy), remedy, "Unsupported remedy.") + }; + } + + public RobotState Add(ERemedyType remedy, int amount) + { + return remedy switch { + ERemedyType.FuelNeutralizer => this with { FuelNeutralizers = FuelNeutralizers + amount }, + ERemedyType.CoolantNeutralizer => this with { CoolantNeutralizers = CoolantNeutralizers + amount }, + ERemedyType.ElectricityNeutralizer => this with { ElectricityNeutralizers = ElectricityNeutralizers + amount }, + ERemedyType.HeatShield => this with { HeatShields = HeatShields + amount }, + _ => throw new ArgumentOutOfRangeException(nameof(remedy), remedy, "Unsupported remedy.") + }; + } + + public RobotState Spend(ERemedyType remedy) + { + return Count(remedy) <= 0 ? this : Add(remedy, -1); + } +} + +public sealed record RulePredicate +{ + public ERulePredicateKind Kind { get; init; } + public GridPosition Position { get; init; } = new(0, 0); + public int Turn { get; init; } + public ELevelState LevelState { get; init; } + public EPropSwitchState PropSwitchState { get; init; } + public EConsumerServiceState ConsumerServiceState { get; init; } + public ECarrierType Carrier { get; init; } + public EBand Band { get; init; } + public bool BoolValue { get; init; } +} + +public sealed record RuleEffect +{ + public ERuleEffectKind Kind { get; init; } + public GridPosition Position { get; init; } = new(0, 0); + public ECarrierType Carrier { get; init; } + public ERemedyType Remedy { get; init; } + public float Amount { get; init; } + public EPropSwitchState PropSwitchState { get; init; } + public string Message { get; init; } = string.Empty; +} + +public sealed record RuleEventState +{ + public string Id { get; init; } = string.Empty; + public bool Enabled { get; init; } = true; + public bool Repeat { get; init; } + public bool Triggered { get; init; } + public int Priority { get; init; } + public ERuleEventPhase Phase { get; init; } + public IReadOnlyList Predicates { get; init; } = Array.Empty(); + public IReadOnlyList Effects { get; init; } = Array.Empty(); + public string ForecastText { get; init; } = string.Empty; +} + +public sealed record Forecast(EForecastKind Kind, GridPosition? Position, int Turns, string Message); + +public sealed record ValidationIssue(string Message, GridPosition? Position = null); + +public sealed record ValidationReport +{ + public IReadOnlyList Errors { get; init; } = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + public bool IsValid => Errors.Count == 0; +} + +public sealed record GlobalState +{ + public int Turn { get; init; } + public int ActionsRemaining { get; init; } = Balancing.Current.ActionsPerTurn; + public ELevelState LevelState { get; init; } = ELevelState.Stable; + public string Status { get; init; } = "STABLE"; + public bool AllSeeingEyeUnlocked { get; init; } + public bool TerminalLoss { get; init; } + public IReadOnlyList Warnings { get; init; } = Array.Empty(); +} + +public sealed record LevelState +{ + public static LevelState Create(string name, int width, int height) + { + if (width < Balancing.Current.MinimumLevelSize || height < Balancing.Current.MinimumLevelSize) + throw new ArgumentOutOfRangeException(nameof(width), $"Levels must be at least {Balancing.Current.MinimumLevelSize}x{Balancing.Current.MinimumLevelSize}."); + + var terrain = Enumerable.Repeat(ECellTerrain.Floor, width * height).ToArray(); + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + if (x == 0 || y == 0 || x == width - 1 || y == height - 1) + terrain[y * width + x] = ECellTerrain.Wall; + } + } + + var level = new LevelState { + Name = name, + Width = width, + Height = height, + Terrain = terrain, + Fuel = CreateUnderground(width, height), + Coolant = CreateUnderground(width, height), + Electricity = CreateUnderground(width, height), + Surface = CreateSurface(width, height), + Props = CreateProps(width, height), + Robot = new() { Position = new(1, 1) } + }; + + return level with { Forecasts = Array.Empty() }; + } + + public bool InBounds(GridPosition position) + { + return position.X >= 0 && position.Y >= 0 && position.X < Width && position.Y < Height; + } + + public int Index(GridPosition position) + { + EnsureInBounds(position); + return position.Y * Width + position.X; + } + + public ECellTerrain GetTerrain(GridPosition position) + { + return Terrain[Index(position)]; + } + + public UndergroundCell GetUnderground(GridPosition position, ECarrierType carrier) + { + return Layer(carrier)[Index(position)]; + } + + public SurfaceState GetSurface(GridPosition position) + { + return Surface[Index(position)]; + } + + public PropState GetProp(GridPosition position) + { + return Props[Index(position)]; + } + + public bool IsFloor(GridPosition position) + { + return InBounds(position) && GetTerrain(position) == ECellTerrain.Floor; + } + + public bool IsClosedDoorEdge(GridPosition a, GridPosition b) + { + return Doors.Any(door => door.State == EDoorState.Closed && SameEdge(door.A, door.B, a, b)); + } + + public LevelState SetTerrain(GridPosition position, ECellTerrain terrain) + { + var next = Terrain.ToArray(); + next[Index(position)] = terrain; + var level = this with { Terrain = next }; + return terrain == ECellTerrain.Wall ? level.ClearFloorOnlyState(position) : level; + } + + public LevelState SetUnderground(GridPosition position, ECarrierType carrier, UndergroundCell cell) + { + var next = Layer(carrier).ToArray(); + next[Index(position)] = cell; + return carrier switch { + ECarrierType.Fuel => this with { Fuel = next }, + ECarrierType.Coolant => this with { Coolant = next }, + ECarrierType.Electricity => this with { Electricity = next }, + _ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.") + }; + } + + public LevelState SetSurface(GridPosition position, SurfaceState surface) + { + var next = Surface.ToArray(); + next[Index(position)] = surface.Clamp(); + return this with { Surface = next }; + } + + public LevelState SetProp(GridPosition position, PropState prop) + { + var next = Props.ToArray(); + next[Index(position)] = prop; + return this with { Props = next }; + } + + public LevelState WithRuntimeArrays(UndergroundCell[] fuel, UndergroundCell[] coolant, UndergroundCell[] electricity, SurfaceState[] surface, PropState[] props) + { + return this with { + Fuel = fuel, + Coolant = coolant, + Electricity = electricity, + Surface = surface, + Props = props + }; + } + + public IReadOnlyList Layer(ECarrierType carrier) + { + return carrier switch { + ECarrierType.Fuel => Fuel, + ECarrierType.Coolant => Coolant, + ECarrierType.Electricity => Electricity, + _ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.") + }; + } + + private LevelState ClearFloorOnlyState(GridPosition position) + { + return SetSurface(position, new()) + .SetProp(position, new()) + .SetUnderground(position, ECarrierType.Fuel, new()) + .SetUnderground(position, ECarrierType.Coolant, new()) + .SetUnderground(position, ECarrierType.Electricity, new()); + } + + private void EnsureInBounds(GridPosition position) + { + if (!InBounds(position)) + throw new ArgumentOutOfRangeException(nameof(position), $"Position {position.X},{position.Y} is outside {Width}x{Height}."); + } + + private static bool SameEdge(GridPosition edgeA, GridPosition edgeB, GridPosition a, GridPosition b) + { + return edgeA == a && edgeB == b || edgeA == b && edgeB == a; + } + + private static UndergroundCell[] CreateUnderground(int width, int height) + { + return Enumerable.Range(0, width * height).Select(_ => new UndergroundCell()).ToArray(); + } + + private static SurfaceState[] CreateSurface(int width, int height) + { + return Enumerable.Range(0, width * height).Select(_ => new SurfaceState()).ToArray(); + } + + private static PropState[] CreateProps(int width, int height) + { + return Enumerable.Range(0, width * height).Select(_ => new PropState()).ToArray(); + } + + public string Name { get; init; } = "New Reactor"; + public int Width { get; init; } = Balancing.Current.DefaultLevelWidth; + public int Height { get; init; } = Balancing.Current.DefaultLevelHeight; + public ECellTerrain[] Terrain { get; init; } = Enumerable.Repeat(ECellTerrain.Floor, Balancing.Current.DefaultLevelWidth * Balancing.Current.DefaultLevelHeight).ToArray(); + public UndergroundCell[] Fuel { get; init; } = CreateUnderground(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight); + public UndergroundCell[] Coolant { get; init; } = CreateUnderground(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight); + public UndergroundCell[] Electricity { get; init; } = CreateUnderground(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight); + public SurfaceState[] Surface { get; init; } = CreateSurface(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight); + public PropState[] Props { get; init; } = CreateProps(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight); + public IReadOnlyList Doors { get; init; } = Array.Empty(); + public IReadOnlyList Leaks { get; init; } = Array.Empty(); + public IReadOnlyList Reactors { get; init; } = Array.Empty(); + public IReadOnlyList RuleEvents { get; init; } = Array.Empty(); + public RobotState Robot { get; init; } = new(); + public GlobalState Global { get; init; } = new(); + public IReadOnlyList Forecasts { get; init; } = Array.Empty(); +} diff --git a/src/ReactorMaintenance.Simulation/ReactorMaintenance.Simulation.csproj b/src/ReactorMaintenance.Simulation/ReactorMaintenance.Simulation.csproj index bb23fb7..de201d1 100644 --- a/src/ReactorMaintenance.Simulation/ReactorMaintenance.Simulation.csproj +++ b/src/ReactorMaintenance.Simulation/ReactorMaintenance.Simulation.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable diff --git a/src/ReactorMaintenance.Simulation/SimulationEngine.cs b/src/ReactorMaintenance.Simulation/SimulationEngine.cs index e113065..b212d4b 100644 --- a/src/ReactorMaintenance.Simulation/SimulationEngine.cs +++ b/src/ReactorMaintenance.Simulation/SimulationEngine.cs @@ -1,150 +1,735 @@ -using ReactorMaintenance.Simulation.Effects; -using ReactorMaintenance.Simulation.Hazards; - -namespace ReactorMaintenance.Simulation; - -public sealed class SimulationEngine(IEnumerable effects, IEnumerable areaEffects, IEnumerable hazards) -{ - private sealed record ForecastKey(EFailureKind Kind, GridPosition? Position); - - public SimulationEngine() - : this( - [new PipeLeakEffect(), new MachineEffect(), new FireAndElectricalHazardEffect(), new CellIntegrityEffect()], - [new SmokeSpreadEffect()], - [new PipeBurstHazard(), new IgnitionHazard(), new MeltdownHazard(), new StabilityCollapseHazard()]) - { - } - - public LevelState AdvanceTurn(LevelState level) - { - 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() }; - if (forecastLevel.Global.Lost) - AddHazardForecasts(forecasts, seen, forecastLevel, Balancing.Current.CurrentForecastTurn); - - AddReactorReadyForecast(forecasts, seen, forecastLevel, Balancing.Current.CurrentForecastTurn); - - if (IsReactorReady(forecastLevel) || forecastLevel.Global.Lost || forecastLevel.Global.ReactorActivated) - return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray(); - - for (var step = Balancing.Current.TurnIncrement; step <= Balancing.Current.MaxForecastStepCount; step++) - { - forecastLevel = AdvanceTurn(forecastLevel, false); - AddHazardForecasts(forecasts, seen, forecastLevel, step); - AddReactorReadyForecast(forecasts, seen, forecastLevel, step); - - if (forecastLevel.Global.Lost || IsReactorReady(forecastLevel) || forecastLevel.Global.ReactorActivated) - break; - } - - return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray(); - } - - public LevelState ActivateReactor(LevelState level) - { - if (!IsReactorReady(level)) - return level with { Global = level.Global with { Status = "REACTOR NOT READY" } }; - - return level with { - Global = level.Global with { - ReactorActivated = true, - Status = "REACTOR ONLINE" - } - }; - } - - private LevelState AdvanceTurn(LevelState level, bool updateForecasts) - { - var cells = level.Cells.ToArray(); - - for (var y = Balancing.Current.FirstGridCoordinate; y < level.Height; y++) - { - for (var x = Balancing.Current.FirstGridCoordinate; x < level.Width; x++) - { - var position = new GridPosition(x, y); - var index = level.Index(position); - var cell = cells[index]; - - if (!cell.IsWalkable) - continue; - - foreach (var effect in m_Effects) - cell = effect.Apply(cell); - - cells[index] = cell with { Hazards = cell.Hazards.Clamp() }; - } - } - - 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 + Balancing.Current.TurnIncrement } - }; - - 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) - { - var reactorHeat = cells.Where(c => c.Prop == ECellProp.Reactor).Select(c => c.Hazards.Heat).DefaultIfEmpty(level.Global.CoreHeat).Max(); - var poweredGenerators = cells.Count(c => c is { Prop: ECellProp.Generator, Powered: true, Hazards.Fire: false }); - var poweredPumps = cells.Count(c => c is { Prop: ECellProp.CoolingPump, Powered: true, Hazards.Fire: false }); - var damagedCriticalCells = cells.Count(c => c.Prop is ECellProp.Reactor or ECellProp.Generator or ECellProp.CoolingPump && c.Hazards.Stability <= Balancing.Current.CriticalCellStabilityThreshold); - var stability = Rules.Clamp(level.Global.FacilityStability - damagedCriticalCells); - var lost = reactorHeat >= Balancing.Current.MeltdownCoreHeatThreshold || stability <= Balancing.Current.StabilityCollapseThreshold; - var status = lost ? reactorHeat >= Balancing.Current.MeltdownCoreHeatThreshold ? "CORE MELTDOWN" : "FACILITY STABILITY COLLAPSE" : "STABILIZE SYSTEMS"; - var global = level.Global with { - CoreHeat = Rules.Clamp(reactorHeat - poweredPumps), - Power = Rules.Clamp(poweredGenerators * Balancing.Current.GeneratorPowerOutput), - Cooling = Rules.Clamp(poweredPumps * Balancing.Current.CoolingPumpOutput), - FacilityStability = stability, - Lost = lost, - Status = status - }; - - return IsReactorReady(level with { Cells = cells, Global = global }) ? global with { Status = "REACTOR READY" } : global; - } - - private static bool IsReactorReady(LevelState level) - { - var hasReactor = level.Cells.Any(c => c.Prop == ECellProp.Reactor); - var hasStablePower = level.Global.Power >= Balancing.Current.ReactorReadyPowerThreshold || level.Cells.Any(c => c is { Prop: ECellProp.Generator, Powered: true, Hazards.Fire: false }); - var hasCooling = level.Global.Cooling >= Balancing.Current.ReactorReadyCoolingThreshold || level.Cells.Any(c => c is { Prop: ECellProp.CoolingPump, Powered: true, Hazards.Fire: false }); - var reactorStable = level.Global.CoreHeat < Balancing.Current.ReactorReadyCoreHeatThreshold; - return hasReactor && hasStablePower && hasCooling && reactorStable && !level.Global.Lost; - } - - private readonly IReadOnlyList m_AreaEffects = areaEffects.ToArray(); - private readonly IReadOnlyList m_Effects = effects.ToArray(); - private readonly IReadOnlyList m_Hazards = hazards.ToArray(); -} \ No newline at end of file +namespace ReactorMaintenance.Simulation; + +public sealed class SimulationEngine +{ + public LevelState MoveRobot(LevelState level, GridPosition destination) + { + if (!CanSpendAction(level) || !level.IsFloor(destination) || level.Robot.Position.ManhattanDistance(destination) != 1) + return Refuse(level, "MOVE BLOCKED"); + + return SpendAction(level with { + Robot = level.Robot with { + Position = destination, + HeatImmunitySteps = Math.Max(0, level.Robot.HeatImmunitySteps - 1) + } + }); + } + + public LevelState InteractProp(LevelState level) + { + if (!CanSpendAction(level)) + return Refuse(level, "NO ACTIONS"); + + var position = level.Robot.Position; + var prop = level.GetProp(position); + if (prop.Type == EPropType.None) + return Refuse(level, "NO PROP"); + + var next = prop.Type switch { + EPropType.Flow or EPropType.Consumer => ToggleProp(level, position, prop), + EPropType.TJunction => level.SetProp(position, prop with { TJunctionMode = NextTJunctionMode(prop.TJunctionMode) }), + EPropType.CrossJunction => level.SetProp(position, prop with { CrossJunctionMode = NextCrossJunctionMode(prop.CrossJunctionMode) }), + EPropType.Door => ToggleDoor(level, position), + EPropType.AllSeeingEyeTerminal => level with { Global = level.Global with { AllSeeingEyeUnlocked = true, Status = "ALL-SEEING-EYE ONLINE" } }, + EPropType.RemedySupply => PickUpRemedy(level, position, prop), + EPropType.ReactorControl => ActivateReactor(level), + _ => level + }; + + return SpendAction(next); + } + + public LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy) + { + if (!CanSpendAction(level)) + return Refuse(level, "NO ACTIONS"); + + var leakIndex = level.Leaks.ToList().FindIndex(leak => !leak.Repaired && leak.Carrier == carrier && leak.AccessPosition == level.Robot.Position); + if (leakIndex < 0) + return Refuse(level, "NO REACHABLE LEAK"); + + var leak = level.Leaks[leakIndex]; + var next = useRemedy ? ApplyElementRemedy(level, leak) : RepairLeak(level, leakIndex, leak); + return SpendAction(next); + } + + public LevelState ApplyHeatShield(LevelState level) + { + if (!CanSpendAction(level) || level.Robot.HeatShields <= 0) + return Refuse(level, "NO HEAT SHIELD"); + + return SpendAction(level with { + Robot = level.Robot.Spend(ERemedyType.HeatShield) with { HeatImmunitySteps = Balancing.Current.HeatShieldSteps } + }); + } + + public LevelState ActivateReactor(LevelState level) + { + var reactorIndex = level.Reactors.ToList().FindIndex(reactor => reactor.ControlPosition == level.Robot.Position); + if (reactorIndex < 0) + return Refuse(level, "NO REACTOR CONTROL"); + + var reactor = level.Reactors[reactorIndex]; + if (!reactor.Ready) + return Refuse(level, "REACTOR NOT READY"); + + var reactors = level.Reactors.ToArray(); + reactors[reactorIndex] = reactor with { Activated = true }; + return level with { + Reactors = reactors, + Global = level.Global with { LevelState = ELevelState.Won, Status = "REACTOR ONLINE" } + }; + } + + public LevelState EndTurn(LevelState level) + { + return ResolveTurn(level with { Global = level.Global with { ActionsRemaining = 0 } }); + } + + public LevelState AdvanceTurn(LevelState level) + { + return ResolveTurn(level); + } + + public IReadOnlyList Forecast(LevelState level) + { + var forecasts = new List(); + var simulated = CopyForForecast(level); + + for (var turn = 0; turn <= Balancing.Current.ForecastHorizon; turn++) + { + AddForecasts(forecasts, simulated, turn); + if (simulated.Global.LevelState is ELevelState.Lost or ELevelState.Ready or ELevelState.Won) + break; + + if (turn < Balancing.Current.ForecastHorizon) + simulated = ResolveTurn(simulated, false); + } + + return forecasts.DistinctBy(forecast => (forecast.Kind, forecast.Position, forecast.Message)).OrderBy(forecast => forecast.Turns).ThenBy(forecast => forecast.Message).ToArray(); + } + + private LevelState ResolveTurn(LevelState level, bool refreshForecasts = true) + { + var report = m_Validator.Validate(level); + if (!report.IsValid) + return level with { Global = level.Global with { LevelState = ELevelState.Lost, Status = report.Errors[0].Message } }; + + var next = ApplyRuleEvents(level, ERuleEventPhase.StartOfSimulation); + next = PropagateNetworks(next); + next = ResolveConsumers(next); + next = InjectLeaks(next); + next = ResolveSurfaceInteractions(next); + next = ResolveRobotSafety(next); + next = DeriveReactorAndLevelState(next); + next = ApplyRuleEvents(next, ERuleEventPhase.EndOfTurn); + next = AdvanceDurations(next); + next = next with { + Global = next.Global with { + Turn = next.Global.Turn + 1, + ActionsRemaining = Balancing.Current.ActionsPerTurn + } + }; + + return refreshForecasts ? next with { Forecasts = Forecast(next) } : next; + } + + private LevelState PropagateNetworks(LevelState level) + { + var fuel = ClearTransient(level.Fuel); + var coolant = ClearTransient(level.Coolant); + var electricity = ClearTransient(level.Electricity); + var next = level.WithRuntimeArrays(fuel, coolant, electricity, level.Surface.ToArray(), level.Props.ToArray()); + + foreach (var carrier in Enum.GetValues()) + next = PropagateCarrier(next, carrier); + + return next; + } + + private static UndergroundCell[] ClearTransient(IReadOnlyList layer) + { + return layer.Select(cell => cell with { Amount = 0, Intensity = 0 }).ToArray(); + } + + private LevelState PropagateCarrier(LevelState level, ECarrierType carrier) + { + var layer = level.Layer(carrier).ToArray(); + var sources = AllPositions(level).Where(position => level.GetProp(position) is { Type: EPropType.Flow, SwitchState: EPropSwitchState.Enabled, Carrier: var sourceCarrier } && sourceCarrier == carrier && level.GetUnderground(position, carrier).CarriesFlow).ToArray(); + + foreach (var source in sources) + ApplySourceFlow(level, layer, source, carrier); + + return carrier switch { + ECarrierType.Fuel => level with { Fuel = layer }, + ECarrierType.Coolant => level with { Coolant = layer }, + ECarrierType.Electricity => level with { Electricity = layer }, + _ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.") + }; + } + + private static void ApplySourceFlow(LevelState level, UndergroundCell[] layer, GridPosition source, ECarrierType carrier) + { + var open = new Queue<(GridPosition Position, int Distance, float AmountFactor, float IntensityFactor)>(); + var best = new Dictionary(); + open.Enqueue((source, 0, 1, 1)); + best[source] = 1; + + while (open.Count > 0) + { + var current = open.Dequeue(); + var amount = Balancing.Current.ClampValue((Balancing.Current.SourceAmount * current.AmountFactor) - (current.Distance * Balancing.Current.DistanceAmountFalloff)); + var intensity = Balancing.Current.ClampValue((Balancing.Current.SourceIntensity * current.IntensityFactor) - (current.Distance * Balancing.Current.DistanceIntensityFalloff)); + var index = level.Index(current.Position); + layer[index] = layer[index] with { + Amount = Math.Max(layer[index].Amount, amount), + Intensity = Math.Max(layer[index].Intensity, intensity) + }; + + foreach (var next in current.Position.Neighbors().Where(level.InBounds)) + { + if (!level.GetUnderground(next, carrier).CarriesFlow) + continue; + + var weights = BranchWeights(level, current.Position, next); + var amountFactor = current.AmountFactor * weights.Amount; + var intensityFactor = current.IntensityFactor * weights.Intensity; + if (amountFactor <= 0 || intensityFactor <= 0) + continue; + + if (best.TryGetValue(next, out var oldBest) && oldBest >= amountFactor) + continue; + + best[next] = amountFactor; + open.Enqueue((next, current.Distance + 1, amountFactor, intensityFactor)); + } + } + } + + private static (float Amount, float Intensity) BranchWeights(LevelState level, GridPosition from, GridPosition to) + { + var prop = level.GetProp(from); + return prop.Type switch { + EPropType.TJunction => TJunctionWeights(prop.TJunctionMode), + EPropType.CrossJunction => CrossJunctionWeights(prop.CrossJunctionMode), + _ => (1, 1) + }; + } + + private static (float Amount, float Intensity) TJunctionWeights(ETJunctionMode mode) + { + var weight = mode switch { + ETJunctionMode.ZeroFour => 0, + ETJunctionMode.OneThree => 0.25f, + ETJunctionMode.TwoTwo => 0.5f, + ETJunctionMode.ThreeOne => 0.75f, + ETJunctionMode.FourZero => 1, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported T-junction mode.") + }; + return (weight, weight); + } + + private static (float Amount, float Intensity) CrossJunctionWeights(ECrossJunctionMode mode) + { + var weight = mode switch { + ECrossJunctionMode.ZeroThreeThree => 0, + ECrossJunctionMode.ThreeZeroThree => 0.5f, + ECrossJunctionMode.ThreeThreeZero => 0.5f, + ECrossJunctionMode.TwoTwoTwo => 1f / 3f, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported cross-junction mode.") + }; + return (weight, weight); + } + + private LevelState ResolveConsumers(LevelState level) + { + var props = level.Props.ToArray(); + foreach (var position in AllPositions(level)) + { + var index = level.Index(position); + var prop = props[index]; + if (prop.Type != EPropType.Consumer) + continue; + + if (prop.SwitchState == EPropSwitchState.Disabled) + { + props[index] = prop with { ServiceState = EConsumerServiceState.Disabled }; + continue; + } + + var underground = level.GetUnderground(position, prop.Carrier); + var supplied = underground.Amount >= Balancing.Current.ConsumerRequiredAmount && underground.Intensity >= Balancing.Current.ConsumerRequiredIntensity; + props[index] = prop with { ServiceState = supplied ? EConsumerServiceState.Producing : EConsumerServiceState.Starved }; + } + + return level with { Props = props }; + } + + private LevelState InjectLeaks(LevelState level) + { + var surface = level.Surface.ToArray(); + foreach (var leak in level.Leaks.Where(leak => !leak.Repaired)) + { + var underground = level.GetUnderground(leak.UndergroundPosition, leak.Carrier); + if (underground.State != EUndergroundState.Leaking) + continue; + + var accessIndex = level.Index(leak.AccessPosition); + if (surface[accessIndex].Blocks(leak.Carrier)) + continue; + + var amount = Balancing.Current.LeakBaseAmount + underground.Amount * Balancing.Current.LeakAmountScale + underground.Intensity * Balancing.Current.LeakIntensityScale; + surface[accessIndex] = AddSurfaceCarrier(surface[accessIndex], leak.Carrier, amount); + } + + return level with { Surface = surface.Select(cell => cell.Clamp()).ToArray() }; + } + + private LevelState ResolveSurfaceInteractions(LevelState level) + { + var deltas = Enumerable.Range(0, level.Width * level.Height).Select(_ => new SurfaceDelta()).ToArray(); + foreach (var position in AllPositions(level).Where(level.IsFloor)) + ApplySameCellInteractions(level, position, deltas); + + foreach (var position in AllPositions(level).Where(level.IsFloor)) + { + foreach (var neighbor in position.Neighbors().Where(level.IsFloor)) + { + if (level.Index(position) >= level.Index(neighbor) || level.IsClosedDoorEdge(position, neighbor)) + continue; + + ApplyAdjacentInteractions(level, position, neighbor, deltas); + } + } + + var surface = level.Surface.ToArray(); + for (var i = 0; i < surface.Length; i++) + surface[i] = deltas[i].Apply(surface[i]).Clamp(); + + return level with { Surface = surface }; + } + + private static void ApplySameCellInteractions(LevelState level, GridPosition position, SurfaceDelta[] deltas) + { + var surface = level.GetSurface(position); + ApplyPair(level, position, position, ECarrierType.Fuel, BandFuel(surface.Fuel), ECarrierType.Electricity, BandElectricity(surface.Electricity), deltas); + ApplyPair(level, position, position, ECarrierType.Fuel, BandFuel(surface.Fuel), null, BandHeat(surface.Heat), deltas); + ApplyPair(level, position, position, ECarrierType.Coolant, BandCoolant(surface.Coolant), ECarrierType.Electricity, BandElectricity(surface.Electricity), deltas); + ApplyPair(level, position, position, ECarrierType.Coolant, BandCoolant(surface.Coolant), null, BandHeat(surface.Heat), deltas); + } + + private static void ApplyAdjacentInteractions(LevelState level, GridPosition a, GridPosition b, SurfaceDelta[] deltas) + { + var surfaceA = level.GetSurface(a); + var surfaceB = level.GetSurface(b); + FlowBetween(level, a, b, surfaceA.Fuel, surfaceB.Fuel, EPairEffect.FuelFlow, deltas); + FlowBetween(level, a, b, surfaceA.Coolant, surfaceB.Coolant, EPairEffect.CoolFlow, deltas); + FlowBetween(level, a, b, surfaceA.Electricity, surfaceB.Electricity, EPairEffect.ChargeFlow, deltas); + FlowBetween(level, a, b, surfaceA.Heat, surfaceB.Heat, EPairEffect.HeatFlow, deltas); + } + + private static void ApplyPair(LevelState level, GridPosition a, GridPosition b, ECarrierType? rowCarrier, EBand rowBand, ECarrierType? colCarrier, EBand colBand, SurfaceDelta[] deltas) + { + ApplyEffect(level, a, b, PairEffect(rowCarrier, rowBand, colCarrier, colBand), deltas); + } + + private static EPairEffect PairEffect(ECarrierType? rowCarrier, EBand rowBand, ECarrierType? colCarrier, EBand colBand) + { + if (rowBand == EBand.Safe && colBand == EBand.Safe) + return EPairEffect.Hold; + + if (rowCarrier == colCarrier) + return rowCarrier switch { + ECarrierType.Fuel => EPairEffect.FuelFlow, + ECarrierType.Coolant => EPairEffect.CoolFlow, + ECarrierType.Electricity => EPairEffect.ChargeFlow, + _ => EPairEffect.HeatFlow + }; + + if (rowCarrier == ECarrierType.Fuel && colCarrier == ECarrierType.Electricity) + return rowBand == EBand.Critical || colBand == EBand.Critical ? EPairEffect.Ignite2 : EPairEffect.Ignite1; + + if (rowCarrier == ECarrierType.Fuel && colCarrier is null) + return rowBand == EBand.Critical || colBand == EBand.Critical ? EPairEffect.Ignite2 : EPairEffect.Warm1; + + if (rowCarrier == ECarrierType.Coolant && colCarrier == ECarrierType.Electricity) + return rowBand == EBand.Critical || colBand == EBand.Critical ? EPairEffect.Short2 : EPairEffect.Short1; + + if (rowCarrier == ECarrierType.Coolant && colCarrier is null) + return rowBand == EBand.Critical || colBand == EBand.Critical ? EPairEffect.Quench2 : EPairEffect.Quench1; + + return EPairEffect.Hold; + } + + private static void ApplyEffect(LevelState level, GridPosition a, GridPosition b, EPairEffect effect, SurfaceDelta[] deltas) + { + var index = level.Index(a); + switch (effect) + { + case EPairEffect.Warm1: + deltas[index].Heat += Balancing.Current.Warm1Amount; + break; + case EPairEffect.Warm2: + deltas[index].Heat += Balancing.Current.Warm2Amount; + break; + case EPairEffect.Quench1: + deltas[index].Heat -= Balancing.Current.Quench1Amount; + break; + case EPairEffect.Quench2: + deltas[index].Heat -= Balancing.Current.Quench2Amount; + break; + case EPairEffect.Short1: + deltas[index].Heat += Balancing.Current.Short1Heat; + deltas[index].Electricity -= Balancing.Current.Short1Discharge; + break; + case EPairEffect.Short2: + deltas[index].Heat += Balancing.Current.Short2Heat; + deltas[index].Electricity -= Balancing.Current.Short2Discharge; + break; + case EPairEffect.Ignite1: + deltas[index].Heat += Balancing.Current.Ignite1Heat; + deltas[index].Fuel -= Balancing.Current.Ignite1FuelConsumption; + break; + case EPairEffect.Ignite2: + deltas[index].Heat += Balancing.Current.Ignite2Heat; + deltas[index].Fuel -= Balancing.Current.Ignite2FuelConsumption; + break; + } + } + + private static void FlowBetween(LevelState level, GridPosition a, GridPosition b, float valueA, float valueB, EPairEffect effect, SurfaceDelta[] deltas) + { + var difference = valueA - valueB; + if (Math.Abs(difference) < 0.01f) + return; + + var amount = difference * (effect == EPairEffect.HeatFlow2 ? Balancing.Current.StrongFlowTransferRatio : Balancing.Current.FlowTransferRatio); + var indexA = level.Index(a); + var indexB = level.Index(b); + + switch (effect) + { + case EPairEffect.FuelFlow: + deltas[indexA].Fuel -= amount; + deltas[indexB].Fuel += amount; + break; + case EPairEffect.CoolFlow: + deltas[indexA].Coolant -= amount; + deltas[indexB].Coolant += amount; + break; + case EPairEffect.ChargeFlow: + deltas[indexA].Electricity -= amount; + deltas[indexB].Electricity += amount; + break; + case EPairEffect.HeatFlow: + case EPairEffect.HeatFlow2: + deltas[indexA].Heat -= amount; + deltas[indexB].Heat += amount; + break; + } + } + + private LevelState ResolveRobotSafety(LevelState level) + { + var surface = level.GetSurface(level.Robot.Position); + var unsafeElement = surface.Fuel >= Balancing.Current.RobotFuelSafetyThreshold || surface.Coolant >= Balancing.Current.RobotCoolantSafetyThreshold || surface.Electricity >= Balancing.Current.RobotElectricitySafetyThreshold; + var unsafeHeat = surface.Heat >= Balancing.Current.RobotHeatSafetyThreshold && level.Robot.HeatImmunitySteps <= 0; + return unsafeElement || unsafeHeat + ? level with { Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = "ROBOT LOST" } } + : level; + } + + private LevelState DeriveReactorAndLevelState(LevelState level) + { + if (level.Global.LevelState is ELevelState.Lost or ELevelState.Won) + return level; + + var reactors = level.Reactors.Select(reactor => reactor with { Ready = IsReactorReady(level, reactor) }).ToArray(); + if (reactors.Any(reactor => reactor.Ready)) + return level with { Reactors = reactors, Global = level.Global with { LevelState = ELevelState.Ready, Status = "REACTOR READY" } }; + + var maxHeat = level.Surface.Where((_, index) => level.Terrain[index] == ECellTerrain.Floor).Select(surface => surface.Heat).DefaultIfEmpty(0).Max(); + if (maxHeat >= Balancing.Current.TerminalHeat) + return level with { Reactors = reactors, Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = "REACTOR HEAT TERMINAL" } }; + + var hasCritical = level.Surface.Any(surface => BandFuel(surface.Fuel) == EBand.Critical || BandCoolant(surface.Coolant) == EBand.Critical || BandElectricity(surface.Electricity) == EBand.Critical || BandHeat(surface.Heat) == EBand.Critical); + var hasCaution = hasCritical || level.Props.Any(prop => prop.ServiceState is EConsumerServiceState.Starved or EConsumerServiceState.Disabled) || level.Leaks.Any(leak => !leak.Repaired); + var state = hasCritical ? ELevelState.Critical : hasCaution ? ELevelState.Caution : ELevelState.Stable; + return level with { Reactors = reactors, Global = level.Global with { LevelState = state, Status = state.ToString().ToUpperInvariant() } }; + } + + private static bool IsReactorReady(LevelState level, ReactorBinding reactor) + { + return HasProducingConsumer(level, reactor.FuelConsumerPosition, ECarrierType.Fuel) + && HasProducingConsumer(level, reactor.CoolantConsumerPosition, ECarrierType.Coolant) + && HasProducingConsumer(level, reactor.ElectricityConsumerPosition, ECarrierType.Electricity) + && level.GetSurface(reactor.ControlPosition).Heat < Balancing.Current.TerminalHeat; + } + + private static bool HasProducingConsumer(LevelState level, GridPosition position, ECarrierType carrier) + { + return level.InBounds(position) && level.GetProp(position) is { Type: EPropType.Consumer, Carrier: var consumerCarrier, ServiceState: EConsumerServiceState.Producing } && consumerCarrier == carrier; + } + + private LevelState ApplyRuleEvents(LevelState level, ERuleEventPhase phase) + { + var next = level; + var events = level.RuleEvents.Select((ruleEvent, index) => (Event: ruleEvent, Index: index)).Where(item => item.Event.Enabled && item.Event.Phase == phase && (item.Event.Repeat || !item.Event.Triggered)).OrderBy(item => item.Event.Priority).ToArray(); + var ruleEvents = next.RuleEvents.ToArray(); + + foreach (var item in events) + { + if (!item.Event.Predicates.All(predicate => PredicateMatches(next, predicate))) + continue; + + foreach (var effect in item.Event.Effects) + next = ApplyRuleEffect(next, effect); + + ruleEvents[item.Index] = item.Event with { Triggered = true }; + } + + return next with { RuleEvents = ruleEvents }; + } + + private static bool PredicateMatches(LevelState level, RulePredicate predicate) + { + return predicate.Kind switch { + ERulePredicateKind.TurnAtLeast => level.Global.Turn >= predicate.Turn, + ERulePredicateKind.LevelStateIs => level.Global.LevelState == predicate.LevelState, + ERulePredicateKind.PropStateAt => level.InBounds(predicate.Position) && level.GetProp(predicate.Position).SwitchState == predicate.PropSwitchState, + ERulePredicateKind.ConsumerStateAt => level.InBounds(predicate.Position) && level.GetProp(predicate.Position).ServiceState == predicate.ConsumerServiceState, + ERulePredicateKind.SurfaceBandAt => level.InBounds(predicate.Position) && SurfaceBand(level.GetSurface(predicate.Position), predicate.Carrier) >= predicate.Band, + ERulePredicateKind.RobotAt => level.Robot.Position == predicate.Position, + ERulePredicateKind.AllSeeingEyeUnlocked => level.Global.AllSeeingEyeUnlocked == predicate.BoolValue, + _ => false + }; + } + + private static LevelState ApplyRuleEffect(LevelState level, RuleEffect effect) + { + return effect.Kind switch { + ERuleEffectKind.StartLeak => StartLeak(level, effect), + ERuleEffectKind.WorsenLeak => level.SetUnderground(effect.Position, effect.Carrier, level.GetUnderground(effect.Position, effect.Carrier) with { State = EUndergroundState.Leaking }), + ERuleEffectKind.RepairNetworkCell => level.SetUnderground(effect.Position, effect.Carrier, level.GetUnderground(effect.Position, effect.Carrier) with { State = EUndergroundState.Intact }), + ERuleEffectKind.DisableNetworkCell => level.SetUnderground(effect.Position, effect.Carrier, new()), + ERuleEffectKind.SetPropEnabled => level.SetProp(effect.Position, level.GetProp(effect.Position) with { SwitchState = effect.PropSwitchState }), + ERuleEffectKind.AddSurfaceHazard => level.SetSurface(effect.Position, AddSurfaceCarrier(level.GetSurface(effect.Position), effect.Carrier, effect.Amount)), + ERuleEffectKind.AddHeat => level.SetSurface(effect.Position, level.GetSurface(effect.Position) with { Heat = level.GetSurface(effect.Position).Heat + effect.Amount }), + ERuleEffectKind.AddInventory => level with { Robot = level.Robot.Add(effect.Remedy, (int)effect.Amount) }, + ERuleEffectKind.MarkTerminalLoss => level with { Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = string.IsNullOrWhiteSpace(effect.Message) ? "TERMINAL FAILURE" : effect.Message } }, + ERuleEffectKind.EmitWarning => level with { Global = level.Global with { Warnings = [.. level.Global.Warnings, effect.Message] } }, + _ => level + }; + } + + private static LevelState StartLeak(LevelState level, RuleEffect effect) + { + var leak = new LeakState { + Carrier = effect.Carrier, + UndergroundPosition = effect.Position, + AccessPosition = effect.Position + }; + return level.SetUnderground(effect.Position, effect.Carrier, level.GetUnderground(effect.Position, effect.Carrier) with { State = EUndergroundState.Leaking }) with { Leaks = [.. level.Leaks, leak] }; + } + + private LevelState AdvanceDurations(LevelState level) + { + var surface = level.Surface.Select(cell => cell with { + FuelBlockTurns = Math.Max(0, cell.FuelBlockTurns - 1), + CoolantBlockTurns = Math.Max(0, cell.CoolantBlockTurns - 1), + ElectricityBlockTurns = Math.Max(0, cell.ElectricityBlockTurns - 1) + }).ToArray(); + + return level with { Surface = surface }; + } + + private static LevelState ToggleProp(LevelState level, GridPosition position, PropState prop) + { + var switchState = prop.SwitchState == EPropSwitchState.Enabled ? EPropSwitchState.Disabled : EPropSwitchState.Enabled; + return level.SetProp(position, prop with { SwitchState = switchState }); + } + + private static LevelState ToggleDoor(LevelState level, GridPosition position) + { + var doors = level.Doors.ToArray(); + var index = Array.FindIndex(doors, door => door.A == position || door.B == position); + if (index < 0) + return level; + + doors[index] = doors[index] with { State = doors[index].State == EDoorState.Open ? EDoorState.Closed : EDoorState.Open }; + return level with { Doors = doors }; + } + + private static LevelState PickUpRemedy(LevelState level, GridPosition position, PropState prop) + { + if (prop.Depleted || level.Robot.Count(prop.RemedyType) >= Balancing.Current.InventoryCapacityPerRemedy) + return level; + + return level.SetProp(position, prop with { Depleted = true }) with { Robot = level.Robot.Add(prop.RemedyType, 1) }; + } + + private static LevelState RepairLeak(LevelState level, int leakIndex, LeakState leak) + { + var leaks = level.Leaks.ToArray(); + leaks[leakIndex] = leak with { Repaired = true }; + return level.SetUnderground(leak.UndergroundPosition, leak.Carrier, level.GetUnderground(leak.UndergroundPosition, leak.Carrier) with { State = EUndergroundState.Intact }) with { Leaks = leaks }; + } + + private static LevelState ApplyElementRemedy(LevelState level, LeakState leak) + { + var remedy = leak.Carrier switch { + ECarrierType.Fuel => ERemedyType.FuelNeutralizer, + ECarrierType.Coolant => ERemedyType.CoolantNeutralizer, + ECarrierType.Electricity => ERemedyType.ElectricityNeutralizer, + _ => throw new ArgumentOutOfRangeException(nameof(leak), leak.Carrier, "Unsupported leak carrier.") + }; + + if (level.Robot.Count(remedy) <= 0) + return Refuse(level, "NO REMEDY"); + + var surface = RemoveSurfaceCarrier(level.GetSurface(leak.AccessPosition), leak.Carrier); + return level.SetSurface(leak.AccessPosition, surface) with { Robot = level.Robot.Spend(remedy) }; + } + + private static SurfaceState AddSurfaceCarrier(SurfaceState surface, ECarrierType carrier, float amount) + { + return carrier switch { + ECarrierType.Fuel => surface with { Fuel = surface.Fuel + amount }, + ECarrierType.Coolant => surface with { Coolant = surface.Coolant + amount }, + ECarrierType.Electricity => surface with { Electricity = surface.Electricity + amount }, + _ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.") + }; + } + + private static SurfaceState RemoveSurfaceCarrier(SurfaceState surface, ECarrierType carrier) + { + return carrier switch { + ECarrierType.Fuel => surface with { Fuel = 0, FuelBlockTurns = Balancing.Current.RemedyBlockTurns }, + ECarrierType.Coolant => surface with { Coolant = 0, CoolantBlockTurns = Balancing.Current.RemedyBlockTurns }, + ECarrierType.Electricity => surface with { Electricity = 0, ElectricityBlockTurns = Balancing.Current.RemedyBlockTurns }, + _ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.") + }; + } + + private LevelState SpendAction(LevelState level) + { + var actions = Math.Max(0, level.Global.ActionsRemaining - 1); + var next = level with { Global = level.Global with { ActionsRemaining = actions } }; + return actions == 0 ? ResolveTurn(next) : next; + } + + private static bool CanSpendAction(LevelState level) + { + return level.Global.LevelState is not (ELevelState.Lost or ELevelState.Won) && level.Global.ActionsRemaining > 0; + } + + private static LevelState Refuse(LevelState level, string message) + { + return level with { Global = level.Global with { Status = message } }; + } + + private static ETJunctionMode NextTJunctionMode(ETJunctionMode mode) + { + return mode == ETJunctionMode.FourZero ? ETJunctionMode.ZeroFour : mode + 1; + } + + private static ECrossJunctionMode NextCrossJunctionMode(ECrossJunctionMode mode) + { + return mode == ECrossJunctionMode.TwoTwoTwo ? ECrossJunctionMode.ZeroThreeThree : mode + 1; + } + + private static EBand SurfaceBand(SurfaceState surface, ECarrierType carrier) + { + return carrier switch { + ECarrierType.Fuel => BandFuel(surface.Fuel), + ECarrierType.Coolant => BandCoolant(surface.Coolant), + ECarrierType.Electricity => BandElectricity(surface.Electricity), + _ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.") + }; + } + + private static EBand BandFuel(float value) + { + return Balancing.Current.Band(value, Balancing.Current.FuelCaution, Balancing.Current.FuelCritical); + } + + private static EBand BandCoolant(float value) + { + return Balancing.Current.Band(value, Balancing.Current.CoolantCaution, Balancing.Current.CoolantCritical); + } + + private static EBand BandElectricity(float value) + { + return Balancing.Current.Band(value, Balancing.Current.ElectricityCaution, Balancing.Current.ElectricityCritical); + } + + private static EBand BandHeat(float value) + { + return Balancing.Current.Band(value, Balancing.Current.HeatCaution, Balancing.Current.HeatCritical); + } + + private static LevelState CopyForForecast(LevelState level) + { + return level with { + Terrain = level.Terrain.ToArray(), + Fuel = level.Fuel.ToArray(), + Coolant = level.Coolant.ToArray(), + Electricity = level.Electricity.ToArray(), + Surface = level.Surface.ToArray(), + Props = level.Props.ToArray(), + Forecasts = Array.Empty() + }; + } + + private static void AddForecasts(List forecasts, LevelState level, int turn) + { + if (level.Global.LevelState == ELevelState.Lost) + forecasts.Add(new(EForecastKind.TerminalLoss, level.Robot.Position, turn, level.Global.Status)); + + if (level.Global.LevelState == ELevelState.Ready) + forecasts.Add(new(EForecastKind.ReactorReady, null, turn, "REACTOR READY")); + + foreach (var position in AllPositions(level)) + { + var prop = level.GetProp(position); + if (prop.Type == EPropType.Consumer && prop.ServiceState == EConsumerServiceState.Starved) + forecasts.Add(new(EForecastKind.ConsumerStarved, position, turn, $"{prop.Carrier} consumer starved")); + + var surface = level.GetSurface(position); + if (BandFuel(surface.Fuel) == EBand.Critical || BandCoolant(surface.Coolant) == EBand.Critical || BandElectricity(surface.Electricity) == EBand.Critical || BandHeat(surface.Heat) == EBand.Critical) + forecasts.Add(new(EForecastKind.HazardGrowth, position, turn, "Critical hazard")); + } + + foreach (var ruleEvent in level.RuleEvents.Where(ruleEvent => ruleEvent.Enabled && !string.IsNullOrWhiteSpace(ruleEvent.ForecastText) && ruleEvent.Predicates.All(predicate => PredicateMatches(level, predicate)))) + forecasts.Add(new(EForecastKind.RuleEvent, null, turn, ruleEvent.ForecastText)); + } + + private static IEnumerable AllPositions(LevelState level) + { + for (var y = 0; y < level.Height; y++) + { + for (var x = 0; x < level.Width; x++) + yield return new(x, y); + } + } + + private sealed class SurfaceDelta + { + public SurfaceState Apply(SurfaceState surface) + { + return surface with { + Fuel = surface.Fuel + Fuel, + Coolant = surface.Coolant + Coolant, + Electricity = surface.Electricity + Electricity, + Heat = surface.Heat + Heat + }; + } + + public float Fuel { get; set; } + public float Coolant { get; set; } + public float Electricity { get; set; } + public float Heat { get; set; } + } + + private readonly LevelValidator m_Validator = new(); +} diff --git a/tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj b/tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj index e6d83c1..89d03a6 100644 --- a/tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj +++ b/tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable diff --git a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs index 6bae661..465a09b 100644 --- a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs +++ b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs @@ -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(() => 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; - } - } -} \ No newline at end of file +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(() => 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(); +}