Centralize simulation balancing values

This commit is contained in:
2026-05-08 21:38:03 +02:00
parent 8ec3c7847c
commit 8018ebbabb
13 changed files with 178 additions and 106 deletions

View File

@@ -4,7 +4,7 @@ C# WinUI 3 + Win2D level editor for the deterministic grid simulation described
## Projects ## Projects
- `src/ReactorMaintenance.Simulation`: UI-independent level model, editor operations, forecasts, simulation turns, and JSON serialization. - `src/ReactorMaintenance.Simulation`: UI-independent level model, editor operations, forecasts, simulation turns, JSON serialization, and `Balancing.cs` tuning values.
- `src/ReactorMaintenance.Win2D`: Win2D editor app for painting square grid cells, loading/saving levels, advancing simulation turns, and activating the reactor. - `src/ReactorMaintenance.Win2D`: Win2D editor app for painting square grid cells, loading/saving levels, advancing simulation turns, and activating the reactor.
- `tests/ReactorMaintenance.Simulation.Tests`: unit tests for deterministic simulation behavior. - `tests/ReactorMaintenance.Simulation.Tests`: unit tests for deterministic simulation behavior.

View File

@@ -0,0 +1,74 @@
namespace ReactorMaintenance.Simulation;
public static class Balancing
{
public static int MinHazardValue => 0;
public static int MaxHazardValue => 10;
public static int DefaultHazardStability => 10;
public static int DefaultCellIntegrity => 10;
public static int DefaultActionsPerTurn => 3;
public static int DefaultCoreHeat => 5;
public static int DefaultFacilityStability => 10;
public static int DefaultPower => 5;
public static int DefaultCooling => 0;
public static int FirstGridCoordinate => 0;
public static int NeighborDistance => 1;
public static int CurrentForecastTurn => 0;
public static int MinimumLevelSize => 4;
public static int DefaultLevelWidth => 16;
public static int DefaultLevelHeight => 12;
public static int DefaultRobotCoordinate => 1;
public static int DefaultPipeFlow => 4;
public static int DefaultPipePressure => 4;
public static int DefaultPressurePipeFlow => 5;
public static int DefaultPressurePipePressure => 6;
public static int DefaultEditedPipeIntegrity => 8;
public static int MinimumLeakRate => 1;
public static int DamagedPipeIntegrity => 4;
public static int RepairedLeakRate => 0;
public static int RepairedElectricalCharge => 0;
public static int HeatToolIncrease => 2;
public static int FireToolMinimumHeat => 7;
public static int FireToolMinimumSmoke => 3;
public static int MaxForecastStepCount => 12;
public static int TurnIncrement => 1;
public static int OverpressureThreshold => 7;
public static int HeatIntegrityDamageThreshold => 10;
public static int PipeFireIntegrityDamage => 1;
public static int FireStabilityDamage => 1;
public static int BurstLeakRate => 3;
public static int BrokenPipeFlow => 0;
public static int ElectrifiedCoolantPoolingThreshold => 3;
public static int ElectricalChargeIncrease => 2;
public static int FuelVaporFireThreshold => 4;
public static int LiquidFuelFireThreshold => 6;
public static int HeatIgnitionThreshold => 8;
public static int ElectricalIgnitionThreshold => 4;
public static int FireHeatIncrease => 2;
public static int FireSmokeIncrease => 2;
public static int FireLiquidFuelConsumption => 1;
public static int FireFuelVaporConsumption => 1;
public static int SmokeDecay => 1;
public static int PressurizedFuelLeakPressureThreshold => 7;
public static int PassiveFuelVaporHeatOffset => 3;
public static int PassiveFuelVaporDivisor => 3;
public static int MinimumCoolantHeatReduction => 1;
public static int CoolantHeatReductionDivisor => 2;
public static int CoolantSteamHeatThreshold => 7;
public static int CoolantSteamSmokeIncrease => 2;
public static int PressureLeakSmokeThreshold => 8;
public static int PressureLeakSmokeIncrease => 1;
public static int GeneratorHeatIncrease => 1;
public static int CoolingPumpHeatReduction => 2;
public static int ReactorHeatIncrease => 1;
public static int SmokeSpreadThreshold => 6;
public static int SmokeSpreadIncrease => 1;
public static int CriticalCellStabilityThreshold => 3;
public static int MeltdownCoreHeatThreshold => 10;
public static int StabilityCollapseThreshold => 0;
public static int GeneratorPowerOutput => 3;
public static int CoolingPumpOutput => 3;
public static int ReactorReadyPowerThreshold => 3;
public static int ReactorReadyCoolingThreshold => 3;
public static int ReactorReadyCoreHeatThreshold => 8;
}

View File

