Rewrite simulation core for design model

This commit is contained in:
2026-05-10 18:41:17 +02:00
parent ca41e009bd
commit 851f6d27e8
23 changed files with 2033 additions and 1192 deletions

View File

@@ -4,8 +4,9 @@
- Branch: `design-rewrite` - Branch: `design-rewrite`
- Scope approved: implement `docs/design.md` end-to-end with deterministic defaults and no backward compatibility. - 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. - Simulation core has been replaced with the first design-native model and deterministic engine slice.
- First commit establishes this tracker only. - 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 ## Completed Work
@@ -13,20 +14,27 @@
- Confirmed deterministic balance defaults should be chosen during implementation. - Confirmed deterministic balance defaults should be chosen during implementation.
- Confirmed a full Win2D editor is required. - Confirmed a full Win2D editor is required.
- Created branch `design-rewrite`. - 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 ## Current Work
- Establish task tracking before code changes. - Commit the first simulation-core rewrite slice.
## Future Work ## 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. 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. Replace balancing with deterministic defaults for all values named by the design. 2. Update the Win2D editor for all authored layers and new runtime inspection.
3. Implement validation errors and warnings from the design. 3. Add editor workflows for reactor bindings, door edge selection, electricity wall leak faces, rule events, and layer-specific painting.
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. 4. Update README and any affected docs to reflect the new schema, .NET target, editor controls, and deterministic defaults.
5. Implement player actions and editor operations for the new model. 5. Build the Win2D project on a Windows-capable environment after the editor rewrite.
6. Replace serialization with a schema-valid current format only. 6. Add broader tests for junction ratios, ambiguous junctions, all rule event families, serialization edge cases, and editor operations.
7. Update the Win2D editor for all authored layers and new runtime inspection. 7. Run cleanup when `dotnet-jb` is available, tests, code review, and iterate until the implementation is clean and maintainable.
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.

View File

