Compare commits

4 Commits

Author SHA1 Message Date
b232c0319f Track rewrite tooling setup 2026-05-10 19:14:28 +02:00
30963a9bde Rework Win2D editor for design model 2026-05-10 18:59:00 +02:00
851f6d27e8 Rewrite simulation core for design model 2026-05-10 18:41:17 +02:00
ca41e009bd Add rewrite task tracker 2026-05-10 18:29:53 +02:00
29 changed files with 2853 additions and 1965 deletions

View File

@@ -1,18 +1,20 @@
# Reactor Maintenance # Reactor Maintenance
C# WinUI 3 + Win2D level editor for the deterministic grid simulation described in `design.md`. C# WinUI 3 + Win2D level editor for the deterministic grid simulation described in `docs/design.md`.
## Projects ## Projects
- `src/ReactorMaintenance.Simulation`: UI-independent level model, editor operations, forecasts, simulation turns, versioned JSON serialization, and swappable difficulty balancing profiles. - `src/ReactorMaintenance.Simulation`: UI-independent level model, editor operations, validation, forecasts, simulation turns, versioned JSON serialization, and deterministic balancing defaults.
- `src/ReactorMaintenance.Win2D`: Win2D editor app for painting floor/wall terrain plus cell props, loading/saving levels, advancing simulation turns, and activating the reactor. - `src/ReactorMaintenance.Win2D`: Win2D editor app for authoring terrain, underground fuel/coolant/electricity networks, props, leaks, doors, surface hazards, robot start, loading/saving levels, ending turns, interacting with props, and activating a ready reactor.
- `tests/ReactorMaintenance.Simulation.Tests`: unit tests for deterministic simulation behavior. - `tests/ReactorMaintenance.Simulation.Tests`: unit tests for deterministic simulation behavior.
## Commands ## Commands
```powershell ```powershell
dotnet test tests\ReactorMaintenance.Simulation.Tests\ReactorMaintenance.Simulation.Tests.csproj dotnet test tests\ReactorMaintenance.Simulation.Tests\ReactorMaintenance.Simulation.Tests.csproj
dotnet build src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64 dotnet build src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64 -p:EnableWindowsTargeting=true
dotnet run --project src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64 dotnet run --project src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64
``` ```
The WinUI/XAML compiler is Windows-specific. On Linux, the simulation tests run normally, but the Win2D app build must be verified in a Windows-capable environment.

49
TASKS.md Normal file
View File

@@ -0,0 +1,49 @@
# Reactor Maintenance Rewrite Tasks
## Current State
- Branch: `design-rewrite`
- Scope approved: implement `docs/design.md` end-to-end with deterministic defaults and no backward compatibility.
- Simulation core has been replaced with the first design-native model and deterministic engine slice.
- Simulation and test projects now target `net10.0` because this Linux environment only has the .NET 10 runtime.
- Win2D editor has been rewritten against the new design model.
- Win2D project now targets `net10.0-windows10.0.19041.0` to match the simulation project.
- Linux can restore and compile the referenced simulation project, but full WinUI/XAML compilation still requires a Windows-capable XAML compiler environment.
## Completed Work
- Read project instructions, Linux instructions, code style, and `docs/design.md`.
- Confirmed deterministic balance defaults should be chosen during implementation.
- Confirmed a full Win2D editor is required.
- Created branch `design-rewrite`.
- Added `TASKS.md` as the required per-commit work tracker.
- Removed the legacy integer hazard/effect/hazard plug-in simulation surface.
- Added design-native terrain, underground carrier layers, surface hazards, props, leaks, doors, reactor bindings, robot inventory, rule events, validation, serialization, and forecasts.
- Added deterministic default balancing values.
- Added a first deterministic simulation pipeline for network propagation, consumers, leaks, surface interactions, robot safety, reactor readiness, rule events, and forecasts.
- Replaced old tests with design-based simulation tests.
- Verified `dotnet test tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj` passes: 11 passed.
- Attempted `dotnet jb cleanupcode --build=False ...`; unavailable in this environment because `dotnet-jb` is not installed.
- Reviewed the first slice and fixed an action-resolution maintainability issue before commit.
- Verified `git diff --check` reports no whitespace errors.
- Ran `dotnet jb cleanupcode --build=False ...` successfully after ReSharper install and normalized line endings back to LF.
- Reworked the Win2D editor for the new model: full tool list, layer-aware painting, terrain, underground carriers, surface hazards, props, doors, leaks, robot, forecasts, save validation, starter level, and simple play actions.
- Removed old editor dependencies on legacy props, pressure pipes, smoke, fire, and global power/cooling/core-stability fields.
- Verified `dotnet test tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj` passes after the editor rewrite: 11 passed.
- Attempted Win2D build on Linux with `dotnet build src/ReactorMaintenance.Win2D/ReactorMaintenance.Win2D.csproj -p:EnableWindowsTargeting=true -p:Platform=x64`; it fails at Windows `XamlCompiler.exe` with exec format error.
- Attempted managed XAML compiler path with `-p:UseXamlCompilerExecutable=false`; it fails loading the WinUI XAML compiler task dependency under this Linux/.NET 10 setup.
- Updated `README.md` for the new design-model editor, .NET 10 target, and Linux/Windows build expectations.
## Current Work
- Commit the Win2D editor rewrite slice.
## Future Work
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. Add advanced editor workflows for explicit reactor binding selection, explicit door edge selection, electricity wall leak face selection, and rule event authoring.
3. Verify and polish the Win2D app on Windows where the XAML compiler can run.
4. Update README and any affected docs to reflect the new schema, .NET target, editor controls, and deterministic defaults.
5. Build the Win2D project on a Windows-capable environment after the editor rewrite.
6. Add broader tests for junction ratios, ambiguous junctions, all rule event families, serialization edge cases, and editor operations.
7. Run cleanup when `dotnet-jb` is available, tests, code review, and iterate until the implementation is clean and maintainable.

13
dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2026.1.1",
"commands": [
"jb"
],
"rollForward": false
}
}
}

View File