@@ -7,13 +7,13 @@ public sealed class CellIntegrityEffect : ISimulationEffect
var integrity = cell.Integrity; var integrity = cell.Integrity;
var hazards = cell.Hazards; var hazards = cell.Hazards;
if (cell is { HasPipe: true, Pressure: > 7 }) if (cell is { HasPipe: true } && cell.Pressure > Balancing.OverpressureThreshold)
integrity -= cell.Pressure - 7; integrity -= cell.Pressure - Balancing.OverpressureThreshold;
if (hazards.Heat >= 10 || hazards.Fire) if (hazards.Heat >= Balancing.HeatIntegrityDamageThreshold || hazards.Fire)
{ {
integrity -= cell.HasPipe ? 1 : 0; integrity -= cell.HasPipe ? Balancing.PipeFireIntegrityDamage : Balancing.MinHazardValue;
hazards = hazards with { Stability = hazards.Stability - 1 }; hazards = hazards with { Stability = hazards.Stability - Balancing.FireStabilityDamage };
} }
cell = cell with { cell = cell with {
@@ -21,12 +21,12 @@ public sealed class CellIntegrityEffect : ISimulationEffect
Hazards = hazards.Clamp() Hazards = hazards.Clamp()
}; };
if (integrity > 0 || !cell.HasPipe) if (integrity > Balancing.MinHazardValue || !cell.HasPipe)
return cell; return cell;
return cell with { return cell with {
LeakRate = Math.Max(cell.LeakRate, 3), LeakRate = Math.Max(cell.LeakRate, Balancing.BurstLeakRate),
Flow = 0, Flow = Balancing.BrokenPipeFlow,
PipeOpen = false PipeOpen = false
}; };
} }

View File

@@ -5,23 +5,23 @@ public sealed class FireAndElectricalHazardEffect : ISimulationEffect
public CellState Apply(CellState cell) public CellState Apply(CellState cell)
{ {
var hazards = cell.Hazards; var hazards = cell.Hazards;
if (hazards.CoolantPooling >= 3 && cell.Powered) if (hazards.CoolantPooling >= Balancing.ElectrifiedCoolantPoolingThreshold && cell.Powered)
hazards = hazards with { ElectricalCharge = hazards.ElectricalCharge + 2 }; hazards = hazards with { ElectricalCharge = hazards.ElectricalCharge + Balancing.ElectricalChargeIncrease };
var hasFuel = hazards.FuelVapor >= 4 || hazards.LiquidFuel >= 6; var hasFuel = hazards.FuelVapor >= Balancing.FuelVaporFireThreshold || hazards.LiquidFuel >= Balancing.LiquidFuelFireThreshold;
var hasIgnition = hazards.Heat >= 8 || hazards.ElectricalCharge >= 4 || cell is { Kind: ECellKind.Generator, Powered: true }; var hasIgnition = hazards.Heat >= Balancing.HeatIgnitionThreshold || hazards.ElectricalCharge >= Balancing.ElectricalIgnitionThreshold || cell is { Kind: ECellKind.Generator, Powered: true };
if ((hasFuel && hasIgnition) || hazards.Fire) if ((hasFuel && hasIgnition) || hazards.Fire)
{ {
hazards = hazards with { hazards = hazards with {
Fire = hasFuel || hazards.Fire, Fire = hasFuel || hazards.Fire,
Heat = hazards.Heat + 2, Heat = hazards.Heat + Balancing.FireHeatIncrease,
Smoke = hazards.Smoke + 2, Smoke = hazards.Smoke + Balancing.FireSmokeIncrease,
LiquidFuel = Math.Max(0, hazards.LiquidFuel - 1), LiquidFuel = Math.Max(Balancing.MinHazardValue, hazards.LiquidFuel - Balancing.FireLiquidFuelConsumption),
FuelVapor = Math.Max(0, hazards.FuelVapor - 1) FuelVapor = Math.Max(Balancing.MinHazardValue, hazards.FuelVapor - Balancing.FireFuelVaporConsumption)
}; };
} }
else if (hazards.Smoke > 0) else if (hazards.Smoke > Balancing.MinHazardValue)
hazards = hazards with { Smoke = hazards.Smoke - 1 }; hazards = hazards with { Smoke = hazards.Smoke - Balancing.SmokeDecay };
return cell with { Hazards = hazards.Clamp() }; return cell with { Hazards = hazards.Clamp() };
} }

View File