@@ -1,78 +1,72 @@
using ReactorMaintenance.Simulation.Difficulties; using ReactorMaintenance.Simulation.Difficulties;
namespace ReactorMaintenance.Simulation; namespace ReactorMaintenance.Simulation;
public abstract class Balancing public abstract class Balancing
{ {
public static Balancing Current { get; set; } = new NormalBalancing(); public static Balancing Current { get; set; } = new NormalBalancing();
public abstract int MinHazardValue { get; } public float ClampValue(float value)
public abstract int MaxHazardValue { get; } {
public abstract int DefaultHazardStability { get; } return Math.Clamp(value, MinValue, MaxValue);
public abstract int DefaultCellIntegrity { get; } }
public abstract int DefaultActionsPerTurn { get; }
public abstract int DefaultCoreHeat { get; } public EBand Band(float value, float caution, float critical)
public abstract int DefaultFacilityStability { get; } {
public abstract int DefaultPower { get; } if (value >= critical)
public abstract int DefaultCooling { get; } return EBand.Critical;
public abstract int FirstGridCoordinate { get; }
public abstract int NeighborDistance { get; } return value >= caution ? EBand.Caution : EBand.Safe;
public abstract int CurrentForecastTurn { get; } }
public abstract int MinimumLevelSize { get; }
public abstract int DefaultLevelWidth { get; } public abstract int DefaultLevelWidth { get; }
public abstract int DefaultLevelHeight { get; } public abstract int DefaultLevelHeight { get; }
public abstract int DefaultRobotCoordinate { get; } public abstract int MinimumLevelSize { get; }
public abstract int DefaultPipeFlow { get; } public abstract int ActionsPerTurn { get; }
public abstract int DefaultPipePressure { get; } public abstract int ForecastHorizon { get; }
public abstract int DefaultPressurePipeFlow { get; } public abstract float MinValue { get; }
public abstract int DefaultPressurePipePressure { get; } public abstract float MaxValue { get; }
public abstract int DefaultEditedPipeIntegrity { get; } public abstract float FuelSafe { get; }
public abstract int MinimumLeakRate { get; } public abstract float FuelCaution { get; }
public abstract int DamagedPipeIntegrity { get; } public abstract float FuelCritical { get; }
public abstract int RepairedLeakRate { get; } public abstract float CoolantSafe { get; }
public abstract int RepairedElectricalCharge { get; } public abstract float CoolantCaution { get; }
public abstract int HeatToolIncrease { get; } public abstract float CoolantCritical { get; }
public abstract int FireToolMinimumHeat { get; } public abstract float ElectricitySafe { get; }
public abstract int FireToolMinimumSmoke { get; } public abstract float ElectricityCaution { get; }
public abstract int MaxForecastStepCount { get; } public abstract float ElectricityCritical { get; }
public abstract int TurnIncrement { get; } public abstract float HeatSafe { get; }
public abstract int OverpressureThreshold { get; } public abstract float HeatCaution { get; }
public abstract int HeatIntegrityDamageThreshold { get; } public abstract float HeatCritical { get; }
public abstract int PipeFireIntegrityDamage { get; } public abstract float TerminalHeat { get; }
public abstract int FireStabilityDamage { get; } public abstract float RobotFuelSafetyThreshold { get; }
public abstract int BurstLeakRate { get; } public abstract float RobotCoolantSafetyThreshold { get; }
public abstract int BrokenPipeFlow { get; } public abstract float RobotElectricitySafetyThreshold { get; }
public abstract int ElectrifiedCoolantPoolingThreshold { get; } public abstract float RobotHeatSafetyThreshold { get; }
public abstract int ElectricalChargeIncrease { get; } public abstract float SourceAmount { get; }
public abstract int FuelVaporFireThreshold { get; } public abstract float SourceIntensity { get; }
public abstract int LiquidFuelFireThreshold { get; } public abstract float DistanceAmountFalloff { get; }
public abstract int HeatIgnitionThreshold { get; } public abstract float DistanceIntensityFalloff { get; }
public abstract int ElectricalIgnitionThreshold { get; } public abstract float ConsumerRequiredAmount { get; }
public abstract int FireHeatIncrease { get; } public abstract float ConsumerRequiredIntensity { get; }
public abstract int FireSmokeIncrease { get; } public abstract float LeakBaseAmount { get; }
public abstract int FireLiquidFuelConsumption { get; } public abstract float LeakAmountScale { get; }
public abstract int FireFuelVaporConsumption { get; } public abstract float LeakIntensityScale { get; }
public abstract int SmokeDecay { get; } public abstract float FlowTransferRatio { get; }
public abstract int PressurizedFuelLeakPressureThreshold { get; } public abstract float StrongFlowTransferRatio { get; }
public abstract int PassiveFuelVaporHeatOffset { get; } public abstract float Warm1Amount { get; }
public abstract int PassiveFuelVaporDivisor { get; } public abstract float Warm2Amount { get; }
public abstract int MinimumCoolantHeatReduction { get; } public abstract float Quench1Amount { get; }
public abstract int CoolantHeatReductionDivisor { get; } public abstract float Quench2Amount { get; }
public abstract int CoolantSteamHeatThreshold { get; } public abstract float Short1Heat { get; }
public abstract int CoolantSteamSmokeIncrease { get; } public abstract float Short1Discharge { get; }
public abstract int PressureLeakSmokeThreshold { get; } public abstract float Short2Heat { get; }
public abstract int PressureLeakSmokeIncrease { get; } public abstract float Short2Discharge { get; }
public abstract int GeneratorHeatIncrease { get; } public abstract float Ignite1Heat { get; }
public abstract int CoolingPumpHeatReduction { get; } public abstract float Ignite1FuelConsumption { get; }
public abstract int ReactorHeatIncrease { get; } public abstract float Ignite2Heat { get; }
public abstract int SmokeSpreadThreshold { get; } public abstract float Ignite2FuelConsumption { get; }
public abstract int SmokeSpreadIncrease { get; } public abstract int RemedyBlockTurns { get; }
public abstract int CriticalCellStabilityThreshold { get; } public abstract int HeatShieldSteps { get; }
public abstract int MeltdownCoreHeatThreshold { get; } public abstract int InventoryCapacityPerRemedy { 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; }
}

View File

@@ -1,76 +1,55 @@
using ReactorMaintenance.Simulation; namespace ReactorMaintenance.Simulation.Difficulties;
namespace ReactorMaintenance.Simulation.Difficulties; public class NormalBalancing : Balancing
{
public class NormalBalancing : Balancing public override int DefaultLevelWidth => 16;
{ public override int DefaultLevelHeight => 12;
public override int MinHazardValue => 0; public override int MinimumLevelSize => 4;
public override int MaxHazardValue => 10; public override int ActionsPerTurn => 3;
public override int DefaultHazardStability => 10; public override int ForecastHorizon => 6;
public override int DefaultCellIntegrity => 10; public override float MinValue => 0;
public override int DefaultActionsPerTurn => 3; public override float MaxValue => 10;
public override int DefaultCoreHeat => 5; public override float FuelSafe => 1.5f;
public override int DefaultFacilityStability => 10; public override float FuelCaution => 3.5f;
public override int DefaultPower => 5; public override float FuelCritical => 6.5f;
public override int DefaultCooling => 0; public override float CoolantSafe => 1.5f;
public override int FirstGridCoordinate => 0; public override float CoolantCaution => 3.5f;
public override int NeighborDistance => 1; public override float CoolantCritical => 6.5f;
public override int CurrentForecastTurn => 0; public override float ElectricitySafe => 1.5f;
public override int MinimumLevelSize => 4; public override float ElectricityCaution => 3.5f;
public override int DefaultLevelWidth => 16; public override float ElectricityCritical => 6.5f;
public override int DefaultLevelHeight => 12; public override float HeatSafe => 2;
public override int DefaultRobotCoordinate => 1; public override float HeatCaution => 5;
public override int DefaultPipeFlow => 4; public override float HeatCritical => 8;
public override int DefaultPipePressure => 4; public override float TerminalHeat => 10;
public override int DefaultPressurePipeFlow => 5; public override float RobotFuelSafetyThreshold => 6.5f;
public override int DefaultPressurePipePressure => 6; public override float RobotCoolantSafetyThreshold => 8;
public override int DefaultEditedPipeIntegrity => 8; public override float RobotElectricitySafetyThreshold => 6.5f;
public override int MinimumLeakRate => 1; public override float RobotHeatSafetyThreshold => 8;
public override int DamagedPipeIntegrity => 4; public override float SourceAmount => 8;
public override int RepairedLeakRate => 0; public override float SourceIntensity => 8;
public override int RepairedElectricalCharge => 0; public override float DistanceAmountFalloff => 0.5f;
public override int HeatToolIncrease => 2; public override float DistanceIntensityFalloff => 0.4f;
public override int FireToolMinimumHeat => 7; public override float ConsumerRequiredAmount => 2.5f;
public override int FireToolMinimumSmoke => 3; public override float ConsumerRequiredIntensity => 2.5f;
public override int MaxForecastStepCount => 12; public override float LeakBaseAmount => 0.5f;
public override int TurnIncrement => 1; public override float LeakAmountScale => 0.15f;
public override int OverpressureThreshold => 7; public override float LeakIntensityScale => 0.1f;
public override int HeatIntegrityDamageThreshold => 10; public override float FlowTransferRatio => 0.05f;
public override int PipeFireIntegrityDamage => 1; public override float StrongFlowTransferRatio => 0.1f;
public override int FireStabilityDamage => 1; public override float Warm1Amount => 0.5f;
public override int BurstLeakRate => 3; public override float Warm2Amount => 1.0f;
public override int BrokenPipeFlow => 0; public override float Quench1Amount => 0.6f;
public override int ElectrifiedCoolantPoolingThreshold => 3; public override float Quench2Amount => 1.2f;
public override int ElectricalChargeIncrease => 2; public override float Short1Heat => 0.8f;
public override int FuelVaporFireThreshold => 4; public override float Short1Discharge => 0.8f;
public override int LiquidFuelFireThreshold => 6; public override float Short2Heat => 1.6f;
public override int HeatIgnitionThreshold => 8; public override float Short2Discharge => 1.5f;
public override int ElectricalIgnitionThreshold => 4; public override float Ignite1Heat => 1.2f;
public override int FireHeatIncrease => 2; public override float Ignite1FuelConsumption => 0.4f;
public override int FireSmokeIncrease => 2; public override float Ignite2Heat => 2.4f;
public override int FireLiquidFuelConsumption => 1; public override float Ignite2FuelConsumption => 0.8f;
public override int FireFuelVaporConsumption => 1; public override int RemedyBlockTurns => 2;
public override int SmokeDecay => 1; public override int HeatShieldSteps => 3;
public override int PressurizedFuelLeakPressureThreshold => 7; public override int InventoryCapacityPerRemedy => 3;
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;
}

View File

@@ -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
};
}
}