@@ -1,4 +1,4 @@
using ReactorMaintenance.Simulation.Difficulties; using ReactorMaintenance.Simulation.Difficulties;
namespace ReactorMaintenance.Simulation; namespace ReactorMaintenance.Simulation;
@@ -6,73 +6,67 @@ 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 MinHazardValue => 0;
public override int MaxHazardValue => 10;
public override int DefaultHazardStability => 10;
public override int DefaultCellIntegrity => 10;
public override int DefaultActionsPerTurn => 3;
public override int DefaultCoreHeat => 5;
public override int DefaultFacilityStability => 10;
public override int DefaultPower => 5;
public override int DefaultCooling => 0;
public override int FirstGridCoordinate => 0;
public override int NeighborDistance => 1;
public override int CurrentForecastTurn => 0;
public override int MinimumLevelSize => 4;
public override int DefaultLevelWidth => 16; public override int DefaultLevelWidth => 16;
public override int DefaultLevelHeight => 12; public override int DefaultLevelHeight => 12;
public override int DefaultRobotCoordinate => 1; public override int MinimumLevelSize => 4;
public override int DefaultPipeFlow => 4; public override int ActionsPerTurn => 3;
public override int DefaultPipePressure => 4; public override int ForecastHorizon => 6;
public override int DefaultPressurePipeFlow => 5; public override float MinValue => 0;
public override int DefaultPressurePipePressure => 6; public override float MaxValue => 10;
public override int DefaultEditedPipeIntegrity => 8; public override float FuelSafe => 1.5f;
public override int MinimumLeakRate => 1; public override float FuelCaution => 3.5f;
public override int DamagedPipeIntegrity => 4; public override float FuelCritical => 6.5f;
public override int RepairedLeakRate => 0; public override float CoolantSafe => 1.5f;
public override int RepairedElectricalCharge => 0; public override float CoolantCaution => 3.5f;
public override int HeatToolIncrease => 2; public override float CoolantCritical => 6.5f;
public override int FireToolMinimumHeat => 7; public override float ElectricitySafe => 1.5f;
public override int FireToolMinimumSmoke => 3; public override float ElectricityCaution => 3.5f;
public override int MaxForecastStepCount => 12; public override float ElectricityCritical => 6.5f;
public override int TurnIncrement => 1; public override float HeatSafe => 2;
public override int OverpressureThreshold => 7; public override float HeatCaution => 5;
public override int HeatIntegrityDamageThreshold => 10; public override float HeatCritical => 8;
public override int PipeFireIntegrityDamage => 1; public override float TerminalHeat => 10;
public override int FireStabilityDamage => 1; public override float RobotFuelSafetyThreshold => 6.5f;
public override int BurstLeakRate => 3; public override float RobotCoolantSafetyThreshold => 8;
public override int BrokenPipeFlow => 0; public override float RobotElectricitySafetyThreshold => 6.5f;
public override int ElectrifiedCoolantPoolingThreshold => 3; public override float RobotHeatSafetyThreshold => 8;
public override int ElectricalChargeIncrease => 2; public override float SourceAmount => 8;
public override int FuelVaporFireThreshold => 4; public override float SourceIntensity => 8;
public override int LiquidFuelFireThreshold => 6; public override float DistanceAmountFalloff => 0.5f;
public override int HeatIgnitionThreshold => 8; public override float DistanceIntensityFalloff => 0.4f;
public override int ElectricalIgnitionThreshold => 4; public override float ConsumerRequiredAmount => 2.5f;
public override int FireHeatIncrease => 2; public override float ConsumerRequiredIntensity => 2.5f;
public override int FireSmokeIncrease => 2; public override float LeakBaseAmount => 0.5f;
public override int FireLiquidFuelConsumption => 1; public override float LeakAmountScale => 0.15f;
public override int FireFuelVaporConsumption => 1; public override float LeakIntensityScale => 0.1f;
public override int SmokeDecay => 1; public override float FlowTransferRatio => 0.05f;
public override int PressurizedFuelLeakPressureThreshold => 7; public override float StrongFlowTransferRatio => 0.1f;
public override int PassiveFuelVaporHeatOffset => 3; public override float Warm1Amount => 0.5f;
public override int PassiveFuelVaporDivisor => 3; public override float Warm2Amount => 1.0f;
public override int MinimumCoolantHeatReduction => 1; public override float Quench1Amount => 0.6f;
public override int CoolantHeatReductionDivisor => 2; public override float Quench2Amount => 1.2f;
public override int CoolantSteamHeatThreshold => 7; public override float Short1Heat => 0.8f;
public override int CoolantSteamSmokeIncrease => 2; public override float Short1Discharge => 0.8f;
public override int PressureLeakSmokeThreshold => 8; public override float Short2Heat => 1.6f;
public override int PressureLeakSmokeIncrease => 1; public override float Short2Discharge => 1.5f;
public override int GeneratorHeatIncrease => 1; public override float Ignite1Heat => 1.2f;
public override int CoolingPumpHeatReduction => 2; public override float Ignite1FuelConsumption => 0.4f;
public override int ReactorHeatIncrease => 1; public override float Ignite2Heat => 2.4f;
public override int SmokeSpreadThreshold => 6; public override float Ignite2FuelConsumption => 0.8f;
public override int SmokeSpreadIncrease => 1; public override int RemedyBlockTurns => 2;
public override int CriticalCellStabilityThreshold => 3; public override int HeatShieldSteps => 3;
public override int MeltdownCoreHeatThreshold => 10; public override int InventoryCapacityPerRemedy => 3;
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,23 +1,35 @@
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,
Door,
AllSeeingEyeTerminal,
FuelRemedySupply,
CoolantRemedySupply,
ElectricityRemedySupply,
HeatRemedySupply,
ReactorControl,
FuelLeak,
CoolantLeak,
ElectricityLeak,
FuelHazard,
CoolantHazard,
ElectricityHazard,
Heat, Heat,
Fire,
Robot Robot
} }
@@ -28,92 +40,126 @@ public static class LevelEditor
if (!level.InBounds(position)) if (!level.InBounds(position))
return level; return level;
if (tool == EEditorTool.Robot) return tool switch {
return level.GetCell(position).IsWalkable ? level with { Robot = position } : level; EEditorTool.Cursor => level,
EEditorTool.Floor => level.SetTerrain(position, ECellTerrain.Floor),
var cell = level.GetCell(position); EEditorTool.Wall => level.SetTerrain(position, ECellTerrain.Wall),
cell = tool switch { EEditorTool.FuelUnderground => SetUnderground(level, position, ECarrierType.Fuel),
EEditorTool.Cursor => cell, EEditorTool.CoolantUnderground => SetUnderground(level, position, ECarrierType.Coolant),
EEditorTool.Floor => cell with { Terrain = ECellTerrain.Floor }, EEditorTool.ElectricityUnderground => SetUnderground(level, position, ECarrierType.Electricity),
EEditorTool.Wall => cell with { EEditorTool.FuelFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Fuel),
Terrain = ECellTerrain.Wall, EEditorTool.CoolantFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Coolant),
Prop = ECellProp.None, EEditorTool.ElectricityFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Electricity),
Pipe = EPipeMedium.None, EEditorTool.FuelConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Fuel),
Flow = Balancing.Current.MinHazardValue, EEditorTool.CoolantConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Coolant),
Pressure = Balancing.Current.MinHazardValue, EEditorTool.ElectricityConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Electricity),
LeakRate = Balancing.Current.MinHazardValue, EEditorTool.TJunction => SetFloorProp(level, position, new() { Type = EPropType.TJunction }),
PipeOpen = false, EEditorTool.CrossJunction => SetFloorProp(level, position, new() { Type = EPropType.CrossJunction }),
Powered = false EEditorTool.Door => SetDoor(level, position),
}, EEditorTool.AllSeeingEyeTerminal => SetFloorProp(level, position, new() { Type = EPropType.AllSeeingEyeTerminal }),
EEditorTool.Reactor => cell with { Terrain = ECellTerrain.Floor, Prop = ECellProp.Reactor }, EEditorTool.FuelRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.FuelNeutralizer }),
EEditorTool.CoolingPump => cell with { EEditorTool.CoolantRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.CoolantNeutralizer }),
Terrain = ECellTerrain.Floor, EEditorTool.ElectricityRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.ElectricityNeutralizer }),
Prop = ECellProp.CoolingPump, EEditorTool.HeatRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.HeatShield }),
Powered = true EEditorTool.ReactorControl => SetReactorControl(level, position),
}, EEditorTool.FuelLeak => SetLeak(level, position, ECarrierType.Fuel),
EEditorTool.Generator => cell with { EEditorTool.CoolantLeak => SetLeak(level, position, ECarrierType.Coolant),
Terrain = ECellTerrain.Floor, EEditorTool.ElectricityLeak => SetLeak(level, position, ECarrierType.Electricity),
Prop = ECellProp.Generator, EEditorTool.FuelHazard => level.SetSurface(position, level.GetSurface(position) with { Fuel = level.GetSurface(position).Fuel + 1 }),
Powered = true EEditorTool.CoolantHazard => level.SetSurface(position, level.GetSurface(position) with { Coolant = level.GetSurface(position).Coolant + 1 }),
}, EEditorTool.ElectricityHazard => level.SetSurface(position, level.GetSurface(position) with { Electricity = level.GetSurface(position).Electricity + 1 }),
EEditorTool.PressureRegulator => cell with { Terrain = ECellTerrain.Floor, Prop = ECellProp.PressureRegulator }, EEditorTool.Heat => level.SetSurface(position, level.GetSurface(position) with { Heat = level.GetSurface(position).Heat + 1 }),
EEditorTool.DiagnosticTerminal => cell with { EEditorTool.Robot => level.IsFloor(position) ? level with { Robot = level.Robot with { Position = position } } : level,
Terrain = ECellTerrain.Floor, _ => level
Prop = ECellProp.DiagnosticTerminal,
Powered = true
},
EEditorTool.ControlTerminal => cell with {
Terrain = ECellTerrain.Floor,
Prop = ECellProp.ControlTerminal,
Powered = true
},
EEditorTool.CoolantPipe => cell with {
Pipe = EPipeMedium.Coolant,
Flow = Balancing.Current.DefaultPipeFlow,
Pressure = Balancing.Current.DefaultPipePressure,
Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity),
PipeOpen = true
},
EEditorTool.FuelPipe => cell with {
Pipe = EPipeMedium.Fuel,
Flow = Balancing.Current.DefaultPipeFlow,
Pressure = Balancing.Current.DefaultPipePressure,
Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity),
PipeOpen = true
},
EEditorTool.PressurePipe => cell with {
Pipe = EPipeMedium.Pressure,
Flow = Balancing.Current.DefaultPressurePipeFlow,
Pressure = Balancing.Current.DefaultPressurePipePressure,
Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity),
PipeOpen = true
},
EEditorTool.Leak => cell with {
LeakRate = Math.Max(Balancing.Current.MinimumLeakRate, cell.LeakRate),
Integrity = Math.Min(cell.Integrity, Balancing.Current.DamagedPipeIntegrity)
},
EEditorTool.Repair => cell with {
LeakRate = Balancing.Current.RepairedLeakRate,
Integrity = Balancing.Current.DefaultCellIntegrity,
Hazards = cell.Hazards with {
Fire = false,
ElectricalCharge = Balancing.Current.RepairedElectricalCharge
}
},
EEditorTool.Heat => cell with { Hazards = cell.Hazards with { Heat = Rules.Clamp(cell.Hazards.Heat + Balancing.Current.HeatToolIncrease) } },
EEditorTool.Fire => cell with {
Hazards = cell.Hazards with {
Fire = !cell.Hazards.Fire,
Heat = Math.Max(cell.Hazards.Heat, Balancing.Current.FireToolMinimumHeat),
Smoke = Math.Max(cell.Hazards.Smoke, Balancing.Current.FireToolMinimumSmoke)
}
},
_ => cell
}; };
}
if (cell.Terrain == ECellTerrain.Wall) public static LevelState BindFirstReactorToConsumers(LevelState level, GridPosition fuelConsumer, GridPosition coolantConsumer, GridPosition electricityConsumer)
cell = cell with { Hazards = new() }; {
if (level.Reactors.Count == 0)
return level;
return level.SetCell(position, cell); var reactors = level.Reactors.ToArray();
reactors[0] = reactors[0] with {
FuelConsumerPosition = fuelConsumer,
CoolantConsumerPosition = coolantConsumer,
ElectricityConsumerPosition = electricityConsumer
};
return level with { Reactors = reactors };
}
private static LevelState SetUnderground(LevelState level, GridPosition position, ECarrierType carrier)
{
return level.SetUnderground(position, carrier, new() { State = EUndergroundState.Intact });
}
private static LevelState SetCarrierProp(LevelState level, GridPosition position, EPropType type, ECarrierType carrier)
{
return SetFloorProp(level, position, new() { Type = type, Carrier = carrier, SwitchState = EPropSwitchState.Enabled });
}
private static LevelState SetFloorProp(LevelState level, GridPosition position, PropState prop)
{
return level.IsFloor(position) ? level.SetProp(position, prop) : level;
}
private static LevelState SetDoor(LevelState level, GridPosition position)
{
if (!level.IsFloor(position))
return level;
var neighbor = position.Neighbors().FirstOrDefault(level.IsFloor);
if (neighbor is null)
return SetFloorProp(level, position, new() { Type = EPropType.Door });
return SetFloorProp(level, position, new() { Type = EPropType.Door }) with {
Doors = [.. level.Doors, new DoorState { A = position, B = neighbor }]
};
}
private static LevelState SetReactorControl(LevelState level, GridPosition position)
{
if (!level.IsFloor(position))
return level;
var id = level.Reactors.Count == 0 ? 1 : level.Reactors.Max(reactor => reactor.ReactorId) + 1;
var levelWithProp = level.SetProp(position, new() { Type = EPropType.ReactorControl, ReactorId = id });
return levelWithProp with {
Reactors = [
.. level.Reactors,
new ReactorBinding {
ReactorId = id,
ControlPosition = position,
FuelConsumerPosition = position,
CoolantConsumerPosition = position,
ElectricityConsumerPosition = position
}
]
};
}
private static LevelState SetLeak(LevelState level, GridPosition position, ECarrierType carrier)
{
if (!level.InBounds(position))
return level;
var accessPosition = carrier == ECarrierType.Electricity && level.GetTerrain(position) == ECellTerrain.Wall
? position.Neighbors().FirstOrDefault(level.IsFloor)
: position;
if (accessPosition is null || !level.IsFloor(accessPosition))
return level;
var next = level.SetUnderground(position, carrier, new() { State = EUndergroundState.Leaking });
return next with {
Leaks = [
.. next.Leaks,
new LeakState {
Carrier = carrier,
UndergroundPosition = position,
AccessPosition = accessPosition
}
]
};
} }
} }

View File