@@ -4,14 +4,14 @@ public sealed class IgnitionHazard : Hazard
{ {
public override IEnumerable<Forecast> Predict(LevelState level, int turns) public override IEnumerable<Forecast> Predict(LevelState level, int turns)
{ {
for (var y = 0; y < level.Height; y++) for (var y = Balancing.FirstGridCoordinate; y < level.Height; y++)
{ {
for (var x = 0; x < level.Width; x++) for (var x = Balancing.FirstGridCoordinate; x < level.Width; x++)
{ {
var position = new GridPosition(x, y); var position = new GridPosition(x, y);
var cell = level.GetCell(position); var cell = level.GetCell(position);
if (cell.Hazards.Fire) if (cell.Hazards.Fire)
yield return new(EFailureKind.Ignition, position, turns, turns == 1 ? $"FUEL IGNITION PREDICTED AT {x},{y} NEXT TURN" : $"FUEL IGNITION PREDICTED AT {x},{y} IN {turns} TURNS"); yield return new(EFailureKind.Ignition, position, turns, turns == Balancing.TurnIncrement ? $"FUEL IGNITION PREDICTED AT {x},{y} NEXT TURN" : $"FUEL IGNITION PREDICTED AT {x},{y} IN {turns} TURNS");
} }
} }
} }

View File

@@ -58,43 +58,43 @@ public static class LevelEditor
}, },
EEditorTool.CoolantPipe => cell with { EEditorTool.CoolantPipe => cell with {
Pipe = EPipeMedium.Coolant, Pipe = EPipeMedium.Coolant,
Flow = 4, Flow = Balancing.DefaultPipeFlow,
Pressure = 4, Pressure = Balancing.DefaultPipePressure,
Integrity = Math.Max(cell.Integrity, 8), Integrity = Math.Max(cell.Integrity, Balancing.DefaultEditedPipeIntegrity),
PipeOpen = true PipeOpen = true
}, },
EEditorTool.FuelPipe => cell with { EEditorTool.FuelPipe => cell with {
Pipe = EPipeMedium.Fuel, Pipe = EPipeMedium.Fuel,
Flow = 4, Flow = Balancing.DefaultPipeFlow,
Pressure = 4, Pressure = Balancing.DefaultPipePressure,
Integrity = Math.Max(cell.Integrity, 8), Integrity = Math.Max(cell.Integrity, Balancing.DefaultEditedPipeIntegrity),
PipeOpen = true PipeOpen = true
}, },
EEditorTool.PressurePipe => cell with { EEditorTool.PressurePipe => cell with {
Pipe = EPipeMedium.Pressure, Pipe = EPipeMedium.Pressure,
Flow = 5, Flow = Balancing.DefaultPressurePipeFlow,
Pressure = 6, Pressure = Balancing.DefaultPressurePipePressure,
Integrity = Math.Max(cell.Integrity, 8), Integrity = Math.Max(cell.Integrity, Balancing.DefaultEditedPipeIntegrity),
PipeOpen = true PipeOpen = true
}, },
EEditorTool.Leak => cell with { EEditorTool.Leak => cell with {
LeakRate = Math.Max(1, cell.LeakRate), LeakRate = Math.Max(Balancing.MinimumLeakRate, cell.LeakRate),
Integrity = Math.Min(cell.Integrity, 4) Integrity = Math.Min(cell.Integrity, Balancing.DamagedPipeIntegrity)
}, },
EEditorTool.Repair => cell with { EEditorTool.Repair => cell with {
LeakRate = 0, LeakRate = Balancing.RepairedLeakRate,
Integrity = 10, Integrity = Balancing.DefaultCellIntegrity,
Hazards = cell.Hazards with { Hazards = cell.Hazards with {
Fire = false, Fire = false,
ElectricalCharge = 0 ElectricalCharge = Balancing.RepairedElectricalCharge
} }
}, },
EEditorTool.Heat => cell with { Hazards = cell.Hazards with { Heat = Rules.Clamp(cell.Hazards.Heat + 2) } }, EEditorTool.Heat => cell with { Hazards = cell.Hazards with { Heat = Rules.Clamp(cell.Hazards.Heat + Balancing.HeatToolIncrease) } },
EEditorTool.Fire => cell with { EEditorTool.Fire => cell with {
Hazards = cell.Hazards with { Hazards = cell.Hazards with {
Fire = !cell.Hazards.Fire, Fire = !cell.Hazards.Fire,
Heat = Math.Max(cell.Hazards.Heat, 7), Heat = Math.Max(cell.Hazards.Heat, Balancing.FireToolMinimumHeat),
Smoke = Math.Max(cell.Hazards.Smoke, 3) Smoke = Math.Max(cell.Hazards.Smoke, Balancing.FireToolMinimumSmoke)
} }
}, },
_ => cell _ => cell

View File