View File

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

View File

@@ -1,8 +0,0 @@
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Simulation.Effects;
public interface IAreaSimulationEffect
{
CellState[] Apply(LevelState level, CellState[] cells);
}

View File

@@ -1,8 +0,0 @@
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Simulation.Effects;
public interface ISimulationEffect
{
CellState Apply(CellState cell);
}

View File

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

View File

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

View File

@@ -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) } };
}
}
}

View File

@@ -1,8 +0,0 @@
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Simulation.Hazards;
public abstract class Hazard
{
public abstract IEnumerable<Forecast> Predict(LevelState level, int turns);
}

View File

@@ -1,20 +0,0 @@
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Simulation.Hazards;
public sealed class IgnitionHazard : Hazard
{
public override IEnumerable<Forecast> 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");
}
}
}
}

View File

@@ -1,12 +0,0 @@
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Simulation.Hazards;
public sealed class MeltdownHazard : Hazard
{
public override IEnumerable<Forecast> Predict(LevelState level, int turns)
{
if (level.Global is { Lost: true, Status: "CORE MELTDOWN" })
yield return new(EFailureKind.Meltdown, null, turns, "CORE MELTDOWN APPROACHING");
}
}

View File

@@ -1,20 +0,0 @@
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Simulation.Hazards;
public sealed class PipeBurstHazard : Hazard
{
public override IEnumerable<Forecast> 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");
}
}
}
}

View File

@@ -1,12 +0,0 @@
using ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Simulation.Hazards;
public sealed class StabilityCollapseHazard : Hazard
{
public override IEnumerable<Forecast> 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");
}
}

View File