@@ -5,28 +5,31 @@ 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}.")
};
return level.Cells.Length != level.Width * level.Height ? throw new InvalidOperationException("Level cell count does not match its dimensions.") : level; var level = file.Level ?? throw new InvalidOperationException("Level file did not contain a level.");
var report = new LevelValidator().Validate(level);
if (!report.IsValid)
throw new InvalidOperationException(report.Errors[0].Message);
return level;
} }
private static readonly JsonSerializerOptions Options = new() { private static readonly JsonSerializerOptions s_Options = new() {
WriteIndented = true, WriteIndented = true,
Converters = { new JsonStringEnumConverter() } Converters = { new JsonStringEnumConverter() }
}; };
@@ -34,6 +37,6 @@ public static class LevelSerializer
private sealed record LevelFile private sealed record LevelFile
{ {
public int Version { get; init; } public int Version { get; init; }
public LevelState Level { get; init; } = new(); 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,4 +1,4 @@
namespace ReactorMaintenance.Simulation; namespace ReactorMaintenance.Simulation;
public enum ECellTerrain public enum ECellTerrain
{ {
@@ -6,102 +6,351 @@ public enum ECellTerrain
Wall Wall
} }
public enum ECellProp public enum ECarrierType
{ {
None, Fuel,
Reactor,
CoolingPump,
Generator,
PressureRegulator,
DiagnosticTerminal,
ControlTerminal
}
public enum EPipeMedium
{
None,
Pressure,
Coolant, Coolant,
Fuel Electricity
} }
public enum EFailureKind public enum EUndergroundState
{ {
PipeBurst, Absent,
Ignition, Intact,
Meltdown, Leaking
StabilityCollapse, }
ReactorReady
public enum EPropType
{
None,
Flow,
Consumer,
TJunction,
CrossJunction,
Door,
AllSeeingEyeTerminal,
RemedySupply,
ReactorControl
}
public enum EPropSwitchState
{
Disabled,
Enabled
}
public enum EConsumerServiceState
{
Unknown,
Disabled,
Starved,
Supplied,
Producing
}
public enum ETJunctionMode
{
ZeroFour,
OneThree,
TwoTwo,
ThreeOne,
FourZero
}
public enum ECrossJunctionMode
{
ZeroThreeThree,
ThreeZeroThree,
ThreeThreeZero,
TwoTwoTwo
}
public enum EDoorState
{
Open,
Closed
}
public enum ERemedyType
{
FuelNeutralizer,
CoolantNeutralizer,
ElectricityNeutralizer,
HeatShield
}
public enum ELevelState
{
Stable,
Caution,
Critical,
Ready,
Lost,
Won
}
public enum EForecastKind
{
TerminalLoss,
ReactorReady,
ConsumerStarved,
HazardGrowth,
RuleEvent
}
public enum ERuleEventPhase
{
StartOfSimulation,
EndOfTurn
}
public enum ERulePredicateKind
{
TurnAtLeast,
LevelStateIs,
PropStateAt,
ConsumerStateAt,
SurfaceBandAt,
RobotAt,
AllSeeingEyeUnlocked
}
public enum ERuleEffectKind
{
StartLeak,
WorsenLeak,
RepairNetworkCell,
DisableNetworkCell,
SetPropEnabled,
AddSurfaceHazard,
AddHeat,
AddInventory,
MarkTerminalLoss,
EmitWarning
}
public enum EBand
{
Safe,
Caution,
Critical
}
public enum EPairEffect
{
Hold,
FuelFlow,
CoolFlow,
ChargeFlow,
HeatFlow,
HeatFlow2,
Warm1,
Warm2,
Quench1,
Quench2,
Short1,
Short2,
Ignite1,
Ignite2
} }
public sealed record GridPosition(int X, int Y) public sealed record GridPosition(int X, int Y)
{ {
public IEnumerable<GridPosition> Neighbors() public IEnumerable<GridPosition> Neighbors()
{ {
yield return new(X - Balancing.Current.NeighborDistance, Y); yield return new(X, Y - 1);
yield return new(X + Balancing.Current.NeighborDistance, Y); yield return new(X + 1, Y);
yield return new(X, Y - Balancing.Current.NeighborDistance); yield return new(X, Y + 1);
yield return new(X, Y + Balancing.Current.NeighborDistance); yield return new(X - 1, Y);
}
public int ManhattanDistance(GridPosition other)
{
return Math.Abs(X - other.X) + Math.Abs(Y - other.Y);
} }
} }
public sealed record HazardState public sealed record UndergroundCell
{ {
public HazardState Clamp() public EUndergroundState State { get; init; }
public float Amount { get; init; }
public float Intensity { get; init; }
public bool IsPresent => State != EUndergroundState.Absent;
public bool CarriesFlow => State is EUndergroundState.Intact or EUndergroundState.Leaking;
}
public sealed record SurfaceState
{ {
public float Fuel { get; init; }
public float Coolant { get; init; }
public float Electricity { get; init; }
public float Heat { get; init; }
public int FuelBlockTurns { get; init; }
public int CoolantBlockTurns { get; init; }
public int ElectricityBlockTurns { get; init; }
public SurfaceState Clamp()
{
var balancing = Balancing.Current;
return this with { return this with {
Heat = Rules.Clamp(Heat), Fuel = balancing.ClampValue(Fuel),
Smoke = Rules.Clamp(Smoke), Coolant = balancing.ClampValue(Coolant),
FuelVapor = Rules.Clamp(FuelVapor), Electricity = balancing.ClampValue(Electricity),
LiquidFuel = Rules.Clamp(LiquidFuel), Heat = balancing.ClampValue(Heat),
CoolantPooling = Rules.Clamp(CoolantPooling), FuelBlockTurns = Math.Max(0, FuelBlockTurns),
ElectricalCharge = Rules.Clamp(ElectricalCharge), CoolantBlockTurns = Math.Max(0, CoolantBlockTurns),
Stability = Rules.Clamp(Stability) ElectricityBlockTurns = Math.Max(0, ElectricityBlockTurns)
}; };
} }
public int Heat { get; init; } public bool Blocks(ECarrierType carrier)
public int Smoke { get; init; } {
public int FuelVapor { get; init; } return carrier switch {
public int LiquidFuel { get; init; } ECarrierType.Fuel => FuelBlockTurns > 0,
public int CoolantPooling { get; init; } ECarrierType.Coolant => CoolantBlockTurns > 0,
public int ElectricalCharge { get; init; } ECarrierType.Electricity => ElectricityBlockTurns > 0,
public int Stability { get; init; } = Balancing.Current.DefaultHazardStability; _ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
public bool Fire { get; init; } };
}
} }
public sealed record CellState public sealed record PropState
{ {
public ECellTerrain Terrain { get; init; } = ECellTerrain.Floor; public EPropType Type { get; init; }
public ECellProp Prop { get; init; } public ECarrierType Carrier { get; init; }
public EPipeMedium Pipe { get; init; } public EPropSwitchState SwitchState { get; init; } = EPropSwitchState.Enabled;
public int Flow { get; init; } public EConsumerServiceState ServiceState { get; init; } = EConsumerServiceState.Unknown;
public int Pressure { get; init; } public ETJunctionMode TJunctionMode { get; init; } = ETJunctionMode.TwoTwo;
public int Integrity { get; init; } = Balancing.Current.DefaultCellIntegrity; public ECrossJunctionMode CrossJunctionMode { get; init; } = ECrossJunctionMode.TwoTwoTwo;
public int LeakRate { get; init; } public ERemedyType RemedyType { get; init; }
public bool PipeOpen { get; init; } = true; public bool Depleted { get; init; }
public bool Powered { get; init; } public int ReactorId { get; init; }
public bool DoorLocked { get; init; }
public HazardState Hazards { get; init; } = new(); public bool IsEnabled => SwitchState == EPropSwitchState.Enabled;
public bool IsWalkable => Terrain != ECellTerrain.Wall; }
public bool HasPipe => Pipe != EPipeMedium.None;
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 sealed record GlobalState
{ {
public int Turn { get; init; } public int Turn { get; init; }
public int ActionsPerTurn { get; init; } = Balancing.Current.DefaultActionsPerTurn; public int ActionsRemaining { get; init; } = Balancing.Current.ActionsPerTurn;
public int CoreHeat { get; init; } = Balancing.Current.DefaultCoreHeat; public ELevelState LevelState { get; init; } = ELevelState.Stable;
public int FacilityStability { get; init; } = Balancing.Current.DefaultFacilityStability; public string Status { get; init; } = "STABLE";
public int Power { get; init; } = Balancing.Current.DefaultPower; public bool AllSeeingEyeUnlocked { get; init; }
public int Cooling { get; init; } = Balancing.Current.DefaultCooling; public bool TerminalLoss { get; init; }
public bool ReactorActivated { get; init; } public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
public bool Lost { get; init; }
public string Status { get; init; } = "STABILIZE SYSTEMS";
} }
public sealed record Forecast(EFailureKind Kind, GridPosition? Position, int Turns, string Message);
public sealed record LevelState public sealed record LevelState
{ {
public static LevelState Create(string name, int width, int height) public static LevelState Create(string name, int width, int height)
@@ -109,73 +358,177 @@ public sealed record LevelState
if (width < Balancing.Current.MinimumLevelSize || height < Balancing.Current.MinimumLevelSize) 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}."); throw new ArgumentOutOfRangeException(nameof(width), $"Levels must be at least {Balancing.Current.MinimumLevelSize}x{Balancing.Current.MinimumLevelSize}.");
var cells = CreateCells(width, height); var terrain = Enumerable.Repeat(ECellTerrain.Floor, width * height).ToArray();
for (var y = Balancing.Current.FirstGridCoordinate; y < height; y++) for (var y = 0; y < height; y++)
{ {
for (var x = Balancing.Current.FirstGridCoordinate; x < width; x++) for (var x = 0; x < width; x++)
{ {
if (x == Balancing.Current.FirstGridCoordinate || y == Balancing.Current.FirstGridCoordinate || x == width - Balancing.Current.NeighborDistance || y == height - Balancing.Current.NeighborDistance) if (x == 0 || y == 0 || x == width - 1 || y == height - 1)
cells[y * width + x] = cells[y * width + x] with { Terrain = ECellTerrain.Wall }; terrain[y * width + x] = ECellTerrain.Wall;
} }
} }
return new() { var level = new LevelState {
Name = name, Name = name,
Width = width, Width = width,
Height = height, Height = height,
Cells = cells, Terrain = terrain,
Robot = new(Balancing.Current.DefaultRobotCoordinate, Balancing.Current.DefaultRobotCoordinate) 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) }
}; };
}
public CellState GetCell(GridPosition position) return level with { Forecasts = Array.Empty<Forecast>() };
{
EnsureInBounds(position);
return Cells[Index(position)];
}
public LevelState SetCell(GridPosition position, CellState cell)
{
EnsureInBounds(position);
var cells = Cells.ToArray();
cells[Index(position)] = cell;
return this with { Cells = cells };
} }
public bool InBounds(GridPosition position) public bool InBounds(GridPosition position)
{ {
return position.X >= Balancing.Current.FirstGridCoordinate && position.Y >= Balancing.Current.FirstGridCoordinate && position.X < Width && position.Y < Height; return position.X >= 0 && position.Y >= 0 && position.X < Width && position.Y < Height;
} }
public int Index(GridPosition position) public int Index(GridPosition position)
{ {
EnsureInBounds(position);
return position.Y * Width + position.X; 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) private void EnsureInBounds(GridPosition position)
{ {
if (!InBounds(position)) if (!InBounds(position))
throw new ArgumentOutOfRangeException(nameof(position), $"Position {position.X},{position.Y} is outside {Width}x{Height}."); throw new ArgumentOutOfRangeException(nameof(position), $"Position {position.X},{position.Y} is outside {Width}x{Height}.");
} }
private static CellState[] CreateCells(int width, int height) private static bool SameEdge(GridPosition edgeA, GridPosition edgeB, GridPosition a, GridPosition b)
{ {
return Enumerable.Range(Balancing.Current.FirstGridCoordinate, width * height).Select(_ => new CellState()).ToArray(); 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 string Name { get; init; } = "New Reactor";
public int Width { get; init; } = Balancing.Current.DefaultLevelWidth; public int Width { get; init; } = Balancing.Current.DefaultLevelWidth;
public int Height { get; init; } = Balancing.Current.DefaultLevelHeight; public int Height { get; init; } = Balancing.Current.DefaultLevelHeight;
public CellState[] Cells { get; init; } = CreateCells(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight); public ECellTerrain[] Terrain { get; init; } = Enumerable.Repeat(ECellTerrain.Floor, Balancing.Current.DefaultLevelWidth * Balancing.Current.DefaultLevelHeight).ToArray();
public GridPosition Robot { get; init; } = new(Balancing.Current.DefaultRobotCoordinate, Balancing.Current.DefaultRobotCoordinate); 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 GlobalState Global { get; init; } = new();
public IReadOnlyList<Forecast> Forecasts { get; init; } = Array.Empty<Forecast>(); public IReadOnlyList<Forecast> Forecasts { get; init; } = Array.Empty<Forecast>();
} }
internal static class Rules
{
public static int Clamp(int value)
{
return Math.Clamp(value, Balancing.Current.MinHazardValue, Balancing.Current.MaxHazardValue);
}
}

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,737 @@
using ReactorMaintenance.Simulation.Effects;
using ReactorMaintenance.Simulation.Hazards;
namespace ReactorMaintenance.Simulation; namespace ReactorMaintenance.Simulation;
public sealed class SimulationEngine(IEnumerable<ISimulationEffect> effects, IEnumerable<IAreaSimulationEffect> areaEffects, IEnumerable<Hazard> hazards) public sealed class SimulationEngine
{ {
private sealed record ForecastKey(EFailureKind Kind, GridPosition? Position); public LevelState MoveRobot(LevelState level, GridPosition destination)
{
if (!CanSpendAction(level) || !level.IsFloor(destination) || level.Robot.Position.ManhattanDistance(destination) != 1)
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 InteractProp(LevelState level)
{ {
if (!CanSpendAction(level))
return Refuse(level, "NO ACTIONS");
var position = level.Robot.Position;
var prop = level.GetProp(position);
if (prop.Type == EPropType.None)
return Refuse(level, "NO PROP");
var next = prop.Type switch {
EPropType.Flow or EPropType.Consumer => ToggleProp(level, position, prop),
EPropType.TJunction => level.SetProp(position, prop with { TJunctionMode = NextTJunctionMode(prop.TJunctionMode) }),
EPropType.CrossJunction => level.SetProp(position, prop with { CrossJunctionMode = NextCrossJunctionMode(prop.CrossJunctionMode) }),
EPropType.Door => ToggleDoor(level, position),
EPropType.AllSeeingEyeTerminal => level with { Global = level.Global with { AllSeeingEyeUnlocked = true, Status = "ALL-SEEING-EYE ONLINE" } },
EPropType.RemedySupply => PickUpRemedy(level, position, prop),
EPropType.ReactorControl => ActivateReactor(level),
_ => level
};
return SpendAction(next);
}
public LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy)
{
if (!CanSpendAction(level))
return Refuse(level, "NO ACTIONS");
var leakIndex = level.Leaks.ToList().FindIndex(leak => !leak.Repaired && leak.Carrier == carrier && leak.AccessPosition == level.Robot.Position);
if (leakIndex < 0)
return Refuse(level, "NO REACHABLE LEAK");
var leak = level.Leaks[leakIndex];
var next = useRemedy ? ApplyElementRemedy(level, leak) : RepairLeak(level, leakIndex, leak);
return SpendAction(next);
}
public LevelState ApplyHeatShield(LevelState level)
{
if (!CanSpendAction(level) || level.Robot.HeatShields <= 0)
return Refuse(level, "NO HEAT SHIELD");
return SpendAction(level with {
Robot = level.Robot.Spend(ERemedyType.HeatShield) with { HeatImmunitySteps = Balancing.Current.HeatShieldSteps }
});
}
public LevelState ActivateReactor(LevelState level)
{
var reactorIndex = level.Reactors.ToList().FindIndex(reactor => reactor.ControlPosition == level.Robot.Position);
if (reactorIndex < 0)
return Refuse(level, "NO REACTOR CONTROL");
var reactor = level.Reactors[reactorIndex];
if (!reactor.Ready)
return Refuse(level, "REACTOR NOT READY");
var reactors = level.Reactors.ToArray();
reactors[reactorIndex] = reactor with { Activated = true };
return level with {
Reactors = reactors,
Global = level.Global with { LevelState = ELevelState.Won, Status = "REACTOR ONLINE" }
};
}
public LevelState EndTurn(LevelState level)
{
return ResolveTurn(level with { Global = level.Global with { ActionsRemaining = 0 } });
} }
public LevelState AdvanceTurn(LevelState level) public LevelState AdvanceTurn(LevelState level)
{ {
return AdvanceTurn(level, true); return ResolveTurn(level);
} }
public IReadOnlyList<Forecast> Forecast(LevelState level) public IReadOnlyList<Forecast> Forecast(LevelState level)
{ {
var forecasts = new List<Forecast>(); var forecasts = new List<Forecast>();
var seen = new HashSet<ForecastKey>(); var simulated = CopyForForecast(level);
var forecastLevel = level with { Cells = level.Cells.ToArray(), Forecasts = Array.Empty<Forecast>() };
if (forecastLevel.Global.Lost)
AddHazardForecasts(forecasts, seen, forecastLevel, Balancing.Current.CurrentForecastTurn);
AddReactorReadyForecast(forecasts, seen, forecastLevel, Balancing.Current.CurrentForecastTurn); for (var turn = 0; turn <= Balancing.Current.ForecastHorizon; turn++)
if (IsReactorReady(forecastLevel) || forecastLevel.Global.Lost || forecastLevel.Global.ReactorActivated)
return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray();
for (var step = Balancing.Current.TurnIncrement; step <= Balancing.Current.MaxForecastStepCount; step++)
{ {
forecastLevel = AdvanceTurn(forecastLevel, false); AddForecasts(forecasts, simulated, turn);
AddHazardForecasts(forecasts, seen, forecastLevel, step); if (simulated.Global.LevelState is ELevelState.Lost or ELevelState.Ready or ELevelState.Won)
AddReactorReadyForecast(forecasts, seen, forecastLevel, step);
if (forecastLevel.Global.Lost || IsReactorReady(forecastLevel) || forecastLevel.Global.ReactorActivated)
break; break;
if (turn < Balancing.Current.ForecastHorizon)
simulated = ResolveTurn(simulated, false);
} }
return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray(); return forecasts.DistinctBy(forecast => (forecast.Kind, forecast.Position, forecast.Message)).OrderBy(forecast => forecast.Turns).ThenBy(forecast => forecast.Message).ToArray();
} }
public LevelState ActivateReactor(LevelState level) private LevelState ResolveTurn(LevelState level, bool refreshForecasts = true)
{ {
if (!IsReactorReady(level)) var report = m_Validator.Validate(level);
return level with { Global = level.Global with { Status = "REACTOR NOT READY" } }; if (!report.IsValid)
return level with { Global = level.Global with { LevelState = ELevelState.Lost, Status = report.Errors[0].Message } };
return level with { var next = ApplyRuleEvents(level, ERuleEventPhase.StartOfSimulation);
Global = level.Global with { next = PropagateNetworks(next);
ReactorActivated = true, next = ResolveConsumers(next);
Status = "REACTOR ONLINE" next = InjectLeaks(next);
next = ResolveSurfaceInteractions(next);
next = ResolveRobotSafety(next);
next = DeriveReactorAndLevelState(next);
next = ApplyRuleEvents(next, ERuleEventPhase.EndOfTurn);
next = AdvanceDurations(next);
next = next with {
Global = next.Global with {
Turn = next.Global.Turn + 1,
ActionsRemaining = Balancing.Current.ActionsPerTurn
} }
}; };
return refreshForecasts ? next with { Forecasts = Forecast(next) } : next;
}
private LevelState PropagateNetworks(LevelState level)
{
var fuel = ClearTransient(level.Fuel);
var coolant = ClearTransient(level.Coolant);
var electricity = ClearTransient(level.Electricity);
var next = level.WithRuntimeArrays(fuel, coolant, electricity, level.Surface.ToArray(), level.Props.ToArray());
foreach (var carrier in Enum.GetValues<ECarrierType>())
next = PropagateCarrier(next, carrier);
return next;
}
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 LevelState AdvanceTurn(LevelState level, bool updateForecasts) private static void ApplySourceFlow(LevelState level, UndergroundCell[] layer, GridPosition source, ECarrierType carrier)
{ {
var cells = level.Cells.ToArray(); 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;
for (var y = Balancing.Current.FirstGridCoordinate; y < level.Height; y++) while (open.Count > 0)
{ {
for (var x = Balancing.Current.FirstGridCoordinate; x < level.Width; x++) var current = open.Dequeue();
{ var amount = Balancing.Current.ClampValue((Balancing.Current.SourceAmount * current.AmountFactor) - (current.Distance * Balancing.Current.DistanceAmountFalloff));
var position = new GridPosition(x, y); var intensity = Balancing.Current.ClampValue((Balancing.Current.SourceIntensity * current.IntensityFactor) - (current.Distance * Balancing.Current.DistanceIntensityFalloff));
var index = level.Index(position); var index = level.Index(current.Position);
var cell = cells[index]; layer[index] = layer[index] with {
Amount = Math.Max(layer[index].Amount, amount),
Intensity = Math.Max(layer[index].Intensity, intensity)
};
if (!cell.IsWalkable) foreach (var next in current.Position.Neighbors().Where(level.InBounds))
{
if (!level.GetUnderground(next, carrier).CarriesFlow)
continue; continue;
foreach (var effect in m_Effects) var weights = BranchWeights(level, current.Position, next);
cell = effect.Apply(cell); var amountFactor = current.AmountFactor * weights.Amount;
var intensityFactor = current.IntensityFactor * weights.Intensity;
if (amountFactor <= 0 || intensityFactor <= 0)
continue;
cells[index] = cell with { Hazards = cell.Hazards.Clamp() }; if (best.TryGetValue(next, out var oldBest) && oldBest >= amountFactor)
continue;
best[next] = amountFactor;
open.Enqueue((next, current.Distance + 1, amountFactor, intensityFactor));
}
} }
} }
foreach (var areaEffect in m_AreaEffects) private static (float Amount, float Intensity) BranchWeights(LevelState level, GridPosition from, GridPosition to)
cells = areaEffect.Apply(level, cells); {
var prop = level.GetProp(from);
return prop.Type switch {
EPropType.TJunction => TJunctionWeights(prop.TJunctionMode),
EPropType.CrossJunction => CrossJunctionWeights(prop.CrossJunctionMode),
_ => (1, 1)
};
}
var global = UpdateGlobal(level, cells); private static (float Amount, float Intensity) TJunctionWeights(ETJunctionMode mode)
var next = level with { {
Cells = cells, var weight = mode switch {
Global = global with { Turn = level.Global.Turn + Balancing.Current.TurnIncrement } 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
}; };
return updateForecasts ? next with { Forecasts = Forecast(next) } : next; 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 void AddHazardForecasts(List<Forecast> forecasts, HashSet<ForecastKey> seen, LevelState level, int turns) private static void ApplyEffect(LevelState level, GridPosition a, GridPosition b, EPairEffect effect, SurfaceDelta[] deltas)
{ {
foreach (var hazard in m_Hazards) var index = level.Index(a);
switch (effect)
{ {
foreach (var forecast in hazard.Predict(level, turns)) case EPairEffect.Warm1:
AddForecast(forecasts, seen, forecast); 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 AddReactorReadyForecast(List<Forecast> forecasts, HashSet<ForecastKey> seen, LevelState level, int turns) private static void FlowBetween(LevelState level, GridPosition a, GridPosition b, float valueA, float valueB, EPairEffect effect, SurfaceDelta[] deltas)
{ {
if (IsReactorReady(level)) var difference = valueA - valueB;
AddForecast(forecasts, seen, new(EFailureKind.ReactorReady, null, turns, "REACTOR READY")); 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 static void AddForecast(List<Forecast> forecasts, HashSet<ForecastKey> seen, Forecast forecast) private LevelState ResolveRobotSafety(LevelState level)
{ {
if (seen.Add(new(forecast.Kind, forecast.Position))) var surface = level.GetSurface(level.Robot.Position);
forecasts.Add(forecast); 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 static GlobalState UpdateGlobal(LevelState level, CellState[] cells) private LevelState DeriveReactorAndLevelState(LevelState level)
{ {
var reactorHeat = cells.Where(c => c.Prop == ECellProp.Reactor).Select(c => c.Hazards.Heat).DefaultIfEmpty(level.Global.CoreHeat).Max(); if (level.Global.LevelState is ELevelState.Lost or ELevelState.Won)
var poweredGenerators = cells.Count(c => c is { Prop: ECellProp.Generator, Powered: true, Hazards.Fire: false }); return level;
var poweredPumps = cells.Count(c => c is { Prop: ECellProp.CoolingPump, Powered: true, Hazards.Fire: false });
var damagedCriticalCells = cells.Count(c => c.Prop is ECellProp.Reactor or ECellProp.Generator or ECellProp.CoolingPump && c.Hazards.Stability <= Balancing.Current.CriticalCellStabilityThreshold); var reactors = level.Reactors.Select(reactor => reactor with { Ready = IsReactorReady(level, reactor) }).ToArray();
var stability = Rules.Clamp(level.Global.FacilityStability - damagedCriticalCells); if (reactors.Any(reactor => reactor.Ready))
var lost = reactorHeat >= Balancing.Current.MeltdownCoreHeatThreshold || stability <= Balancing.Current.StabilityCollapseThreshold; return level with { Reactors = reactors, Global = level.Global with { LevelState = ELevelState.Ready, Status = "REACTOR READY" } };
var status = lost ? reactorHeat >= Balancing.Current.MeltdownCoreHeatThreshold ? "CORE MELTDOWN" : "FACILITY STABILITY COLLAPSE" : "STABILIZE SYSTEMS";
var global = level.Global with { var maxHeat = level.Surface.Where((_, index) => level.Terrain[index] == ECellTerrain.Floor).Select(surface => surface.Heat).DefaultIfEmpty(0).Max();
CoreHeat = Rules.Clamp(reactorHeat - poweredPumps), if (maxHeat >= Balancing.Current.TerminalHeat)
Power = Rules.Clamp(poweredGenerators * Balancing.Current.GeneratorPowerOutput), return level with { Reactors = reactors, Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = "REACTOR HEAT TERMINAL" } };
Cooling = Rules.Clamp(poweredPumps * Balancing.Current.CoolingPumpOutput),
FacilityStability = stability, 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);
Lost = lost, var hasCaution = hasCritical || level.Props.Any(prop => prop.ServiceState is EConsumerServiceState.Starved or EConsumerServiceState.Disabled) || level.Leaks.Any(leak => !leak.Repaired);
Status = status 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.")
}; };
return IsReactorReady(level with { Cells = cells, Global = global }) ? global with { Status = "REACTOR READY" } : global; 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 bool IsReactorReady(LevelState level) private static SurfaceState AddSurfaceCarrier(SurfaceState surface, ECarrierType carrier, float amount)
{ {
var hasReactor = level.Cells.Any(c => c.Prop == ECellProp.Reactor); return carrier switch {
var hasStablePower = level.Global.Power >= Balancing.Current.ReactorReadyPowerThreshold || level.Cells.Any(c => c is { Prop: ECellProp.Generator, Powered: true, Hazards.Fire: false }); ECarrierType.Fuel => surface with { Fuel = surface.Fuel + amount },
var hasCooling = level.Global.Cooling >= Balancing.Current.ReactorReadyCoolingThreshold || level.Cells.Any(c => c is { Prop: ECellProp.CoolingPump, Powered: true, Hazards.Fire: false }); ECarrierType.Coolant => surface with { Coolant = surface.Coolant + amount },
var reactorStable = level.Global.CoreHeat < Balancing.Current.ReactorReadyCoreHeatThreshold; ECarrierType.Electricity => surface with { Electricity = surface.Electricity + amount },
return hasReactor && hasStablePower && hasCooling && reactorStable && !level.Global.Lost; _ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
} }
private readonly IReadOnlyList<IAreaSimulationEffect> m_AreaEffects = areaEffects.ToArray(); private static SurfaceState RemoveSurfaceCarrier(SurfaceState surface, ECarrierType carrier)
private readonly IReadOnlyList<ISimulationEffect> m_Effects = effects.ToArray(); {
private readonly IReadOnlyList<Hazard> m_Hazards = hazards.ToArray(); 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

@@ -15,7 +15,9 @@
<AppBarButton Icon="OpenFile" Label="Open" Click="Open_Click" /> <AppBarButton Icon="OpenFile" Label="Open" Click="Open_Click" />
<AppBarButton Icon="Save" Label="Save" Click="Save_Click" /> <AppBarButton Icon="Save" Label="Save" Click="Save_Click" />
<AppBarSeparator /> <AppBarSeparator />
<AppBarButton Icon="Play" Label="Simulate" Click="Simulate_Click" /> <AppBarButton Icon="Play" Label="End Turn" Click="EndTurn_Click" />
<AppBarButton Label="Interact" Click="Interact_Click" />
<AppBarButton Label="Heat Shield" Click="HeatShield_Click" />
<AppBarButton Icon="Accept" Label="Activate" Click="Activate_Click" /> <AppBarButton Icon="Accept" Label="Activate" Click="Activate_Click" />
</CommandBar> </CommandBar>
@@ -39,14 +41,17 @@
<DataTemplate> <DataTemplate>
<ToggleButton IsChecked="{Binding IsSelected, Mode=TwoWay}" <ToggleButton IsChecked="{Binding IsSelected, Mode=TwoWay}"
Checked="ToolToggle_Checked" ToolTipService.ToolTip="{Binding Label}" Checked="ToolToggle_Checked" ToolTipService.ToolTip="{Binding Label}"
Padding="5" Margin="0,0,8,8"> Width="112" MinHeight="46" Padding="6" Margin="0,0,8,8">
<Image Width="96" Height="96" Source="{Binding Icon}" Stretch="Uniform" /> <TextBlock Text="{Binding Label}" TextWrapping="WrapWholeWords" TextAlignment="Center"
FontSize="12" />
</ToggleButton> </ToggleButton>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Brush applies to the selected cell." Foreground="#9EA7AE" TextWrapping="Wrap" /> <TextBlock Text="Left click selects or paints. Right click clears surface prop and hazards."
<TextBlock Text="Left click paints. Use Robot to set the start position." Foreground="#9EA7AE" Foreground="#9EA7AE" TextWrapping="Wrap" />
<TextBlock Text="Door chooses the first adjacent floor edge. Reactor controls auto-bind to the first available consumers."
Foreground="#9EA7AE"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
@@ -95,15 +100,7 @@
<DataTemplate> <DataTemplate>
<Border BorderBrush="#46515A" BorderThickness="1" Padding="8" Margin="0,0,0,8" <Border BorderBrush="#46515A" BorderThickness="1" Padding="8" Margin="0,0,0,8"
CornerRadius="3"> CornerRadius="3">
<Grid ColumnSpacing="8"> <TextBlock Text="{Binding Message}" Foreground="#F4F1E8" TextWrapping="Wrap" />
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Image Width="28" Height="28" Source="{Binding Icon}" />
<TextBlock Grid.Column="1" Text="{Binding Message}" Foreground="#F4F1E8"
TextWrapping="Wrap" />
</Grid>
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>

View File

@@ -1,11 +1,10 @@
using Microsoft.Graphics.Canvas; using Microsoft.Graphics.Canvas;
using Microsoft.Graphics.Canvas.UI; using Microsoft.Graphics.Canvas.Text;
using Microsoft.Graphics.Canvas.UI.Xaml; using Microsoft.Graphics.Canvas.UI.Xaml;
using Microsoft.UI; using Microsoft.UI;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media.Imaging;
using ReactorMaintenance.Simulation; using ReactorMaintenance.Simulation;
using System.Globalization; using System.Globalization;
using Windows.Foundation; using Windows.Foundation;
@@ -21,23 +20,17 @@ public sealed partial class MainWindow
{ {
private sealed record CanvasLayout(double CellSize, double OriginX, double OriginY) private sealed record CanvasLayout(double CellSize, double OriginX, double OriginY)
{ {
public Rect CellRect(int x, int y) public Rect CellRect(GridPosition position)
{ {
return new(OriginX + (x * CellSize), OriginY + (y * CellSize), CellSize, CellSize); return new(OriginX + position.X * CellSize, OriginY + position.Y * CellSize, CellSize, CellSize);
}
public Rect DualTileRect(int x, int y)
{
return new(OriginX + ((x - 0.5) * CellSize), OriginY + ((y - 0.5) * CellSize), CellSize, CellSize);
} }
} }
private sealed record ForecastViewModel(BitmapImage Icon, string Message); private sealed record ForecastViewModel(string Message);
private sealed class EditorToolViewModel(EEditorTool tool, BitmapImage? icon, string label) private sealed class EditorToolViewModel(EEditorTool tool, string label)
{ {
public EEditorTool Tool { get; } = tool; public EEditorTool Tool { get; } = tool;
public BitmapImage? Icon { get; } = icon;
public string Label { get; } = label; public string Label { get; } = label;
public bool IsSelected { get; set; } public bool IsSelected { get; set; }
} }
@@ -47,7 +40,7 @@ public sealed partial class MainWindow
InitializeComponent(); InitializeComponent();
m_Level = BuildStarterLevel(); m_Level = BuildStarterLevel();
m_EditorTools = Enum.GetValues<EEditorTool>().Select(tool => new EditorToolViewModel(tool, EditorToolIcon(tool), tool.ToString()) { IsSelected = tool == m_SelectedTool }).ToArray(); m_EditorTools = Enum.GetValues<EEditorTool>().Select(tool => new EditorToolViewModel(tool, ToolLabel(tool)) { IsSelected = tool == m_SelectedTool }).ToArray();
ToolPicker.ItemsSource = m_EditorTools; ToolPicker.ItemsSource = m_EditorTools;
RefreshInspector(); RefreshInspector();
} }
@@ -63,18 +56,6 @@ public sealed partial class MainWindow
m_RobotSprite = await LoadCanvasBitmapAsync(sender, "Images", "Props", "robot.png"); m_RobotSprite = await LoadCanvasBitmapAsync(sender, "Images", "Props", "robot.png");
m_LeakSprite = await LoadCanvasBitmapAsync(sender, "Images", "Props", "leak.png"); m_LeakSprite = await LoadCanvasBitmapAsync(sender, "Images", "Props", "leak.png");
m_HeatSprite = await LoadCanvasBitmapAsync(sender, "Images", "Props", "heat.png"); m_HeatSprite = await LoadCanvasBitmapAsync(sender, "Images", "Props", "heat.png");
m_FireSprite = await LoadCanvasBitmapAsync(sender, "Images", "Props", "fire.png");
m_PropSprites[ECellProp.Reactor] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "reactor.png");
m_PropSprites[ECellProp.CoolingPump] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "cooling-pump.png");
m_PropSprites[ECellProp.Generator] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "generator.png");
m_PropSprites[ECellProp.PressureRegulator] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "pressure-regulator.png");
m_PropSprites[ECellProp.DiagnosticTerminal] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "diagnostic-terminal.png");
m_PropSprites[ECellProp.ControlTerminal] = await LoadCanvasBitmapAsync(sender, "Images", "Props", "control-terminal.png");
m_PipeTilemaps[EPipeMedium.Pressure] = await LoadCanvasBitmapAsync(sender, "Images", "Pipes", "pipe-pressure-tilemap.png");
m_PipeTilemaps[EPipeMedium.Coolant] = await LoadCanvasBitmapAsync(sender, "Images", "Pipes", "pipe-coolant-tilemap.png");
m_PipeTilemaps[EPipeMedium.Fuel] = await LoadCanvasBitmapAsync(sender, "Images", "Pipes", "pipe-fuel-tilemap.png");
} }
private static async Task<CanvasBitmap> LoadCanvasBitmapAsync(CanvasControl sender, params string[] pathParts) private static async Task<CanvasBitmap> LoadCanvasBitmapAsync(CanvasControl sender, params string[] pathParts)
@@ -85,13 +66,13 @@ public sealed partial class MainWindow
private void ToolToggle_Checked(object sender, RoutedEventArgs e) private void ToolToggle_Checked(object sender, RoutedEventArgs e)
{ {
if ((sender as FrameworkElement)?.DataContext is EditorToolViewModel tool) if ((sender as FrameworkElement)?.DataContext is not EditorToolViewModel tool)
{ return;
m_SelectedTool = tool.Tool; m_SelectedTool = tool.Tool;
foreach (var editorTool in m_EditorTools) foreach (var editorTool in m_EditorTools)
editorTool.IsSelected = editorTool == tool; editorTool.IsSelected = editorTool == tool;
} }
}
private void New_Click(object sender, RoutedEventArgs e) private void New_Click(object sender, RoutedEventArgs e)
{ {
@@ -115,8 +96,8 @@ public sealed partial class MainWindow
return; return;
var json = await FileIO.ReadTextAsync(file); var json = await FileIO.ReadTextAsync(file);
m_Level = LevelSerializer.Deserialize(json); var loaded = LevelSerializer.Deserialize(json);
m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) }; m_Level = loaded with { Forecasts = m_Simulation.Forecast(loaded) };
m_CurrentFile = file; m_CurrentFile = file;
m_SelectedCell = null; m_SelectedCell = null;
RefreshInspector(); RefreshInspector();
@@ -133,6 +114,10 @@ public sealed partial class MainWindow
{ {
try try
{ {
var report = new LevelValidator().Validate(m_Level);
if (!report.IsValid)
throw new InvalidOperationException(report.Errors[0].Message);
var file = m_CurrentFile; var file = m_CurrentFile;
if (file is null) if (file is null)
{ {
@@ -156,9 +141,23 @@ public sealed partial class MainWindow
} }
} }
private void Simulate_Click(object sender, RoutedEventArgs e) private void EndTurn_Click(object sender, RoutedEventArgs e)
{ {
m_Level = m_Simulation.AdvanceTurn(m_Level); m_Level = m_Simulation.EndTurn(m_Level);
RefreshInspector();
LevelCanvas.Invalidate();
}
private void Interact_Click(object sender, RoutedEventArgs e)
{
m_Level = m_Simulation.InteractProp(m_Level);
RefreshInspector();
LevelCanvas.Invalidate();
}
private void HeatShield_Click(object sender, RoutedEventArgs e)
{
m_Level = m_Simulation.ApplyHeatShield(m_Level);
RefreshInspector(); RefreshInspector();
LevelCanvas.Invalidate(); LevelCanvas.Invalidate();
} }
@@ -175,13 +174,14 @@ public sealed partial class MainWindow
var point = e.GetCurrentPoint(LevelCanvas); var point = e.GetCurrentPoint(LevelCanvas);
if (point.Properties.IsRightButtonPressed) if (point.Properties.IsRightButtonPressed)
{ {
RemovePropAt(point.Position); ClearAt(point.Position);
e.Handled = true; e.Handled = true;
return; return;
} }
if (point.Properties.IsLeftButtonPressed) if (!point.Properties.IsLeftButtonPressed)
{ return;
_ = LevelCanvas.CapturePointer(e.Pointer); _ = LevelCanvas.CapturePointer(e.Pointer);
m_LeftPointerDown = true; m_LeftPointerDown = true;
m_LeftPointerDownPoint = point.Position; m_LeftPointerDownPoint = point.Position;
@@ -189,20 +189,20 @@ public sealed partial class MainWindow
m_DragExceededClickThreshold = false; m_DragExceededClickThreshold = false;
e.Handled = true; e.Handled = true;
} }
}
private void LevelCanvas_PointerMoved(object sender, PointerRoutedEventArgs e) private void LevelCanvas_PointerMoved(object sender, PointerRoutedEventArgs e)
{ {
if (!m_LeftPointerDown)
return;
var point = e.GetCurrentPoint(LevelCanvas); var point = e.GetCurrentPoint(LevelCanvas);
if (m_LeftPointerDown)
{
var deltaX = point.Position.X - m_LastPanPoint.X; var deltaX = point.Position.X - m_LastPanPoint.X;
var deltaY = point.Position.Y - m_LastPanPoint.Y; var deltaY = point.Position.Y - m_LastPanPoint.Y;
m_LastPanPoint = point.Position; m_LastPanPoint = point.Position;
var totalDeltaX = point.Position.X - m_LeftPointerDownPoint.X; var totalDeltaX = point.Position.X - m_LeftPointerDownPoint.X;
var totalDeltaY = point.Position.Y - m_LeftPointerDownPoint.Y; var totalDeltaY = point.Position.Y - m_LeftPointerDownPoint.Y;
if (Math.Sqrt((totalDeltaX * totalDeltaX) + (totalDeltaY * totalDeltaY)) > c_ClickPixelThreshold) if (Math.Sqrt(totalDeltaX * totalDeltaX + totalDeltaY * totalDeltaY) > c_ClickPixelThreshold)
m_DragExceededClickThreshold = true; m_DragExceededClickThreshold = true;
m_PanX += deltaX; m_PanX += deltaX;
@@ -210,8 +210,6 @@ public sealed partial class MainWindow
ClampPan(); ClampPan();
LevelCanvas.Invalidate(); LevelCanvas.Invalidate();
e.Handled = true; e.Handled = true;
return;
}
} }
private void LevelCanvas_PointerReleased(object sender, PointerRoutedEventArgs e) private void LevelCanvas_PointerReleased(object sender, PointerRoutedEventArgs e)
@@ -226,6 +224,12 @@ public sealed partial class MainWindow
e.Handled = true; e.Handled = true;
} }
private void LevelCanvas_PointerExited(object sender, PointerRoutedEventArgs e)
{
m_LeftPointerDown = false;
m_DragExceededClickThreshold = false;
}
private void LevelCanvas_PointerWheelChanged(object sender, PointerRoutedEventArgs e) private void LevelCanvas_PointerWheelChanged(object sender, PointerRoutedEventArgs e)
{ {
var point = e.GetCurrentPoint(LevelCanvas); var point = e.GetCurrentPoint(LevelCanvas);
@@ -237,59 +241,34 @@ public sealed partial class MainWindow
e.Handled = true; e.Handled = true;
} }
private void ZoomAt(Point point, double zoomFactor)
{
var oldLayout = GetLayout();
var cellX = (point.X - oldLayout.OriginX) / oldLayout.CellSize;
var cellY = (point.Y - oldLayout.OriginY) / oldLayout.CellSize;
m_Zoom = Math.Clamp(m_Zoom * zoomFactor, c_MinZoom, c_MaxZoom);
var newCellSize = GetBaseCellSize() * m_Zoom;
var originWithoutPan = GetCenteredOrigin(newCellSize);
m_PanX = point.X - originWithoutPan.X - (cellX * newCellSize);
m_PanY = point.Y - originWithoutPan.Y - (cellY * newCellSize);
ClampPan();
LevelCanvas.Invalidate();
}
private void SelectOrPaintAt(Point point) private void SelectOrPaintAt(Point point)
{
if (m_SelectedTool == EEditorTool.Cursor)
SelectAt(point);
else
PaintAt(point);
}
private void SelectAt(Point point)
{ {
if (!TryGetGridPosition(point, out var position)) if (!TryGetGridPosition(point, out var position))
return; return;
m_SelectedCell = position; m_SelectedCell = position;
RefreshInspector(); if (m_SelectedTool != EEditorTool.Cursor)
LevelCanvas.Invalidate();
}
private void RemovePropAt(Point point)
{ {
if (!TryGetGridPosition(point, out var position))
return;
var cell = m_Level.GetCell(position);
m_SelectedCell = position;
m_Level = m_Level.SetCell(position, cell with { Prop = ECellProp.None });
m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) };
RefreshInspector();
LevelCanvas.Invalidate();
}
private void PaintAt(Point point)
{
if (!TryGetGridPosition(point, out var position))
return;
m_SelectedCell = position;
m_Level = LevelEditor.Apply(m_Level, position, m_SelectedTool); m_Level = LevelEditor.Apply(m_Level, position, m_SelectedTool);
m_Level = AutoBindReactors(m_Level);
m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) };
}
RefreshInspector();
LevelCanvas.Invalidate();
}
private void ClearAt(Point point)
{
if (!TryGetGridPosition(point, out var position))
return;
m_SelectedCell = position;
m_Level = m_Level.SetProp(position, new()).SetSurface(position, new()) with {
Leaks = m_Level.Leaks.Where(leak => leak.AccessPosition != position && leak.UndergroundPosition != position).ToArray(),
Doors = m_Level.Doors.Where(door => door.A != position && door.B != position).ToArray(),
Reactors = m_Level.Reactors.Where(reactor => reactor.ControlPosition != position).ToArray()
};
m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) }; m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) };
RefreshInspector(); RefreshInspector();
LevelCanvas.Invalidate(); LevelCanvas.Invalidate();
@@ -302,212 +281,125 @@ public sealed partial class MainWindow
drawing.Clear(ColorHelper.FromArgb(255, 16, 18, 21)); drawing.Clear(ColorHelper.FromArgb(255, 16, 18, 21));
DrawTerrain(drawing, layout); DrawTerrain(drawing, layout);
DrawCellOverlays(drawing, layout); DrawUnderground(drawing, layout);
//DrawGrid(drawing, layout); DrawSurface(drawing, layout);
DrawDoors(drawing, layout);
DrawProps(drawing, layout);
DrawLeaks(drawing, layout);
DrawRobot(drawing, layout); DrawRobot(drawing, layout);
DrawGrid(drawing, layout);
} }
private void DrawTerrain(CanvasDrawingSession drawing, CanvasLayout layout) private void DrawTerrain(CanvasDrawingSession drawing, CanvasLayout layout)
{ {
for (var y = 0; y <= m_Level.Height; y++) foreach (var position in AllPositions())
{ {
for (var x = 0; x <= m_Level.Width; x++) var rect = layout.CellRect(position);
{ var color = m_Level.GetTerrain(position) == ECellTerrain.Wall ? ColorHelper.FromArgb(255, 41, 47, 52) : ColorHelper.FromArgb(255, 32, 38, 42);
DrawDualTerrainTile(drawing, layout.DualTileRect(x, y), GetDualTileMask(x, y)); drawing.FillRectangle(rect, color);
}
} }
} }
private void DrawCellOverlays(CanvasDrawingSession drawing, CanvasLayout layout) private void DrawUnderground(CanvasDrawingSession drawing, CanvasLayout layout)
{ {
for (var y = 0; y < m_Level.Height; y++) foreach (var position in AllPositions())
{ {
for (var x = 0; x < m_Level.Width; x++) var rect = Inset(layout.CellRect(position), 0.18);
{ DrawUndergroundCell(drawing, rect, position, ECarrierType.Fuel, c_FuelColor);
var position = new GridPosition(x, y); DrawUndergroundCell(drawing, Inset(rect, -0.08), position, ECarrierType.Coolant, c_CoolantColor);
var cell = m_Level.GetCell(position); DrawUndergroundCell(drawing, Inset(rect, -0.16), position, ECarrierType.Electricity, c_ElectricityColor);
var rect = layout.CellRect(x, y);
DrawPipe(drawing, position, cell, rect);
if (cell.LeakRate > 0)
DrawImage(drawing, m_LeakSprite, Inset(rect, 0.12));
if (cell.Hazards.Heat > 0)
DrawImage(drawing, m_HeatSprite, Inset(rect, 0.08), Math.Clamp(cell.Hazards.Heat / 10.0f, 0.35f, 0.9f));
if (cell.Hazards.Fire)
DrawImage(drawing, m_FireSprite, Inset(rect, 0.08));
if (m_SelectedCell == position)
drawing.DrawRectangle(rect, Colors.White, 3);
DrawCellProp(drawing, cell, rect);
}
} }
} }
private void DrawPipe(CanvasDrawingSession drawing, GridPosition position, CellState cell, Rect rect) private void DrawUndergroundCell(CanvasDrawingSession drawing, Rect rect, GridPosition position, ECarrierType carrier, Color color)
{ {
if (!cell.HasPipe || !m_PipeTilemaps.TryGetValue(cell.Pipe, out var tilemap)) var cell = m_Level.GetUnderground(position, carrier);
if (!cell.IsPresent)
return; return;
var sourceRect = PipeTileSourceRect(GetPipeConnectionMask(position, cell.Pipe)); drawing.DrawRectangle(rect, color, cell.State == EUndergroundState.Leaking ? 4 : 2);
drawing.DrawImage(tilemap, rect, sourceRect, 1.0f, CanvasImageInterpolation.HighQualityCubic); if (cell.Amount > 0 || cell.Intensity > 0)
drawing.FillCircle((float)(rect.X + rect.Width / 2), (float)(rect.Y + rect.Height / 2), (float)Math.Max(2, rect.Width * 0.08), color);
} }
private int GetPipeConnectionMask(GridPosition position, EPipeMedium medium) private void DrawSurface(CanvasDrawingSession drawing, CanvasLayout layout)
{ {
var mask = 0; foreach (var position in AllPositions().Where(m_Level.IsFloor))
if (HasMatchingPipe(position with { Y = position.Y - 1 }, medium)) {
mask |= c_NorthConnection; var surface = m_Level.GetSurface(position);
var rect = layout.CellRect(position);
if (HasMatchingPipe(position with { X = position.X + 1 }, medium)) FillHazard(drawing, rect, surface.Fuel, c_FuelColor, 0.08);
mask |= c_EastConnection; FillHazard(drawing, rect, surface.Coolant, c_CoolantColor, 0.18);
FillHazard(drawing, rect, surface.Electricity, c_ElectricityColor, 0.28);
if (HasMatchingPipe(position with { Y = position.Y + 1 }, medium)) if (surface.Heat > 0)
mask |= c_SouthConnection; DrawImage(drawing, m_HeatSprite, Inset(rect, 0.18), Math.Clamp(surface.Heat / Balancing.Current.MaxValue, 0.25f, 0.9f));
}
if (HasMatchingPipe(position with { X = position.X - 1 }, medium))
mask |= c_WestConnection;
return mask;
} }
private bool HasMatchingPipe(GridPosition position, EPipeMedium medium) private static void FillHazard(CanvasDrawingSession drawing, Rect rect, float amount, Color color, double inset)
{ {
return m_Level.InBounds(position) && m_Level.GetCell(position).Pipe == medium; if (amount <= 0)
}
private static Rect PipeTileSourceRect(int connectionMask)
{
var tileIndex = connectionMask switch {
0 => 0,
c_NorthConnection => 1,
c_EastConnection => 2,
c_SouthConnection => 3,
c_WestConnection => 4,
c_NorthConnection | c_EastConnection => 5,
c_EastConnection | c_SouthConnection => 6,
c_SouthConnection | c_WestConnection => 7,
c_WestConnection | c_NorthConnection => 8,
c_NorthConnection | c_SouthConnection => 9,
c_EastConnection | c_WestConnection => 10,
c_NorthConnection | c_EastConnection | c_SouthConnection => 11,
c_EastConnection | c_SouthConnection | c_WestConnection => 12,
c_SouthConnection | c_WestConnection | c_NorthConnection => 13,
c_WestConnection | c_NorthConnection | c_EastConnection => 14,
c_NorthConnection | c_EastConnection | c_SouthConnection | c_WestConnection => 15,
_ => throw new ArgumentOutOfRangeException(nameof(connectionMask), connectionMask, "Unsupported pipe connection mask.")
};
return new(
tileIndex % c_PipeTilemapColumns * c_PipeTilemapTileSize,
tileIndex / c_PipeTilemapColumns * c_PipeTilemapTileSize,
c_PipeTilemapTileSize,
c_PipeTilemapTileSize);
}
private static void DrawImage(CanvasDrawingSession drawing, CanvasBitmap? image, Rect rect, float opacity = 1)
{
if (image is not null)
drawing.DrawImage(image, rect, image.Bounds, opacity, CanvasImageInterpolation.HighQualityCubic);
}
private static Rect Inset(Rect rect, double fraction)
{
var inset = rect.Width * fraction;
return new(rect.X + inset, rect.Y + inset, rect.Width - inset * 2, rect.Height - inset * 2);
}
private void DrawDualTerrainTile(CanvasDrawingSession drawing, Rect rect, int floorMask)
{
if (m_TerrainTilemap is null)
return; return;
var wallMask = c_AllCorners ^ floorMask; var alpha = (byte)Math.Clamp(40 + amount / Balancing.Current.MaxValue * 130, 40, 170);
var sourceRect = TilemapSourceRect(wallMask); drawing.FillRectangle(Inset(rect, inset), ColorHelper.FromArgb(alpha, color.R, color.G, color.B));
drawing.DrawImage(m_TerrainTilemap, rect, sourceRect, 1.0f, CanvasImageInterpolation.HighQualityCubic);
} }
private static Rect TilemapSourceRect(int wallMask) private void DrawDoors(CanvasDrawingSession drawing, CanvasLayout layout)
{ {
var tilePosition = wallMask switch { foreach (var door in m_Level.Doors)
c_BottomLeftCorner => new(0, 0), {
c_TopRightCorner | c_BottomRightCorner => new(1, 0), var centerA = Center(layout.CellRect(door.A));
c_TopLeftCorner | c_BottomLeftCorner | c_BottomRightCorner => new(2, 0), var centerB = Center(layout.CellRect(door.B));
c_BottomLeftCorner | c_BottomRightCorner => new(3, 0), drawing.DrawLine((float)centerA.X, (float)centerA.Y, (float)centerB.X, (float)centerB.Y, door.State == EDoorState.Open ? Colors.LightGreen : Colors.OrangeRed, 5);
c_TopLeftCorner | c_BottomRightCorner => new(0, 1), }
c_BottomLeftCorner | c_TopRightCorner | c_BottomRightCorner => new(1, 1),
c_AllCorners => new(2, 1),
c_TopLeftCorner | c_BottomLeftCorner | c_TopRightCorner => new(3, 1),
c_TopRightCorner => new(0, 2),
c_TopLeftCorner | c_TopRightCorner => new(1, 2),
c_TopLeftCorner | c_TopRightCorner | c_BottomRightCorner => new(2, 2),
c_BottomLeftCorner | c_TopLeftCorner => new(3, 2),
0 => new(0, 3),
c_BottomRightCorner => new(1, 3),
c_BottomLeftCorner | c_TopRightCorner => new(2, 3),
c_TopLeftCorner => new GridPosition(3, 3),
_ => throw new ArgumentOutOfRangeException(nameof(wallMask), wallMask, "Unsupported tile mask.")
};
return new(
tilePosition.X * c_TilemapTileSize,
tilePosition.Y * c_TilemapTileSize,
c_TilemapTileSize,
c_TilemapTileSize);
} }
private int GetDualTileMask(int x, int y) private void DrawProps(CanvasDrawingSession drawing, CanvasLayout layout)
{ {
var mask = 0; foreach (var position in AllPositions())
if (GetTerrainOrWall(x - 1, y - 1) == ECellTerrain.Floor) {
mask |= c_TopLeftCorner; var prop = m_Level.GetProp(position);
if (prop.Type == EPropType.None)
continue;
if (GetTerrainOrWall(x, y - 1) == ECellTerrain.Floor) var rect = Inset(layout.CellRect(position), 0.18);
mask |= c_TopRightCorner; drawing.FillRoundedRectangle(rect, 4, 4, PropColor(prop));
DrawCenteredText(drawing, PropLabel(prop), rect, Colors.White, Math.Max(10, (float)(layout.CellSize * 0.22)));
if (GetTerrainOrWall(x - 1, y) == ECellTerrain.Floor) }
mask |= c_BottomLeftCorner;
if (GetTerrainOrWall(x, y) == ECellTerrain.Floor)
mask |= c_BottomRightCorner;
return mask;
} }
private ECellTerrain GetTerrainOrWall(int x, int y) private void DrawLeaks(CanvasDrawingSession drawing, CanvasLayout layout)
{ {
var position = new GridPosition(x, y); foreach (var leak in m_Level.Leaks.Where(leak => !leak.Repaired))
return m_Level.InBounds(position) ? m_Level.GetCell(position).Terrain : ECellTerrain.Wall; DrawImage(drawing, m_LeakSprite, Inset(layout.CellRect(leak.AccessPosition), 0.1));
}
private void DrawCellProp(CanvasDrawingSession drawing, CellState cell, Rect rect)
{
if (m_PropSprites.TryGetValue(cell.Prop, out var sprite))
drawing.DrawImage(sprite, rect, sprite.Bounds, 1.0f, CanvasImageInterpolation.HighQualityCubic);
}
private void DrawGrid(CanvasDrawingSession drawing, CanvasLayout layout)
{
for (var x = 0; x <= m_Level.Width; x++)
{
var xPos = (float)(layout.OriginX + (x * layout.CellSize));
drawing.DrawLine(xPos, (float)layout.OriginY, xPos, (float)(layout.OriginY + (m_Level.Height * layout.CellSize)), ColorHelper.FromArgb(120, 91, 104, 115), 1);
}
for (var y = 0; y <= m_Level.Height; y++)
{
var yPos = (float)(layout.OriginY + (y * layout.CellSize));
drawing.DrawLine((float)layout.OriginX, yPos, (float)(layout.OriginX + (m_Level.Width * layout.CellSize)), yPos, ColorHelper.FromArgb(120, 91, 104, 115), 1);
}
} }
private void DrawRobot(CanvasDrawingSession drawing, CanvasLayout layout) private void DrawRobot(CanvasDrawingSession drawing, CanvasLayout layout)
{ {
var rect = layout.CellRect(m_Level.Robot.X, m_Level.Robot.Y); DrawImage(drawing, m_RobotSprite, Inset(layout.CellRect(m_Level.Robot.Position), 0.04));
DrawImage(drawing, m_RobotSprite, rect); }
private void DrawGrid(CanvasDrawingSession drawing, CanvasLayout layout)
{
foreach (var position in AllPositions())
{
var rect = layout.CellRect(position);
drawing.DrawRectangle(rect, ColorHelper.FromArgb(90, 91, 104, 115), 1);
if (m_SelectedCell == position)
drawing.DrawRectangle(rect, Colors.White, 3);
}
}
private static void DrawCenteredText(CanvasDrawingSession drawing, string text, Rect rect, Color color, float fontSize)
{
using var format = new CanvasTextFormat {
FontSize = fontSize,
HorizontalAlignment = CanvasHorizontalAlignment.Center,
VerticalAlignment = CanvasVerticalAlignment.Center,
WordWrapping = CanvasWordWrapping.NoWrap
};
drawing.DrawText(text, rect, color, format);
} }
private bool TryGetGridPosition(Point point, out GridPosition position) private bool TryGetGridPosition(Point point, out GridPosition position)
@@ -539,19 +431,14 @@ public sealed partial class MainWindow
{ {
var availableWidth = Math.Max(1, LevelCanvas.ActualWidth); var availableWidth = Math.Max(1, LevelCanvas.ActualWidth);
var availableHeight = Math.Max(1, LevelCanvas.ActualHeight); var availableHeight = Math.Max(1, LevelCanvas.ActualHeight);
return new((availableWidth - (cellSize * m_Level.Width)) / 2, (availableHeight - (cellSize * m_Level.Height)) / 2); return new((availableWidth - cellSize * m_Level.Width) / 2, (availableHeight - cellSize * m_Level.Height) / 2);
} }
private void ClampPan() private void ClampPan()
{ {
var cellSize = GetBaseCellSize() * m_Zoom; var cellSize = GetBaseCellSize() * m_Zoom;
var contentWidth = cellSize * m_Level.Width; m_PanX = ClampAxisPan(m_PanX, cellSize * m_Level.Width, Math.Max(1, LevelCanvas.ActualWidth));
var contentHeight = cellSize * m_Level.Height; m_PanY = ClampAxisPan(m_PanY, cellSize * m_Level.Height, Math.Max(1, LevelCanvas.ActualHeight));
var availableWidth = Math.Max(1, LevelCanvas.ActualWidth);
var availableHeight = Math.Max(1, LevelCanvas.ActualHeight);
m_PanX = ClampAxisPan(m_PanX, contentWidth, availableWidth);
m_PanY = ClampAxisPan(m_PanY, contentHeight, availableHeight);
} }
private static double ClampAxisPan(double pan, double contentSize, double availableSize) private static double ClampAxisPan(double pan, double contentSize, double availableSize)
@@ -563,152 +450,258 @@ public sealed partial class MainWindow
return Math.Clamp(pan, -maxPan, maxPan); return Math.Clamp(pan, -maxPan, maxPan);
} }
private void ZoomAt(Point point, double zoomFactor)
{
var oldLayout = GetLayout();
var cellX = (point.X - oldLayout.OriginX) / oldLayout.CellSize;
var cellY = (point.Y - oldLayout.OriginY) / oldLayout.CellSize;
m_Zoom = Math.Clamp(m_Zoom * zoomFactor, c_MinZoom, c_MaxZoom);
var newCellSize = GetBaseCellSize() * m_Zoom;
var originWithoutPan = GetCenteredOrigin(newCellSize);
m_PanX = point.X - originWithoutPan.X - cellX * newCellSize;
m_PanY = point.Y - originWithoutPan.Y - cellY * newCellSize;
ClampPan();
LevelCanvas.Invalidate();
}
private void RefreshInspector() private void RefreshInspector()
{ {
LevelNameText.Text = m_Level.Name; LevelNameText.Text = m_Level.Name;
TurnText.Text = m_Level.Global.Turn.ToString(CultureInfo.InvariantCulture); TurnText.Text = m_Level.Global.Turn.ToString(CultureInfo.InvariantCulture);
StatusText.Text = m_Level.Global.Status; StatusText.Text = $"{m_Level.Global.LevelState}: {m_Level.Global.Status}";
GlobalText.Text = $"Power: {m_Level.Global.Power}/10\n" + $"Cooling: {m_Level.Global.Cooling}/10\n" + $"Core Heat: {m_Level.Global.CoreHeat}/10\n" + $"Facility Stability: {m_Level.Global.FacilityStability}/10"; GlobalText.Text = $"Actions: {m_Level.Global.ActionsRemaining}/{Balancing.Current.ActionsPerTurn}\n"
+ $"All-seeing-eye: {(m_Level.Global.AllSeeingEyeUnlocked ? "unlocked" : "locked")}\n"
+ $"Inventory: F {m_Level.Robot.FuelNeutralizers}, C {m_Level.Robot.CoolantNeutralizers}, E {m_Level.Robot.ElectricityNeutralizers}, H {m_Level.Robot.HeatShields}\n"
+ $"Heat immunity steps: {m_Level.Robot.HeatImmunitySteps}\n"
+ $"Reactors: {m_Level.Reactors.Count}, Leaks: {m_Level.Leaks.Count}, Doors: {m_Level.Doors.Count}";
if (m_SelectedCell is { } position && m_Level.InBounds(position)) CellText.Text = m_SelectedCell is { } position && m_Level.InBounds(position) ? CellInspectionText(position) : "No cell selected.";
{ ForecastList.ItemsSource = m_Level.Forecasts.Select(forecast => new ForecastViewModel($"{forecast.Turns}: {forecast.Message}")).ToArray();
var cell = m_Level.GetCell(position);
CellText.Text = $"Position: {position.X},{position.Y}\n" + $"Terrain: {cell.Terrain}\n" + $"Prop: {cell.Prop}\n" + $"Pipe: {cell.Pipe}\n" + $"Flow: {cell.Flow}, Pressure: {cell.Pressure}\n" + $"Integrity: {cell.Integrity}, Leak: {cell.LeakRate}\n" + $"Heat: {cell.Hazards.Heat}, Smoke: {cell.Hazards.Smoke}\n" + $"Fuel Vapor: {cell.Hazards.FuelVapor}, Fuel: {cell.Hazards.LiquidFuel}\n" + $"Coolant: {cell.Hazards.CoolantPooling}, Charge: {cell.Hazards.ElectricalCharge}";
}
else
CellText.Text = "No cell selected.";
ForecastList.ItemsSource = m_Level.Forecasts.Select(forecast => new ForecastViewModel(FailureIcon(forecast.Kind), forecast.Message)).ToArray();
} }
private static BitmapImage FailureIcon(EFailureKind kind) private string CellInspectionText(GridPosition position)
{ {
return ImageFromOutputPath("Images", "Failures", FailureIconFileName(kind)); var prop = m_Level.GetProp(position);
var surface = m_Level.GetSurface(position);
var fuel = m_Level.GetUnderground(position, ECarrierType.Fuel);
var coolant = m_Level.GetUnderground(position, ECarrierType.Coolant);
var electricity = m_Level.GetUnderground(position, ECarrierType.Electricity);
return $"Position: {position.X},{position.Y}\n"
+ $"Terrain: {m_Level.GetTerrain(position)}\n"
+ $"Prop: {prop.Type} {prop.Carrier} {prop.SwitchState} {prop.ServiceState}\n"
+ $"Fuel: {UndergroundText(fuel)}\n"
+ $"Coolant: {UndergroundText(coolant)}\n"
+ $"Electricity: {UndergroundText(electricity)}\n"
+ $"Surface fuel/coolant/electricity/heat: {Format(surface.Fuel)} / {Format(surface.Coolant)} / {Format(surface.Electricity)} / {Format(surface.Heat)}\n"
+ $"Blocks F/C/E: {surface.FuelBlockTurns}/{surface.CoolantBlockTurns}/{surface.ElectricityBlockTurns}";
} }
private static string FailureIconFileName(EFailureKind kind) private static string UndergroundText(UndergroundCell cell)
{ {
return kind switch { return $"{cell.State} amount {Format(cell.Amount)} intensity {Format(cell.Intensity)}";
EFailureKind.PipeBurst => "failure-pipe-burst.png",
EFailureKind.Ignition => "failure-ignition.png",
EFailureKind.Meltdown => "failure-meltdown.png",
EFailureKind.StabilityCollapse => "failure-stability-collapse.png",
EFailureKind.ReactorReady => "failure-reactor-ready.png",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unsupported failure kind.")
};
} }
private static BitmapImage? EditorToolIcon(EEditorTool tool) private static string Format(float value)
{ {
return tool switch { return value.ToString("0.0", CultureInfo.InvariantCulture);
EEditorTool.Cursor => PropImage("cursor.png"),
EEditorTool.Floor => PropImage("floor.png"),
EEditorTool.Wall => PropImage("wall.png"),
EEditorTool.Reactor => PropImage("reactor.png"),
EEditorTool.CoolingPump => PropImage("cooling-pump.png"),
EEditorTool.Generator => PropImage("generator.png"),
EEditorTool.PressureRegulator => PropImage("pressure-regulator.png"),
EEditorTool.DiagnosticTerminal => PropImage("diagnostic-terminal.png"),
EEditorTool.ControlTerminal => PropImage("control-terminal.png"),
EEditorTool.CoolantPipe => PipeImage("pipe-coolant-tilemap.png"),
EEditorTool.FuelPipe => PipeImage("pipe-fuel-tilemap.png"),
EEditorTool.PressurePipe => PipeImage("pipe-pressure-tilemap.png"),
EEditorTool.Leak => PropImage("leak.png"),
EEditorTool.Repair => PropImage("repair.png"),
EEditorTool.Heat => PropImage("heat.png"),
EEditorTool.Fire => PropImage("fire.png"),
EEditorTool.Robot => PropImage("robot.png"),
_ => throw new ArgumentOutOfRangeException(nameof(tool), tool, "Unsupported editor tool.")
};
}
private static BitmapImage PropImage(string fileName)
{
return ImageFromOutputPath("Images", "Props", fileName);
}
private static BitmapImage PipeImage(string fileName)
{
return ImageFromOutputPath("Images", "Pipes", fileName);
}
private static BitmapImage ImageFromOutputPath(params string[] pathParts)
{
return new(new(Path.Combine([AppContext.BaseDirectory, .. pathParts])));
} }
private static LevelState BuildStarterLevel() private static LevelState BuildStarterLevel()
{ {
var level = LevelState.Create("Cooling Sector B", 16, 12); var level = LevelState.Create("Cooling Sector B", 16, 12);
level = level.SetCell(new(3, 5), new() { level = AddNetwork(level, ECarrierType.Fuel, new(2, 3), new(5, 3));
Prop = ECellProp.CoolingPump, level = AddNetwork(level, ECarrierType.Coolant, new(2, 5), new(5, 5));
Pipe = EPipeMedium.Coolant, level = AddNetwork(level, ECarrierType.Electricity, new(2, 7), new(5, 7));
Flow = 5, level = level.SetProp(new(2, 3), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
Pressure = 5, level = level.SetProp(new(2, 5), new() { Type = EPropType.Flow, Carrier = ECarrierType.Coolant });
Powered = true level = level.SetProp(new(2, 7), new() { Type = EPropType.Flow, Carrier = ECarrierType.Electricity });
}); level = level.SetProp(new(5, 3), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Fuel });
level = level.SetCell(new(4, 5), new() { level = level.SetProp(new(5, 5), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Coolant });
Pipe = EPipeMedium.Coolant, level = level.SetProp(new(5, 7), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Electricity });
Flow = 5, level = level.SetProp(new(10, 5), new() { Type = EPropType.ReactorControl, ReactorId = 1 });
Pressure = 7 level = level.SetProp(new(11, 4), new() { Type = EPropType.AllSeeingEyeTerminal });
}); level = level.SetProp(new(11, 6), new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.HeatShield });
level = level.SetCell(new(5, 5), new() { level = level.SetUnderground(new(4, 5), ECarrierType.Coolant, new() { State = EUndergroundState.Leaking }) with {
Pipe = EPipeMedium.Coolant, Leaks = [new LeakState { Carrier = ECarrierType.Coolant, UndergroundPosition = new(4, 5), AccessPosition = new(4, 5) }],
Flow = 3, Doors = [new DoorState { A = new(8, 5), B = new(9, 5), State = EDoorState.Closed }],
Pressure = 8, Robot = new() { Position = new(10, 5) },
LeakRate = 2, Reactors = [
Integrity = 4 new ReactorBinding {
}); ReactorId = 1,
level = level.SetCell(new(6, 5), new() { ControlPosition = new(10, 5),
Pipe = EPipeMedium.Coolant, FuelConsumerPosition = new(5, 3),
Flow = 3, CoolantConsumerPosition = new(5, 5),
Pressure = 7 ElectricityConsumerPosition = new(5, 7)
});
level = level.SetCell(new(8, 5), new() {
Prop = ECellProp.Reactor,
Hazards = new() {
Heat = 6,
Stability = 8
} }
}); ]
level = level.SetCell(new(2, 8), new() { };
Prop = ECellProp.Generator,
Pipe = EPipeMedium.Fuel,
Flow = 4,
Pressure = 6,
Powered = true
});
level = level.SetCell(new(11, 4), new() {
Prop = ECellProp.DiagnosticTerminal,
Powered = true
});
level = level.SetCell(new(12, 8), new() {
Prop = ECellProp.ControlTerminal,
Powered = true
});
return level with { Forecasts = new SimulationEngine().Forecast(level) }; return level with { Forecasts = new SimulationEngine().Forecast(level) };
} }
private const int c_TilemapTileSize = 512; private static LevelState AddNetwork(LevelState level, ECarrierType carrier, GridPosition start, GridPosition end)
private const int c_PipeTilemapTileSize = 256; {
private const int c_PipeTilemapColumns = 4; var minX = Math.Min(start.X, end.X);
private const int c_TopLeftCorner = 1; var maxX = Math.Max(start.X, end.X);
private const int c_TopRightCorner = 2; var minY = Math.Min(start.Y, end.Y);
private const int c_BottomLeftCorner = 4; var maxY = Math.Max(start.Y, end.Y);
private const int c_BottomRightCorner = 8; for (var y = minY; y <= maxY; y++)
private const int c_AllCorners = c_TopLeftCorner | c_TopRightCorner | c_BottomLeftCorner | c_BottomRightCorner; {
private const int c_NorthConnection = 1; for (var x = minX; x <= maxX; x++)
private const int c_EastConnection = 2; level = level.SetUnderground(new(x, y), carrier, new() { State = EUndergroundState.Intact });
private const int c_SouthConnection = 4; }
private const int c_WestConnection = 8;
return level;
}
private static LevelState AutoBindReactors(LevelState level)
{
if (level.Reactors.Count == 0)
return level;
var fuel = FirstConsumer(level, ECarrierType.Fuel);
var coolant = FirstConsumer(level, ECarrierType.Coolant);
var electricity = FirstConsumer(level, ECarrierType.Electricity);
var reactors = level.Reactors.Select(reactor => reactor with {
FuelConsumerPosition = fuel ?? reactor.FuelConsumerPosition,
CoolantConsumerPosition = coolant ?? reactor.CoolantConsumerPosition,
ElectricityConsumerPosition = electricity ?? reactor.ElectricityConsumerPosition
})
.ToArray();
return level with { Reactors = reactors };
}
private static GridPosition? FirstConsumer(LevelState level, ECarrierType carrier)
{
return AllPositions(level).FirstOrDefault(position => level.GetProp(position) is { Type: EPropType.Consumer, Carrier: var propCarrier } && propCarrier == carrier);
}
private IEnumerable<GridPosition> AllPositions()
{
return AllPositions(m_Level);
}
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 static Rect Inset(Rect rect, double fraction)
{
var inset = rect.Width * fraction;
return new(rect.X + inset, rect.Y + inset, rect.Width - inset * 2, rect.Height - inset * 2);
}
private static Point Center(Rect rect)
{
return new(rect.X + rect.Width / 2, rect.Y + rect.Height / 2);
}
private static void DrawImage(CanvasDrawingSession drawing, CanvasBitmap? image, Rect rect, float opacity = 1)
{
if (image is not null)
drawing.DrawImage(image, rect, image.Bounds, opacity);
}
private static Color PropColor(PropState prop)
{
return prop.Type switch {
EPropType.Flow => CarrierColor(prop.Carrier),
EPropType.Consumer => ColorHelper.FromArgb(255, 93, 123, 170),
EPropType.TJunction or EPropType.CrossJunction => ColorHelper.FromArgb(255, 143, 111, 178),
EPropType.Door => ColorHelper.FromArgb(255, 187, 119, 55),
EPropType.AllSeeingEyeTerminal => ColorHelper.FromArgb(255, 85, 151, 156),
EPropType.RemedySupply => ColorHelper.FromArgb(255, 76, 145, 86),
EPropType.ReactorControl => ColorHelper.FromArgb(255, 177, 72, 73),
_ => Colors.Gray
};
}
private static Color CarrierColor(ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => c_FuelColor,
ECarrierType.Coolant => c_CoolantColor,
ECarrierType.Electricity => c_ElectricityColor,
_ => Colors.White
};
}
private static string PropLabel(PropState prop)
{
return prop.Type switch {
EPropType.Flow => $"{CarrierShort(prop.Carrier)} SRC",
EPropType.Consumer => $"{CarrierShort(prop.Carrier)} CON",
EPropType.TJunction => $"T {prop.TJunctionMode}",
EPropType.CrossJunction => $"X {prop.CrossJunctionMode}",
EPropType.Door => "DOOR",
EPropType.AllSeeingEyeTerminal => "EYE",
EPropType.RemedySupply => RemedyShort(prop.RemedyType),
EPropType.ReactorControl => "REACT",
_ => string.Empty
};
}
private static string CarrierShort(ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => "F",
ECarrierType.Coolant => "C",
ECarrierType.Electricity => "E",
_ => "?"
};
}
private static string RemedyShort(ERemedyType remedy)
{
return remedy switch {
ERemedyType.FuelNeutralizer => "F REM",
ERemedyType.CoolantNeutralizer => "C REM",
ERemedyType.ElectricityNeutralizer => "E REM",
ERemedyType.HeatShield => "H SHD",
_ => "REM"
};
}
private static string ToolLabel(EEditorTool tool)
{
return tool switch {
EEditorTool.FuelUnderground => "Fuel Net",
EEditorTool.CoolantUnderground => "Coolant Net",
EEditorTool.ElectricityUnderground => "Electric Net",
EEditorTool.FuelFlow => "Fuel Source",
EEditorTool.CoolantFlow => "Coolant Source",
EEditorTool.ElectricityFlow => "Electric Source",
EEditorTool.FuelConsumer => "Fuel Consumer",
EEditorTool.CoolantConsumer => "Coolant Consumer",
EEditorTool.ElectricityConsumer => "Electric Consumer",
EEditorTool.AllSeeingEyeTerminal => "Eye Terminal",
EEditorTool.FuelRemedySupply => "Fuel Remedy",
EEditorTool.CoolantRemedySupply => "Coolant Remedy",
EEditorTool.ElectricityRemedySupply => "Electric Remedy",
EEditorTool.HeatRemedySupply => "Heat Shield",
EEditorTool.ReactorControl => "Reactor",
EEditorTool.FuelHazard => "Fuel Hazard",
EEditorTool.CoolantHazard => "Coolant Hazard",
EEditorTool.ElectricityHazard => "Electric Hazard",
_ => tool.ToString()
};
}
private const double c_MinZoom = 0.5; private const double c_MinZoom = 0.5;
private const double c_MaxZoom = 4; private const double c_MaxZoom = 4;
private const double c_ZoomStep = 1.15; private const double c_ZoomStep = 1.15;
private const double c_ClickPixelThreshold = 10; private const double c_ClickPixelThreshold = 10;
private static readonly Color c_FuelColor = ColorHelper.FromArgb(255, 220, 170, 68);
private static readonly Color c_CoolantColor = ColorHelper.FromArgb(255, 57, 174, 196);
private static readonly Color c_ElectricityColor = ColorHelper.FromArgb(255, 236, 226, 82);
private readonly SimulationEngine m_Simulation = new(); private readonly SimulationEngine m_Simulation = new();
private readonly Dictionary<ECellProp, CanvasBitmap> m_PropSprites = [];
private readonly Dictionary<EPipeMedium, CanvasBitmap> m_PipeTilemaps = [];
private StorageFile? m_CurrentFile; private StorageFile? m_CurrentFile;
private LevelState m_Level; private LevelState m_Level;
private IReadOnlyList<EditorToolViewModel> m_EditorTools = []; private IReadOnlyList<EditorToolViewModel> m_EditorTools = [];
@@ -725,5 +718,4 @@ public sealed partial class MainWindow
private CanvasBitmap? m_RobotSprite; private CanvasBitmap? m_RobotSprite;
private CanvasBitmap? m_LeakSprite; private CanvasBitmap? m_LeakSprite;
private CanvasBitmap? m_HeatSprite; private CanvasBitmap? m_HeatSprite;
private CanvasBitmap? m_FireSprite;
} }

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework> <TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion> <TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>ReactorMaintenance.Win2D</RootNamespace> <RootNamespace>ReactorMaintenance.Win2D</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
@@ -13,6 +13,7 @@
<WindowsPackageType>None</WindowsPackageType> <WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

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