@@ -5,9 +5,9 @@ public sealed class MachineEffect : ISimulationEffect
public CellState Apply(CellState cell) public CellState Apply(CellState cell)
{ {
var hazards = cell.Kind switch { var hazards = cell.Kind switch {
ECellKind.Generator when cell.Powered => cell.Hazards with { Heat = cell.Hazards.Heat + 1 }, ECellKind.Generator when cell.Powered => cell.Hazards with { Heat = cell.Hazards.Heat + Balancing.GeneratorHeatIncrease },
ECellKind.CoolingPump when cell.Powered => cell.Hazards with { Heat = cell.Hazards.Heat - 2 }, ECellKind.CoolingPump when cell.Powered => cell.Hazards with { Heat = cell.Hazards.Heat - Balancing.CoolingPumpHeatReduction },
ECellKind.Reactor => cell.Hazards with { Heat = cell.Hazards.Heat + 1 }, ECellKind.Reactor => cell.Hazards with { Heat = cell.Hazards.Heat + Balancing.ReactorHeatIncrease },
_ => cell.Hazards _ => cell.Hazards
}; };

View File

@@ -34,10 +34,10 @@ public sealed record GridPosition(int X, int Y)
{ {
public IEnumerable<GridPosition> Neighbors() public IEnumerable<GridPosition> Neighbors()
{ {
yield return new(X - 1, Y); yield return new(X - Balancing.NeighborDistance, Y);
yield return new(X + 1, Y); yield return new(X + Balancing.NeighborDistance, Y);
yield return new(X, Y - 1); yield return new(X, Y - Balancing.NeighborDistance);
yield return new(X, Y + 1); yield return new(X, Y + Balancing.NeighborDistance);
} }
} }
@@ -62,7 +62,7 @@ public sealed record HazardState
public int LiquidFuel { get; init; } public int LiquidFuel { get; init; }
public int CoolantPooling { get; init; } public int CoolantPooling { get; init; }
public int ElectricalCharge { get; init; } public int ElectricalCharge { get; init; }
public int Stability { get; init; } = 10; public int Stability { get; init; } = Balancing.DefaultHazardStability;
public bool Fire { get; init; } public bool Fire { get; init; }
} }
@@ -72,7 +72,7 @@ public sealed record CellState
public EPipeMedium Pipe { get; init; } public EPipeMedium Pipe { get; init; }
public int Flow { get; init; } public int Flow { get; init; }
public int Pressure { get; init; } public int Pressure { get; init; }
public int Integrity { get; init; } = 10; public int Integrity { get; init; } = Balancing.DefaultCellIntegrity;
public int LeakRate { get; init; } public int LeakRate { get; init; }
public bool PipeOpen { get; init; } = true; public bool PipeOpen { get; init; } = true;
public bool Powered { get; init; } public bool Powered { get; init; }
@@ -85,11 +85,11 @@ public sealed record CellState
public sealed record GlobalState public sealed record GlobalState
{ {
public int Turn { get; init; } public int Turn { get; init; }
public int ActionsPerTurn { get; init; } = 3; public int ActionsPerTurn { get; init; } = Balancing.DefaultActionsPerTurn;
public int CoreHeat { get; init; } = 5; public int CoreHeat { get; init; } = Balancing.DefaultCoreHeat;
public int FacilityStability { get; init; } = 10; public int FacilityStability { get; init; } = Balancing.DefaultFacilityStability;
public int Power { get; init; } = 5; public int Power { get; init; } = Balancing.DefaultPower;
public int Cooling { get; init; } = 0; public int Cooling { get; init; } = Balancing.DefaultCooling;
public bool ReactorActivated { get; init; } public bool ReactorActivated { get; init; }
public bool Lost { get; init; } public bool Lost { get; init; }
public string Status { get; init; } = "STABILIZE SYSTEMS"; public string Status { get; init; } = "STABILIZE SYSTEMS";
@@ -101,15 +101,15 @@ public sealed record LevelState
{ {
public static LevelState Create(string name, int width, int height) public static LevelState Create(string name, int width, int height)
{ {
if (width < 4 || height < 4) if (width < Balancing.MinimumLevelSize || height < Balancing.MinimumLevelSize)
throw new ArgumentOutOfRangeException(nameof(width), "Levels must be at least 4x4."); throw new ArgumentOutOfRangeException(nameof(width), $"Levels must be at least {Balancing.MinimumLevelSize}x{Balancing.MinimumLevelSize}.");
var cells = CreateCells(width, height); var cells = CreateCells(width, height);
for (var y = 0; y < height; y++) for (var y = Balancing.FirstGridCoordinate; y < height; y++)
{ {
for (var x = 0; x < width; x++) for (var x = Balancing.FirstGridCoordinate; x < width; x++)
{ {
if (x == 0 || y == 0 || x == width - 1 || y == height - 1) if (x == Balancing.FirstGridCoordinate || y == Balancing.FirstGridCoordinate || x == width - Balancing.NeighborDistance || y == height - Balancing.NeighborDistance)
cells[y * width + x] = cells[y * width + x] with { Kind = ECellKind.Wall }; cells[y * width + x] = cells[y * width + x] with { Kind = ECellKind.Wall };
} }
} }
@@ -119,7 +119,7 @@ public sealed record LevelState
Width = width, Width = width,
Height = height, Height = height,
Cells = cells, Cells = cells,
Robot = new(1, 1) Robot = new(Balancing.DefaultRobotCoordinate, Balancing.DefaultRobotCoordinate)
}; };
} }
@@ -139,7 +139,7 @@ public sealed record LevelState
public bool InBounds(GridPosition position) public bool InBounds(GridPosition position)
{ {
return position.X >= 0 && position.Y >= 0 && position.X < Width && position.Y < Height; return position.X >= Balancing.FirstGridCoordinate && position.Y >= Balancing.FirstGridCoordinate && position.X < Width && position.Y < Height;
} }
public int Index(GridPosition position) public int Index(GridPosition position)
@@ -155,14 +155,14 @@ public sealed record LevelState
private static CellState[] CreateCells(int width, int height) private static CellState[] CreateCells(int width, int height)
{ {
return Enumerable.Range(0, width * height).Select(_ => new CellState()).ToArray(); return Enumerable.Range(Balancing.FirstGridCoordinate, width * height).Select(_ => new CellState()).ToArray();
} }
public string Name { get; init; } = "New Reactor"; public string Name { get; init; } = "New Reactor";
public int Width { get; init; } = 16; public int Width { get; init; } = Balancing.DefaultLevelWidth;
public int Height { get; init; } = 12; public int Height { get; init; } = Balancing.DefaultLevelHeight;
public CellState[] Cells { get; init; } = CreateCells(16, 12); public CellState[] Cells { get; init; } = CreateCells(Balancing.DefaultLevelWidth, Balancing.DefaultLevelHeight);
public GridPosition Robot { get; init; } = new(1, 1); public GridPosition Robot { get; init; } = new(Balancing.DefaultRobotCoordinate, Balancing.DefaultRobotCoordinate);
public GlobalState Global { get; init; } = new(); public GlobalState Global { get; init; } = new();
public IReadOnlyList<Forecast> Forecasts { get; init; } = Array.Empty<Forecast>(); public IReadOnlyList<Forecast> Forecasts { get; init; } = Array.Empty<Forecast>();
} }
@@ -171,6 +171,6 @@ internal static class Rules
{ {
public static int Clamp(int value) public static int Clamp(int value)
{ {
return Math.Clamp(value, 0, 10); return Math.Clamp(value, Balancing.MinHazardValue, Balancing.MaxHazardValue);
} }
} }