@@ -1,119 +1,165 @@
namespace ReactorMaintenance.Simulation; namespace ReactorMaintenance.Simulation;
public enum EEditorTool public enum EEditorTool
{ {
Cursor, Cursor,
Floor, Floor,
Wall, Wall,
Reactor, FuelUnderground,
CoolingPump, CoolantUnderground,
Generator, ElectricityUnderground,
PressureRegulator, FuelFlow,
DiagnosticTerminal, CoolantFlow,
ControlTerminal, ElectricityFlow,
CoolantPipe, FuelConsumer,
FuelPipe, CoolantConsumer,
PressurePipe, ElectricityConsumer,
Leak, TJunction,
Repair, CrossJunction,
Heat, Door,
Fire, AllSeeingEyeTerminal,
Robot FuelRemedySupply,
} CoolantRemedySupply,
ElectricityRemedySupply,
public static class LevelEditor HeatRemedySupply,
{ ReactorControl,
public static LevelState Apply(LevelState level, GridPosition position, EEditorTool tool) FuelLeak,
{ CoolantLeak,
if (!level.InBounds(position)) ElectricityLeak,
return level; FuelHazard,
CoolantHazard,
if (tool == EEditorTool.Robot) ElectricityHazard,
return level.GetCell(position).IsWalkable ? level with { Robot = position } : level; Heat,
Robot
var cell = level.GetCell(position); }
cell = tool switch {
EEditorTool.Cursor => cell, public static class LevelEditor
EEditorTool.Floor => cell with { Terrain = ECellTerrain.Floor }, {
EEditorTool.Wall => cell with { public static LevelState Apply(LevelState level, GridPosition position, EEditorTool tool)
Terrain = ECellTerrain.Wall, {
Prop = ECellProp.None, if (!level.InBounds(position))
Pipe = EPipeMedium.None, return level;
Flow = Balancing.Current.MinHazardValue,
Pressure = Balancing.Current.MinHazardValue, return tool switch {
LeakRate = Balancing.Current.MinHazardValue, EEditorTool.Cursor => level,
PipeOpen = false, EEditorTool.Floor => level.SetTerrain(position, ECellTerrain.Floor),
Powered = false EEditorTool.Wall => level.SetTerrain(position, ECellTerrain.Wall),
}, EEditorTool.FuelUnderground => SetUnderground(level, position, ECarrierType.Fuel),
EEditorTool.Reactor => cell with { Terrain = ECellTerrain.Floor, Prop = ECellProp.Reactor }, EEditorTool.CoolantUnderground => SetUnderground(level, position, ECarrierType.Coolant),
EEditorTool.CoolingPump => cell with { EEditorTool.ElectricityUnderground => SetUnderground(level, position, ECarrierType.Electricity),
Terrain = ECellTerrain.Floor, EEditorTool.FuelFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Fuel),
Prop = ECellProp.CoolingPump, EEditorTool.CoolantFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Coolant),
Powered = true EEditorTool.ElectricityFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Electricity),
}, EEditorTool.FuelConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Fuel),
EEditorTool.Generator => cell with { EEditorTool.CoolantConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Coolant),
Terrain = ECellTerrain.Floor, EEditorTool.ElectricityConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Electricity),
Prop = ECellProp.Generator, EEditorTool.TJunction => SetFloorProp(level, position, new() { Type = EPropType.TJunction }),
Powered = true EEditorTool.CrossJunction => SetFloorProp(level, position, new() { Type = EPropType.CrossJunction }),
}, EEditorTool.Door => SetDoor(level, position),
EEditorTool.PressureRegulator => cell with { Terrain = ECellTerrain.Floor, Prop = ECellProp.PressureRegulator }, EEditorTool.AllSeeingEyeTerminal => SetFloorProp(level, position, new() { Type = EPropType.AllSeeingEyeTerminal }),
EEditorTool.DiagnosticTerminal => cell with { EEditorTool.FuelRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.FuelNeutralizer }),
Terrain = ECellTerrain.Floor, EEditorTool.CoolantRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.CoolantNeutralizer }),
Prop = ECellProp.DiagnosticTerminal, EEditorTool.ElectricityRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.ElectricityNeutralizer }),
Powered = true EEditorTool.HeatRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.HeatShield }),
}, EEditorTool.ReactorControl => SetReactorControl(level, position),
EEditorTool.ControlTerminal => cell with { EEditorTool.FuelLeak => SetLeak(level, position, ECarrierType.Fuel),
Terrain = ECellTerrain.Floor, EEditorTool.CoolantLeak => SetLeak(level, position, ECarrierType.Coolant),
Prop = ECellProp.ControlTerminal, EEditorTool.ElectricityLeak => SetLeak(level, position, ECarrierType.Electricity),
Powered = true 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.CoolantPipe => cell with { EEditorTool.ElectricityHazard => level.SetSurface(position, level.GetSurface(position) with { Electricity = level.GetSurface(position).Electricity + 1 }),
Pipe = EPipeMedium.Coolant, EEditorTool.Heat => level.SetSurface(position, level.GetSurface(position) with { Heat = level.GetSurface(position).Heat + 1 }),
Flow = Balancing.Current.DefaultPipeFlow, EEditorTool.Robot => level.IsFloor(position) ? level with { Robot = level.Robot with { Position = position } } : level,
Pressure = Balancing.Current.DefaultPipePressure, _ => level
Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity), };
PipeOpen = true }
},
EEditorTool.FuelPipe => cell with { public static LevelState BindFirstReactorToConsumers(LevelState level, GridPosition fuelConsumer, GridPosition coolantConsumer, GridPosition electricityConsumer)
Pipe = EPipeMedium.Fuel, {
Flow = Balancing.Current.DefaultPipeFlow, if (level.Reactors.Count == 0)
Pressure = Balancing.Current.DefaultPipePressure, return level;
Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity),
PipeOpen = true var reactors = level.Reactors.ToArray();
}, reactors[0] = reactors[0] with {
EEditorTool.PressurePipe => cell with { FuelConsumerPosition = fuelConsumer,
Pipe = EPipeMedium.Pressure, CoolantConsumerPosition = coolantConsumer,
Flow = Balancing.Current.DefaultPressurePipeFlow, ElectricityConsumerPosition = electricityConsumer
Pressure = Balancing.Current.DefaultPressurePipePressure, };
Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity), return level with { Reactors = reactors };
PipeOpen = true }
},
EEditorTool.Leak => cell with { private static LevelState SetUnderground(LevelState level, GridPosition position, ECarrierType carrier)
LeakRate = Math.Max(Balancing.Current.MinimumLeakRate, cell.LeakRate), {
Integrity = Math.Min(cell.Integrity, Balancing.Current.DamagedPipeIntegrity) return level.SetUnderground(position, carrier, new() { State = EUndergroundState.Intact });
}, }
EEditorTool.Repair => cell with {
LeakRate = Balancing.Current.RepairedLeakRate, private static LevelState SetCarrierProp(LevelState level, GridPosition position, EPropType type, ECarrierType carrier)
Integrity = Balancing.Current.DefaultCellIntegrity, {
Hazards = cell.Hazards with { return SetFloorProp(level, position, new() { Type = type, Carrier = carrier, SwitchState = EPropSwitchState.Enabled });
Fire = false, }
ElectricalCharge = Balancing.Current.RepairedElectricalCharge
} private static LevelState SetFloorProp(LevelState level, GridPosition position, PropState prop)
}, {
EEditorTool.Heat => cell with { Hazards = cell.Hazards with { Heat = Rules.Clamp(cell.Hazards.Heat + Balancing.Current.HeatToolIncrease) } }, return level.IsFloor(position) ? level.SetProp(position, prop) : level;
EEditorTool.Fire => cell with { }
Hazards = cell.Hazards with {
Fire = !cell.Hazards.Fire, private static LevelState SetDoor(LevelState level, GridPosition position)
Heat = Math.Max(cell.Hazards.Heat, Balancing.Current.FireToolMinimumHeat), {
Smoke = Math.Max(cell.Hazards.Smoke, Balancing.Current.FireToolMinimumSmoke) if (!level.IsFloor(position))
} return level;
},
_ => cell var neighbor = position.Neighbors().FirstOrDefault(level.IsFloor);
}; if (neighbor is null)
return SetFloorProp(level, position, new() { Type = EPropType.Door });
if (cell.Terrain == ECellTerrain.Wall)
cell = cell with { Hazards = new() }; return SetFloorProp(level, position, new() { Type = EPropType.Door }) with {
Doors = [.. level.Doors, new DoorState { A = position, B = neighbor }]
return level.SetCell(position, cell); };
} }
}
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
}
]
};
}
}

View File

@@ -1,39 +1,42 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace ReactorMaintenance.Simulation; namespace ReactorMaintenance.Simulation;
public static class LevelSerializer public static class LevelSerializer
{ {
private const int c_CurrentVersion = 1; private const int c_CurrentVersion = 2;
public static string Serialize(LevelState level) public static string Serialize(LevelState level)
{ {
return JsonSerializer.Serialize(new LevelFile { return JsonSerializer.Serialize(new LevelFile {
Version = c_CurrentVersion, Version = c_CurrentVersion,
Level = level Level = level
}, Options); }, s_Options);
} }
public static LevelState Deserialize(string json) public static LevelState Deserialize(string json)
{ {
var file = JsonSerializer.Deserialize<LevelFile>(json, Options) ?? throw new InvalidOperationException("Level file did not contain a level."); var file = JsonSerializer.Deserialize<LevelFile>(json, s_Options) ?? throw new InvalidOperationException("Level file did not contain a level.");
var level = file.Version switch { if (file.Version != c_CurrentVersion)
c_CurrentVersion => file.Level, throw new InvalidOperationException($"Unsupported level file version {file.Version}. Expected {c_CurrentVersion}.");
_ => throw new InvalidOperationException($"Unsupported level file version {file.Version}.")
}; var level = file.Level ?? throw new InvalidOperationException("Level file did not contain a level.");
var report = new LevelValidator().Validate(level);
return level.Cells.Length != level.Width * level.Height ? throw new InvalidOperationException("Level cell count does not match its dimensions.") : level; if (!report.IsValid)
} throw new InvalidOperationException(report.Errors[0].Message);
private static readonly JsonSerializerOptions Options = new() { return level;
WriteIndented = true, }
Converters = { new JsonStringEnumConverter() }
}; private static readonly JsonSerializerOptions s_Options = new() {
WriteIndented = true,
private sealed record LevelFile Converters = { new JsonStringEnumConverter() }
{ };
public int Version { get; init; }
public LevelState Level { get; init; } = new(); private sealed record LevelFile
} {
} public int Version { get; init; }
public LevelState? Level { get; init; }
}
}

View File