View File

@@ -4,13 +4,13 @@ public sealed class PipeBurstHazard : Hazard
{ {
public override IEnumerable<Forecast> Predict(LevelState level, int turns) public override IEnumerable<Forecast> Predict(LevelState level, int turns)
{ {
for (var y = 0; y < level.Height; y++) for (var y = Balancing.FirstGridCoordinate; y < level.Height; y++)
{ {
for (var x = 0; x < level.Width; x++) for (var x = Balancing.FirstGridCoordinate; x < level.Width; x++)
{ {
var position = new GridPosition(x, y); var position = new GridPosition(x, y);
var cell = level.GetCell(position); var cell = level.GetCell(position);
if (cell is { HasPipe: true, PipeOpen: false, Flow: 0, LeakRate: >= 3 }) if (cell is { HasPipe: true, PipeOpen: false } && cell.Flow == Balancing.BrokenPipeFlow && cell.LeakRate >= Balancing.BurstLeakRate)
yield return new(EFailureKind.PipeBurst, position, turns, $"PIPE BURST PREDICTED AT {x},{y} IN {turns} TURNS"); yield return new(EFailureKind.PipeBurst, position, turns, $"PIPE BURST PREDICTED AT {x},{y} IN {turns} TURNS");
} }
} }

View File

@@ -4,20 +4,20 @@ public sealed class PipeLeakEffect : ISimulationEffect
{ {
public CellState Apply(CellState cell) public CellState Apply(CellState cell)
{ {
if (!cell.HasPipe || cell.LeakRate <= 0) if (!cell.HasPipe || cell.LeakRate <= Balancing.MinHazardValue)
return cell; return cell;
var hazards = cell.Pipe switch { var hazards = cell.Pipe switch {
EPipeMedium.Fuel => cell.Hazards with { EPipeMedium.Fuel => cell.Hazards with {
LiquidFuel = cell.Hazards.LiquidFuel + cell.LeakRate, LiquidFuel = cell.Hazards.LiquidFuel + cell.LeakRate,
FuelVapor = cell.Hazards.FuelVapor + (cell.Pressure >= 7 ? cell.LeakRate : Math.Max(0, cell.Hazards.Heat - 3) / 3) FuelVapor = cell.Hazards.FuelVapor + (cell.Pressure >= Balancing.PressurizedFuelLeakPressureThreshold ? cell.LeakRate : Math.Max(Balancing.MinHazardValue, cell.Hazards.Heat - Balancing.PassiveFuelVaporHeatOffset) / Balancing.PassiveFuelVaporDivisor)
}, },
EPipeMedium.Coolant => cell.Hazards with { EPipeMedium.Coolant => cell.Hazards with {
CoolantPooling = cell.Hazards.CoolantPooling + cell.LeakRate, CoolantPooling = cell.Hazards.CoolantPooling + cell.LeakRate,
Heat = cell.Hazards.Heat - Math.Max(1, cell.LeakRate / 2), Heat = cell.Hazards.Heat - Math.Max(Balancing.MinimumCoolantHeatReduction, cell.LeakRate / Balancing.CoolantHeatReductionDivisor),
Smoke = cell.Hazards.Smoke + (cell.Hazards.Heat >= 7 ? 2 : 0) Smoke = cell.Hazards.Smoke + (cell.Hazards.Heat >= Balancing.CoolantSteamHeatThreshold ? Balancing.CoolantSteamSmokeIncrease : Balancing.MinHazardValue)
}, },
EPipeMedium.Pressure => cell.Hazards with { Smoke = cell.Hazards.Smoke + (cell.Pressure >= 8 ? 1 : 0) }, EPipeMedium.Pressure => cell.Hazards with { Smoke = cell.Hazards.Smoke + (cell.Pressure >= Balancing.PressureLeakSmokeThreshold ? Balancing.PressureLeakSmokeIncrease : Balancing.MinHazardValue) },
_ => cell.Hazards _ => cell.Hazards
}; };

View File

@@ -23,14 +23,14 @@ public sealed class SimulationEngine(IEnumerable<ISimulationEffect> effects, IEn
var seen = new HashSet<ForecastKey>(); var seen = new HashSet<ForecastKey>();
var forecastLevel = level with { Cells = level.Cells.ToArray(), Forecasts = Array.Empty<Forecast>() }; var forecastLevel = level with { Cells = level.Cells.ToArray(), Forecasts = Array.Empty<Forecast>() };
if (forecastLevel.Global.Lost) if (forecastLevel.Global.Lost)
AddHazardForecasts(forecasts, seen, forecastLevel, 0); AddHazardForecasts(forecasts, seen, forecastLevel, Balancing.CurrentForecastTurn);
AddReactorReadyForecast(forecasts, seen, forecastLevel, 0); AddReactorReadyForecast(forecasts, seen, forecastLevel, Balancing.CurrentForecastTurn);
if (IsReactorReady(forecastLevel) || forecastLevel.Global.Lost || forecastLevel.Global.ReactorActivated) if (IsReactorReady(forecastLevel) || forecastLevel.Global.Lost || forecastLevel.Global.ReactorActivated)
return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray(); return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray();
for (var step = 1; step <= c_MaxForecastStepCount; step++) for (var step = Balancing.TurnIncrement; step <= Balancing.MaxForecastStepCount; step++)
{ {
forecastLevel = AdvanceTurn(forecastLevel, false); forecastLevel = AdvanceTurn(forecastLevel, false);
AddHazardForecasts(forecasts, seen, forecastLevel, step); AddHazardForecasts(forecasts, seen, forecastLevel, step);
@@ -60,9 +60,9 @@ public sealed class SimulationEngine(IEnumerable<ISimulationEffect> effects, IEn
{ {
var cells = level.Cells.ToArray(); var cells = level.Cells.ToArray();
for (var y = 0; y < level.Height; y++) for (var y = Balancing.FirstGridCoordinate; y < level.Height; y++)
{ {
for (var x = 0; x < level.Width; x++) for (var x = Balancing.FirstGridCoordinate; x < level.Width; x++)
{ {
var position = new GridPosition(x, y); var position = new GridPosition(x, y);
var index = level.Index(position); var index = level.Index(position);
@@ -84,7 +84,7 @@ public sealed class SimulationEngine(IEnumerable<ISimulationEffect> effects, IEn
var global = UpdateGlobal(level, cells); var global = UpdateGlobal(level, cells);
var next = level with { var next = level with {
Cells = cells, Cells = cells,
Global = global with { Turn = level.Global.Turn + 1 } Global = global with { Turn = level.Global.Turn + Balancing.TurnIncrement }
}; };
return updateForecasts ? next with { Forecasts = Forecast(next) } : next; return updateForecasts ? next with { Forecasts = Forecast(next) } : next;
@@ -116,14 +116,14 @@ public sealed class SimulationEngine(IEnumerable<ISimulationEffect> effects, IEn
var reactorHeat = cells.Where(c => c.Kind == ECellKind.Reactor).Select(c => c.Hazards.Heat).DefaultIfEmpty(level.Global.CoreHeat).Max(); var reactorHeat = cells.Where(c => c.Kind == ECellKind.Reactor).Select(c => c.Hazards.Heat).DefaultIfEmpty(level.Global.CoreHeat).Max();
var poweredGenerators = cells.Count(c => c is { Kind: ECellKind.Generator, Powered: true, Hazards.Fire: false }); var poweredGenerators = cells.Count(c => c is { Kind: ECellKind.Generator, Powered: true, Hazards.Fire: false });
var poweredPumps = cells.Count(c => c is { Kind: ECellKind.CoolingPump, Powered: true, Hazards.Fire: false }); var poweredPumps = cells.Count(c => c is { Kind: ECellKind.CoolingPump, Powered: true, Hazards.Fire: false });
var damagedCriticalCells = cells.Count(c => c.Kind is ECellKind.Reactor or ECellKind.Generator or ECellKind.CoolingPump && c.Hazards.Stability <= 3); var damagedCriticalCells = cells.Count(c => c.Kind is ECellKind.Reactor or ECellKind.Generator or ECellKind.CoolingPump && c.Hazards.Stability <= Balancing.CriticalCellStabilityThreshold);
var stability = Rules.Clamp(level.Global.FacilityStability - damagedCriticalCells); var stability = Rules.Clamp(level.Global.FacilityStability - damagedCriticalCells);
var lost = reactorHeat >= 10 || stability <= 0; var lost = reactorHeat >= Balancing.MeltdownCoreHeatThreshold || stability <= Balancing.StabilityCollapseThreshold;
var status = lost ? reactorHeat >= 10 ? "CORE MELTDOWN" : "FACILITY STABILITY COLLAPSE" : "STABILIZE SYSTEMS"; var status = lost ? reactorHeat >= Balancing.MeltdownCoreHeatThreshold ? "CORE MELTDOWN" : "FACILITY STABILITY COLLAPSE" : "STABILIZE SYSTEMS";
var global = level.Global with { var global = level.Global with {
CoreHeat = Rules.Clamp(reactorHeat - poweredPumps), CoreHeat = Rules.Clamp(reactorHeat - poweredPumps),
Power = Rules.Clamp(poweredGenerators * 3), Power = Rules.Clamp(poweredGenerators * Balancing.GeneratorPowerOutput),
Cooling = Rules.Clamp(poweredPumps * 3), Cooling = Rules.Clamp(poweredPumps * Balancing.CoolingPumpOutput),
FacilityStability = stability, FacilityStability = stability,
Lost = lost, Lost = lost,
Status = status Status = status
@@ -135,15 +135,13 @@ public sealed class SimulationEngine(IEnumerable<ISimulationEffect> effects, IEn
private static bool IsReactorReady(LevelState level) private static bool IsReactorReady(LevelState level)
{ {
var hasReactor = level.Cells.Any(c => c.Kind == ECellKind.Reactor); var hasReactor = level.Cells.Any(c => c.Kind == ECellKind.Reactor);
var hasStablePower = level.Global.Power >= 3 || level.Cells.Any(c => c is { Kind: ECellKind.Generator, Powered: true, Hazards.Fire: false }); var hasStablePower = level.Global.Power >= Balancing.ReactorReadyPowerThreshold || level.Cells.Any(c => c is { Kind: ECellKind.Generator, Powered: true, Hazards.Fire: false });
var hasCooling = level.Global.Cooling >= 3 || level.Cells.Any(c => c is { Kind: ECellKind.CoolingPump, Powered: true, Hazards.Fire: false }); var hasCooling = level.Global.Cooling >= Balancing.ReactorReadyCoolingThreshold || level.Cells.Any(c => c is { Kind: ECellKind.CoolingPump, Powered: true, Hazards.Fire: false });
var reactorStable = level.Global.CoreHeat < 8; var reactorStable = level.Global.CoreHeat < Balancing.ReactorReadyCoreHeatThreshold;
return hasReactor && hasStablePower && hasCooling && reactorStable && !level.Global.Lost; return hasReactor && hasStablePower && hasCooling && reactorStable && !level.Global.Lost;
} }
private const int c_MaxForecastStepCount = 12;
private readonly IReadOnlyList<IAreaSimulationEffect> m_AreaEffects = areaEffects.ToArray(); private readonly IReadOnlyList<IAreaSimulationEffect> m_AreaEffects = areaEffects.ToArray();
private readonly IReadOnlyList<ISimulationEffect> m_Effects = effects.ToArray(); private readonly IReadOnlyList<ISimulationEffect> m_Effects = effects.ToArray();
private readonly IReadOnlyList<Hazard> m_Hazards = hazards.ToArray(); private readonly IReadOnlyList<Hazard> m_Hazards = hazards.ToArray();
} }

View File

@@ -5,13 +5,13 @@ public sealed class SmokeSpreadEffect : IAreaSimulationEffect
public CellState[] Apply(LevelState level, CellState[] cells) public CellState[] Apply(LevelState level, CellState[] cells)
{ {
var next = cells.ToArray(); var next = cells.ToArray();
for (var y = 0; y < level.Height; y++) for (var y = Balancing.FirstGridCoordinate; y < level.Height; y++)
{ {
for (var x = 0; x < level.Width; x++) for (var x = Balancing.FirstGridCoordinate; x < level.Width; x++)
{ {
var position = new GridPosition(x, y); var position = new GridPosition(x, y);
var cell = cells[level.Index(position)]; var cell = cells[level.Index(position)];
if (cell.Hazards.Smoke < 6) if (cell.Hazards.Smoke < Balancing.SmokeSpreadThreshold)
continue; continue;
SpreadToNeighbors(level, next, position); SpreadToNeighbors(level, next, position);
@@ -29,7 +29,7 @@ public sealed class SmokeSpreadEffect : IAreaSimulationEffect
if (!neighborCell.IsWalkable || neighborCell.DoorLocked) if (!neighborCell.IsWalkable || neighborCell.DoorLocked)
continue; continue;
next[level.Index(neighbor)] = neighborCell with { Hazards = neighborCell.Hazards with { Smoke = Rules.Clamp(neighborCell.Hazards.Smoke + 1) } }; next[level.Index(neighbor)] = neighborCell with { Hazards = neighborCell.Hazards with { Smoke = Rules.Clamp(neighborCell.Hazards.Smoke + Balancing.SmokeSpreadIncrease) } };
} }
} }
} }

View File

@@ -9,9 +9,9 @@ public sealed class SimulationEngineTests
.SetCell(new(2, 2), new() { .SetCell(new(2, 2), new() {
Kind = ECellKind.Generator, Kind = ECellKind.Generator,
Pipe = EPipeMedium.Fuel, Pipe = EPipeMedium.Fuel,
LeakRate = 4, LeakRate = Balancing.FuelVaporFireThreshold,
Pressure = 8, Pressure = Balancing.PressurizedFuelLeakPressureThreshold + Balancing.NeighborDistance,
Integrity = 8, Integrity = Balancing.DefaultEditedPipeIntegrity,
Powered = true Powered = true
}); });
@@ -26,13 +26,13 @@ public sealed class SimulationEngineTests
var level = LevelState.Create("Wet cable", 6, 6) var level = LevelState.Create("Wet cable", 6, 6)
.SetCell(new(3, 3), new() { .SetCell(new(3, 3), new() {
Pipe = EPipeMedium.Coolant, Pipe = EPipeMedium.Coolant,
LeakRate = 3, LeakRate = Balancing.ElectrifiedCoolantPoolingThreshold,
Powered = true Powered = true
}); });
var next = m_Engine.AdvanceTurn(level); var next = m_Engine.AdvanceTurn(level);
Assert.True(next.GetCell(new(3, 3)).Hazards.ElectricalCharge >= 2); Assert.True(next.GetCell(new(3, 3)).Hazards.ElectricalCharge >= Balancing.ElectricalChargeIncrease);
} }
[Fact] [Fact]
@@ -42,7 +42,7 @@ public sealed class SimulationEngineTests
.SetCell(new(2, 2), new() { .SetCell(new(2, 2), new() {
Hazards = new() { Hazards = new() {
Fire = true, Fire = true,
Smoke = 6 Smoke = Balancing.SmokeSpreadThreshold
} }
}); });
@@ -99,8 +99,8 @@ public sealed class SimulationEngineTests
var forecasts = engine.Forecast(level); var forecasts = engine.Forecast(level);
Assert.Equal(12, forecasts.Count); Assert.Equal(Balancing.MaxForecastStepCount, forecasts.Count);
Assert.Equal(12, forecasts.Max(forecast => forecast.Turns)); Assert.Equal(Balancing.MaxForecastStepCount, forecasts.Max(forecast => forecast.Turns));
} }
[Fact] [Fact]
@@ -109,7 +109,7 @@ public sealed class SimulationEngineTests
var level = LevelState.Create("Meltdown", 6, 6) var level = LevelState.Create("Meltdown", 6, 6)
.SetCell(new(2, 2), new() { .SetCell(new(2, 2), new() {
Kind = ECellKind.Reactor, Kind = ECellKind.Reactor,
Hazards = new() { Heat = 9 } Hazards = new() { Heat = Balancing.MeltdownCoreHeatThreshold - Balancing.ReactorHeatIncrease }
}); });
var forecasts = m_Engine.Forecast(level); var forecasts = m_Engine.Forecast(level);
@@ -122,7 +122,7 @@ public sealed class SimulationEngineTests
{ {
var level = LevelState.Create("Lost", 6, 6) with { var level = LevelState.Create("Lost", 6, 6) with {
Global = new() { Global = new() {
CoreHeat = 10, CoreHeat = Balancing.MeltdownCoreHeatThreshold,
Lost = true, Lost = true,
Status = "CORE MELTDOWN" Status = "CORE MELTDOWN"
} }
@@ -139,9 +139,9 @@ public sealed class SimulationEngineTests
var level = LevelState.Create("Collapse", 6, 6) var level = LevelState.Create("Collapse", 6, 6)
.SetCell(new(2, 2), new() { .SetCell(new(2, 2), new() {
Kind = ECellKind.Generator, Kind = ECellKind.Generator,
Hazards = new() { Stability = 3 } Hazards = new() { Stability = Balancing.CriticalCellStabilityThreshold }
}) with { }) with {
Global = new() { FacilityStability = 1 } Global = new() { FacilityStability = Balancing.FireStabilityDamage }
}; };
var forecasts = m_Engine.Forecast(level); var forecasts = m_Engine.Forecast(level);