@@ -0,0 +1,203 @@
namespace ReactorMaintenance.Simulation;
public sealed class LevelValidator
{
public ValidationReport Validate(LevelState level)
{
var errors = new List<ValidationIssue>();
var warnings = new List<ValidationIssue>();
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<ValidationIssue> 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<ValidationIssue> 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<ValidationIssue> 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<ValidationIssue> 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<ValidationIssue> 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<ValidationIssue> errors, List<ValidationIssue> 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<ValidationIssue> 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<ValidationIssue> 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<ECarrierType>().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<ValidationIssue> 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<ValidationIssue> warnings)
{
foreach (var carrier in Enum.GetValues<ECarrierType>())
{
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<GridPosition>();
var open = new Queue<GridPosition>();
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;
}
}

View File

@@ -1,181 +1,535 @@
namespace ReactorMaintenance.Simulation; namespace ReactorMaintenance.Simulation;
public enum ECellTerrain public enum ECellTerrain
{ {
Floor, Floor,
Wall Wall
} }
public enum ECellProp public enum ECarrierType
{ {
None, Fuel,
Reactor, Coolant,
CoolingPump, Electricity
Generator, }
PressureRegulator,
DiagnosticTerminal, public enum EUndergroundState
ControlTerminal {
} Absent,
Intact,
public enum EPipeMedium Leaking
{ }
None,
Pressure, public enum EPropType
Coolant, {
Fuel None,
} Flow,
Consumer,
public enum EFailureKind TJunction,
{ CrossJunction,
PipeBurst, Door,
Ignition, AllSeeingEyeTerminal,
Meltdown, RemedySupply,
StabilityCollapse, ReactorControl
ReactorReady }
}
public enum EPropSwitchState
public sealed record GridPosition(int X, int Y) {
{ Disabled,
public IEnumerable<GridPosition> Neighbors() Enabled
{ }
yield return new(X - Balancing.Current.NeighborDistance, Y);
yield return new(X + Balancing.Current.NeighborDistance, Y); public enum EConsumerServiceState
yield return new(X, Y - Balancing.Current.NeighborDistance); {
yield return new(X, Y + Balancing.Current.NeighborDistance); Unknown,
} Disabled,
} Starved,
Supplied,
public sealed record HazardState Producing
{ }
public HazardState Clamp()
{ public enum ETJunctionMode
return this with { {
Heat = Rules.Clamp(Heat), ZeroFour,
Smoke = Rules.Clamp(Smoke), OneThree,
FuelVapor = Rules.Clamp(FuelVapor), TwoTwo,
LiquidFuel = Rules.Clamp(LiquidFuel), ThreeOne,
CoolantPooling = Rules.Clamp(CoolantPooling), FourZero
ElectricalCharge = Rules.Clamp(ElectricalCharge), }
Stability = Rules.Clamp(Stability)
}; public enum ECrossJunctionMode
} {
ZeroThreeThree,
public int Heat { get; init; } ThreeZeroThree,
public int Smoke { get; init; } ThreeThreeZero,
public int FuelVapor { get; init; } TwoTwoTwo
public int LiquidFuel { get; init; } }
public int CoolantPooling { get; init; }
public int ElectricalCharge { get; init; } public enum EDoorState
public int Stability { get; init; } = Balancing.Current.DefaultHazardStability; {
public bool Fire { get; init; } Open,
} Closed
}
public sealed record CellState
{ public enum ERemedyType
public ECellTerrain Terrain { get; init; } = ECellTerrain.Floor; {
public ECellProp Prop { get; init; } FuelNeutralizer,
public EPipeMedium Pipe { get; init; } CoolantNeutralizer,
public int Flow { get; init; } ElectricityNeutralizer,
public int Pressure { get; init; } HeatShield
public int Integrity { get; init; } = Balancing.Current.DefaultCellIntegrity; }
public int LeakRate { get; init; }
public bool PipeOpen { get; init; } = true; public enum ELevelState
public bool Powered { get; init; } {
public bool DoorLocked { get; init; } Stable,
public HazardState Hazards { get; init; } = new(); Caution,
public bool IsWalkable => Terrain != ECellTerrain.Wall; Critical,
public bool HasPipe => Pipe != EPipeMedium.None; Ready,
} Lost,
Won
public sealed record GlobalState }
{
public int Turn { get; init; } public enum EForecastKind
public int ActionsPerTurn { get; init; } = Balancing.Current.DefaultActionsPerTurn; {
public int CoreHeat { get; init; } = Balancing.Current.DefaultCoreHeat; TerminalLoss,
public int FacilityStability { get; init; } = Balancing.Current.DefaultFacilityStability; ReactorReady,
public int Power { get; init; } = Balancing.Current.DefaultPower; ConsumerStarved,
public int Cooling { get; init; } = Balancing.Current.DefaultCooling; HazardGrowth,
public bool ReactorActivated { get; init; } RuleEvent
public bool Lost { get; init; } }
public string Status { get; init; } = "STABILIZE SYSTEMS";
} public enum ERuleEventPhase
{
public sealed record Forecast(EFailureKind Kind, GridPosition? Position, int Turns, string Message); StartOfSimulation,
EndOfTurn
public sealed record LevelState }
{
public static LevelState Create(string name, int width, int height) public enum ERulePredicateKind
{ {
if (width < Balancing.Current.MinimumLevelSize || height < Balancing.Current.MinimumLevelSize) TurnAtLeast,
throw new ArgumentOutOfRangeException(nameof(width), $"Levels must be at least {Balancing.Current.MinimumLevelSize}x{Balancing.Current.MinimumLevelSize}."); LevelStateIs,
PropStateAt,
var cells = CreateCells(width, height); ConsumerStateAt,
for (var y = Balancing.Current.FirstGridCoordinate; y < height; y++) SurfaceBandAt,
{ RobotAt,
for (var x = Balancing.Current.FirstGridCoordinate; x < width; x++) AllSeeingEyeUnlocked
{ }
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 }; public enum ERuleEffectKind
} {
} StartLeak,
WorsenLeak,
return new() { RepairNetworkCell,
Name = name, DisableNetworkCell,
Width = width, SetPropEnabled,
Height = height, AddSurfaceHazard,
Cells = cells, AddHeat,
Robot = new(Balancing.Current.DefaultRobotCoordinate, Balancing.Current.DefaultRobotCoordinate) AddInventory,
}; MarkTerminalLoss,
} EmitWarning
}
public CellState GetCell(GridPosition position)
{ public enum EBand
EnsureInBounds(position); {
return Cells[Index(position)]; Safe,
} Caution,
Critical
public LevelState SetCell(GridPosition position, CellState cell) }
{
EnsureInBounds(position); public enum EPairEffect
var cells = Cells.ToArray(); {
cells[Index(position)] = cell; Hold,
return this with { Cells = cells }; FuelFlow,
} CoolFlow,
ChargeFlow,
public bool InBounds(GridPosition position) HeatFlow,
{ HeatFlow2,
return position.X >= Balancing.Current.FirstGridCoordinate && position.Y >= Balancing.Current.FirstGridCoordinate && position.X < Width && position.Y < Height; Warm1,
} Warm2,
Quench1,
public int Index(GridPosition position) Quench2,
{ Short1,
return position.Y * Width + position.X; Short2,
} Ignite1,
Ignite2
private void EnsureInBounds(GridPosition position) }
{
if (!InBounds(position)) public sealed record GridPosition(int X, int Y)
throw new ArgumentOutOfRangeException(nameof(position), $"Position {position.X},{position.Y} is outside {Width}x{Height}."); {
} public IEnumerable<GridPosition> Neighbors()
{
private static CellState[] CreateCells(int width, int height) yield return new(X, Y - 1);
{ yield return new(X + 1, Y);
return Enumerable.Range(Balancing.Current.FirstGridCoordinate, width * height).Select(_ => new CellState()).ToArray(); yield return new(X, Y + 1);
} yield return new(X - 1, Y);
}
public string Name { get; init; } = "New Reactor";
public int Width { get; init; } = Balancing.Current.DefaultLevelWidth; public int ManhattanDistance(GridPosition other)
public int Height { get; init; } = Balancing.Current.DefaultLevelHeight; {
public CellState[] Cells { get; init; } = CreateCells(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight); return Math.Abs(X - other.X) + Math.Abs(Y - other.Y);
public GridPosition Robot { get; init; } = new(Balancing.Current.DefaultRobotCoordinate, Balancing.Current.DefaultRobotCoordinate); }
public GlobalState Global { get; init; } = new(); }
public IReadOnlyList<Forecast> Forecasts { get; init; } = Array.Empty<Forecast>();
} public sealed record UndergroundCell
{
internal static class Rules public EUndergroundState State { get; init; }
{ public float Amount { get; init; }
public static int Clamp(int value) public float Intensity { get; init; }
{
return Math.Clamp(value, Balancing.Current.MinHazardValue, Balancing.Current.MaxHazardValue); 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<RulePredicate> Predicates { get; init; } = Array.Empty<RulePredicate>();
public IReadOnlyList<RuleEffect> Effects { get; init; } = Array.Empty<RuleEffect>();
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<ValidationIssue> Errors { get; init; } = Array.Empty<ValidationIssue>();
public IReadOnlyList<ValidationIssue> Warnings { get; init; } = Array.Empty<ValidationIssue>();
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<string> Warnings { get; init; } = Array.Empty<string>();
}
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<Forecast>() };
}
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<UndergroundCell> 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<DoorState> Doors { get; init; } = Array.Empty<DoorState>();
public IReadOnlyList<LeakState> Leaks { get; init; } = Array.Empty<LeakState>();
public IReadOnlyList<ReactorBinding> Reactors { get; init; } = Array.Empty<ReactorBinding>();
public IReadOnlyList<RuleEventState> RuleEvents { get; init; } = Array.Empty<RuleEventState>();
public RobotState Robot { get; init; } = new();
public GlobalState Global { get; init; } = new();
public IReadOnlyList<Forecast> Forecasts { get; init; } = Array.Empty<Forecast>();
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

View File

@@ -1,150 +1,735 @@
using ReactorMaintenance.Simulation.Effects; namespace ReactorMaintenance.Simulation;
using ReactorMaintenance.Simulation.Hazards;
public sealed class SimulationEngine
namespace ReactorMaintenance.Simulation; {
public LevelState MoveRobot(LevelState level, GridPosition destination)
public sealed class SimulationEngine(IEnumerable<ISimulationEffect> effects, IEnumerable<IAreaSimulationEffect> areaEffects, IEnumerable<Hazard> hazards) {
{ if (!CanSpendAction(level) || !level.IsFloor(destination) || level.Robot.Position.ManhattanDistance(destination) != 1)
private sealed record ForecastKey(EFailureKind Kind, GridPosition? Position); return Refuse(level, "MOVE BLOCKED");
public SimulationEngine() return SpendAction(level with {
: this( Robot = level.Robot with {
[new PipeLeakEffect(), new MachineEffect(), new FireAndElectricalHazardEffect(), new CellIntegrityEffect()], Position = destination,
[new SmokeSpreadEffect()], HeatImmunitySteps = Math.Max(0, level.Robot.HeatImmunitySteps - 1)
[new PipeBurstHazard(), new IgnitionHazard(), new MeltdownHazard(), new StabilityCollapseHazard()]) }
{ });
} }
public LevelState AdvanceTurn(LevelState level) public LevelState InteractProp(LevelState level)
{ {
return AdvanceTurn(level, true); if (!CanSpendAction(level))
} return Refuse(level, "NO ACTIONS");
public IReadOnlyList<Forecast> Forecast(LevelState level) var position = level.Robot.Position;
{ var prop = level.GetProp(position);
var forecasts = new List<Forecast>(); if (prop.Type == EPropType.None)
var seen = new HashSet<ForecastKey>(); return Refuse(level, "NO PROP");
var forecastLevel = level with { Cells = level.Cells.ToArray(), Forecasts = Array.Empty<Forecast>() };
if (forecastLevel.Global.Lost) var next = prop.Type switch {
AddHazardForecasts(forecasts, seen, forecastLevel, Balancing.Current.CurrentForecastTurn); EPropType.Flow or EPropType.Consumer => ToggleProp(level, position, prop),
EPropType.TJunction => level.SetProp(position, prop with { TJunctionMode = NextTJunctionMode(prop.TJunctionMode) }),
AddReactorReadyForecast(forecasts, seen, forecastLevel, Balancing.Current.CurrentForecastTurn); EPropType.CrossJunction => level.SetProp(position, prop with { CrossJunctionMode = NextCrossJunctionMode(prop.CrossJunctionMode) }),
EPropType.Door => ToggleDoor(level, position),
if (IsReactorReady(forecastLevel) || forecastLevel.Global.Lost || forecastLevel.Global.ReactorActivated) EPropType.AllSeeingEyeTerminal => level with { Global = level.Global with { AllSeeingEyeUnlocked = true, Status = "ALL-SEEING-EYE ONLINE" } },
return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray(); EPropType.RemedySupply => PickUpRemedy(level, position, prop),
EPropType.ReactorControl => ActivateReactor(level),
for (var step = Balancing.Current.TurnIncrement; step <= Balancing.Current.MaxForecastStepCount; step++) _ => level
{ };
forecastLevel = AdvanceTurn(forecastLevel, false);
AddHazardForecasts(forecasts, seen, forecastLevel, step); return SpendAction(next);
AddReactorReadyForecast(forecasts, seen, forecastLevel, step); }
if (forecastLevel.Global.Lost || IsReactorReady(forecastLevel) || forecastLevel.Global.ReactorActivated) public LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy)
break; {
} if (!CanSpendAction(level))
return Refuse(level, "NO ACTIONS");
return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray();
} var leakIndex = level.Leaks.ToList().FindIndex(leak => !leak.Repaired && leak.Carrier == carrier && leak.AccessPosition == level.Robot.Position);
if (leakIndex < 0)
public LevelState ActivateReactor(LevelState level) return Refuse(level, "NO REACHABLE LEAK");
{
if (!IsReactorReady(level)) var leak = level.Leaks[leakIndex];
return level with { Global = level.Global with { Status = "REACTOR NOT READY" } }; var next = useRemedy ? ApplyElementRemedy(level, leak) : RepairLeak(level, leakIndex, leak);
return SpendAction(next);
return level with { }
Global = level.Global with {
ReactorActivated = true, public LevelState ApplyHeatShield(LevelState level)
Status = "REACTOR ONLINE" {
} if (!CanSpendAction(level) || level.Robot.HeatShields <= 0)
}; return Refuse(level, "NO HEAT SHIELD");
}
return SpendAction(level with {
private LevelState AdvanceTurn(LevelState level, bool updateForecasts) Robot = level.Robot.Spend(ERemedyType.HeatShield) with { HeatImmunitySteps = Balancing.Current.HeatShieldSteps }
{ });
var cells = level.Cells.ToArray(); }
for (var y = Balancing.Current.FirstGridCoordinate; y < level.Height; y++) public LevelState ActivateReactor(LevelState level)
{ {
for (var x = Balancing.Current.FirstGridCoordinate; x < level.Width; x++) var reactorIndex = level.Reactors.ToList().FindIndex(reactor => reactor.ControlPosition == level.Robot.Position);
{ if (reactorIndex < 0)
var position = new GridPosition(x, y); return Refuse(level, "NO REACTOR CONTROL");
var index = level.Index(position);
var cell = cells[index]; var reactor = level.Reactors[reactorIndex];
if (!reactor.Ready)
if (!cell.IsWalkable) return Refuse(level, "REACTOR NOT READY");
continue;
var reactors = level.Reactors.ToArray();
foreach (var effect in m_Effects) reactors[reactorIndex] = reactor with { Activated = true };
cell = effect.Apply(cell); return level with {
Reactors = reactors,
cells[index] = cell with { Hazards = cell.Hazards.Clamp() }; Global = level.Global with { LevelState = ELevelState.Won, Status = "REACTOR ONLINE" }
} };
} }
foreach (var areaEffect in m_AreaEffects) public LevelState EndTurn(LevelState level)
cells = areaEffect.Apply(level, cells); {
return ResolveTurn(level with { Global = level.Global with { ActionsRemaining = 0 } });
var global = UpdateGlobal(level, cells); }
var next = level with {
Cells = cells, public LevelState AdvanceTurn(LevelState level)
Global = global with { Turn = level.Global.Turn + Balancing.Current.TurnIncrement } {
}; return ResolveTurn(level);
}
return updateForecasts ? next with { Forecasts = Forecast(next) } : next;
} public IReadOnlyList<Forecast> Forecast(LevelState level)
{
private void AddHazardForecasts(List<Forecast> forecasts, HashSet<ForecastKey> seen, LevelState level, int turns) var forecasts = new List<Forecast>();
{ var simulated = CopyForForecast(level);
foreach (var hazard in m_Hazards)
{ for (var turn = 0; turn <= Balancing.Current.ForecastHorizon; turn++)
foreach (var forecast in hazard.Predict(level, turns)) {
AddForecast(forecasts, seen, forecast); AddForecasts(forecasts, simulated, turn);
} if (simulated.Global.LevelState is ELevelState.Lost or ELevelState.Ready or ELevelState.Won)
} break;
private static void AddReactorReadyForecast(List<Forecast> forecasts, HashSet<ForecastKey> seen, LevelState level, int turns) if (turn < Balancing.Current.ForecastHorizon)
{ simulated = ResolveTurn(simulated, false);
if (IsReactorReady(level)) }
AddForecast(forecasts, seen, new(EFailureKind.ReactorReady, null, turns, "REACTOR READY"));
} return forecasts.DistinctBy(forecast => (forecast.Kind, forecast.Position, forecast.Message)).OrderBy(forecast => forecast.Turns).ThenBy(forecast => forecast.Message).ToArray();
}
private static void AddForecast(List<Forecast> forecasts, HashSet<ForecastKey> seen, Forecast forecast)
{ private LevelState ResolveTurn(LevelState level, bool refreshForecasts = true)
if (seen.Add(new(forecast.Kind, forecast.Position))) {
forecasts.Add(forecast); var report = m_Validator.Validate(level);
} if (!report.IsValid)
return level with { Global = level.Global with { LevelState = ELevelState.Lost, Status = report.Errors[0].Message } };
private static GlobalState UpdateGlobal(LevelState level, CellState[] cells)
{ var next = ApplyRuleEvents(level, ERuleEventPhase.StartOfSimulation);
var reactorHeat = cells.Where(c => c.Prop == ECellProp.Reactor).Select(c => c.Hazards.Heat).DefaultIfEmpty(level.Global.CoreHeat).Max(); next = PropagateNetworks(next);
var poweredGenerators = cells.Count(c => c is { Prop: ECellProp.Generator, Powered: true, Hazards.Fire: false }); next = ResolveConsumers(next);
var poweredPumps = cells.Count(c => c is { Prop: ECellProp.CoolingPump, Powered: true, Hazards.Fire: false }); next = InjectLeaks(next);
var damagedCriticalCells = cells.Count(c => c.Prop is ECellProp.Reactor or ECellProp.Generator or ECellProp.CoolingPump && c.Hazards.Stability <= Balancing.Current.CriticalCellStabilityThreshold); next = ResolveSurfaceInteractions(next);
var stability = Rules.Clamp(level.Global.FacilityStability - damagedCriticalCells); next = ResolveRobotSafety(next);
var lost = reactorHeat >= Balancing.Current.MeltdownCoreHeatThreshold || stability <= Balancing.Current.StabilityCollapseThreshold; next = DeriveReactorAndLevelState(next);
var status = lost ? reactorHeat >= Balancing.Current.MeltdownCoreHeatThreshold ? "CORE MELTDOWN" : "FACILITY STABILITY COLLAPSE" : "STABILIZE SYSTEMS"; next = ApplyRuleEvents(next, ERuleEventPhase.EndOfTurn);
var global = level.Global with { next = AdvanceDurations(next);
CoreHeat = Rules.Clamp(reactorHeat - poweredPumps), next = next with {
Power = Rules.Clamp(poweredGenerators * Balancing.Current.GeneratorPowerOutput), Global = next.Global with {
Cooling = Rules.Clamp(poweredPumps * Balancing.Current.CoolingPumpOutput), Turn = next.Global.Turn + 1,
FacilityStability = stability, ActionsRemaining = Balancing.Current.ActionsPerTurn
Lost = lost, }
Status = status };
};
return refreshForecasts ? next with { Forecasts = Forecast(next) } : next;
return IsReactorReady(level with { Cells = cells, Global = global }) ? global with { Status = "REACTOR READY" } : global; }
}
private LevelState PropagateNetworks(LevelState level)
private static bool IsReactorReady(LevelState level) {
{ var fuel = ClearTransient(level.Fuel);
var hasReactor = level.Cells.Any(c => c.Prop == ECellProp.Reactor); var coolant = ClearTransient(level.Coolant);
var hasStablePower = level.Global.Power >= Balancing.Current.ReactorReadyPowerThreshold || level.Cells.Any(c => c is { Prop: ECellProp.Generator, Powered: true, Hazards.Fire: false }); var electricity = ClearTransient(level.Electricity);
var hasCooling = level.Global.Cooling >= Balancing.Current.ReactorReadyCoolingThreshold || level.Cells.Any(c => c is { Prop: ECellProp.CoolingPump, Powered: true, Hazards.Fire: false }); var next = level.WithRuntimeArrays(fuel, coolant, electricity, level.Surface.ToArray(), level.Props.ToArray());
var reactorStable = level.Global.CoreHeat < Balancing.Current.ReactorReadyCoreHeatThreshold;
return hasReactor && hasStablePower && hasCooling && reactorStable && !level.Global.Lost; foreach (var carrier in Enum.GetValues<ECarrierType>())
} next = PropagateCarrier(next, carrier);
private readonly IReadOnlyList<IAreaSimulationEffect> m_AreaEffects = areaEffects.ToArray(); return next;
private readonly IReadOnlyList<ISimulationEffect> m_Effects = effects.ToArray(); }
private readonly IReadOnlyList<Hazard> m_Hazards = hazards.ToArray();
} private static UndergroundCell[] ClearTransient(IReadOnlyList<UndergroundCell> 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<GridPosition, float>();
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<Forecast>()
};
}
private static void AddForecasts(List<Forecast> 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<GridPosition> 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();
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>

View File

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